Skip to content
5 changes: 5 additions & 0 deletions doc/api/platform-specific-settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ Platform-specific Settings
.. autosummary::
:nosignatures:

AlsaSettings
AsioSettings
CoreAudioSettings
WasapiSettings

.. autodata:: DEVICE_FROM_EXTRA_SETTINGS

.. autoclass:: AlsaSettings

.. autoclass:: AsioSettings

.. autoclass:: CoreAudioSettings
Expand Down
1 change: 1 addition & 0 deletions doc/fake__sounddevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class FakeLibrary:

paFloat32 = paInt32 = paInt24 = paInt16 = paInt8 = paUInt8 = NotImplemented
paFramesPerBufferUnspecified = 0
paUseHostApiSpecificDeviceSpecification = -2

def Pa_Initialize(self):
return 0
Expand Down
9 changes: 9 additions & 0 deletions sounddevice_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 96 additions & 11 deletions src/sounddevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
* Module-wide default settings: `default`

* Platform-specific settings:
`AsioSettings`, `CoreAudioSettings`, `WasapiSettings`
`AlsaSettings`, `AsioSettings`, `CoreAudioSettings`, `WasapiSettings`

* PortAudio streams, using NumPy arrays:
`Stream`, `InputStream`, `OutputStream`
Expand Down Expand Up @@ -91,6 +91,9 @@
next(iter(_sounddevice_data.__path__)), 'portaudio-binaries', _libname)
_lib: ... = _ffi.dlopen(_libname)

DEVICE_FROM_EXTRA_SETTINGS = _lib.paUseHostApiSpecificDeviceSpecification
"""Select a device through *extra_settings*, e.g. with `AlsaSettings`."""

_sampleformats: ... = {
'float32': _lib.paFloat32,
'int32': _lib.paInt32,
Expand Down Expand Up @@ -689,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,
'input', device=device, channels=channels, dtype=dtype, latency=0,
extra_settings=extra_settings, samplerate=samplerate)
_check(_lib.Pa_IsFormatSupported(parameters, _ffi.NULL, samplerate))

Expand All @@ -703,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,
'output', device=device, channels=channels, dtype=dtype, latency=0,
extra_settings=extra_settings, samplerate=samplerate)
_check(_lib.Pa_IsFormatSupported(_ffi.NULL, parameters, samplerate))

