From 56f22697c793afcce64fbd9c24cb4c8ebe229d43 Mon Sep 17 00:00:00 2001 From: szlop Date: Tue, 21 Mar 2023 13:27:36 +0100 Subject: [PATCH 1/6] Added the optional maxlatency argument to the member functions play, player, record and recorder. The arguments takes an integeger value and restricts the latency of the PulseAudio buffering to the given number of sample frames. If maxlatency is set, buffer underflows or overflows will occur, when the processing cannot keep up. --- soundcard/pulseaudio.py | 51 ++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 3458d2f..9e61489 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -501,7 +501,7 @@ class _Speaker(_SoundCard): def __repr__(self): return ''.format(self.name, self.channels) - def player(self, samplerate, channels=None, blocksize=None): + def player(self, samplerate, channels=None, blocksize=None, maxlatency=None): """Create Player for playing audio. Parameters @@ -517,10 +517,9 @@ def player(self, samplerate, channels=None, blocksize=None): blocksize : int Will play this many samples at a time. Choose a lower block size for lower latency and more CPU usage. - exclusive_mode : bool, optional - Windows only: open sound card in exclusive mode, which - might be necessary for short block lengths or high - sample rates or optimal performance. Default is ``False``. + maxlatency : int + Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur when + processing cannot keep up. Returns ------- @@ -528,9 +527,9 @@ def player(self, samplerate, channels=None, blocksize=None): """ if channels is None: channels = self.channels - return _Player(self._id, samplerate, channels, blocksize) + return _Player(self._id, samplerate, channels, blocksize, maxlatency) - def play(self, data, samplerate, channels=None, blocksize=None): + def play(self, data, samplerate, channels=None, blocksize=None, maxlatency=None): """Play some audio data. Parameters @@ -548,10 +547,13 @@ def play(self, data, samplerate, channels=None, blocksize=None): blocksize : int Will play this many samples at a time. Choose a lower block size for lower latency and more CPU usage. + maxlatency : int + Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur, + when the processing cannot keep up. """ if channels is None: channels = self.channels - with _Player(self._id, samplerate, channels, blocksize) as s: + with _Player(self._id, samplerate, channels, blocksize, maxlatency) as s: s.play(data) def _get_info(self): @@ -582,7 +584,7 @@ def isloopback(self): """bool : Whether this microphone is recording a speaker.""" return self._get_info()['device.class'] == 'monitor' - def recorder(self, samplerate, channels=None, blocksize=None): + def recorder(self, samplerate, channels=None, blocksize=None, maxlatency=None): """Create Recorder for recording audio. Parameters @@ -598,6 +600,9 @@ def recorder(self, samplerate, channels=None, blocksize=None): blocksize : int Will record this many samples at a time. Choose a lower block size for lower latency and more CPU usage. + maxlatency : int + Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur, + when the processing cannot keep up. exclusive_mode : bool, optional Windows only: open sound card in exclusive mode, which might be necessary for short block lengths or high @@ -609,9 +614,9 @@ def recorder(self, samplerate, channels=None, blocksize=None): """ if channels is None: channels = self.channels - return _Recorder(self._id, samplerate, channels, blocksize) + return _Recorder(self._id, samplerate, channels, blocksize, maxlatency) - def record(self, numframes, samplerate, channels=None, blocksize=None): + def record(self, numframes, samplerate, channels=None, blocksize=None, maxlatency=None): """Record some audio data. Parameters @@ -629,6 +634,10 @@ def record(self, numframes, samplerate, channels=None, blocksize=None): blocksize : int Will record this many samples at a time. Choose a lower block size for lower latency and more CPU usage. + maxlatency : int + Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur, + when the processing cannot keep up. + Returns ------- @@ -637,7 +646,7 @@ def record(self, numframes, samplerate, channels=None, blocksize=None): """ if channels is None: channels = self.channels - with _Recorder(self._id, samplerate, channels, blocksize) as r: + with _Recorder(self._id, samplerate, channels, blocksize, maxlatency) as r: return r.record(numframes) @@ -653,12 +662,14 @@ class _Stream: """ - def __init__(self, id, samplerate, channels, blocksize=None, name='outputstream'): + def __init__(self, id, samplerate, channels, blocksize=None, maxlatency=None, + name='outputstream'): self._id = id self._samplerate = samplerate self._name = name self._blocksize = blocksize self.channels = channels + self._maxlatency = maxlatency def __enter__(self): samplespec = _ffi.new("pa_sample_spec*") @@ -693,12 +704,14 @@ def __enter__(self): errno = _pulse._pa_context_errno(_pulse.context) raise RuntimeError("stream creation failed with error ", errno) bufattr = _ffi.new("pa_buffer_attr*") - bufattr.maxlength = 2**32-1 # max buffer length - numchannels = self.channels if isinstance(self.channels, int) else len(self.channels) - bufattr.fragsize = self._blocksize*numchannels*4 if self._blocksize else 2**32-1 # recording block sys.getsizeof() - bufattr.minreq = 2**32-1 # start requesting more data at this bytes - bufattr.prebuf = 2**32-1 # start playback after this bytes are available - bufattr.tlength = self._blocksize*numchannels*4 if self._blocksize else 2**32-1 # buffer length in bytes on server + numchannels = samplespec.channels + bytes_per_sample = 4 # for _pa.PA_SAMPLE_FLOAT32LE + bufattr.maxlength = self._maxlatency * numchannels * bytes_per_sample if self._maxlatency else 2 ** 32 - 1 + bufattr.fragsize = self._blocksize * numchannels * bytes_per_sample if self._blocksize else 2 ** 32 - 1 + bufattr.minreq = 2 ** 32 - 1 # start requesting more data at this bytes + bufattr.prebuf = 2 ** 32 - 1 # start playback after prebuf bytes are available + bufattr.tlength = self._blocksize * numchannels * bytes_per_sample if self._blocksize else 2 ** 32 - 1 # buffer length in bytes on server + self._connect_stream(bufattr) while _pulse._pa_stream_get_state(self.stream) not in [_pa.PA_STREAM_READY, _pa.PA_STREAM_FAILED]: time.sleep(0.01) From cefc8fc598ca783911f52ae4a2f53944c25df17d Mon Sep 17 00:00:00 2001 From: szlop Date: Tue, 21 Mar 2023 14:32:32 +0100 Subject: [PATCH 2/6] Added optional report_under_overflow argument to the player and play member functions of the PulseAudio back end. If set to True, debug information will be printed to the terminal, whenever a buffer underflow or overflow occurs during playback. --- soundcard/pulseaudio.py | 39 ++++++++++++++++++++++++++++++++------- soundcard/pulseaudio.py.h | 4 ++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 9e61489..c67346b 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -18,7 +18,7 @@ except OSError: # Try explicit file name, if the general does not work (e.g. on nixos) _pa = _ffi.dlopen('libpulse.so') - + # First, we need to define a global _PulseAudio proxy for interacting # with the C API: @@ -286,6 +286,10 @@ def __exit__(self_, exc_type, exc_value, traceback): _pa_stream_writable_size = _lock(_pa.pa_stream_writable_size) _pa_stream_write = _lock(_pa.pa_stream_write) _pa_stream_set_read_callback = _pa.pa_stream_set_read_callback + _pa_stream_set_overflow_callback = _pa.pa_stream_set_overflow_callback + _pa_stream_set_underflow_callback = _pa.pa_stream_set_underflow_callback + _pa_stream_get_underflow_index = _pa.pa_stream_get_underflow_index + _pulse = _PulseAudio() atexit.register(_pulse._shutdown) @@ -501,7 +505,7 @@ class _Speaker(_SoundCard): def __repr__(self): return ''.format(self.name, self.channels) - def player(self, samplerate, channels=None, blocksize=None, maxlatency=None): + def player(self, samplerate, channels=None, blocksize=None, maxlatency=None, report_under_overflow=False): """Create Player for playing audio. Parameters @@ -520,6 +524,8 @@ def player(self, samplerate, channels=None, blocksize=None, maxlatency=None): maxlatency : int Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur when processing cannot keep up. + report_under_overflow : bool, optional + Linux only: print debug information to terminal, whenever buffer underflows or overflows occur. Returns ------- @@ -527,9 +533,9 @@ def player(self, samplerate, channels=None, blocksize=None, maxlatency=None): """ if channels is None: channels = self.channels - return _Player(self._id, samplerate, channels, blocksize, maxlatency) + return _Player(self._id, samplerate, channels, blocksize, maxlatency, report_under_overflow) - def play(self, data, samplerate, channels=None, blocksize=None, maxlatency=None): + def play(self, data, samplerate, channels=None, blocksize=None, maxlatency=None, report_under_overflow=False): """Play some audio data. Parameters @@ -550,10 +556,12 @@ def play(self, data, samplerate, channels=None, blocksize=None, maxlatency=None) maxlatency : int Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur, when the processing cannot keep up. + report_under_overflow : bool, optional + Linux only: print debug information to terminal, whenever buffer underflows or overflows occur. """ if channels is None: channels = self.channels - with _Player(self._id, samplerate, channels, blocksize, maxlatency) as s: + with _Player(self._id, samplerate, channels, blocksize, maxlatency, report_under_overflow) as s: s.play(data) def _get_info(self): @@ -662,7 +670,7 @@ class _Stream: """ - def __init__(self, id, samplerate, channels, blocksize=None, maxlatency=None, + def __init__(self, id, samplerate, channels, blocksize=None, maxlatency=None, report_under_overflow=False, name='outputstream'): self._id = id self._samplerate = samplerate @@ -670,6 +678,7 @@ def __init__(self, id, samplerate, channels, blocksize=None, maxlatency=None, self._blocksize = blocksize self.channels = channels self._maxlatency = maxlatency + self._report_under_overflow = report_under_overflow def __enter__(self): samplespec = _ffi.new("pa_sample_spec*") @@ -753,8 +762,24 @@ class _Player(_Stream): """ def _connect_stream(self, bufattr): + if self._report_under_overflow: + @_ffi.callback("pa_stream_notify_cb_t") + def overflow_callback(stream, userdata): + print('Overflow detected.') + + self._overflow_callback = overflow_callback + _pulse._pa_stream_set_overflow_callback(self.stream, overflow_callback, _ffi.NULL) + + @_ffi.callback("pa_stream_notify_cb_t") + def underflow_callback(stream, userdata): + time_underflow = _pulse._pa_stream_get_underflow_index(stream) + print('Underflow detected at position ' + str(time_underflow)) + + self._underflow_callback = underflow_callback + _pulse._pa_stream_set_underflow_callback(self.stream, underflow_callback, _ffi.NULL) + _pulse._pa_stream_connect_playback(self.stream, self._id.encode(), bufattr, _pa.PA_STREAM_ADJUST_LATENCY, - _ffi.NULL, _ffi.NULL) + _ffi.NULL, _ffi.NULL) def play(self, data): """Play some audio data. diff --git a/soundcard/pulseaudio.py.h b/soundcard/pulseaudio.py.h index 2d82ee9..3a0f1f1 100644 --- a/soundcard/pulseaudio.py.h +++ b/soundcard/pulseaudio.py.h @@ -415,5 +415,9 @@ pa_stream_state_t pa_stream_get_state(pa_stream *p); typedef void(*pa_stream_request_cb_t)(pa_stream *p, size_t nbytes, void *userdata); void pa_stream_set_read_callback(pa_stream *p, pa_stream_request_cb_t cb, void *userdata); +typedef void (*pa_stream_notify_cb_t)(pa_stream *p, void *userdata); +void pa_stream_set_overflow_callback(pa_stream *p, pa_stream_notify_cb_t cb, void *userdata); +void pa_stream_set_underflow_callback(pa_stream *p, pa_stream_notify_cb_t cb, void *userdata); +int64_t pa_stream_get_underflow_index(const pa_stream *p); pa_operation* pa_stream_update_timing_info(pa_stream *s, pa_stream_success_cb_t cb, void *userdata); From f48e2da7efd61b07ed08f884b8220058a73ce5b5 Mon Sep 17 00:00:00 2001 From: szlop <65676717+szlop@users.noreply.github.com> Date: Tue, 21 Mar 2023 15:02:35 +0100 Subject: [PATCH 3/6] Update README.rst Added short explanation of maxlatency and report_under_overflow parameters. --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index 2372ed6..1a8d421 100644 --- a/README.rst +++ b/README.rst @@ -112,6 +112,13 @@ In order to request lower latencies, pass a ``blocksize`` to ``player`` or try to honor your request as best it can. On Windows/WASAPI, setting ``exclusive_mode=True`` might help, too (this is currently experimental). +In Linux, it is possible to restrict the latency by setting the optional +parameter ``maxlatency``, which takes an integer number of samples. The setting +of this parameter limits the buffer size of the PulseAudio backend. If your +algorithm cannot keep up with the playback/recording, buffer underflows or overflows +will occur. Underflow and overflow events can be displayed by setting the optional +argument ``report_under_overflow`` to ``True``. + Another source of latency is in the ``record`` function, which buffers output up to the requested ``numframes``. In general, for optimal latency, you should use a ``numframes`` significantly lower than the ``blocksize`` above, maybe by a From 6bc41dd8d3a55c9a86ad45efc84181e9e69d7be2 Mon Sep 17 00:00:00 2001 From: szlop Date: Sat, 15 Apr 2023 11:57:54 +0200 Subject: [PATCH 4/6] Reformated docstrings in pulseaudio.py to a length of 72 characters as proposed by PEP8. --- soundcard/pulseaudio.py | 141 ++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 70 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index c67346b..159ca3e 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -36,7 +36,6 @@ def _lock_and_block(func): block until the operation has finished. Use this for pulseaudio functions that return a `pa_operation *`. - """ def func_with_lock(*args, **kwargs): self = args[0] @@ -48,8 +47,8 @@ def func_with_lock(*args, **kwargs): def channel_name_map(): - """ - Return a dict containing the channel position index for every channel position name string. + """Return a dict containing the channel position index for every + channel position name string. """ channel_indices = { @@ -84,7 +83,6 @@ class _PulseAudio: Any function that would return a `pa_operation *` in pulseaudio will block until the operation has finished. - """ def __init__(self): @@ -105,8 +103,8 @@ def _infer_program_name(): """Get current progam name. Will handle `./script.py`, `python path/to/script.py`, - `python -m module.submodule` and `python -c 'code(x=y)'`. - See https://docs.python.org/3/using/cmdline.html#interface-options + `python -m module.submodule` and `python -c 'code(x=y)'`. See + https://docs.python.org/3/using/cmdline.html#interface-options """ import sys prog_name = sys.argv[0] @@ -140,7 +138,7 @@ def _block_operation(self, operation): @property def name(self): - """Return application name stored in client proplist""" + """Return application name stored in client proplist.""" idx = self._pa_context_get_index(self.context) if idx < 0: # PA_INVALID_INDEX == -1 raise RuntimeError("Could not get client index of PulseAudio context.") @@ -241,9 +239,8 @@ def callback(context, server_info, userdata): def _lock_mainloop(self): """Context manager for locking the mainloop. - Hold this lock before calling any pulseaudio function while - the mainloop is running. - + Hold this lock before calling any pulseaudio function while the + mainloop is running. """ class Lock(): @@ -323,8 +320,9 @@ def get_speaker(id): Parameters ---------- id : int or str - can be a backend id string (Windows, Linux) or a device id int (MacOS), a substring of the - speaker name, or a fuzzy-matched pattern for the speaker name. + can be a backend id string (Windows, Linux) or a device id int + (MacOS), a substring of the speaker name, or a fuzzy-matched + pattern for the speaker name. Returns ------- @@ -385,8 +383,9 @@ def get_microphone(id, include_loopback=False, exclude_monitors=True): Parameters ---------- id : int or str - can be a backend id string (Windows, Linux) or a device id int (MacOS), a substring of the - speaker name, or a fuzzy-matched pattern for the speaker name. + can be a backend id string (Windows, Linux) or a device id int + (MacOS), a substring of the speaker name, or a fuzzy-matched + pattern for the speaker name. include_loopback : bool allow recording of speaker outputs exclude_monitors : bool @@ -408,8 +407,8 @@ def get_microphone(id, include_loopback=False, exclude_monitors=True): def _match_soundcard(id, soundcards, include_loopback=False): """Find id in a list of soundcards. - id can be a pulseaudio id, a substring of the microphone name, or - a fuzzy-matched pattern for the microphone name. + id can be a pulseaudio id, a substring of the microphone name, or a + fuzzy-matched pattern for the microphone name. """ if not include_loopback: soundcards_by_id = {soundcard['id']: soundcard for soundcard in soundcards @@ -455,8 +454,8 @@ def set_name(name): Parameters ---------- name : str - The application using the soundcard - will be identified by the OS using this name. + The application using the soundcard will be identified by the + OS using this name. """ _pulse.name = name @@ -467,11 +466,10 @@ def __init__(self, *, id): @property def channels(self): - """int or list(int): Either the number of channels, or a list of - channel indices. Index -1 is the mono mixture of all channels, - and subsequent numbers are channel numbers (left, right, - center, ...) - + """int or list(int): Either the number of channels, or a list + of channel indices. Index -1 is the mono mixture of all + channels, and subsequent numbers are channel numbers (left, + right, center, ...) """ return self._get_info()['channels'] @@ -493,13 +491,12 @@ class _Speaker(_SoundCard): """A soundcard output. Can be used to play audio. Use the :func:`play` method to play one piece of audio, or use the - :func:`player` method to get a context manager for playing continuous - audio. + :func:`player` method to get a context manager for playing + continuous audio. Multiple calls to :func:`play` play immediately and concurrently, while the :func:`player` schedules multiple pieces of audio one after another. - """ def __repr__(self): @@ -514,18 +511,20 @@ def player(self, samplerate, channels=None, blocksize=None, maxlatency=None, rep The desired sampling rate in Hz channels : {int, list(int)}, optional Play on these channels. For example, ``[0, 3]`` will play - stereo data on the physical channels one and four. - Defaults to use all available channels. + stereo data on the physical channels one and four. Defaults + to use all available channels. On Linux, channel ``-1`` is the mono mix of all channels. On macOS, channel ``-1`` is silence. blocksize : int - Will play this many samples at a time. Choose a lower - block size for lower latency and more CPU usage. + Will play this many samples at a time. Choose a lower block + size for lower latency and more CPU usage. maxlatency : int - Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur when + Linux only: restrict latency to maxlatency sample frames. + If set, buffer underflows or overflows will occur when processing cannot keep up. report_under_overflow : bool, optional - Linux only: print debug information to terminal, whenever buffer underflows or overflows occur. + Linux only: print debug information to terminal, whenever + buffer underflows or overflows occur. Returns ------- @@ -541,23 +540,26 @@ def play(self, data, samplerate, channels=None, blocksize=None, maxlatency=None, Parameters ---------- data : numpy array - The audio data to play. Must be a *frames x channels* Numpy array. + The audio data to play. Must be a *frames x channels* Numpy + array. samplerate : int The desired sampling rate in Hz channels : {int, list(int)}, optional Play on these channels. For example, ``[0, 3]`` will play - stereo data on the physical channels one and four. - Defaults to use all available channels. + stereo data on the physical channels one and four. Defaults + to use all available channels. On Linux, channel ``-1`` is the mono mix of all channels. On macOS, channel ``-1`` is silence. blocksize : int - Will play this many samples at a time. Choose a lower - block size for lower latency and more CPU usage. + Will play this many samples at a time. Choose a lower block + size for lower latency and more CPU usage. maxlatency : int - Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur, - when the processing cannot keep up. + Linux only: restrict latency to maxlatency sample frames. + If set, buffer underflows or overflows will occur, when the + processing cannot keep up. report_under_overflow : bool, optional - Linux only: print debug information to terminal, whenever buffer underflows or overflows occur. + Linux only: print debug information to terminal, whenever + buffer underflows or overflows occur. """ if channels is None: channels = self.channels @@ -578,7 +580,6 @@ class _Microphone(_SoundCard): Multiple calls to :func:`record` record immediately and concurrently, while the :func:`recorder` schedules multiple pieces of audio to be recorded one after another. - """ def __repr__(self): @@ -600,8 +601,8 @@ def recorder(self, samplerate, channels=None, blocksize=None, maxlatency=None): samplerate : int The desired sampling rate in Hz channels : {int, list(int)}, optional - Record on these channels. For example, ``[0, 3]`` will record - stereo data from the physical channels one and four. + Record on these channels. For example, ``[0, 3]`` will + record stereo data from the physical channels one and four. Defaults to use all available channels. On Linux, channel ``-1`` is the mono mix of all channels. On macOS, channel ``-1`` is silence. @@ -609,8 +610,9 @@ def recorder(self, samplerate, channels=None, blocksize=None, maxlatency=None): Will record this many samples at a time. Choose a lower block size for lower latency and more CPU usage. maxlatency : int - Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur, - when the processing cannot keep up. + Linux only: restrict latency to maxlatency sample frames. + If set, buffer underflows or overflows will occur, when the + processing cannot keep up. exclusive_mode : bool, optional Windows only: open sound card in exclusive mode, which might be necessary for short block lengths or high @@ -634,8 +636,8 @@ def record(self, numframes, samplerate, channels=None, blocksize=None, maxlatenc samplerate : int The desired sampling rate in Hz channels : {int, list(int)}, optional - Record on these channels. For example, ``[0, 3]`` will record - stereo data from the physical channels one and four. + Record on these channels. For example, ``[0, 3]`` will + record stereo data from the physical channels one and four. Defaults to use all available channels. On Linux, channel ``-1`` is the mono mix of all channels. On macOS, channel ``-1`` is silence. @@ -643,14 +645,15 @@ def record(self, numframes, samplerate, channels=None, blocksize=None, maxlatenc Will record this many samples at a time. Choose a lower block size for lower latency and more CPU usage. maxlatency : int - Linux only: restrict latency to maxlatency sample frames. If set, buffer underflows or overflows will occur, - when the processing cannot keep up. - + Linux only: restrict latency to maxlatency sample frames. + If set, buffer underflows or overflows will occur, when + the processing cannot keep up. Returns ------- data : numpy array - The recorded audio data. Will be a *frames x channels* Numpy array. + The recorded audio data. Will be a *frames x channels* + Numpy array. """ if channels is None: channels = self.channels @@ -758,7 +761,6 @@ class _Player(_Stream): This context manager can only be entered once, and can not be used after it is closed. - """ def _connect_stream(self, bufattr): @@ -793,17 +795,17 @@ def play(self, data): This function will return *before* all data has been played, so that additional data can be provided for gapless playback. - The amount of buffering can be controlled through the - blocksize of the player object. + The amount of buffering can be controlled through the blocksize + of the player object. - If data is provided faster than it is played, later pieces - will be queued up and played one after another. + If data is provided faster than it is played, later pieces will + be queued up and played one after another. Parameters ---------- data : numpy array - The audio data to play. Must be a *frames x channels* Numpy array. - + The audio data to play. Must be a *frames x channels* Numpy + array. """ data = numpy.array(data, dtype='float32', order='C') @@ -830,12 +832,11 @@ class _Recorder(_Stream): Audio recording is available as soon as the context manager is entered. Recorded audio data can be read using the :func:`record` - method. If no audio data is available, :func:`record` will block until - the requested amount of audio data has been recorded. + method. If no audio data is available, :func:`record` will block + until the requested amount of audio data has been recorded. This context manager can only be entered once, and can not be used after it is closed. - """ def __init__(self, *args, **kwargs): @@ -852,12 +853,12 @@ def read_callback(stream, nbytes, userdata): _pulse._pa_stream_set_read_callback(self.stream, read_callback, _ffi.NULL) def _record_chunk(self): - '''Record one chunk of audio data, as returned by pulseaudio + """Record one chunk of audio data, as returned by pulseaudio - The data will be returned as a 1D numpy array, which will be used by - the `record` method. This function is the interface of the `_Recorder` - object with pulseaudio - ''' + The data will be returned as a 1D numpy array, which will be used + by the `record` method. This function is the interface of the + `_Recorder` object with pulseaudio + """ data_ptr = _ffi.new('void**') nbytes_ptr = _ffi.new('size_t*') readable_bytes = _pulse._pa_stream_readable_size(self.stream) @@ -907,8 +908,8 @@ def record(self, numframes=None): Returns ------- data : numpy array - The recorded audio data. Will be a *frames x channels* Numpy array. - + The recorded audio data. Will be a *frames x channels* Numpy + array. """ if numframes is None: return numpy.reshape(numpy.concatenate([self.flush().ravel(), self._record_chunk()]), @@ -938,8 +939,8 @@ def flush(self): Returns ------- data : numpy array - The recorded audio data. Will be a *frames x channels* Numpy array. - + The recorded audio data. Will be a *frames x channels* + Numpy array. """ last_chunk = numpy.reshape(self._pending_chunk, [-1, self.channels]) self._pending_chunk = numpy.zeros((0, ), dtype='float32') From 02022e6420d059e4b9d1f159113e68f2a1a22e0d Mon Sep 17 00:00:00 2001 From: szlop Date: Sat, 15 Apr 2023 12:03:27 +0200 Subject: [PATCH 5/6] - Fixed some typos in docstrings. - Replaced all occurrences of pulseaudio in comments by upper case Pulseaudio. - Replaced all occurrences of numpy in comments by upper case Numpy. --- soundcard/pulseaudio.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index 159ca3e..a9794d9 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -23,7 +23,8 @@ # with the C API: def _lock(func): - """Call a pulseaudio function while holding the mainloop lock.""" + """Call a Pulseaudio function while holding the mainloop lock.""" + def func_with_lock(*args, **kwargs): self = args[0] with self._lock_mainloop(): @@ -32,10 +33,10 @@ def func_with_lock(*args, **kwargs): def _lock_and_block(func): - """Call a pulseaudio function while holding the mainloop lock, and - block until the operation has finished. + """Call a Pulseaudio function while holding the mainloop lock, and + block until the operation has finished. - Use this for pulseaudio functions that return a `pa_operation *`. + Use this for Pulseaudio functions that return a `pa_operation *`. """ def func_with_lock(*args, **kwargs): self = args[0] @@ -71,17 +72,17 @@ def channel_name_map(): class _PulseAudio: - """Proxy for communcation with Pulseaudio. + """Proxy for communication with Pulseaudio. - This holds the pulseaudio main loop, and a pulseaudio context. + This holds the Pulseaudio main loop, and a Pulseaudio context. Together, these provide the building blocks for interacting with pulseaudio. - This can be used to query the pulseaudio server for sources, + This can be used to query the Pulseaudio server for sources, sinks, and server information, and provides thread-safe access to - the main pulseaudio functions. + the main Pulseaudio functions. - Any function that would return a `pa_operation *` in pulseaudio + Any function that would return a `pa_operation *` in Pulseaudio will block until the operation has finished. """ @@ -100,7 +101,7 @@ def __init__(self): @staticmethod def _infer_program_name(): - """Get current progam name. + """Get current program name. Will handle `./script.py`, `python path/to/script.py`, `python -m module.submodule` and `python -c 'code(x=y)'`. See @@ -239,7 +240,7 @@ def callback(context, server_info, userdata): def _lock_mainloop(self): """Context manager for locking the mainloop. - Hold this lock before calling any pulseaudio function while the + Hold this lock before calling any Pulseaudio function while the mainloop is running. """ @@ -250,7 +251,7 @@ def __exit__(self_, exc_type, exc_value, traceback): _pa.pa_threaded_mainloop_unlock(self.mainloop) return Lock() - # create thread-safe versions of all used pulseaudio functions: + # create thread-safe versions of all used Pulseaudio functions: _pa_context_get_source_info_list = _lock_and_block(_pa.pa_context_get_source_info_list) _pa_context_get_source_info_by_name = _lock_and_block(_pa.pa_context_get_source_info_by_name) _pa_context_get_sink_info_list = _lock_and_block(_pa.pa_context_get_sink_info_list) @@ -405,9 +406,9 @@ def get_microphone(id, include_loopback=False, exclude_monitors=True): def _match_soundcard(id, soundcards, include_loopback=False): - """Find id in a list of soundcards. + """Find id in a list of sound cards. - id can be a pulseaudio id, a substring of the microphone name, or a + id can be a Pulseaudio id, a substring of the microphone name, or a fuzzy-matched pattern for the microphone name. """ if not include_loopback: @@ -454,7 +455,7 @@ def set_name(name): Parameters ---------- name : str - The application using the soundcard will be identified by the + The application using the sound card will be identified by the OS using this name. """ _pulse.name = name @@ -853,11 +854,11 @@ def read_callback(stream, nbytes, userdata): _pulse._pa_stream_set_read_callback(self.stream, read_callback, _ffi.NULL) def _record_chunk(self): - """Record one chunk of audio data, as returned by pulseaudio + """Record one chunk of audio data, as returned by Pulseaudio - The data will be returned as a 1D numpy array, which will be used + The data will be returned as a 1D Numpy array, which will be used by the `record` method. This function is the interface of the - `_Recorder` object with pulseaudio + `_Recorder` object with Pulseaudio """ data_ptr = _ffi.new('void**') nbytes_ptr = _ffi.new('size_t*') @@ -884,7 +885,7 @@ def record(self, numframes=None): """Record a block of audio data. The data will be returned as a *frames × channels* float32 - numpy array. This function will wait until ``numframes`` + Numpy array. This function will wait until ``numframes`` frames have been recorded. If numframes is given, it will return exactly ``numframes`` frames, and buffer the rest for later. From 3182f57e8dc70d8aab0cd8a2e84f2277f96e86ab Mon Sep 17 00:00:00 2001 From: szlop Date: Sat, 15 Apr 2023 12:08:23 +0200 Subject: [PATCH 6/6] - Reformated pulseaudio.py to match PEP8 recommendations. --- soundcard/pulseaudio.py | 62 ++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/soundcard/pulseaudio.py b/soundcard/pulseaudio.py index a9794d9..a5af9da 100644 --- a/soundcard/pulseaudio.py +++ b/soundcard/pulseaudio.py @@ -19,6 +19,7 @@ # Try explicit file name, if the general does not work (e.g. on nixos) _pa = _ffi.dlopen('libpulse.so') + # First, we need to define a global _PulseAudio proxy for interacting # with the C API: @@ -29,6 +30,7 @@ def func_with_lock(*args, **kwargs): self = args[0] with self._lock_mainloop(): return func(*args[1:], **kwargs) + return func_with_lock @@ -38,12 +40,14 @@ def _lock_and_block(func): Use this for Pulseaudio functions that return a `pa_operation *`. """ + def func_with_lock(*args, **kwargs): self = args[0] with self._lock_mainloop(): operation = func(*args[1:], **kwargs) self._block_operation(operation) self._pa_operation_unref(operation) + return func_with_lock @@ -51,7 +55,6 @@ def channel_name_map(): """Return a dict containing the channel position index for every channel position name string. """ - channel_indices = { _ffi.string(_pa.pa_channel_position_to_string(idx)).decode('utf-8'): idx for idx in range(_pa.PA_CHANNEL_POSITION_MAX) @@ -95,9 +98,11 @@ def __init__(self): _pa.pa_context_connect(self.context, _ffi.NULL, _pa.PA_CONTEXT_NOFLAGS, _ffi.NULL) _pa.pa_threaded_mainloop_start(self.mainloop) - while self._pa_context_get_state(self.context) in (_pa.PA_CONTEXT_UNCONNECTED, _pa.PA_CONTEXT_CONNECTING, _pa.PA_CONTEXT_AUTHORIZING, _pa.PA_CONTEXT_SETTING_NAME): + while self._pa_context_get_state(self.context) in ( + _pa.PA_CONTEXT_UNCONNECTED, _pa.PA_CONTEXT_CONNECTING, + _pa.PA_CONTEXT_AUTHORIZING, _pa.PA_CONTEXT_SETTING_NAME): time.sleep(0.001) - assert self._pa_context_get_state(self.context)==_pa.PA_CONTEXT_READY + assert self._pa_context_get_state(self.context) == _pa.PA_CONTEXT_READY @staticmethod def _infer_program_name(): @@ -144,11 +149,13 @@ def name(self): if idx < 0: # PA_INVALID_INDEX == -1 raise RuntimeError("Could not get client index of PulseAudio context.") name = None + @_ffi.callback("pa_client_info_cb_t") def callback(context, client_info, eol, userdata): nonlocal name if not eol: name = _ffi.string(client_info.name).decode('utf-8') + self._pa_context_get_client_info(self.context, idx, callback, _ffi.NULL) assert name is not None return name @@ -156,10 +163,12 @@ def callback(context, client_info, eol, userdata): @name.setter def name(self, name): rv = None + @_ffi.callback("pa_context_success_cb_t") def callback(context, success, userdata): nonlocal rv rv = success + self._pa_context_set_name(self.context, name.encode(), callback, _ffi.NULL) assert rv is not None if rv == 0: @@ -169,17 +178,20 @@ def callback(context, success, userdata): def source_list(self): """Return a list of dicts of information about available sources.""" info = [] + @_ffi.callback("pa_source_info_cb_t") def callback(context, source_info, eol, userdata): if not eol: info.append(dict(name=_ffi.string(source_info.description).decode('utf-8'), id=_ffi.string(source_info.name).decode('utf-8'))) + self._pa_context_get_source_info_list(self.context, callback, _ffi.NULL) return info def source_info(self, id): """Return a dictionary of information about a specific source.""" info = [] + @_ffi.callback("pa_source_info_cb_t") def callback(context, source_info, eol, userdata): if not eol: @@ -199,17 +211,21 @@ def callback(context, source_info, eol, userdata): def sink_list(self): """Return a list of dicts of information about available sinks.""" info = [] + @_ffi.callback("pa_sink_info_cb_t") def callback(context, sink_info, eol, userdata): if not eol: info.append((dict(name=_ffi.string(sink_info.description).decode('utf-8'), id=_ffi.string(sink_info.name).decode('utf-8')))) + self._pa_context_get_sink_info_list(self.context, callback, _ffi.NULL) return info def sink_info(self, id): """Return a dictionary of information about a specific sink.""" + info = [] + @_ffi.callback("pa_sink_info_cb_t") def callback(context, sink_info, eol, userdata): if not eol: @@ -221,19 +237,23 @@ def callback(context, sink_info, eol, userdata): data = _pa.pa_proplist_gets(sink_info.proplist, prop.encode()) info_dict[prop] = _ffi.string(data).decode('utf-8') if data else None info.append(info_dict) + self._pa_context_get_sink_info_by_name(self.context, id.encode(), callback, _ffi.NULL) return info[0] @property def server_info(self): """Return a dictionary of information about the server.""" + info = {} + @_ffi.callback("pa_server_info_cb_t") def callback(context, server_info, userdata): info['server version'] = _ffi.string(server_info.server_version).decode('utf-8') info['server name'] = _ffi.string(server_info.server_name).decode('utf-8') info['default sink id'] = _ffi.string(server_info.default_sink_name).decode('utf-8') info['default source id'] = _ffi.string(server_info.default_source_name).decode('utf-8') + self._pa_context_get_server_info(self.context, callback, _ffi.NULL) return info @@ -247,8 +267,10 @@ def _lock_mainloop(self): class Lock(): def __enter__(self_): _pa.pa_threaded_mainloop_lock(self.mainloop) + def __exit__(self_, exc_type, exc_value, traceback): _pa.pa_threaded_mainloop_unlock(self.mainloop) + return Lock() # create thread-safe versions of all used Pulseaudio functions: @@ -292,13 +314,13 @@ def __exit__(self_, exc_type, exc_value, traceback): _pulse = _PulseAudio() atexit.register(_pulse._shutdown) + def all_speakers(): """A list of all connected speakers. Returns ------- speakers : list(_Speaker) - """ return [_Speaker(id=s['id']) for s in _pulse.sink_list] @@ -309,7 +331,6 @@ def default_speaker(): Returns ------- speaker : _Speaker - """ name = _pulse.server_info['default sink id'] return get_speaker(name) @@ -328,7 +349,6 @@ def get_speaker(id): Returns ------- speaker : _Speaker - """ speakers = _pulse.sink_list return _Speaker(id=_match_soundcard(id, speakers)['id']) @@ -350,9 +370,7 @@ def all_microphones(include_loopback=False, exclude_monitors=True): Returns ------- microphones : list(_Microphone) - """ - if not exclude_monitors: warnings.warn("The exclude_monitors flag is being replaced by the include_loopback flag", DeprecationWarning) include_loopback = not exclude_monitors @@ -396,7 +414,6 @@ def get_microphone(id, include_loopback=False, exclude_monitors=True): ------- microphone : _Microphone """ - if not exclude_monitors: warnings.warn("The exclude_monitors flag is being replaced by the include_loopback flag", DeprecationWarning) include_loopback = not exclude_monitors @@ -671,7 +688,6 @@ class _Stream: This context manager can only be entered once, and can not be used after it is closed. - """ def __init__(self, id, samplerate, channels, blocksize=None, maxlatency=None, report_under_overflow=False, @@ -723,7 +739,9 @@ def __enter__(self): bufattr.fragsize = self._blocksize * numchannels * bytes_per_sample if self._blocksize else 2 ** 32 - 1 bufattr.minreq = 2 ** 32 - 1 # start requesting more data at this bytes bufattr.prebuf = 2 ** 32 - 1 # start playback after prebuf bytes are available - bufattr.tlength = self._blocksize * numchannels * bytes_per_sample if self._blocksize else 2 ** 32 - 1 # buffer length in bytes on server + + # buffer length in bytes on server + bufattr.tlength = self._blocksize * numchannels * bytes_per_sample if self._blocksize else 2 ** 32 - 1 self._connect_stream(bufattr) while _pulse._pa_stream_get_state(self.stream) not in [_pa.PA_STREAM_READY, _pa.PA_STREAM_FAILED]: @@ -736,7 +754,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): - if isinstance(self, _Player): # only playback streams need to drain + if isinstance(self, _Player): # only playback streams need to drain _pulse._pa_stream_drain(self.stream, _ffi.NULL, _ffi.NULL) _pulse._pa_stream_disconnect(self.stream) while _pulse._pa_stream_get_state(self.stream) not in (_pa.PA_STREAM_TERMINATED, _pa.PA_STREAM_FAILED): @@ -749,7 +767,7 @@ def latency(self): _pulse._pa_stream_update_timing_info(self.stream, _ffi.NULL, _ffi.NULL) microseconds = _ffi.new("pa_usec_t*") _pulse._pa_stream_get_latency(self.stream, microseconds, _ffi.NULL) - return microseconds[0] / 1000000 # 1_000_000 (3.5 compat) + return microseconds[0] / 1000000 # 1_000_000 (3.5 compat) class _Player(_Stream): @@ -811,15 +829,16 @@ def play(self, data): data = numpy.array(data, dtype='float32', order='C') if data.ndim == 1: - data = data[:, None] # force 2d + data = data[:, None] # force 2d if data.ndim != 2: raise TypeError('data must be 1d or 2d, not {}d'.format(data.ndim)) if data.shape[1] == 1 and self.channels != 1: data = numpy.tile(data, [1, self.channels]) if data.shape[1] != self.channels: - raise TypeError('second dimension of data must be equal to the number of channels, not {}'.format(data.shape[1])) + raise TypeError( + 'second dimension of data must be equal to the number of channels, not {}'.format(data.shape[1])) while data.nbytes > 0: - nwrite = _pulse._pa_stream_writable_size(self.stream) // (4 * self.channels) # 4 bytes per sample + nwrite = _pulse._pa_stream_writable_size(self.stream) // (4 * self.channels) # 4 bytes per sample if nwrite == 0: time.sleep(0.001) @@ -828,6 +847,7 @@ def play(self, data): _pulse._pa_stream_write(self.stream, bytes, len(bytes), _ffi.NULL, 0, _pa.PA_SEEK_RELATIVE) data = data[nwrite:] + class _Recorder(_Stream): """A context manager for an active input stream. @@ -842,14 +862,16 @@ class _Recorder(_Stream): def __init__(self, *args, **kwargs): super(_Recorder, self).__init__(*args, **kwargs) - self._pending_chunk = numpy.zeros((0, ), dtype='float32') + self._pending_chunk = numpy.zeros((0,), dtype='float32') self._record_event = threading.Event() def _connect_stream(self, bufattr): _pulse._pa_stream_connect_record(self.stream, self._id.encode(), bufattr, _pa.PA_STREAM_ADJUST_LATENCY) + @_ffi.callback("pa_stream_request_cb_t") def read_callback(stream, nbytes, userdata): self._record_event.set() + self._callback = read_callback _pulse._pa_stream_set_read_callback(self.stream, read_callback, _ffi.NULL) @@ -876,7 +898,7 @@ def _record_chunk(self): buffer = _ffi.buffer(data_ptr[0], nbytes_ptr[0]) chunk = numpy.frombuffer(buffer, dtype='float32').copy() if data_ptr[0] == _ffi.NULL and nbytes_ptr[0] != 0: - chunk = numpy.zeros(nbytes_ptr[0]//4, dtype='float32') + chunk = numpy.zeros(nbytes_ptr[0] // 4, dtype='float32') if nbytes_ptr[0] > 0: _pulse._pa_stream_drop(self.stream) return chunk @@ -926,7 +948,7 @@ def record(self, numframes=None): while captured_frames < numframes: chunk = self._record_chunk() captured_data.append(chunk) - captured_frames += len(chunk)/self.channels + captured_frames += len(chunk) / self.channels to_split = int(len(chunk) - (captured_frames - numframes) * self.channels) captured_data[-1], self._pending_chunk = numpy.split(captured_data[-1], [to_split]) return numpy.reshape(numpy.concatenate(captured_data), [-1, self.channels]) @@ -944,5 +966,5 @@ def flush(self): Numpy array. """ last_chunk = numpy.reshape(self._pending_chunk, [-1, self.channels]) - self._pending_chunk = numpy.zeros((0, ), dtype='float32') + self._pending_chunk = numpy.zeros((0,), dtype='float32') return last_chunk