Coverage for /home/antoine/projects/xpra-git/dist/python3/lib64/python/xpra/sound/src.py : 38%
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.
7import sys
8import os.path
9from queue import Queue
10from gi.repository import GObject
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
29log = Logger("sound")
30gstlog = Logger("gstreamer")
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_")
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)
45class SoundSource(SoundPipeline):
47 __gsignals__ = SoundPipeline.__generic_signals__.copy()
48 __gsignals__.update({
49 "new-buffer" : n_arg_signal(3),
50 })
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)
180 def __repr__(self):
181 return "SoundSource('%s' - %s)" % (self.pipeline_str, self.state)
183 def cleanup(self):
184 SoundPipeline.cleanup(self)
185 self.src_type = ""
186 self.sink = None
187 self.caps = None
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
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)
234 def on_new_preroll(self, _appsink):
235 gstlog('new preroll')
236 return GST_FLOW_OK
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)
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)
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
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)
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
346GObject.type_register(SoundSource)
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")
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
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)
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)
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()
411 from gi.repository import GLib
412 glib_mainloop = GLib.MainLoop()
414 ss.connect("new-buffer", new_buffer)
415 ss.start()
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)
429 try:
430 glib_mainloop.run()
431 except Exception as e:
432 log.error("main loop error: %s", e)
433 ss.stop()
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
444if __name__ == "__main__":
445 sys.exit(main())