Skip to content

Add AlsaSettings for opening an ALSA PCM by address - #644

Open
whatisyourname0 wants to merge 5 commits into
spatialaudio:masterfrom
whatisyourname0:feat/alsa-direct-device
Open

whatisyourname0 wants to merge 5 commits into
spatialaudio:masterfrom
whatisyourname0:feat/alsa-direct-device

Conversation

@whatisyourname0

@whatisyourname0 whatisyourname0 commented Oct 17, 2026 •

Copy link
Copy Markdown

Applications with an ALSA PCM address can open it directly through AlsaSettings(device_string=...), using PortAudio's PaAlsaStreamInfo.deviceString. Direct device selection is explicit: pass device=sd.DEVICE_FROM_EXTRA_SETTINGS, corresponding to PortAudio's paUseHostApiSpecificDeviceSpecification.

This implements the ALSA device-string item discussed in #4 through the existing platform-specific settings interface. Related PR #85 proposes a broader host-API interface. This change does not resolve all of #4.

import sounddevice as sd

# ALSA's null PCM requires no physical audio device.
with sd.RawInputStream(
    device=sd.DEVICE_FROM_EXTRA_SETTINGS,
    samplerate=48000,
    channels=2,
    dtype='int16',
    blocksize=1024,
    latency=4096 / 48000,
    extra_settings=sd.AlsaSettings(device_string='null'),
) as stream:
    data, overflowed = stream.read(1024)
    print(len(data), overflowed)

Replace 'null' with a hardware PCM address or named ALSA configuration as needed.

API behavior

  • AlsaSettings alone does not override default.device. Select DEVICE_FROM_EXTRA_SETTINGS explicitly, either in the call or in default.device. PortAudio rejects ALSA settings combined with a regular device index; the error is propagated.
  • Shared stream-parameter and convenience-function code no longer checks for the AlsaSettings type. Device information is queried only when required for channel-count/sample-rate defaults or symbolic latency. A failed lookup raises a ValueError naming the parameters that require explicit values, with the original PortAudioError preserved as __cause__. This handling is host-API-independent.
  • Direct streams require explicit or configured channel count, sample rate and finite nonnegative numeric latency. 'low' and 'high' require enumerated device information and raise an error for the sentinel.
  • Format checks ignore latency, as PortAudio does, and do not query device information solely for it.
  • Input, output, direct duplex and mixed direct/indexed duplex are supported. stream.device reports -2 for a directly specified direction. Settings and their C buffers remain alive through stream close.
  • Convenience output with explicit mapping=[1] needs device information for automatic stereo detection, so it fails for direct devices. Use OutputStream with an explicit channel count instead.

Validation and separate tests

The feature PR includes implementation, API documentation and the documentation-only PortAudio stub update needed to expose the constant. It adds no test infrastructure, dependencies, CI steps or lockfile changes.

The updated suite and standalone instructions are on test/alsa-direct-device-regressions, pinned at 5411e0d. That branch differs from feature commit a942c46 only by the five files under tests/.

Current local validation:

  • 132 passed on Linux ARM64, CPython 3.14.7, PortAudio 19.6.0, using an installed wheel built from feature commit a942c46 and the separate test suite. Tests use isolated ALSA null/file PCMs and require no physical devices.
  • The differential suite runs 74 identical public API calls in separate baseline (626fcb0) and candidate processes, using the same ALSA configuration. 72 outcomes match exactly, including index/name/default selection, raw input/output/duplex, format checks and float32 input callbacks. Two intentional differences are asserted explicitly: an invalid numeric device now fails during opening instead of device-info lookup, and format checks ignore invalid default latency. No other differences were observed in this Linux matrix.
  • To include those 74 differential cases, build the baseline and set SOUNDDEVICE_BASELINE_PATH as documented in the test README. Without that environment variable, those cases are explicitly skipped and the 58 direct-ALSA cases run.
  • Additional error tests cover combined missing parameters, format checks that never request latency, exception chaining and propagation of unrelated exceptions without wrapping.
  • Coverage includes byte-exact input, reopening after failure, explicit/default sentinel selection, unchanged index/name selection, missing defaults and symbolic-latency errors, generic query-failure propagation and recovery, settings without an ALSA class identity, mixed duplex and settings lifetime.
  • Import/device/version smoke checks, Pyright source analysis, warning-as-error Sphinx HTML build, focused Ruff checks on the tests, git diff --check, and uv lock --check passed.

