Skip to content

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

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

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

Conversation

@whatisyourname0

@whatisyourname0 whatisyourname0 commented Oct 17, 2026 •

Copy link
Copy Markdown

This adds AlsaSettings(device_string) so an application can open an ALSA PCM address through PortAudio's PaAlsaStreamInfo.deviceString. Select it explicitly with device=sd.DEVICE_FROM_EXTRA_SETTINGS, corresponding to paUseHostApiSpecificDeviceSpecification.

This implements the ALSA device-string part of #4 through the existing platform-specific settings interface. It does not resolve all of #4; #85 proposes a broader host-API interface.

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('null'),
) as stream:
    data, overflowed = stream.read(1024)
    print(len(data), overflowed)

Replace 'null' with a hardware PCM address or a named ALSA configuration. A PCM address is not a persistent hardware identifier.

Behavior

  • AlsaSettings alone does not override device selection. Set the sentinel in the call or in sd.default.device. PortAudio rejects these settings with a regular ALSA device index.
  • Shared parameter handling queries device information only when channels, sample rate or symbolic latency require it. It has no AlsaSettings type checks. If lookup fails, it raises TypeError naming the first unresolved parameter and chains the original PortAudioError.
  • For direct streams, pass channels and sample rate or configure sd.default.channels and sd.default.samplerate. Latency must be numeric; 'low' and 'high' need device information unavailable for the sentinel. Format checks do not use latency.
  • Input, output, duplex and mixed direct/indexed duplex are covered. A directly specified direction reports -2 in stream.device.
  • Settings and their C buffers remain alive during the native open/format-check call. PortAudio's ALSA backend does not retain deviceString afterward, so streams do not keep the settings alive until close.
  • A small guard rejects non-positive channel counts for the sentinel to work around a PortAudio C assertion that can abort the process. Its comment links to the reviewer's reproduction. Embedded NUL characters are rejected to prevent address truncation.
  • Direct output with mapping=[1] in play(), or output_mapping=[1] in playrec(), needs enumerated device information for automatic stereo detection. Use OutputStream or Stream, respectively, with the channel count specified.

Compatibility and validation

Feature revision: e9a9f0d.

The separate regression branch at adfdf2b differs from that feature revision only by six files under tests/. The feature PR adds no test dependencies, CI steps or lockfile changes.

All 204 tests passed, without skips, on Linux ARM64 and CPython 3.14.7 against each of:

  • Installed PortAudio 19.6.0.
  • PortAudio built with ALSA from 1ca0eee, which reports 19.7.0-devel.

The suite includes 125 identical public API calls against base 626fcb0 and the candidate in separate processes using the same synthetic ALSA configuration. It covers index/name/default selection, blocking and callback input/output/duplex, configured and device-derived defaults, format checks and play()/rec()/playrec().

114 outcomes match exactly. Eleven cases assert three intentional differences:

  1. An invalid numeric device with fully specified parameters now fails during stream opening, rather than during device lookup.
  2. Format checks ignore invalid default latency.
  3. Nine cases with unavailable device information and missing channels and/or sample rate now raise TypeError, with the original PortAudioError as the cause. Code catching only PortAudioError needs to account for this changed failure path.

The remaining 79 cases cover direct-device behavior, errors, exact captured bytes, reopen/recovery and settings lifetime. Lifetime tests check ownership during native calls and exercise blocking/callback I/O after collection. Pyright, warning-as-error Sphinx HTML build, focused Ruff checks and git diff --check also passed.

See the test README for reproduction commands. Set SOUNDDEVICE_BASELINE_PATH to the built base source to include the 125 differential cases; without it those cases are explicitly skipped.

Limits

These runs use isolated ALSA plug/null/file PCMs. They do not establish physical-device behavior, hotplug, real-time performance, negotiated hardware buffer sizes, or native Windows/macOS compatibility. The final revision has not been rerun across the earlier Python-version matrix. PortAudio initialization may still enumerate devices; no performance improvement or automatic hardware-identity resolution is claimed.

@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 Outdated
Comment thread doc/api/platform-specific-settings.rst Outdated
Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
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?

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.

