Hide keyboard shortcuts

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. 

7 

8import os.path 

9from threading import Event 

10from gi.repository import GLib 

11 

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 

20 

21log = Logger("server") 

22soundlog = Logger("sound") 

23httplog = Logger("http") 

24 

25PRIVATE_PULSEAUDIO = envbool("XPRA_PRIVATE_PULSEAUDIO", True) 

26 

27 

28""" 

29Mixin for servers that handle audio forwarding. 

30""" 

31class AudioServer(StubServerMixin): 

32 

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 

48 

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) 

64 

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. 

72 

73 def do_audio_setup(self): 

74 self.init_pulseaudio() 

75 self.init_sound_options() 

76 

77 def cleanup(self): 

78 self.audio_init_done.wait(5) 

79 self.cleanup_pulseaudio() 

80 

81 

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 {} 

90 

91 

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 

104 

105 

106 def get_http_scripts(self) -> dict: 

107 return { 

108 "/audio.mp3" : self.http_audio_mp3_request, 

109 } 

110 

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) 

203 

204 

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) 

314 

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) 

391 

392 

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) 

428 

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) 

440 

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() 

448 

449 

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 

461 

462 

463 def _process_sound_control(self, proto, packet): 

464 ss = self.get_server_source(proto) 

465 if ss: 

466 ss.sound_control(*packet[1:]) 

467 

468 def _process_sound_data(self, proto, packet): 

469 ss = self.get_server_source(proto) 

470 if ss: 

471 ss.sound_data(*packet[1:]) 

472 

473 

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 })