To run the separate tests against this PR on Linux with PortAudio installed:

# In the fork checkout, on feat/alsa-direct-device:
git fetch origin test/alsa-direct-device-regressions
validation_dir=$(mktemp -d /tmp/sounddevice-alsa.XXXXXX)
git archive 5411e0d6d2ec0f9926c08dfe006ebfb39f15dce8 tests | tar -x -C "$validation_dir"
python -m pip install . pytest numpy
python -m pytest -q -o cache_dir="$validation_dir/.pytest_cache" "$validation_dir/tests"

The fixture sets its isolated ALSA configuration before PortAudio initialization and cleans up its temporary PCM configuration/data. The extracted test files remain in $validation_dir until manually removed. Testing infrastructure can be discussed separately in the context of #14 and #131.

Limits

The revised sentinel API has not been rerun across the earlier multi-version Python matrix or on physical hardware. Native Linux USB capture, hotplug and native macOS/Windows runtime behavior remain unverified. Local external link checking was not repeated; hosted checks are tracked on this PR.

An ALSA PCM address is not persistent hardware identity. Serial lookup, hotplug resolution and application recovery remain outside this patch. PortAudio initialization may still enumerate devices. No performance improvement or negotiated hardware buffer size is claimed.

Earlier validation of the pre-sentinel candidate (not rerun for this revision)

At original candidate 07f9935, CPython 3.7–3.14 passed the original 33 synthetic tests per version; PyPy 3.9/3.11 passed 32 with one optional NumPy skip. Python 3.7/3.8 used a wheel built with Python 3.14, so those results establish runtime compatibility only. The original suite and instructions remain available at that immutable commit.

Earlier physical input check

A separate local run used a MacBook Pro built-in microphone through Apple Virtualization.framework and an experimental virtio-snd guest kernel. The Linux runtime was a chroot containing Python 3.14.7, PortAudio 19.6.0 and ALSA 1.2.14. The installed sounddevice source matched this candidate byte-for-byte.

arecord completed two 10-second captures. A C program using PaAlsaStreamInfo.deviceString completed 60 seconds and a 5-second reopen. With query_devices() forbidden, AlsaSettings('hw:0,0') completed 60 seconds (2,880,000 mono frames), two 5-second reopens and recovery after a nonexistent PCM failed. All captures had nonzero signal, with no reported read errors or overflows. The host input route was monitored as the built-in microphone. Audio samples were processed in memory; only aggregate measurements were saved.

This establishes physical input through that particular VZ guest, including experimental capture compatibility changes. Native Linux USB devices, hotplug and stock virtio-snd remain unverified. The capture trial did not run the recorder or Docker Compose. Guest shutdown did not complete within the harness timeout and required a second launcher signal to force-stop the test VM; orderly VM shutdown was not established.

@mgeier

mgeier commented Oct 18, 2026

Copy link
Copy Markdown
Member

Thanks for this PR!

It seems to add two (or more?) different things, though: a new ALSA setting and a whole new testing framework.

I think both are valuable, but they should be discussed separately.

Regarding tests, there are already some discussions in #14 and #131, and there has been a very recent PR that also comes with its own test framework: #645.

@whatisyourname0

Copy link
Copy Markdown
Author

Thanks for the review! I agree that adding AlsaSettings and introducing test infrastructure should be discussed separately.

I'll update this PR to focus on the ALSA PCM-address support and its documentation, removing the new test suite, test dependencies, and test-related CI changes from the diff. I'll preserve the regression tests on a separate branch and keep the validation results and reproduction instructions available for reviewing the feature.

Thanks also for pointing me to #14, #131, and #645. I'll follow up on testing separately, building on those discussions before proposing a shared approach.

@whatisyourname0

Copy link
Copy Markdown
Author

Done in 8e0aeba: this PR now contains only the ALSA PCM-address support and its documentation. The tests and reproduction instructions are preserved on test/alsa-direct-device-regressions, with links and validation details in the updated PR description.

@mgeier

mgeier commented Oct 22, 2026

Copy link
Copy Markdown
Member

Thanks for the update!

I like the AlsaSettings class, but I don't like its special handling in code that should ideally be platform-independent.

Would overriding default.device when AlsaSettings is supplied fit the existing API, or should direct selection require an explicit sentinel?

I think an explicit sentinel would be better, as it would make the implementation more straightforward and it would be closer to PortAudio's C API.

