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#!/usr/bin/env python 

2# This file is part of Xpra. 

3# Copyright (C) 2010-2020 Antoine Martin <antoine@xpra.org> 

4# Xpra is released under the terms of the GNU GPL v2, or, at your option, any 

5# later version. See the file COPYING for details. 

6 

7import sys 

8import os.path 

9from queue import Queue 

10from gi.repository import GObject 

11 

12from xpra.os_util import SIGNAMES, monotonic_time 

13from xpra.util import csv, envint, envbool, envfloat 

14from xpra.sound.sound_pipeline import SoundPipeline 

15from xpra.gtk_common.gobject_util import n_arg_signal 

16from xpra.sound.gstreamer_util import ( 

17 get_source_plugins, plugin_str, get_encoder_elements, 

18 get_encoder_default_options, normv, 

19 get_encoders, get_queue_time, has_plugins, 

20 MP3, CODEC_ORDER, MUXER_DEFAULT_OPTIONS, ENCODER_NEEDS_AUDIOCONVERT, 

21 SOURCE_NEEDS_AUDIOCONVERT, ENCODER_CANNOT_USE_CUTTER, CUTTER_NEEDS_CONVERT, 

22 CUTTER_NEEDS_RESAMPLE, MS_TO_NS, GST_QUEUE_LEAK_DOWNSTREAM, 

23 GST_FLOW_OK, 

24 ) 

25from xpra.net.compression import compressed_wrapper 

26from xpra.scripts.config import InitExit 

27from xpra.log import Logger 

28 

29log = Logger("sound") 

30gstlog = Logger("gstreamer") 

31 

32APPSINK = os.environ.get("XPRA_SOURCE_APPSINK", "appsink name=sink emit-signals=true max-buffers=10 drop=true sync=false async=false qos=false") 

33JITTER = envint("XPRA_SOUND_SOURCE_JITTER", 0) 

34SOURCE_QUEUE_TIME = get_queue_time(50, "SOURCE_") 

35 

36BUFFER_TIME = envint("XPRA_SOUND_SOURCE_BUFFER_TIME", 0) #ie: 64 

37LATENCY_TIME = envint("XPRA_SOUND_SOURCE_LATENCY_TIME", 0) #ie: 32 

38BUNDLE_METADATA = envbool("XPRA_SOUND_BUNDLE_METADATA", True) 

39LOG_CUTTER = envbool("XPRA_SOUND_LOG_CUTTER", False) 

40CUTTER_THRESHOLD = envfloat("XPRA_CUTTER_THRESHOLD", "0.0001") 

41CUTTER_PRE_LENGTH = envint("XPRA_CUTTER_PRE_LENGTH", 100) 

42CUTTER_RUN_LENGTH = envint("XPRA_CUTTER_RUN_LENGTH", 1000) 

43 

44 

45class SoundSource(SoundPipeline): 

46 

47 __gsignals__ = SoundPipeline.__generic_signals__.copy() 

48 __gsignals__.update({ 

49 "new-buffer" : n_arg_signal(3), 

50 }) 

51 

52 def __init__(self, src_type=None, src_options=None, codecs=(), codec_options=None, volume=1.0): 

53 if not src_type: 

54 try: 

55 from xpra.sound.pulseaudio.pulseaudio_util import get_pa_device_options 

56 monitor_devices = get_pa_device_options(True, False) 

57 log.info("found pulseaudio monitor devices: %s", monitor_devices) 

58 except ImportError as e: 

59 log.warn("Warning: pulseaudio is not available!") 

60 log.warn(" %s", e) 

61 monitor_devices = [] 

62 if not monitor_devices: 

63 log.warn("could not detect any pulseaudio monitor devices") 

64 log.warn(" a test source will be used instead") 

65 src_type = "audiotestsrc" 

66 default_src_options = {"wave":2, "freq":100, "volume":0.4} 

67 else: 

68 monitor_device = monitor_devices.items()[0][0] 

69 log.info("using pulseaudio source device:") 

70 log.info(" '%s'", monitor_device) 

71 src_type = "pulsesrc" 

72 default_src_options = {"device" : monitor_device} 