Moved these notes into the device_string section as well.

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 can still put it into a note directive, if you like.

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, I moved those details into a note.

Comment thread src/sounddevice.py Outdated
@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 Outdated
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')

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.

I removed the explicit type and empty-string checks. I retained the embedded-NUL check because C would silently interpret 'null\0different-device' as 'null', opening a different PCM than the supplied address. I added a regression test for that truncation.

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 is a quite unrealistic edge case, and I suggest we either document it or check it, but not both.

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.

Hmm, if that's the case, I kept the check and removed the duplicate at the following commit

Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
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.

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.

I removed the type and boolean checks, but found a problem when removing the positivity check: with PortAudio 19.6.0 on Linux ARM64, opening the direct 'null' PCM with channels=0 aborts the Python process inside PortAudio.

I therefore retained a minimal non-positive channel-count check for DEVICE_FROM_EXTRA_SETTINGS, after the normal CFFI conversion. The regression tests run these cases in subprocesses so a native abort is reported as a test failure.

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.

Is this CPU-specific?
I can reproduce it on x86_64.

This seems to be a bug in PortAudio. Would you like to check if that has already been reported?

@whatisyourname0 whatisyourname0 Oct 25, 2026 •

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.

ARM64 was my test environment (Macbook M4 Pro). Thanks for checking x86_64 too.

Besides, I couldn’t find an existing PortAudio issue for this validation. Maybe I should check it out at the PortAudio.

Comment thread src/sounddevice.py Outdated
Comment thread src/sounddevice.py Outdated
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?

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.

@whatisyourname0

Copy link
Copy Markdown
Author

Thanks for the detailed review! I’ve pushed the changes in 7641867 and replied to the individual comments. I kept two small checks for the native abort and address-truncation cases described above.

I also updated the separate regression-test branch. All 133 tests pass on Linux ARM64 with Python 3.14.7 and PortAudio 19.6.0, including 74 baseline comparisons. Type checking and the documentation build pass too. These tests use ALSA null/file PCMs; they don’t establish physical-device or other-platform compatibility.

Comment thread src/sounddevice.py Outdated
Comment on lines +830 to +831
# Keep the settings and their C buffers alive for the stream.
self._extra_settings = iextra, oextra

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 returning the extra settings from _get_stream_parameters() is necessary.
Instead, you can just keep alive the extra_settings the user passed in.

The only downside is that if a pair of extra settings are given, both are kept alive, even in input-only and output-only streams. But users will probably not create a pair when they only need a single settings object.

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 that I'm thinking about it ... why does it even need to be kept alive?

I assume the device string is only accessed in Pa_OpenStream and never again?

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.

Removed the extra return value and, following your later comment, the stream-owned references too at the following commit.

Thanks for pointing out!

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.

Checked the PortAudio 19.6.0 and current upstream branch, and neither retains the string after opening.

Removing the references and verified that blocking and callback I/O work after the settings are collected is the right behavior in this time, I think.

Comment thread src/sounddevice.py Outdated
device, channels, sampleformat, latency,
extra_settings._streaminfo if extra_settings else _ffi.NULL))
return parameters, dtype, samplesize, samplerate
# PortAudio's ALSA direct-device path asserts on non-positive channels.

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, because "assert" has very different behavior in C and Python.

Also, it would be good to mention that this works around a bug in PortAudio, and ideally an issue link should be provided.

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 for pointing it out, as I'm pretty confused how they behaveonf those both language) I clarified that tihs works around a PortAudio C assertion that can abort the process, and linked to your reproduction.

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.

explicitly or through defaults

Where does this formulation come from? LLM?

I find it hard to understand.

@whatisyourname0

Copy link
Copy Markdown
Author

explicitly or through defaults
Where does this formulation come from? LLM?
I find it hard to understand.

Yes to be honest, that wording came from LLM-assisted slop drafting, because of usage of my LLM to convert & double check my English expression. My apologies for the slops :-(

Anyway, I replaced it with direct instructions like “Set channels to an integer.” from the following commit.

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