Maybe something like

DEVICE_FROM_EXTRA_SETTINGS = _lib.paUseHostApiSpecificDeviceSpecification

Maybe you can come up with a better name?

Your example would then look something like this:

with sd.RawInputStream(
    device=sd.DEVICE_FROM_EXTRA_SETTINGS,
    samplerate=48000,
    channels=2,
    dtype='int16',
    blocksize=1024,
    latency=4096 / 48000,
    extra_settings=sd.AlsaSettings(device_string='null'),
) as stream:
    data, overflowed = stream.read(1024)
    print(len(data), overflowed)

Is requiring numeric latency acceptable for this path?

It is kinda annoying, but I guess we don't really have a choice, right?

However, I would also prefer the handling of this to be less host-API-specific.

Whenever 'low' or 'high' is given and query_devices(device) is not successful, we could raise an error. There is no need to make any of this specific to ALSA or any other host API.

@whatisyourname0

Copy link
Copy Markdown
Author

Thanks for the review, and that makes sense. Your proposed name communicates the intent clearly than mine.

I'll also rework the shared parameter handling around whether device information is available, removing the AlsaSettings type checks from that path. If 'low' or 'high' latency requires device information that cannot be queried, it should produce an error. I'll apply the same principle to channel-count, and sample-rate defaults.

I'll update the examples and the separately maintained regression tests to cover the changed behavior.

@whatisyourname0

whatisyourname0 commented Oct 23, 2026 •

Copy link
Copy Markdown
Author

Updated and pushed in ffcda44: device selection now uses DEVICE_FROM_EXTRA_SETTINGS, and shared parameter handling no longer checks for AlsaSettings. The examples and PR description are updated too.

@mgeier mgeier left a comment

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.

Here's a first round of review ... I must postpone the rest because I couldn't manage to run this yet, I always get a sounddevice.PortAudioError: Error querying device -2. Is there anything special I need to do for this to work?

Comment thread src/sounddevice.py
* Module-wide default settings: `default`

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

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.

You could also add the new class here.

Comment on lines +18 to +21
Pass this as ``device`` to select a device through ``extra_settings``.
Device information is unavailable for this value, so channels and sample
rate must be set explicitly or through defaults, and stream latency must
be numeric. Format checks do not require latency.

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.

Why are you adding this text here instead of sounddevice.py?

Also, this text seems a bit long ... what about linking to AlsaSettings (which is AFAIK the only host API that supports it) and having all the details documented there, avoiding some repetition?

Comment thread src/sounddevice.py
if prime_output_buffers_using_stream_callback:
stream_flags |= _lib.paPrimeOutputBuffersUsingStreamCallback

# Retain effective settings, including their C buffers, through close.

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.

This comment is hard to understand. I guess it's about what in other places is called "keep alive"?

Comment thread src/sounddevice.py
Comment on lines +815 to +818
if extra_settings is None:
extra_settings = tuple(default.extra_settings)
self._extra_settings = (tuple(_split(extra_settings)),
tuple(default.extra_settings))

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.

Can you explain how that combination of tuple and _split() and handling of default is supposed to work?

Note that _split() is also used later, and _get_stream_parameters() is supposed to handle default values.

Comment thread src/sounddevice.py
Comment on lines +1674 to +1676
*extra_settings*, e.g. with `AlsaSettings`. In this case,
channels, sample rate and numeric latency must 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.

Again, I don't think this needs to be repeated here:

Suggested change
*extra_settings*, e.g. with `AlsaSettings`. In this case,
channels, sample rate and numeric latency must be supplied
explicitly or through defaults.
*extra_settings*, e.g. with `AlsaSettings`.

Comment thread src/sounddevice.py
class AlsaSettings:

