diff --git a/doc/api/platform-specific-settings.rst b/doc/api/platform-specific-settings.rst index dc5e1ed..e5f8117 100644 --- a/doc/api/platform-specific-settings.rst +++ b/doc/api/platform-specific-settings.rst @@ -8,10 +8,13 @@ Platform-specific Settings .. autosummary:: :nosignatures: + AlsaSettings AsioSettings CoreAudioSettings WasapiSettings +.. autoclass:: AlsaSettings + .. autoclass:: AsioSettings .. autoclass:: CoreAudioSettings diff --git a/sounddevice_build.py b/sounddevice_build.py index 08790d0..9548f1d 100644 --- a/sounddevice_build.py +++ b/sounddevice_build.py @@ -233,6 +233,15 @@ typedef unsigned long PaWinWaveFormatChannelMask; +/* pa_linux_alsa.h */ +typedef struct PaAlsaStreamInfo { + unsigned long size; + PaHostApiTypeId hostApiType; + unsigned long version; + const char *deviceString; +} PaAlsaStreamInfo; +void PaAlsa_InitializeStreamInfo(PaAlsaStreamInfo *info); + /* pa_asio.h */ #define paAsioUseChannelSelectors 0x01 diff --git a/src/sounddevice.py b/src/sounddevice.py index b3268cb..1e5a20a 100644 --- a/src/sounddevice.py +++ b/src/sounddevice.py @@ -173,7 +173,8 @@ def play(data, samplerate=None, mapping=None, blocking=False, loop=False, """ ctx = _CallbackContext(loop=loop) - ctx.frames = ctx.check_data(data, mapping, kwargs.get('device')) + ctx.frames = ctx.check_data( + data, mapping, kwargs.get('device'), kwargs.get('extra_settings')) def callback(outdata, frames, time, status): assert len(outdata) == frames @@ -359,7 +360,8 @@ def playrec(data, samplerate=None, channels=None, dtype=None, """ ctx = _CallbackContext() - output_frames = ctx.check_data(data, output_mapping, kwargs.get('device')) + output_frames = ctx.check_data( + data, output_mapping, kwargs.get('device'), kwargs.get('extra_settings')) if dtype is None: dtype = ctx.data.dtype # ignore module defaults out, input_frames = ctx.check_out(out, output_frames, channels, dtype, @@ -690,7 +692,7 @@ def check_input_settings(device=None, channels=None, dtype=None, """ parameters, dtype, samplesize, samplerate = _get_stream_parameters( 'input', device=device, channels=channels, dtype=dtype, latency=None, - extra_settings=extra_settings, samplerate=samplerate) + extra_settings=extra_settings, samplerate=samplerate, for_check=True) _check(_lib.Pa_IsFormatSupported(parameters, _ffi.NULL, samplerate)) @@ -704,7 +706,7 @@ def check_output_settings(device=None, channels=None, dtype=None, """ parameters, dtype, samplesize, samplerate = _get_stream_parameters( 'output', device=device, channels=channels, dtype=dtype, latency=None, - extra_settings=extra_settings, samplerate=samplerate) + extra_settings=extra_settings, samplerate=samplerate, for_check=True) _check(_lib.Pa_IsFormatSupported(_ffi.NULL, parameters, samplerate)) @@ -808,6 +810,12 @@ def __init__(self, kind, samplerate=None, blocksize=None, device=None, if prime_output_buffers_using_stream_callback: stream_flags |= _lib.paPrimeOutputBuffersUsingStreamCallback + # Retain effective settings, including their C buffers, through close. + if extra_settings is None: + extra_settings = tuple(default.extra_settings) + self._extra_settings = (tuple(_split(extra_settings)), + tuple(default.extra_settings)) + if kind == 'duplex': idevice, odevice = _split(device) ichannels, ochannels = _split(channels) @@ -2167,7 +2175,7 @@ class default: See Also -------- - AsioSettings, CoreAudioSettings, WasapiSettings + AlsaSettings, AsioSettings, CoreAudioSettings, WasapiSettings """ samplerate: ... = None @@ -2316,6 +2324,44 @@ class CallbackAbort(Exception): """ +class AlsaSettings: + + def __init__(self, device_string): + """ALSA-specific settings for opening a PCM by address. + + Use as ``extra_settings`` with ``device=None`` (or ``-2``). + The address overrides `default.device`. Channels and sample rate + must be supplied explicitly or through defaults. Streams also + require a numeric latency in seconds. + + Parameters + ---------- + device_string : str + ALSA PCM address, such as ``'hw:2,0'`` or a named PCM from the + ALSA configuration. It must be nonempty and contain no NUL. + An ALSA address is not a persistent hardware identifier. + + Notes + ----- + The stream's device attribute reports PortAudio's special value + ``-2``. Passing an explicit device index or name is an error. + For direct output with ``mapping=[1]`` in `play()` or `playrec()`, + use `OutputStream` with explicit channels instead; automatic + stereo detection requires enumerated device information. + + """ + if not isinstance(device_string, str): + raise TypeError('device_string must be a string') + if not device_string or '\0' in device_string: + raise ValueError('device_string must be nonempty and contain no NUL') + _check(_lib.Pa_HostApiTypeIdToHostApiIndex(_lib.paALSA), + 'ALSA host API is unavailable') + self._device_string = _ffi.new('char[]', device_string.encode('utf-8')) + self._streaminfo: ... = _ffi.new('PaAlsaStreamInfo*') + _lib.PaAlsa_InitializeStreamInfo(self._streaminfo) + self._streaminfo.deviceString = self._device_string + + class AsioSettings: def __init__(self, channel_selectors): @@ -2541,7 +2587,7 @@ def __init__(self, loop=False): self.event = threading.Event() self.status = CallbackFlags() - def check_data(self, data, mapping, device): + def check_data(self, data, mapping, device, extra_settings=None): """Check data and output mapping.""" import numpy as np data = np.asarray(data) @@ -2563,6 +2609,13 @@ def check_data(self, data, mapping, device): # first two channels, which is unexpected when specifying mapping=[1]. # In this case, we play silence on the second channel, but only if the # device actually supports a second channel: + effective_settings = (default.extra_settings if extra_settings is None + else extra_settings) + if (mapping_is_explicit and np.array_equal(mapping, [0]) and + isinstance(_select_input_or_output(effective_settings, 'output'), + AlsaSettings)): + raise ValueError('For ALSA direct output mapping=[1], use ' + 'OutputStream with explicit channels') if (mapping_is_explicit and np.array_equal(mapping, [0]) and query_devices(device, 'output')['max_output_channels'] >= 2): channels = 2 @@ -2730,12 +2783,22 @@ def _check_dtype(dtype): def _get_stream_parameters(kind, device, channels, dtype, latency, - extra_settings: ..., samplerate): + extra_settings: ..., samplerate, *, for_check=False): """Get parameters for one direction (input or output) of a stream.""" assert kind in ('input', 'output') - if device is None: - device = default.device - device = _get_device_id(device, kind, raise_on_error=True) + if extra_settings is None: + extra_settings = default.extra_settings + extra_settings = _select_input_or_output(extra_settings, kind) + direct_alsa = isinstance(extra_settings, AlsaSettings) + if direct_alsa: + device = _select_input_or_output(device, kind) + if device is not None and device != -2: + raise ValueError('AlsaSettings conflicts with an explicit device') + device = _lib.paUseHostApiSpecificDeviceSpecification + else: + if device is None: + device = default.device + device = _get_device_id(device, kind, raise_on_error=True) if channels is None: channels = default.channels channels = _select_input_or_output(channels, kind) @@ -2745,15 +2808,33 @@ def _get_stream_parameters(kind, device, channels, dtype, latency, if latency is None: latency = default.latency latency = _select_input_or_output(latency, kind) - if extra_settings is None: - extra_settings = default.extra_settings - extra_settings = _select_input_or_output(extra_settings, kind) if samplerate is None: samplerate = default.samplerate - info = query_devices(device) - if channels is None: - channels = info['max_' + kind + '_channels'] + if direct_alsa: + import math + import numbers + if (isinstance(channels, bool) or + not isinstance(channels, numbers.Integral) or channels < 1): + raise ValueError('AlsaSettings requires a positive channel count') + if (isinstance(samplerate, bool) or + not isinstance(samplerate, numbers.Real) or + not math.isfinite(samplerate) or samplerate <= 0): + raise ValueError('AlsaSettings requires a positive finite sample rate') + if for_check: + latency = 0.0 # Pa_IsFormatSupported ignores suggested latency. + elif (isinstance(latency, bool) or + not isinstance(latency, numbers.Real) or + not math.isfinite(latency) or latency < 0): + raise ValueError('AlsaSettings requires finite numeric latency >= 0') + else: + info = query_devices(device) + if channels is None: + channels = info['max_' + kind + '_channels'] + if latency in ('low', 'high'): + latency = info['default_' + latency + '_' + kind + '_latency'] + if samplerate is None: + samplerate = info['default_samplerate'] try: # If NumPy is available, get canonical dtype name dtype = _sys.modules['numpy'].dtype(dtype).name @@ -2764,10 +2845,6 @@ def _get_stream_parameters(kind, device, channels, dtype, latency, except KeyError as e: raise ValueError('Invalid ' + kind + ' sample format') from e samplesize = _check(_lib.Pa_GetSampleSize(sampleformat)) - if latency in ('low', 'high'): - latency = info['default_' + latency + '_' + kind + '_latency'] - if samplerate is None: - samplerate = info['default_samplerate'] parameters: ... = _ffi.new('PaStreamParameters*', ( device, channels, sampleformat, latency, extra_settings._streaminfo if extra_settings else _ffi.NULL))