73 src_options = default_src_options 

74 if src_type not in get_source_plugins(): 

75 raise InitExit(1, "invalid source plugin '%s', valid options are: %s" % (src_type, ",".join(get_source_plugins()))) 

76 matching = [x for x in CODEC_ORDER if (x in codecs and x in get_encoders())] 

77 log("SoundSource(..) found matching codecs %s", matching) 

78 if not matching: 

79 raise InitExit(1, "no matching codecs between arguments '%s' and supported list '%s'" % (csv(codecs), csv(get_encoders().keys()))) 

80 codec = matching[0] 

81 encoder, fmt, stream_compressor = get_encoder_elements(codec) 

82 super().__init__(codec) 

83 self.queue = None 

84 self.caps = None 

85 self.volume = None 

86 self.sink = None 

87 self.src = None 

88 self.src_type = src_type 

89 self.timestamp = None 

90 self.min_timestamp = 0 

91 self.max_timestamp = 0 

92 self.pending_metadata = [] 

93 self.buffer_latency = True 

94 self.jitter_queue = None 

95 self.container_format = (fmt or "").replace("mux", "").replace("pay", "") 

96 self.stream_compressor = stream_compressor 

97 if src_options is None: 

98 src_options = {} 

99 src_options["name"] = "src" 

100 source_str = plugin_str(src_type, src_options) 

101 #FIXME: this is ugly and relies on the fact that we don't pass any codec options to work! 

102 pipeline_els = [source_str] 

103 log("has plugin(timestamp)=%s", has_plugins("timestamp")) 

104 if has_plugins("timestamp"): 

105 pipeline_els.append("timestamp name=timestamp") 

106 if SOURCE_QUEUE_TIME>0: 

107 queue_el = ["queue", 

108 "name=queue", 

109 "min-threshold-time=0", 

110 "max-size-buffers=0", 

111 "max-size-bytes=0", 

112 "max-size-time=%s" % (SOURCE_QUEUE_TIME*MS_TO_NS), 

113 "leaky=%s" % GST_QUEUE_LEAK_DOWNSTREAM] 

114 pipeline_els += [" ".join(queue_el)] 

115 if encoder in ENCODER_NEEDS_AUDIOCONVERT or src_type in SOURCE_NEEDS_AUDIOCONVERT: 

116 pipeline_els += ["audioconvert"] 

117 if CUTTER_THRESHOLD>0 and encoder not in ENCODER_CANNOT_USE_CUTTER and not fmt: 

118 pipeline_els.append("cutter threshold=%.4f run-length=%i pre-length=%i leaky=false name=cutter" % ( 

119 CUTTER_THRESHOLD, CUTTER_RUN_LENGTH*MS_TO_NS, CUTTER_PRE_LENGTH*MS_TO_NS)) 

120 if encoder in CUTTER_NEEDS_CONVERT: 

121 pipeline_els.append("audioconvert") 

122 if encoder in CUTTER_NEEDS_RESAMPLE: 

123 pipeline_els.append("audioresample") 

124 pipeline_els.append("volume name=volume volume=%s" % volume) 

125 if encoder: 

126 encoder_str = plugin_str(encoder, codec_options or get_encoder_default_options(encoder)) 

127 pipeline_els.append(encoder_str) 

128 if fmt: 

129 fmt_str = plugin_str(fmt, MUXER_DEFAULT_OPTIONS.get(fmt, {})) 

130 pipeline_els.append(fmt_str) 

131 pipeline_els.append(APPSINK) 

132 if not self.setup_pipeline_and_bus(pipeline_els): 

133 return 

134 self.timestamp = self.pipeline.get_by_name("timestamp") 

135 self.volume = self.pipeline.get_by_name("volume") 

136 self.sink = self.pipeline.get_by_name("sink") 

137 if SOURCE_QUEUE_TIME>0: 

138 self.queue = self.pipeline.get_by_name("queue") 

139 if self.queue: 

140 try: 

141 self.queue.set_property("silent", True) 

142 except Exception as e: 

143 log("cannot make queue silent: %s", e) 