def __init__(self, device_string):
"""ALSA-specific settings for opening a PCM by address.

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.

The other classes use something like this:

Suggested change
"""ALSA-specific settings for opening a PCM by address.
"""ALSA-specific input/output settings.

You can also use the next sentence from the other classes, to keep them consistent.

Comment thread src/sounddevice.py
Comment on lines +2337 to +2341
Use as ``extra_settings`` with
``device=sd.DEVICE_FROM_EXTRA_SETTINGS``. Supplying these settings
alone does not override `default.device`. Channels and sample rate
must be supplied explicitly or through defaults. Streams also
require a numeric latency in seconds.

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.

I think this should be moved to the documentation of device_string, because theoretically other things might be added in the future, then this paragraph might not make sense anymore.

Comment thread src/sounddevice.py
Comment on lines +2350 to +2357
Notes
-----
The stream's device attribute reports PortAudio's special value
``-2`` when `DEVICE_FROM_EXTRA_SETTINGS` is selected.
PortAudio rejects these settings with a regular ALSA device index.
For direct output with ``mapping=[1]`` in `play()` or `playrec()`,
use `OutputStream` with explicit channels instead; automatic
stereo detection requires enumerated device information.

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.

I think these notes also belong into the device_string section?

Comment thread src/sounddevice.py
@@ -690,7 +693,7 @@
"""
parameters, dtype, samplesize, samplerate = _get_stream_parameters(
'input', device=device, channels=channels, dtype=dtype, latency=None,

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.

This whole for_check argument seems unnecessarily complicated, why not just pass 0 as latency?

Suggested change
'input', device=device, channels=channels, dtype=dtype, latency=None,
'input', device=device, channels=channels, dtype=dtype, latency=0,

@whatisyourname0

Copy link
Copy Markdown
Author

Thanks for trying this. I reproduced the same error when channels, samplerate, or numeric latency is omitted. In particular, omitting latency leaves the default 'high', which requires device information that isn't available for the sentinel. The current error doesn't explain that clearly.

This minimal example works locally on Linux with the current PR head:

import sounddevice as sd

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

If this exact example also fails on your setup, could you share the full traceback and your PortAudio version? That should help identify which device-information lookup is being reached.

Thanks also for the inline review comments. I'll address those separately.

@mgeier

mgeier commented Oct 24, 2026

Copy link
Copy Markdown
Member

Thanks, that was it!

As I mentioned in #644 (comment), you should check for failure (namely for a PortAudioError) when calling query_devices() and then, depending on what was given by the user, raise a different error (maybe wrapping the original exception?) explaining the problem.

This way, we would get meaningful error messages for missing channels, samplerate and latency.

@whatisyourname0

Copy link
Copy Markdown
Author

Thanks for confirming! I misunderstood that part of your earlier suggestion and only propagated the original error. I'll make a change that catches PortAudioError from query_devices() and report which parameters need explicit values, while preserving the original exception as the cause. This change will make it stay host-API-independent.

@mgeier mgeier left a comment

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.

Here's the rest of my review, let me know if anything is unclear!

Comment thread src/sounddevice.py
Comment on lines +2370 to +2373
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')

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.

I think those errors are not really necessary:

Suggested change
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')

Comment thread src/sounddevice.py
Comment on lines +2374 to +2375
_check(_lib.Pa_HostApiTypeIdToHostApiIndex(_lib.paALSA),
'ALSA host API is unavailable')

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.

What is this check for?

Comment thread src/sounddevice.py
Comment on lines +2842 to +2843
if extra_settings is None:
raise ValueError('DEVICE_FROM_EXTRA_SETTINGS requires extra_settings')

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.

I don't think this error is necessary:

Suggested change
if extra_settings is None:
raise ValueError('DEVICE_FROM_EXTRA_SETTINGS requires extra_settings')

Without it, the error is

sounddevice.PortAudioError: Error opening RawInputStream: Invalid device [PaErrorCode -9996]

I think that's good enough for this niche use case.

Comment thread src/sounddevice.py
Comment on lines +2844 to +2846
if (isinstance(channels, bool) or
not isinstance(channels, numbers.Integral) or channels < 1):
raise ValueError('A positive channel count is required')

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.

Why would you check this?

We are nowhere else checking for positivity or non-bool-ness. I think the behavior is reasonable enough without this check.

Comment thread src/sounddevice.py
Comment on lines +2847 to +2850
if (isinstance(samplerate, bool) or
not isinstance(samplerate, numbers.Real) or
not math.isfinite(samplerate) or samplerate <= 0):
raise ValueError('A positive finite sample rate is required')

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.

Same here, why this check?

Comment thread src/sounddevice.py
Comment on lines +2851 to +2854
if (isinstance(latency, bool) or
not isinstance(latency, numbers.Real) or
not math.isfinite(latency) or latency < 0):
raise ValueError('Finite numeric latency >= 0 is required')

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.

Same question here.

This is not about 'high'/'low', right? This was already checked before ...

Comment thread src/sounddevice.py
Comment on lines +2817 to +2837
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']

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?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants