Skip to content

Audio: fix the Linux ALSA backends and their settings - #2363

Merged
jcelerier merged 24 commits into
masterfrom
fix/alsa-backends
Oct 28, 2026
Merged

jcelerier merged 24 commits into
masterfrom
fix/alsa-backends

Conversation

@jcelerier

@jcelerier jcelerier commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Fixes for the Linux audio backends: ALSA (raw), ALSA (PortAudio), ALSA (MiniAudio). Depends on ossia/libossia#966, which this branch's submodule points to.

Reported symptoms, all reproduced on an Axe-Fx III, B&W MM-1, Logitech BRIO and an HDA codec:

  • "The desired audio settings could not be applied" when changing settings
  • no sound after a settings change
  • ALSA raw playback too slow on the Axe-Fx

Root causes

  • Slow playback. When the saved device was unknown (e.g. after using miniaudio), ALSAFactory::initialize() fell back to sysdefault with at least one input. That is dmix + dsnoop in duplex, which ran at 33 % speed (fixed in libossia, and no longer the fallback).
  • "Could not be applied", PortAudio. A restart reopened the device while the previous engine still held it, so it failed until the reaper timer destroyed that engine.
  • "Could not be applied", ALSA raw. The panel offered rates and buffer sizes from min..max ranges; surround* PCMs opened with none of the 77 combinations offered. snd_device_name_hint lists no hw: device at all.
  • No sound after switching drivers. The previous driver's panel reacted to the new driver's initialize() and queued "No device" as the output, which was committed on OK.

Changes

  • ALSA (raw) panel:
    • Cards enumerated as hw:CARD=<id>,DEV=<n> (stable across USB re-enumeration), plus plughw: and default "with conversion".
    • Each direction probed as the engine opens it, so only rates and buffer sizes that open are offered.
    • Separate output and input devices, capture-only devices included; channel counts.
    • Busy devices stay listed, "(in use)", with what they offered when free. A note when input and output are on different cards.
  • ALSA (MiniAudio): exclusive access (hw:, period = buffer size). Ids stored as :CARD=<id>,DEV=<n>, with old :N,M ids converted. A missing device is asked for by name instead of silently using the default device.
  • ALSA (PortAudio) panel: separate devices and channel counts, rates from Pa_IsFormatSupported, no busy retries while probing, and devices matched across card renumbering.
  • Engine start and lifetime:
    • The error dialog shows why ("ALSA: cannot open hw:CARD=MM1,DEV=0 for playback: Device or resource busy").
    • The stopped engine is destroyed before a restart.
    • An engine whose stream died is stopped, reported and freed.
  • Settings:
    • On a driver change, the previous panel is removed first and its queued device/rate/channel values are dropped (SettingsCommandDispatcher::discardDeferred).
    • A rate or buffer size returned to its current value drops the queued one.
  • Dropout counter: a label next to the engine button, hidden at 0, with the period and latencies in its tooltip.
  • "Lock memory" setting, on by default (mlockall with MCL_ONFAULT, only with an unlimited memlock limit). Sound files the audio thread reads from a mapping (WAV) stay locked and are read in by a background thread when loaded, as a sampler loads its samples, so playback never waits for the disk. Only the GUI's waveform cache is unlocked.
  • Lazy scans: the ALSA raw, PortAudio and miniaudio device scans no longer run at startup unless that driver is used (~1.5 s, and probes of the jack/pulse plugins).

Verification

  • Settings flows driven in the running app on a private display:
    • switching from miniaudio to ALSA raw,
    • input/output selection and the lists offered,
    • a busy device's error dialog,
    • PortAudio restarts on the same device,
    • the dropout counter,
    • the Lock memory toggle and RSS (same with and without locking).
  • Engines measured on the hardware through libossia#966's harness.
  • Not tested: macOS, Windows, WebAssembly builds (the shared code paths were kept behind the existing platform guards), and acoustic checks after the fixes.

Known open

Input and output on two different cards (e.g. BRIO in, Axe-Fx out) drift with no compensation. Small buffers can fail there (the engine now reports it), and long sessions will slip every few minutes. A playback-driven loop with a resampled capture FIFO would fix it; that is not in this PR.

🤖 Generated with Claude Code

jcelerier and others added 22 commits October 26, 2026 00:07
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Selecting a driver initializes it, which changes the model while the
previous driver's panel is still alive: the miniaudio panel, not finding the
new device name in its list, reset its combo box and queued
"No device" as the output, committed with OK. Switching from miniaudio to
ALSA (raw) gave an engine without output, silently.

The previous panel is removed before the driver changes, and the device,
channel, rate and buffer size values it queued are dropped
(SettingsCommandDispatcher::discardDeferred).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The device list came from snd_device_name_hint, which has no hw: entry at
all: sysdefault, surround*, iec958, usbstream and the plugin PCMs. Rates and
buffer sizes were the standard values within min..max of an unconstrained
configuration; with zita, surround21/41/50/51 opened with none of the 77
combinations offered, sysdefault with 21 of 49. The fallback for an unknown
device was sysdefault with at least one input: dmix and dsnoop in duplex,
the configuration that ran at a third of the speed.

- cards are enumerated (snd_card_next, ctl) as hw:CARD=<id>,DEV=<n>,
  stable when USB devices enumerate in another order, with the card's name;
  plughw: and "default" follow as "with conversion"
- each direction is probed as the engine opens it (mmap access, a format
  zita handles) and lists the rates and buffer sizes it accepts at each rate
- separate output and input devices, capture-only devices included; channel
  counts within the device's range; different cards are pointed out
- a busy device stays listed, "(in use)", with what it offered when free
- names from another backend are replaced by the first hardware card,
  without inputs; an ALSA name that is not plugged in is kept
- the scan happens when the driver is used, not at startup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the ALSA factory asks for exclusive access: "hw:" with a period of the
  buffer size instead of dmix's 1024 x 3, which also dropped out 11 to 16
  times a second in duplex; the engine falls back to shared when busy
- ids ":<card index>,<dev>" are stored as ":CARD=<card id>,DEV=<dev>",
  which miniaudio still prefixes with hw/dmix/dsnoop; saved ":3,0" ids
  are converted when read
- a saved device that is not listed is asked for by name: a zero id means
  the system default device, which played instead of the unplugged one
- devices are scanned on first use instead of in the constructor (about
  1 s, and it probes the jack and pulse plugins)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PortAudio's ALSA names end with the card index, "MM-1: USB Audio (hw:3,0)".
When it changes, the saved device was not found and initialize() replaced
it with the default device. The exact name is tried first, then the same
name without the index. Pa_Initialize (about 370 ms) now runs when the
driver is used, not at every startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the exception text reaches the warning: "ALSA: cannot open
  hw:CARD=MM1,DEV=0 for playback: Device or resource busy" instead of
  "The desired audio settings could not be applied"
- restart_engine() destroys the stopped engine before opening the new one:
  a device can only be opened once
- an engine whose stream died (audio_engine::failed(): unplugged device,
  continuous under-runs) is stopped and reported

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A label in the audio toolbar shows the xruns the engine counted since it
started, hidden while there are none; its tooltip has the period and the
latencies. Nothing showed them: a glitching configuration was only audible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cept

The panel had one device for both directions, with every channel it has
(8 in and 8 out on an Axe-Fx), and the generic rate list, which PortAudio's
ALSA host refuses when the hardware lacks the rate. Output and input are
chosen separately with their channel counts; the rates are the ones
Pa_IsFormatSupported accepts for that choice. A device saved for a
direction it does not have (from the single-device panel) reads as none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
failed() is also reported for a device that under-runs on every period,
e.g. a full-speed USB device asked for a period shorter than its packets
when four periods were not enough.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "Lock memory" setting, on by default, keeps score's memory resident
(ossia::lock_process_memory) from startup, and unlocks it when turned off.
It needs an unlimited memlock limit; with a finite one score runs as before
and logs why memory is not locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…leaks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With "Lock memory" on, mlockall(MCL_FUTURE) locks every later mapping,
files included: each page of a sound file, waveform cache or mapped file
port read so far stayed in RAM, and a project with gigabytes of audio could
not have its page cache reclaimed. The long-lived file mappings are
unlocked right after mapping (ossia::unlock_memory).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- an engine whose stream died gets no more callbacks, so it never set
  stop_received and stayed in previous_audio until exit: it is destroyed
  right after stop(), which closed its stream and joined its thread
- the panel's rate or buffer size going back to the current value now drops
  the value queued before: it was applied on accept while the panel showed
  the current one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the panel keeps PortAudio initialized for its lifetime (released around
  Rescan, which has to enumerate again) and caches the rates per devices
  and channel counts: each spin box step re-enumerated every ALSA device
  and opened the device eight times
- a device found under another card index is stored under its current
  name, so its rates are listed and the setting follows it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- what a PCM offered when free is remembered per direction: a card whose
  capture is always held never cached its playback
- no rate or buffer size of 0 is queued when the lists are empty
  (SCORE_DISABLE_ALSA)
- the pcm/card info buffers are allocated once per scan, not per card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pa_IsFormatSupported opens the device, retrying a busy one for a second
(pa_linux_alsa.c, busyRetries_ = 100 x 10 ms): with the device in use, by
score's own engine for instance, each panel update froze for eight seconds.
Busy retries are off while probing, and the first busy answer ends it.
Rescan enumerates once instead of twice; PortAudio keeps its device list
while a PortAudio engine runs, which the comment now says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
snd_pcm_hw_params_test_* each check one parameter with all the others
free: a buffer of 4 x 128 frames passed with a period of 512. The period is
now set on a copy before testing the buffer. The channel count is set to
the nearest of 2 first, as period sizes can depend on the frame size (HDA
periods are whole multiples of 128 bytes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…priority

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jcelerier and others added 2 commits October 26, 2026 13:45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit unlocked the mapping of WAV files, but the audio thread
reads the samples straight from it: an evicted page meant a disk read in
the audio callback, and WAV became the only format that could be evicted
(the decoded ones are heap memory, locked like the rest). The mapping stays
locked, and a background thread reads the whole file in when it is loaded,
as a sampler loads its samples: playback never waits for the disk, and
with "Lock memory" on the samples then stay in RAM. Mapped avnd file ports
stay locked too, as their non-mapped path is heap memory. Only the waveform
cache, which only the GUI reads, is unlocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 48.814% (-0.3%) from 49.148% — fix/alsa-backends into master

@jcelerier
jcelerier merged commit c72f0fb into master Oct 28, 2026
58 of 61 checks passed
@jcelerier
jcelerier deleted the fix/alsa-backends branch October 28, 2026 02:42
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