144 self.sink.set_property("enable-last-sample", False) 

145 self.skipped_caps = set() 

146 if JITTER>0: 

147 self.jitter_queue = Queue() 

148 #Gst 1.0: 

149 self.sink.connect("new-sample", self.on_new_sample) 

150 self.sink.connect("new-preroll", self.on_new_preroll) 

151 self.src = self.pipeline.get_by_name("src") 

152 for x in ("actual-buffer-time", "actual-latency-time"): 

153 try: 

154 gstlog("initial %s: %s", x, self.src.get_property(x)) 

155 except Exception as e: 

156 gstlog("no %s property on %s: %s", x, self.src, e) 

157 self.buffer_latency = False 

158 #if the env vars have been set, try to honour the settings: 

159 global BUFFER_TIME, LATENCY_TIME 

160 if BUFFER_TIME>0: 

161 if BUFFER_TIME<LATENCY_TIME: 

162 log.warn("Warning: latency (%ims) must be lower than the buffer time (%ims)", LATENCY_TIME, BUFFER_TIME) 

163 else: 

164 log("latency tuning for %s, will try to set buffer-time=%i, latency-time=%i", 

165 src_type, BUFFER_TIME, LATENCY_TIME) 

166 def settime(attr, v): 

167 try: 

168 cval = self.src.get_property(attr) 

