From b1a555fa99c0718e7b8caaea377f0e6b70d94295 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Wed, 30 Apr 2025 19:42:54 +0200 Subject: [PATCH 01/11] Fix some Pyright errors in sounddevice.py --- sounddevice.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/sounddevice.py b/sounddevice.py index b486b00..fd7152d 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -69,7 +69,7 @@ break else: raise OSError('PortAudio library not found') - _lib = _ffi.dlopen(_libname) + _lib: ... = _ffi.dlopen(_libname) except OSError: if _platform.system() == 'Darwin': _libname = 'libportaudio.dylib' @@ -83,7 +83,7 @@ import _sounddevice_data _libname = _os.path.join( next(iter(_sounddevice_data.__path__)), 'portaudio-binaries', _libname) - _lib = _ffi.dlopen(_libname) + _lib: ... = _ffi.dlopen(_libname) _sampleformats = { 'float32': _lib.paFloat32, @@ -571,7 +571,7 @@ def query_devices(device=None, kind=None): if not info: raise PortAudioError(f'Error querying device {device}') assert info.structVersion == 2 - name_bytes = _ffi.string(info.name) + name_bytes = _ffi_string(info.name) try: # We don't know beforehand if DirectSound and MME device names use # 'utf-8' or 'mbcs' encoding. Let's try 'utf-8' first, because it more @@ -651,7 +651,7 @@ def query_hostapis(index=None): raise PortAudioError(f'Error querying host API {index}') assert info.structVersion == 1 return { - 'name': _ffi.string(info.name).decode(), + 'name': _ffi_string(info.name).decode(), 'devices': [_lib.Pa_HostApiDeviceIndexToDeviceIndex(index, i) for i in range(info.deviceCount)], 'default_input_device': info.defaultInputDevice, @@ -721,7 +721,7 @@ def get_portaudio_version(): (1899, 'PortAudio V19-devel (built Feb 15 2014 23:28:00)') """ - return _lib.Pa_GetVersion(), _ffi.string(_lib.Pa_GetVersionText()).decode() + return _lib.Pa_GetVersion(), _ffi_string(_lib.Pa_GetVersionText()).decode() class _StreamBase: @@ -1204,7 +1204,7 @@ def _raw_read(self, frames): """ channels, _ = _split(self._channels) samplesize, _ = _split(self._samplesize) - data = _ffi.new('signed char[]', channels * samplesize * frames) + data = _ffi.new('signed char[]', channels * samplesize * frames) # type: ignore err = _lib.Pa_ReadStream(self._ptr, data, frames) if err == _lib.paInputOverflowed: overflowed = True @@ -1302,7 +1302,7 @@ def _raw_write(self, data): pass # input is not a buffer _, samplesize = _split(self._samplesize) _, channels = _split(self._channels) - samples, remainder = divmod(len(data), samplesize) + samples, remainder = divmod(len(data), samplesize) # type: ignore if remainder: raise ValueError('len(data) not divisible by samplesize') frames, remainder = divmod(samples, channels) @@ -2237,7 +2237,8 @@ def reset(self): if not hasattr(_ffi, 'I_AM_FAKE'): # This object shadows the 'default' class, except when building the docs. - default = default() + _default_class = default + default: _default_class = default() class PortAudioError(Exception): @@ -2508,9 +2509,8 @@ class _CallbackContext: """Helper class for reuse in play()/rec()/playrec() callbacks.""" blocksize = None - data = None - out = None frame = 0 + frames: int input_channels = output_channels = None input_dtype = output_dtype = None input_mapping = output_mapping = None @@ -2557,7 +2557,7 @@ def check_data(self, data, mapping, device): if len(mapping) + len(silent_channels) != channels: raise ValueError('each channel may only appear once in mapping') - self.data = data + self.data: np.typing.NDArray = data self.output_channels = channels self.output_dtype = dtype self.output_mapping = mapping @@ -2641,8 +2641,8 @@ def callback_exit(self): def finished_callback(self): self.event.set() # Drop temporary audio buffers to free memory - self.data = None - self.out = None + del self.data + del self.out # Drop CFFI objects to avoid reference cycles self.stream._callback = None self.stream._finished_callback = None @@ -2675,6 +2675,10 @@ def wait(self, ignore_errors=True): return self.status if self.status else None +def _ffi_string(cdata) -> bytes: + return _ffi.string(cdata) # type: ignore + + def _remove_self(d): """Return a copy of d without the 'self' entry.""" d = d.copy() @@ -2749,7 +2753,7 @@ def _get_stream_parameters(kind, device, channels, dtype, latency, latency = info['default_' + latency + '_' + kind + '_latency'] if samplerate is None: samplerate = info['default_samplerate'] - parameters = _ffi.new('PaStreamParameters*', ( + parameters: ... = _ffi.new('PaStreamParameters*', ( device, channels, sampleformat, latency, extra_settings._streaminfo if extra_settings else _ffi.NULL)) return parameters, dtype, samplesize, samplerate @@ -2805,7 +2809,7 @@ def _check(err, msg=''): if err >= 0: return err - errormsg = _ffi.string(_lib.Pa_GetErrorText(err)).decode() + errormsg = _ffi_string(_lib.Pa_GetErrorText(err)).decode() if msg: errormsg = f'{msg}: {errormsg}' @@ -2816,7 +2820,7 @@ def _check(err, msg=''): # in scenarios where multiple APIs are being used simultaneously. info = _lib.Pa_GetLastHostErrorInfo() host_api = _lib.Pa_HostApiTypeIdToHostApiIndex(info.hostApiType) - hosterror_text = _ffi.string(info.errorText).decode() + hosterror_text = _ffi_string(info.errorText).decode() hosterror_info = host_api, info.errorCode, hosterror_text raise PortAudioError(errormsg, err, hosterror_info) From 40a8308423be9f101211379b05bdda9b6a4e60d2 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 26 May 2025 20:11:13 +0200 Subject: [PATCH 02/11] type-erase query_devices() and query_hostapis() --- sounddevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sounddevice.py b/sounddevice.py index fd7152d..836d227 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -453,7 +453,7 @@ def get_stream(): raise RuntimeError('play()/rec()/playrec() was not called yet') -def query_devices(device=None, kind=None): +def query_devices(device=None, kind=None) -> ...: """Return information about available devices. Information and capabilities of PortAudio devices. @@ -606,7 +606,7 @@ def query_devices(device=None, kind=None): return device_dict -def query_hostapis(index=None): +def query_hostapis(index=None) -> ...: """Return information about available host APIs. Parameters From ce803c754ac384001921c990333273458ee67218 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Sat, 17 May 2025 15:11:06 +0200 Subject: [PATCH 03/11] examples: fix Pyright errors --- examples/play_long_file.py | 2 +- examples/play_long_file_raw.py | 2 +- examples/rec_gui.py | 6 ++---- examples/spectrogram.py | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/examples/play_long_file.py b/examples/play_long_file.py index eb42895..8e3d39d 100755 --- a/examples/play_long_file.py +++ b/examples/play_long_file.py @@ -96,7 +96,7 @@ def callback(outdata, frames, time, status): callback=callback, finished_callback=event.set) with stream: timeout = args.blocksize * args.buffersize / f.samplerate - while len(data): + while len(data): # type: ignore data = f.read(args.blocksize) q.put(data, timeout=timeout) event.wait() # Wait until playback is finished diff --git a/examples/play_long_file_raw.py b/examples/play_long_file_raw.py index 267af24..c2bd4e8 100755 --- a/examples/play_long_file_raw.py +++ b/examples/play_long_file_raw.py @@ -87,7 +87,7 @@ def callback(outdata, frames, time, status): callback=callback, finished_callback=event.set) with stream: timeout = args.blocksize * args.buffersize / f.samplerate - while data: + while data: # type: ignore data = f.buffer_read(args.blocksize, dtype='float32') q.put(data, timeout=timeout) event.wait() # Wait until playback is finished diff --git a/examples/rec_gui.py b/examples/rec_gui.py index a5b4701..ed40c80 100755 --- a/examples/rec_gui.py +++ b/examples/rec_gui.py @@ -75,8 +75,6 @@ def validate(self): class RecGui(tk.Tk): - stream = None - def __init__(self): super().__init__() @@ -122,7 +120,7 @@ def __init__(self): self.update_gui() def create_stream(self, device=None): - if self.stream is not None: + with contextlib.suppress(AttributeError): self.stream.close() self.stream = sd.InputStream( device=device, channels=1, callback=self.audio_callback) @@ -205,7 +203,7 @@ def on_settings(self, *args): def init_buttons(self): self.rec_button['text'] = 'record' self.rec_button['command'] = self.on_rec - if self.stream: + if hasattr(self, 'stream'): self.rec_button['state'] = 'normal' self.settings_button['state'] = 'normal' diff --git a/examples/spectrogram.py b/examples/spectrogram.py index 8dc8a65..2292394 100755 --- a/examples/spectrogram.py +++ b/examples/spectrogram.py @@ -32,7 +32,7 @@ def int_or_str(text): print(sd.query_devices()) parser.exit(0) parser = argparse.ArgumentParser( - description=__doc__ + '\n\nSupported keys:' + usage_line, + description=__doc__ + '\n\nSupported keys:' + usage_line, # type: ignore formatter_class=argparse.RawDescriptionHelpFormatter, parents=[parser]) parser.add_argument( From 058a7b9788947cffa566d6d585aa31e7e48dec33 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Sat, 17 May 2025 22:32:34 +0200 Subject: [PATCH 04/11] CI: run Pyright on examples --- .github/workflows/static-analyzers.yml | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/static-analyzers.yml diff --git a/.github/workflows/static-analyzers.yml b/.github/workflows/static-analyzers.yml new file mode 100644 index 0000000..13887e0 --- /dev/null +++ b/.github/workflows/static-analyzers.yml @@ -0,0 +1,30 @@ +name: Run static analysis tools +on: [push, pull_request] +jobs: + static-analysis: + runs-on: ubuntu-latest + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3" + - name: Double-check Python version + run: | + python --version + - name: Clone Git repository + uses: actions/checkout@v4 + - name: Install sounddevice module + run: | + python -m pip install . + - name: Install Pyright + run: | + python -m pip install pyright + - name: Check sounddevice module with Pyright + run: | + # TODO + - name: Install dependencies for examples + run: | + python -m pip install ffmpeg-python matplotlib soundfile + - name: Check examples with Pyright + run: | + python -m pyright examples/*.py From 17ced1ce61b3637ce0fd2d7d2bad7cb755c7b0a4 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Sun, 18 May 2025 11:20:24 +0200 Subject: [PATCH 05/11] Re-assign sd.default without losing docstring --- sounddevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sounddevice.py b/sounddevice.py index 836d227..c229e66 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -2237,8 +2237,8 @@ def reset(self): if not hasattr(_ffi, 'I_AM_FAKE'): # This object shadows the 'default' class, except when building the docs. - _default_class = default - default: _default_class = default() + _default_instance: ... = default() + default = _default_instance class PortAudioError(Exception): From d6f52537460ba49b808fee1a23a760497a9df9f3 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Wed, 21 May 2025 21:05:38 +0200 Subject: [PATCH 06/11] type-erase some internal variables --- sounddevice.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sounddevice.py b/sounddevice.py index c229e66..8df164a 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -85,7 +85,7 @@ next(iter(_sounddevice_data.__path__)), 'portaudio-binaries', _libname) _lib: ... = _ffi.dlopen(_libname) -_sampleformats = { +_sampleformats: ... = { 'float32': _lib.paFloat32, 'int32': _lib.paInt32, 'int24': _lib.paInt24, @@ -2052,7 +2052,7 @@ class _InputOutputPair: _indexmapping = {'input': 0, 'output': 1} def __init__(self, parent, default_attr): - self._pair = [None, None] + self._pair: ... = [None, None] self._parent = parent self._default_attr = default_attr @@ -2715,7 +2715,7 @@ def _check_dtype(dtype): def _get_stream_parameters(kind, device, channels, dtype, latency, - extra_settings, samplerate): + extra_settings: ..., samplerate): """Get parameters for one direction (input or output) of a stream.""" assert kind in ('input', 'output') if device is None: From b7a927cadc1a12a35f35024720600f96828f7fab Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Wed, 21 May 2025 21:07:20 +0200 Subject: [PATCH 07/11] type-erase "default" attributes --- sounddevice.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/sounddevice.py b/sounddevice.py index 8df164a..98b6957 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -2113,7 +2113,7 @@ class default: _pairs = 'device', 'channels', 'dtype', 'latency', 'extra_settings' # The class attributes listed in _pairs are only provided here for static # analysis tools and for the docs. They're overwritten in __init__(). - device = None, None + device: ... = None, None """Index or query string of default input/output device. If not overwritten, this is queried from PortAudio. @@ -2123,7 +2123,8 @@ class default: `default`, `query_devices()`, the *device* argument of `Stream` """ - channels = _default_channels = None, None + _default_channels = None, None + channels: ... = _default_channels """Default number of input/output channels. See Also @@ -2131,7 +2132,8 @@ class default: `default`, `query_devices()`, the *channels* argument of `Stream` """ - dtype = _default_dtype = 'float32', 'float32' + _default_dtype = 'float32', 'float32' + dtype: ... = _default_dtype """Default data type used for input/output samples. The types ``'float32'``, ``'int32'``, ``'int16'``, ``'int8'`` and @@ -2147,9 +2149,11 @@ class default: `default`, `numpy:numpy.dtype`, the *dtype* argument of `Stream` """ - latency = _default_latency = 'high', 'high' + _default_latency = 'high', 'high' + latency: ... = _default_latency """See the *latency* argument of `Stream`.""" - extra_settings = _default_extra_settings = None, None + _default_extra_settings = None, None + extra_settings: ... = _default_extra_settings """Host-API-specific input/output settings. See Also @@ -2157,7 +2161,7 @@ class default: AsioSettings, CoreAudioSettings, WasapiSettings """ - samplerate = None + samplerate: ... = None """Sampling frequency in Hertz (= frames per second). See Also @@ -2165,7 +2169,7 @@ class default: `default`, `query_devices()` """ - blocksize = _lib.paFramesPerBufferUnspecified + blocksize: ... = _lib.paFramesPerBufferUnspecified """See the *blocksize* argument of `Stream`.""" clip_off = False """Disable clipping. From b3e1746115184fa11803c54e5e6e4d068cbf14b4 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Wed, 21 May 2025 21:10:31 +0200 Subject: [PATCH 08/11] remove some class variables --- sounddevice.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sounddevice.py b/sounddevice.py index 98b6957..f787545 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -2512,12 +2512,10 @@ def __init__(self, exclusive=False, auto_convert=False, explicit_sample_format=F class _CallbackContext: """Helper class for reuse in play()/rec()/playrec() callbacks.""" - blocksize = None frame = 0 frames: int input_channels = output_channels = None input_dtype = output_dtype = None - input_mapping = output_mapping = None silent_channels = None def __init__(self, loop=False): From 5b55bd689f94dad2f086562b75331d6aae063076 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Wed, 21 May 2025 21:26:23 +0200 Subject: [PATCH 09/11] CI: run pyright on sounddevice.py --- .github/workflows/static-analyzers.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/static-analyzers.yml b/.github/workflows/static-analyzers.yml index 13887e0..8d9b3d4 100644 --- a/.github/workflows/static-analyzers.yml +++ b/.github/workflows/static-analyzers.yml @@ -16,12 +16,12 @@ jobs: - name: Install sounddevice module run: | python -m pip install . - - name: Install Pyright + - name: Install Pyright and NumPy run: | - python -m pip install pyright + python -m pip install pyright numpy - name: Check sounddevice module with Pyright run: | - # TODO + python -m pyright sounddevice.py - name: Install dependencies for examples run: | python -m pip install ffmpeg-python matplotlib soundfile From 1d30bc8f764fdca2054efe93057d2831f7a57b61 Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 26 May 2025 20:17:33 +0200 Subject: [PATCH 10/11] Add explicit parens around tuple (for PyPy 3.7 compatibility) --- sounddevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sounddevice.py b/sounddevice.py index f787545..42c4337 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -2113,7 +2113,7 @@ class default: _pairs = 'device', 'channels', 'dtype', 'latency', 'extra_settings' # The class attributes listed in _pairs are only provided here for static # analysis tools and for the docs. They're overwritten in __init__(). - device: ... = None, None + device: ... = (None, None) """Index or query string of default input/output device. If not overwritten, this is queried from PortAudio. From ebc6ec5911660bfe2dc62ec0ac8659d829df2c6a Mon Sep 17 00:00:00 2001 From: Matthias Geier Date: Mon, 26 May 2025 20:33:24 +0200 Subject: [PATCH 11/11] Add type hint for default.hostapi --- sounddevice.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sounddevice.py b/sounddevice.py index 42c4337..72e5f29 100644 --- a/sounddevice.py +++ b/sounddevice.py @@ -2229,10 +2229,12 @@ def _default_device(self): _lib.Pa_GetDefaultOutputDevice()) @property - def hostapi(self): + def hostapi(self): # type: ignore """Index of the default host API (read-only).""" return _check(_lib.Pa_GetDefaultHostApi()) + hostapi: int + def reset(self): """Reset all attributes to their "factory default".""" vars(self).clear()