Expand Down Expand Up @@ -1661,6 +1664,8 @@ def __init__(self, samplerate=None, blocksize=None,
all space-separated parts in the right order. Each device
string contains the name of the corresponding host API in
the end. The string comparison is case-insensitive.
Use `DEVICE_FROM_EXTRA_SETTINGS` to select a device through
*extra_settings*, e.g. with `AlsaSettings`.
channels : int or pair of int, optional
The number of channels of sound to be delivered to the
stream callback or accessed by `read()` or `write()`. It
Expand Down Expand Up @@ -2167,7 +2172,7 @@ class default:

See Also
--------
AsioSettings, CoreAudioSettings, WasapiSettings
AlsaSettings, AsioSettings, CoreAudioSettings, WasapiSettings

"""
samplerate: ... = None
Expand Down Expand Up @@ -2316,6 +2321,59 @@ class CallbackAbort(Exception):
"""


class AlsaSettings:

def __init__(self, device_string):
"""ALSA-specific input/output settings.

Objects of this class can be used as *extra_settings* argument
to `Stream()` (and variants) or as `default.extra_settings`.

Parameters
----------
device_string : str
ALSA PCM address, such as ``'hw:2,0'`` or a named PCM from the
ALSA configuration.
An ALSA address is not a persistent hardware identifier.

Use with ``device=sd.DEVICE_FROM_EXTRA_SETTINGS``. Supplying
these settings alone does not override `default.device`.
Pass *channels* and *samplerate* when creating the stream,
or set `default.channels` and `default.samplerate` beforehand.
Set *latency* to a number of seconds.
Format checks do not require latency.

.. note::

The stream's device attribute reports PortAudio's special
value ``-2``. PortAudio rejects these settings with a regular
ALSA device index. For direct output with ``mapping=[1]`` in
`play()` or ``output_mapping=[1]`` in `playrec()`, automatic
stereo detection needs enumerated device information.
Use `OutputStream` instead
of `play()`, or `Stream` instead of `playrec()`, and specify
the channel count.

Examples
--------
>>> import sounddevice as sd
>>> with sd.RawInputStream(
... device=sd.DEVICE_FROM_EXTRA_SETTINGS,
... samplerate=48000, channels=2, dtype='int16',
... blocksize=1024, latency=4096 / 48000,
... extra_settings=sd.AlsaSettings('null')) as stream:
... data, overflowed = stream.read(1024)

"""
device_string = device_string.encode('utf-8')
if b'\0' in device_string:
raise ValueError('device_string must contain no NUL characters')
self._device_string = _ffi.new('char[]', device_string)
self._streaminfo: ... = _ffi.new('PaAlsaStreamInfo*')
_lib.PaAlsa_InitializeStreamInfo(self._streaminfo)
self._streaminfo.deviceString = self._device_string


class AsioSettings:

def __init__(self, channel_selectors):
Expand Down Expand Up @@ -2751,9 +2809,35 @@ def _get_stream_parameters(kind, device, channels, dtype, latency,
if samplerate is None:
samplerate = default.samplerate

info = query_devices(device)
if channels is None:
channels = info['max_' + kind + '_channels']
if channels is None or samplerate is None or latency in ('low', 'high'):
try:
info = query_devices(device)
error = None
except PortAudioError as e:
info = {}
error = e
if channels is None:
if error is not None:
raise TypeError(
'Cannot query device information. '
'Set channels to an integer.'
) from error
channels = info['max_' + kind + '_channels']
if samplerate is None:
if error is not None:
raise TypeError(
'Cannot query device information. '
'Set samplerate to a number.'
) from error
samplerate = info['default_samplerate']
if latency in ('low', 'high'):
if error is not None:
raise TypeError(
'Cannot query device information. '
'Set latency to a number of seconds.'
) from error
latency = info['default_' + latency + '_' + kind + '_latency']
Comment on lines +2812 to +2839

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the if-conditions appear 3 times ... I think 2 times is enough. Maybe something like this (error messages can be improved):

Suggested change
if channels is None or samplerate is None or latency in ('low', 'high'):
try:
info = query_devices(device)
except PortAudioError as error:
required = []
if channels is None:
required.append('channels')
if samplerate is None:
required.append('samplerate')
if latency in ('low', 'high'):
required.append('numeric latency')
raise ValueError(
'Cannot determine {} stream parameters from device information; '
'specify {} explicitly or through defaults'.format(
kind, ', '.join(required))) from error
if channels is None:
channels = info['max_' + kind + '_channels']
if samplerate is None:
samplerate = info['default_samplerate']
if latency in ('low', 'high'):
latency = info['default_' + latency + '_' + kind + '_latency']
if channels is None or samplerate is None or latency in ('low', 'high'):
try:
info = query_devices(device)
error = None
except PortAudioError as e:
info = {}
error = e
if channels is None:
if error is None:
channels = info['max_' + kind + '_channels']
else:
raise TypeError('channels=None is not supported') from error
if samplerate is None:
if error is None:
samplerate = info['default_samplerate']
else:
raise TypeError('samplerate=None is not supported') from error
if latency in ('low', 'high'):
if error is None:
latency = info['default_' + latency + '_' + kind + '_latency']
else:
raise TypeError("latency='low'/'high' is not supported") from error

What do you think about that?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is clearer. I adopted that structure, raising TypeError for the first unresolved parameter and preserving the original PortAudioError as the cause. The messages indicate which value needs to be supplied explicitly or through defaults.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explicitly or through defaults

Where does this formulation come from? LLM?

I find it hard to understand.


try:
# If NumPy is available, get canonical dtype name
dtype = _sys.modules['numpy'].dtype(dtype).name
Expand All @@ -2764,13 +2848,14 @@ 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))
# Work around a PortAudio bug: direct ALSA streams with channels <= 0
# can abort the process in a C assertion instead of returning an error.
# https://github.com/spatialaudio/python-sounddevice/pull/644#discussion_r4370395754
if device == DEVICE_FROM_EXTRA_SETTINGS and parameters.channelCount <= 0:
raise ValueError('A positive channel count is required')
return parameters, dtype, samplesize, samplerate


Expand Down