Coverage for /home/antoine/projects/xpra-git/dist/python3/lib64/python/xpra/server/mixins/audio_server.py : 32%
Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# -*- coding: utf-8 -*-
2# This file is part of Xpra.
3# Copyright (C) 2010-2020 Antoine Martin <antoine@xpra.org>
4# Copyright (C) 2008 Nathaniel Smith <njs@pobox.com>
5# Xpra is released under the terms of the GNU GPL v2, or, at your option, any
6# later version. See the file COPYING for details.
8import os.path
9from threading import Event
10from gi.repository import GLib
12from xpra.os_util import pollwait, monotonic_time, bytestostr, osexpand, OSX, POSIX
13from xpra.util import typedict, envbool, csv, engs
14from xpra.make_thread import start_thread
15from xpra.platform import get_username
16from xpra.platform.paths import get_icon_filename
17from xpra.scripts.parsing import sound_option
18from xpra.server.mixins.stub_server_mixin import StubServerMixin
19from xpra.log import Logger
21log = Logger("server")
22soundlog = Logger("sound")
23httplog = Logger("http")
25PRIVATE_PULSEAUDIO = envbool("XPRA_PRIVATE_PULSEAUDIO", True)
28"""
29Mixin for servers that handle audio forwarding.
30"""
31class AudioServer(StubServerMixin):
33 def __init__(self):
34 self.audio_init_done = Event()
35 self.pulseaudio = False
36 self.pulseaudio_command = None
37 self.pulseaudio_configure_commands = []
38 self.pulseaudio_proc = None
39 self.pulseaudio_private_dir = None
40 self.pulseaudio_private_socket = None
41 self.sound_source_plugin = None
42 self.supports_speaker = False
43 self.supports_microphone = False
44 self.speaker_codecs = ()
45 self.microphone_codecs = ()
46 self.sound_properties = typedict()
47 self.av_sync = False
49 def init(self, opts):
50 self.sound_source_plugin = opts.sound_source
51 self.supports_speaker = sound_option(opts.speaker) in ("on", "off")
52 if self.supports_speaker:
53 self.speaker_codecs = opts.speaker_codec
54 self.supports_microphone = sound_option(opts.microphone) in ("on", "off")
55 if self.supports_microphone:
56 self.microphone_codecs = opts.microphone_codec
57 self.pulseaudio = opts.pulseaudio
58 self.pulseaudio_command = opts.pulseaudio_command
59 self.pulseaudio_configure_commands = opts.pulseaudio_configure_commands
60 log("AudioServer.init(..) supports speaker=%s, microphone=%s",
61 self.supports_speaker, self.supports_microphone)
62 self.av_sync = opts.av_sync
63 log("AudioServer.init(..) av-sync=%s", self.av_sync)
65 def setup(self):
66 #the setup code will mostly be waiting for subprocesses to run,
67 #so do it in a separate thread
68 #and just wait for the results where needed:
69 start_thread(self.do_audio_setup, "audio-setup", True)
70 #we don't use threaded_setup() here because it would delay
71 #all the other mixins that use it, for no good reason.
73 def do_audio_setup(self):
74 self.init_pulseaudio()
75 self.init_sound_options()
77 def cleanup(self):
78 self.audio_init_done.wait(5)
79 self.cleanup_pulseaudio()
82 def get_info(self, _proto) -> dict:
83 self.audio_init_done.wait(5)
84 info = {}
85 if self.pulseaudio is not False:
86 info["pulseaudio"] = self.get_pulseaudio_info()
87 if self.sound_properties:
88 info["sound"] = self.sound_properties
89 return {}
92 def get_server_features(self, source) -> dict:
93 d = {
94 "av-sync" : {
95 "" : self.av_sync,
96 "enabled" : self.av_sync,
97 },
98 "sound" : {
99 "ogg-latency-fix" : True, #warning removed in v4 clients
100 },
101 }
102 log("get_server_features(%s)=%s", source, d)
103 return d
106 def get_http_scripts(self) -> dict:
107 return {
108 "/audio.mp3" : self.http_audio_mp3_request,
109 }
111 def http_audio_mp3_request(self, handler):
112 def err(code=500):
113 handler.send_response(code)
114 return None
115 from xpra.server.http_handler import parse_url
116 args = parse_url(handler)
117 if not args:
118 return err()
119 httplog("http_audio_mp3_request(%s) args=%s", handler, args)
120 uuid = args.get("uuid")
121 if not uuid:
122 httplog.warn("Warning: http-stream audio request, missing uuid")
123 return err()
124 source = None
125 for x in self._server_sources.values():
126 if x.uuid==uuid:
127 source = x
128 break
129 if not source:
130 httplog.warn("Warning: no client matching uuid '%s'", uuid)
131 return err()
132 #don't close the connection when handler.finish() is called,
133 #we will continue to write to this socket as we process more buffers:
134 finish = handler.finish
135 def do_finish():
136 try:
137 finish()
138 except Exception:
139 log("error calling %s", finish, exc_info=True)
140 def noop():
141 pass
142 handler.finish = noop
143 state = {}
144 def new_buffer(_sound_source, data, _metadata, packet_metadata=()):
145 if state.get("failed"):
146 return
147 if not state.get("started"):
148 httplog.warn("buffer received but stream is not started yet")
149 source.stop_sending_sound()
150 err()
151 do_finish()
152 return
153 count = state.get("buffers", 0)
154 httplog("new_buffer [%i] for %s sound stream: %i bytes", count, state.get("codec", "?"), len(data))
155 #httplog("buffer %i: %s", count, hexstr(data))
156 state["buffers"] = count+1
157 try:
158 for x in packet_metadata:
159 handler.wfile.write(x)
160 handler.wfile.write(data)
161 handler.wfile.flush()
162 except Exception as e:
163 state["failed"] = True
164 httplog("failed to send new audio buffer", exc_info=True)
165 httplog.warn("Error: failed to send audio packet:")
166 httplog.warn(" %s", e)
167 source.stop_sending_sound()
168 do_finish()
169 def new_stream(sound_source, codec):
170 codec = bytestostr(codec)
171 httplog("new_stream: %s", codec)
172 sound_source.codec = codec
173 headers = {
174 "Content-type" : "audio/mpeg",
175 }
176 try:
177 handler.send_response(200)
178 for k,v in headers.items():
179 handler.send_header(k, v)
180 handler.end_headers()
181 except ValueError:
182 httplog("new_stream error writing headers", exc_info=True)
183 state["failed"] = True
184 source.stop_sending_sound()
185 do_finish()
186 else:
187 state["started"] = True
188 state["buffers"] = 0
189 state["codec"] = codec
190 start = monotonic_time()
191 def timeout_check():
192 self.http_stream_check_timers.pop(start, None)
193 if not state.get("started"):
194 err()
195 source.stop_sending_sound()
196 if source.sound_source:
197 source.stop_sending_sound()
198 def start_sending_sound():
199 source.start_sending_sound("mp3", volume=1.0, new_stream=new_stream,
200 new_buffer=new_buffer, skip_client_codec_check=True)
201 GLib.idle_add(start_sending_sound)
202 self.http_stream_check_timers[start] = (self.timeout_add(1000*5, timeout_check), source.stop_sending_sound)
205 def init_pulseaudio(self):
206 soundlog("init_pulseaudio() pulseaudio=%s, pulseaudio_command=%s", self.pulseaudio, self.pulseaudio_command)
207 if self.pulseaudio is False:
208 return
209 if not self.pulseaudio_command:
210 soundlog.warn("Warning: pulseaudio command is not defined")
211 return
212 #environment initialization:
213 # 1) make sure that the sound subprocess will use the devices
214 # we define in the pulseaudio command
215 # (it is too difficult to parse the pulseaudio_command,
216 # so we just hope that it matches this):
217 # Note: speaker is the source and microphone the sink,
218 # because things are reversed on the server.
219 os.environ.update({
220 "XPRA_PULSE_SOURCE_DEVICE_NAME" : "Xpra-Speaker",
221 "XPRA_PULSE_SINK_DEVICE_NAME" : "Xpra-Microphone",
222 })
223 # 2) whitelist the env vars that pulseaudio may use:
224 PA_ENV_WHITELIST = ("DBUS_SESSION_BUS_ADDRESS", "DBUS_SESSION_BUS_PID", "DBUS_SESSION_BUS_WINDOWID",
225 "DISPLAY", "HOME", "HOSTNAME", "LANG", "PATH",
226 "PWD", "SHELL", "XAUTHORITY",
227 "XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE",
228 "XPRA_PULSE_SOURCE_DEVICE_NAME", "XPRA_PULSE_SINK_DEVICE_NAME",
229 )
230 env = dict((k,v) for k,v in self.get_child_env().items() if k in PA_ENV_WHITELIST)
231 # 3) use a private pulseaudio server, so each xpra
232 # session can have its own server,
233 # create a directory for each display:
234 if PRIVATE_PULSEAUDIO and POSIX and not OSX:
235 from xpra.platform.xposix.paths import _get_xpra_runtime_dir, get_runtime_dir
236 rd = osexpand(get_runtime_dir())
237 if not rd or not os.path.exists(rd) or not os.path.isdir(rd):
238 log.warn("Warning: the runtime directory '%s' does not exist,", rd)
239 log.warn(" cannot start a private pulseaudio server")
240 else:
241 xpra_rd = _get_xpra_runtime_dir()
242 assert xpra_rd, "bug: no xpra runtime dir"
243 display = os.environ.get("DISPLAY", "").lstrip(":")
244 self.pulseaudio_private_dir = osexpand(os.path.join(xpra_rd, "pulse-%s" % display))
245 if not os.path.exists(self.pulseaudio_private_dir):
246 os.mkdir(self.pulseaudio_private_dir, 0o700)
247 env["XDG_RUNTIME_DIR"] = self.pulseaudio_private_dir
248 self.pulseaudio_private_socket = os.path.join(self.pulseaudio_private_dir, "pulse", "native")
249 os.environ["XPRA_PULSE_SERVER"] = self.pulseaudio_private_socket
250 import shlex
251 cmd = shlex.split(self.pulseaudio_command)
252 cmd = list(osexpand(x) for x in cmd)
253 #find the absolute path to the command:
254 pa_cmd = cmd[0]
255 if not os.path.isabs(pa_cmd):
256 pa_path = None
257 for x in os.environ.get("PATH", "").split(os.path.pathsep):
258 t = os.path.join(x, pa_cmd)
259 if os.path.exists(t):
260 pa_path = t
261 break
262 if not pa_path:
263 msg = "pulseaudio not started: '%s' command not found" % pa_cmd
264 if self.pulseaudio is None:
265 soundlog.info(msg)
266 else:
267 soundlog.warn(msg)
268 return
269 cmd[0] = pa_cmd
270 started_at = monotonic_time()
271 def pulseaudio_warning():
272 soundlog.warn("Warning: pulseaudio has terminated shortly after startup.")
273 soundlog.warn(" pulseaudio is limited to a single instance per user account,")
274 soundlog.warn(" and one may be running already for user '%s'.", get_username())
275 soundlog.warn(" To avoid this warning, either fix the pulseaudio command line")
276 soundlog.warn(" or use the 'pulseaudio=no' option.")
277 def pulseaudio_ended(proc):
278 soundlog("pulseaudio_ended(%s) pulseaudio_proc=%s, returncode=%s, closing=%s",
279 proc, self.pulseaudio_proc, proc.returncode, self._closing)
280 if self.pulseaudio_proc is None or self._closing:
281 #cleared by cleanup already, ignore
282 return
283 elapsed = monotonic_time()-started_at
284 if elapsed<2:
285 self.timeout_add(1000, pulseaudio_warning)
286 else:
287 soundlog.warn("Warning: the pulseaudio server process has terminated after %i seconds", int(elapsed))
288 self.pulseaudio_proc = None
289 import subprocess
290 try:
291 soundlog("pulseaudio cmd=%s", " ".join(cmd))
292 soundlog("pulseaudio env=%s", env)
293 self.pulseaudio_proc = subprocess.Popen(cmd, env=env)
294 except Exception as e:
295 soundlog("Popen(%s)", cmd, exc_info=True)
296 soundlog.error("Error: failed to start pulseaudio:")
297 soundlog.error(" %s", e)
298 return
299 self.add_process(self.pulseaudio_proc, "pulseaudio", cmd, ignore=True, callback=pulseaudio_ended)
300 if self.pulseaudio_proc:
301 soundlog.info("pulseaudio server started with pid %s", self.pulseaudio_proc.pid)
302 if self.pulseaudio_private_socket:
303 soundlog.info(" private server socket path:")
304 soundlog.info(" '%s'", self.pulseaudio_private_socket)
305 os.environ["PULSE_SERVER"] = "unix:%s" % self.pulseaudio_private_socket
306 def configure_pulse():
307 p = self.pulseaudio_proc
308 if p is None or p.poll() is not None:
309 return
310 for i, x in enumerate(self.pulseaudio_configure_commands):
311 proc = subprocess.Popen(x, env=env, shell=True)
312 self.add_process(proc, "pulseaudio-configure-command-%i" % i, x, ignore=True)
313 self.timeout_add(2*1000, configure_pulse)
315 def cleanup_pulseaudio(self):
316 self.audio_init_done.wait(5)
317 proc = self.pulseaudio_proc
318 if not proc:
319 return
320 soundlog("cleanup_pa() process.poll()=%s, pid=%s", proc.poll(), proc.pid)
321 if self.is_child_alive(proc):
322 self.pulseaudio_proc = None
323 soundlog.info("stopping pulseaudio with pid %s", proc.pid)
324 try:
325 #first we try pactl (required on Ubuntu):
326 import subprocess
327 cmd = ["pactl", "exit"]
328 proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
329 self.add_process(proc, "pactl exit", cmd, True)
330 r = pollwait(proc)
331 #warning: pactl will return 0 whether it succeeds or not...
332 #but we can't kill the process because Ubuntu starts a new one
333 if r!=0 and self.is_child_alive(proc):
334 #fallback to using SIGINT:
335 proc.terminate()
336 except Exception as e:
337 soundlog.warn("cleanup_pulseaudio() error stopping %s", proc, exc_info=True)
338 #only log the full stacktrace if the process failed to terminate:
339 if self.is_child_alive(proc):
340 soundlog.error("Error: stopping pulseaudio: %s", e, exc_info=True)
341 if self.pulseaudio_private_socket and self.is_child_alive(proc):
342 #wait for the pulseaudio process to exit,
343 #it will delete the socket:
344 soundlog("pollwait()=%s", pollwait(proc))
345 if self.pulseaudio_private_socket and not self.is_child_alive(proc):
346 #wait for the socket to get cleaned up
347 #(it should be removed by the pulseaudio server as it exits)
348 import time
349 now = monotonic_time()
350 while (monotonic_time()-now)<1 and os.path.exists(self.pulseaudio_private_socket):
351 time.sleep(0.1)
352 if self.pulseaudio_private_dir:
353 if os.path.exists(self.pulseaudio_private_socket):
354 log.warn("Warning: the pulseaudio private socket file still exists:")
355 log.warn(" '%s'", self.pulseaudio_private_socket)
356 log.warn(" the private pulseaudio directory containing it will not be removed")
357 else:
358 import glob
359 pulse = os.path.join(self.pulseaudio_private_dir, "pulse")
360 native = os.path.join(pulse, "native")
361 dirs = []
362 dbus_dirs = glob.glob("%s/dbus-*" % self.pulseaudio_private_dir)
363 if len(dbus_dirs)==1:
364 dbus_dir = dbus_dirs[0]
365 if os.path.isdir(dbus_dir):
366 services_dir = os.path.join(dbus_dir, "services")
367 dirs.append(services_dir)
368 dirs.append(dbus_dir)
369 dirs += [native, pulse, self.pulseaudio_private_dir]
370 path = None
371 try:
372 for d in dirs:
373 path = os.path.abspath(d)
374 soundlog("removing private directory '%s'", path)
375 if os.path.exists(path) and os.path.isdir(path):
376 os.rmdir(path)
377 log.info("removing private directory '%s'", self.pulseaudio_private_dir)
378 except OSError as e:
379 soundlog("cleanup_pulseaudio() error removing '%s'", path, exc_info=True)
380 soundlog.error("Error: failed to cleanup the pulseaudio private directory")
381 soundlog.error(" '%s'", self.pulseaudio_private_dir)
382 soundlog.error(" %s", e)
383 try:
384 files = os.listdir(path)
385 if files:
386 soundlog.error(" found %i file%s in '%s':", len(files), engs(files), path)
387 for f in files:
388 soundlog.error(" - '%s'", f)
389 except OSError:
390 soundlog.error("cleanup_pulseaudio() error accessing '%s'", path, exc_info=True)
393 def init_sound_options(self):
394 def sound_option_or_all(*_args):
395 return []
396 if self.supports_speaker or self.supports_microphone:
397 try:
398 from xpra.sound.common import sound_option_or_all
399 from xpra.sound.wrapper import query_sound
400 self.sound_properties = query_sound()
401 assert self.sound_properties, "query did not return any data"
402 def vinfo(k):
403 val = self.sound_properties.tupleget(k)
404 assert val, "%s not found in sound properties" % bytestostr(k)
405 return ".".join(bytestostr(x) for x in val[:3])
406 bits = self.sound_properties.intget("python.bits", 32)
407 soundlog.info("GStreamer version %s for Python %s %i-bit",
408 vinfo("gst.version"), vinfo("python.version"), bits)
409 except Exception as e:
410 soundlog("failed to query sound", exc_info=True)
411 soundlog.error("Error: failed to query sound subsystem:")
412 soundlog.error(" %s", e)
413 self.speaker_allowed = False
414 self.microphone_allowed = False
415 encoders = self.sound_properties.strtupleget("encoders")
416 decoders = self.sound_properties.strtupleget("decoders")
417 self.speaker_codecs = sound_option_or_all("speaker-codec", self.speaker_codecs, encoders)
418 self.microphone_codecs = sound_option_or_all("microphone-codec", self.microphone_codecs, decoders)
419 if not self.speaker_codecs:
420 self.supports_speaker = False
421 if not self.microphone_codecs:
422 self.supports_microphone = False
423 #query_pulseaudio_properties may access X11,
424 #do this from the main thread:
425 if bool(self.sound_properties):
426 GLib.idle_add(self.query_pulseaudio_properties)
427 GLib.idle_add(self.log_sound_properties)
429 def query_pulseaudio_properties(self):
430 try:
431 from xpra.sound.pulseaudio.pulseaudio_util import set_icon_path, get_info as get_pa_info
432 pa_info = get_pa_info()
433 soundlog("pulseaudio info=%s", pa_info)
434 self.sound_properties.update(pa_info)
435 set_icon_path(get_icon_filename("xpra.png"))
436 except ImportError as e:
437 if POSIX and not OSX:
438 log.warn("Warning: failed to set pulseaudio tagging icon:")
439 log.warn(" %s", e)
441 def log_sound_properties(self):
442 soundlog("init_sound_options speaker: supported=%s, encoders=%s",
443 self.supports_speaker, csv(self.speaker_codecs))
444 soundlog("init_sound_options microphone: supported=%s, decoders=%s",
445 self.supports_microphone, csv(self.microphone_codecs))
446 soundlog("init_sound_options sound properties=%s", self.sound_properties)
447 self.audio_init_done.set()
450 def get_pulseaudio_info(self) -> dict:
451 info = {
452 "command" : self.pulseaudio_command,
453 "configure-commands" : self.pulseaudio_configure_commands,
454 }
455 if self.pulseaudio_proc and self.pulseaudio_proc.poll() is None:
456 info["pid"] = self.pulseaudio_proc.pid
457 if self.pulseaudio_private_dir and self.pulseaudio_private_socket:
458 info["private-directory"] = self.pulseaudio_private_dir
459 info["private-socket"] = self.pulseaudio_private_socket
460 return info
463 def _process_sound_control(self, proto, packet):
464 ss = self.get_server_source(proto)
465 if ss:
466 ss.sound_control(*packet[1:])
468 def _process_sound_data(self, proto, packet):
469 ss = self.get_server_source(proto)
470 if ss:
471 ss.sound_data(*packet[1:])
474 def init_packet_handlers(self):
475 if self.supports_speaker or self.supports_microphone:
476 self.add_packet_handlers({
477 "sound-control" : self._process_sound_control,
478 "sound-data" : self._process_sound_data,
479 })