169 gstlog("default: %s=%i", attr, cval//1000) 

170 if v>=0: 

171 self.src.set_property(attr, v*1000) 

172 gstlog("overriding with: %s=%i", attr, v) 

173 except Exception as e: 

174 log.warn("source %s does not support '%s': %s", self.src_type, attr, e) 

175 settime("buffer-time", BUFFER_TIME) 

176 settime("latency-time", LATENCY_TIME) 

177 self.init_file(codec) 

178 

179 

180 def __repr__(self): 

181 return "SoundSource('%s' - %s)" % (self.pipeline_str, self.state) 

182 

183 def cleanup(self): 

184 SoundPipeline.cleanup(self) 

185 self.src_type = "" 

186 self.sink = None 

187 self.caps = None 

188 

189 def get_info(self) -> dict: 

190 info = SoundPipeline.get_info(self) 

191 if self.queue: 

192 info["queue"] = {"cur" : self.queue.get_property("current-level-time")//MS_TO_NS} 

193 if CUTTER_THRESHOLD>0 and (self.min_timestamp or self.max_timestamp): 

194 info["cutter.min-timestamp"] = self.min_timestamp 

195 info["cutter.max-timestamp"] = self.max_timestamp 

196 if self.buffer_latency: 

197 for x in ("actual-buffer-time", "actual-latency-time"): 

198 v = self.src.get_property(x) 

199 if v>=0: 

200 info[x] = v 

201 src_info = info.setdefault("src", {}) 

202 for x in ( 

203 "actual-buffer-time", "actual-latency-time", 

204 "buffer-time", "latency-time", 

205 "provide-clock", 

206 ): 

207 try: 

208 v = self.src.get_property(x) 

209 if v>=0: 

210 src_info[x] = v 

211 except Exception as e: 

212 log.warn("Warning: %s", e) 

213 return info 

214 

215 

216 def do_parse_element_message(self, _message, name, props=None): 

217 if name=="cutter" and props: 

218 above = props.get("above") 

219 ts = props.get("timestamp", 0) 

220 if above is False: 

221 self.max_timestamp = ts 

222 self.min_timestamp = 0 

223 elif above is True: 

224 self.max_timestamp = 0 

225 self.min_timestamp = ts 

226 if LOG_CUTTER: 

227 l = gstlog.info 

228 else: 

229 l = gstlog 

230 l("cutter message, above=%s, min-timestamp=%s, max-timestamp=%s", 

231 above, self.min_timestamp, self.max_timestamp) 

232 

233 

234 def on_new_preroll(self, _appsink): 

235 gstlog('new preroll') 

236 return GST_FLOW_OK 

237 

238 def on_new_sample(self, _bus): 

239 sample = self.sink.emit("pull-sample") 

240 buf = sample.get_buffer() 

241 pts = normv(buf.pts) 

242 if self.min_timestamp>0 and pts<self.min_timestamp: 

243 gstlog("cutter: skipping buffer with pts=%s (min-timestamp=%s)", pts, self.min_timestamp) 

244 return GST_FLOW_OK 

245 if self.max_timestamp>0 and pts>self.max_timestamp: 

246 gstlog("cutter: skipping buffer with pts=%s (max-timestamp=%s)", pts, self.max_timestamp) 

247 return GST_FLOW_OK 

248 size = buf.get_size() 

249 data = buf.extract_dup(0, size) 

250 duration = normv(buf.duration) 

251 metadata = { 

252 "timestamp" : pts, 

253 "duration" : duration, 

254 } 

255 if self.timestamp: 

256 delta = self.timestamp.get_property("delta") 

257 ts = (pts+delta)//1000000 #ns to ms 

258 now = monotonic_time() 

259 latency = int(1000*now)-ts 

260 #log.info("emit_buffer: delta=%i, pts=%i, ts=%s, time=%s, latency=%ims", 

261 # delta, pts, ts, now, (latency//1000000)) 

262 ts_info = { 

263 "ts" : ts, 

264 "latency" : latency, 

265 } 

266 metadata.update(ts_info) 

267 self.info.update(ts_info) 

268 if pts==-1 and duration==-1 and BUNDLE_METADATA and len(self.pending_metadata)<10: 

269 self.pending_metadata.append(data) 

270 return GST_FLOW_OK 

271 return self._emit_buffer(data, metadata) 

272 

273 def _emit_buffer(self, data, metadata): 

274 if self.stream_compressor and data: 

275 cdata = compressed_wrapper("sound", data, level=9, 

276 zlib=False, 

277 lz4=self.stream_compressor=="lz4", 

278 lzo=self.stream_compressor=="lzo", 

279 can_inline=True) 

280 if len(cdata)<len(data)*90//100: 

281 log("compressed using %s from %i bytes down to %i bytes", self.stream_compressor, len(data), len(cdata)) 

282 metadata["compress"] = self.stream_compressor 

283 data = cdata 

284 else: 

285 log("skipped inefficient %s stream compression: %i bytes down to %i bytes", 

286 self.stream_compressor, len(data), len(cdata)) 

287 if self.state=="stopped": 

288 #don't bother 

289 return GST_FLOW_OK 

290 if JITTER>0: 

291 #will actually emit the buffer after a random delay 

292 if self.jitter_queue.empty(): 

293 #queue was empty, schedule a timer to flush it 

294 from random import randint 

295 jitter = randint(1, JITTER) 

296 self.timeout_add(jitter, self.flush_jitter_queue) 

297 log("emit_buffer: will flush jitter queue in %ims", jitter) 

298 for x in self.pending_metadata: 

299 self.jitter_queue.put((x, {})) 

300 self.pending_metadata = [] 

301 self.jitter_queue.put((data, metadata)) 

302 return GST_FLOW_OK 

303 log("emit_buffer data=%s, len=%i, metadata=%s", type(data), len(data), metadata) 

304 return self.do_emit_buffer(data, metadata) 

305 

306 

307 def caps_to_dict(self, caps): 

308 if not caps: 

309 return {} 

310 d = {} 

311 try: 

312 for cap in caps: 

313 name = cap.get_name() 

314 capd = {} 

315 for k in cap.keys(): 

316 v = cap[k] 

317 if isinstance(v, (str, int)): 

318 capd[k] = cap[k] 

319 elif k not in self.skipped_caps: 

320 log("skipping %s cap key %s=%s of type %s", name, k, v, type(v)) 

321 d[name] = capd 

322 except Exception as e: 

323 log.error("Error parsing '%s':", caps) 

324 log.error(" %s", e) 

325 return d 

326 

327 

328 def flush_jitter_queue(self): 

329 while not self.jitter_queue.empty(): 

330 d,m = self.jitter_queue.get(False) 

331 self.do_emit_buffer(d, m) 

332 

333 def do_emit_buffer(self, data, metadata): 

334 self.inc_buffer_count() 

335 self.inc_byte_count(len(data)) 

336 for x in self.pending_metadata: 

337 self.inc_buffer_count() 

338 self.inc_byte_count(len(x)) 

339 metadata["time"] = int(monotonic_time()*1000) 

340 self.save_to_file(*(self.pending_metadata+[data])) 

341 self.idle_emit("new-buffer", data, metadata, self.pending_metadata) 

342 self.pending_metadata = [] 

343 self.emit_info() 

344 return GST_FLOW_OK 

345 

346GObject.type_register(SoundSource) 

347 

348 

349def main(): 

350 from xpra.platform import program_context 

351 with program_context("Xpra-Sound-Source"): 

352 if "-v" in sys.argv: 

353 log.enable_debug() 

354 sys.argv.remove("-v") 

355 

356 if len(sys.argv) not in (2, 3): 

357 log.error("usage: %s filename [codec] [--encoder=rencode]", sys.argv[0]) 

358 return 1 

359 filename = sys.argv[1] 

360 if filename=="-": 

361 from xpra.os_util import disable_stdout_buffering 

362 disable_stdout_buffering() 

363 elif os.path.exists(filename): 

364 log.error("file %s already exists", filename) 

365 return 1 

366 codec = None 

367 

368 encoders = get_encoders() 

369 if len(sys.argv)==3: 

370 codec = sys.argv[2] 

371 if codec not in encoders: 

372 log.error("invalid codec: %s, codecs supported: %s", codec, encoders) 

373 return 1 

374 else: 

375 parts = filename.split(".") 

376 if len(parts)>1: 

377 extension = parts[-1] 

378 if extension.lower() in encoders: 

379 codec = extension.lower() 

380 log.info("guessed codec %s from file extension %s", codec, extension) 

381 if codec is None: 

382 codec = MP3 

383 log.info("using default codec: %s", codec) 

384 

385 #in case we're running against pulseaudio, 

386 #try to setup the env: 

387 try: 

388 from xpra.platform.paths import get_icon_filename 

389 f = get_icon_filename("xpra.png") 

390 from xpra.sound.pulseaudio.pulseaudio_util import add_audio_tagging_env 

391 add_audio_tagging_env(icon_path=f) 

392 except Exception as e: 

393 log.warn("failed to setup pulseaudio tagging: %s", e) 

394 

395 from threading import Lock 

396 if filename=="-": 

397 f = sys.stdout 

398 else: 

399 f = open(filename, "wb") 

400 ss = SoundSource(codecs=[codec]) 

401 lock = Lock() 

402 def new_buffer(_soundsource, data, metadata, packet_metadata): 

403 log.info("new buffer: %s bytes (%s), metadata=%s", len(data), type(data), metadata) 

404 with lock: 

405 if f: 

406 for x in packet_metadata: 

407 f.write(x) 

408 f.write(data) 

409 f.flush() 

410 

411 from gi.repository import GLib 

412 glib_mainloop = GLib.MainLoop() 

413 

414 ss.connect("new-buffer", new_buffer) 

415 ss.start() 

416 

417 import signal 

418 def deadly_signal(sig, _frame): 

419 log.warn("got deadly signal %s", SIGNAMES.get(sig, sig)) 

420 GLib.idle_add(ss.stop) 

421 GLib.idle_add(glib_mainloop.quit) 

422 def force_quit(_sig, _frame): 

423 sys.exit() 

424 signal.signal(signal.SIGINT, force_quit) 

425 signal.signal(signal.SIGTERM, force_quit) 

426 signal.signal(signal.SIGINT, deadly_signal) 

427 signal.signal(signal.SIGTERM, deadly_signal) 

428 

429 try: 

430 glib_mainloop.run() 

431 except Exception as e: 

432 log.error("main loop error: %s", e) 

433 ss.stop() 

434 

435 f.flush() 

436 if f!=sys.stdout: 

437 log.info("wrote %s bytes to %s", f.tell(), filename) 

438 with lock: 

439 f.close() 

440 f = None 

441 return 0 

442 

443 

444if __name__ == "__main__": 

445 sys.exit(main())