Skip to content

Audio: fix the Linux ALSA backends and real-time threads - #966

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

jcelerier merged 18 commits into
masterfrom
fix/alsa-backends

Conversation

@jcelerier

@jcelerier jcelerier commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Fixes for the three Linux ALSA backends (zita-alsa-pcmi "ALSA raw", PortAudio, miniaudio) and for real-time threads, diagnosed on an Axe-Fx III (USB high speed, implicit feedback), B&W MM-1 (USB full speed), Logitech BRIO (capture only) and an HDA codec, then checked against the PortAudio 19.7.0, alsa-lib 1.2.16.1, Linux 7.3 sound/usb and miniaudio 0.11.25 sources.

Used by ossia/score#2363, which bumps this submodule.

Root causes found

  • Real-time threads were not real-time. set_thread_realtime() always asked for SCHED_FIFO 99 and ignored the error. With the usual RLIMIT_RTPRIO of 95 or 98, the kernel refuses the whole request, so the ALSA audio thread, the execution pool, the MIDI thread and the clock ran at normal priority. miniaudio's worker had the same problem (it asks for the maximum). PortAudio's own real-time mode only gives FIFO 1.
  • ALSA raw timeline at 1/3 speed. With dmix + dsnoop (unlinkable) and 2 periods, pcm_wait() waits for one stream while the other under-runs, every other cycle: 33 % of frames were processed and the pitch was 1 % off. zita also silently replaced the requested period with the device's.
  • PortAudio "cannot set parameters". stop() did not close the stream, so reopening the same hw: device failed with "Device unavailable" until the engine was destroyed.
  • miniaudio always used dmix/dsnoop. It used a period of 1024 x 3 whatever the buffer size, and duplex dropped out 11 to 16 times a second.

Changes

  • thread: the priority is honoured and clamped to RLIMIT_RTPRIO (Linux), a refusal is logged once, and set_current_thread_realtime() serves library-created threads. lock_process_memory() uses mlockall(MCL_CURRENT|MCL_FUTURE|MCL_ONFAULT), only with an unlimited memlock limit. preload_memory() reads a mapped file in on a background thread (MADV_POPULATE_READ, PrefetchVirtualMemory). unlock_memory() is for mapped data only the GUI reads.
  • alsa_engine (zita):
    • Start with 2 periods, 3 when the streams cannot be linked, and +1 (up to 4) when a short period keeps under-running; a count the device refuses falls back to the one that ran. Full-speed USB in low-latency mode cannot start with two periods under 2 ms.
    • The negotiated period is used and reported. Playback has to use capture's period exactly, and a mismatch has its own error. A capture that cannot be configured drops the inputs.
    • Dead devices are detected (poll errors take recover()'s result; POLLNVAL/HUP; DISCONNECTED), as are streams that under-run without ever processing. A suspended device is resumed, and capture is always prepared after an xrun.
    • Errors say which device and direction failed and why. A missing playback is an error, never a silent capture-only engine.
    • Planar copies; the output latency is measured with snd_pcm_delay(); the start thread owns its promise.
  • portaudio_engine:
    • stop() aborts and closes the stream. On ALSA, the latency is (n-1) x max(bs, the device's minimum period), with n = 4 below 2 ms and 3 otherwise, following pa_linux_alsa.c's period computation.
    • failed() includes a callback watchdog. xruns are counted once per host period.
    • The host API filter compared an index with a type id. Devices are matched across card renumbering.
  • miniaudio_engine: a share mode (exclusive with a shared fallback), explicit periods, MA_PTHREAD_REALTIME_THREAD_PRIORITY, ma_device_uninit on start failure, and real latencies.
  • audio_engine: failed(), an xruns counter, and input/output latencies.
  • pulseaudio_engine: running() was inverted (true while corked); stop() now corks; pa_operation leaks fixed.
  • libasound: card enumeration and hw_params test functions.

Verification

  • Every engine was exercised on the hardware with a standalone harness driving the real engine classes: timeline speed, xruns, scheduling class of the audio thread, restart on the same device, and error paths (busy device, unsupported rate, capture-only card).
  • Results: 100 % speed on every supported configuration, FIFO 95 on every audio thread, restarts succeed, and the error messages name the cause.
  • Not tested: macOS, Windows, WebAssembly (read only), PulseAudio in score (the backend is not registered), device unplug.
  • Known open: input and output on two different cards drift with no compensation; small buffers can fail there, and long sessions will slip.

🤖 Generated with Claude Code

jcelerier and others added 17 commits October 25, 2026 23:24
set_thread_realtime() always asked for SCHED_FIFO 99 whatever the caller
passed, and ignored the result. Without CAP_SYS_NICE the kernel refuses a
priority above RLIMIT_RTPRIO outright rather than lowering it, so with the
common limit of 98 (or 95) no ossia thread was real-time: not the ALSA audio
thread, the execution pool, the MIDI thread nor the clock.

The requested priority is now honoured, clamped to the policy range and to
the rlimit; a failure is logged and returned. The default becomes 95, below
the kernel's own priority-99 threads. set_current_thread_realtime() covers
threads created by an audio API. The stray pthread_setcancelstate(), which
acted on the calling thread, is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With dmix + dsnoop (e.g. "sysdefault", score's fallback device) capture and
playback cannot be linked; with 2 periods pcm_wait() waits for one stream
while the other under-runs, every other cycle: a third of the frames were
processed, so the timeline ran at a third of its speed, with dropouts.

- streams that cannot be linked start with 3 periods; with periods == 0 a
  start that keeps under-running is retried with one more period, up to 4
  (full-speed USB devices need more than 2 ms in two periods)
- the period the device chose is used: zita replaces the requested one with
  the nearest it accepts, the engine kept processing in requested-size chunks
- a device that stops answering, or under-runs continuously, ends the loop
  and running() turns false instead of polling forever
- pcm_start() failures throw; open, rate, period and format errors are
  reported per direction instead of zita's generic -2
- plug PCMs get the requested channel count instead of being cut to 2
- zita prefills the periods it obtained, not the ones requested
- capture and playback copy straight to the planar buffers
- input/output latencies and an xrun counter on audio_engine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stop() only stopped the stream: it stayed open until Pa_Terminate() in the
destructor, and score destroys a stopped engine later, from a timer. Opening
the same device again in the meantime, as a settings change does, failed with
"Device unavailable".

- stop() closes the stream
- on ALSA the latency comes from the buffer size (two buffers, at least 3 ms
  for full-speed USB): a fixed 10 ms made the period 256 frames for a buffer
  of 128
- the callback thread gets real-time priority (PortAudio's own is FIFO 1)
- status flags count as xruns; errors carry the device and host error text
- an ALSA device whose card index changed, "(hw:3,0)" becoming "(hw:2,0)",
  is still found

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In shared mode ALSA devices go through dmix/dsnoop, whose period (1024 x 3
here) is used whatever the buffer size, and duplex on one card drops out.
The engine takes a share mode: exclusive opens "hw:" with a period of the
buffer size and falls back to shared when the device is busy.

- ma_device_uninit() when ma_device_start() fails: the device stayed open
  for the life of the process
- errors carry ma_result_description()
- the callback thread gets real-time priority on Linux: miniaudio asks for
  the maximum, which the rtprio limit refuses
- input/output latencies from the device's period

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
running() cannot tell a host that the device went away: its meaning varies
per backend (PulseAudio's is true while corked). failed() is false unless the
stream stopped without stop() being called: the ALSA loop's dead-device and
continuous under-run guards, a PortAudio stream no longer active, a miniaudio
device in the stopped state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For listing hardware PCMs by card (snd_card_next, snd_ctl_pcm_next_device,
pcm_info) under stable names, and for offering only the rates and buffer
sizes a device accepts (snd_pcm_hw_params_test_*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zita initialises with whichever direction opened: with the output device
busy and an input on another card, the engine ran on capture alone, silent,
and reported success. A missing playback is now an error; a missing capture
still only removes the inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
running() returned pa_stream_is_corked(): true when paused, false while
playing (and true on error). audio_engine::sync() only waits for the audio
thread when the engine runs, so set_tick() never waited on this backend.
stop() now corks the stream: it kept playing until the destructor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mlockall(MCL_CURRENT | MCL_FUTURE | MCL_ONFAULT): a real-time thread never
waits for a page to come back from swap. MCL_ONFAULT locks pages as they are
first touched; without it every reserved range would be made resident (same
RSS here with and without locking, 652 MB). MCL_FUTURE makes every later
allocation fail beyond RLIMIT_MEMLOCK, so memory is only locked when that
limit is unlimited; otherwise the reason is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- the rtprio limit is compared as rlim_t: a limit of 2^31 or 2^32 narrowed
  to a negative or zero int, making std::clamp's lo > hi (UB)
- the priority is only clamped to the policy range and the rlimit on Linux:
  on macOS, where SCHED_FIFO tops out at 47, requests above it still fail
  as they did, rather than making pool, MIDI and clock threads real-time
- a refused priority is logged once per process, not once per thread
- set_current_thread_realtime() leaves a thread that already has a real-time
  policy (JACK, PipeWire choose their own), and audio callbacks can ask it
  not to log, which allocates and locks
- unlock_memory() for memory-mapped media files: with lock_process_memory()
  on, every page read from a mapped file stays resident, 45 MB of 45 MB
  after reading a file here, 0 after unlocking it
- sys/mman.h and sys/resource.h are only included where they exist

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

- a period count the device refuses (3 for unlinked streams, one more after
  under-runs) falls back to the count that configured and ran, instead of
  throwing
- a capture that cannot be configured drops the inputs, as a capture that
  cannot be opened already did
- the start check holds up the caller: it now only runs for periods of 5 ms
  or less, for at most about 130 ms (it took 250 ms at 512 frames and 2.2 s
  at 4096 on every start)
- the start thread owns the promise: the constructor could destroy it while
  set_value() was still running
- with capture unopened and playback misconfigured, the error is about
  playback

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

The callbacks ask for real-time priority without logging, and keep a
priority the audio server chose. PortAudio's own ALSA real-time mode is not
used: it runs the callback at SCHED_FIFO 1, which would now be kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pa_stream_cork() returns an operation that has to be unreferenced; each
start and stop leaked one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked against PortAudio 19.7.0's ALSA host:
- the period: the host asks for (bs + latency) / 4 rounded up to a multiple
  of bs and at least the device's minimum, and a buffer of latency + period.
  The latency is now (n - 1) periods of max(bs, the device's minimum period
  read through alsa-lib) rounded up to bs: n periods of that size. The 3 ms
  floor made the period 64 for a buffer of 32 at 48 kHz, 128 for 64 at
  96 kHz. n is 4 below 2 ms, 3 otherwise: with no retry possible, an HDA
  codec at 96 kHz / 64 ran at 31 % with 3 periods, 100 % with 4
- failed() also reports an active stream whose callback stopped: the host
  polls on without calling back when every period under-runs, or after an
  unplug that does not end the stream
- stop() aborts: stopping waits for the host's poll loop, seconds on a
  stalled device, and the ALSA host drops the buffer either way
- channel counts and latencies are set before the stream starts: the
  callback read them before they were assigned; latencies are rounded
- the xrun flags of one host period reach every callback it is split into:
  counted once per time stamp
- the host API filter compared an index with a type id: with ALSA as host
  API 0 it matched devices of every host API; "(plughw:" names are matched
  across card renumbering too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- poll errors take recover()'s result: an unplugged device (POLLERR |
  POLLNVAL, every ioctl -ENODEV) made recover() fail while pcm_wait still
  reported a recovered xrun, so the engine failed with "under-runs
  continuously" instead of "stopped responding"; POLLNVAL and POLLHUP count
  as errors (a POLLNVAL-only fd spun in the poll loop)
- recover() fails on a disconnected device and resumes a suspended one
- the capture side is prepared after an xrun even when linked: a plugin
  (plughw) only resets its own buffer pointers in its prepare, so capture
  could slip against playback after each xrun
- playback has to use exactly the period capture got: "near" could pick
  another one, which then failed the capture check as an unsupported buffer
  size; this now has its own error
- the channel count is the nearest the device has (some only have 2 and 8,
  or even counts), not the requested one clamped to min..max
- pcm_start() checks the space available (an error turned into a huge
  unsigned count) and the mmap areas; the engine loop checks them too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On USB the hardware pointer is where the driver submitted the frames, not
where they are heard (sound/usb/pcm.c): fsize x periods missed the packets
in flight and the codec FIFOs. The latency is now snd_pcm_delay() minus the
period just written, averaged over periods 8 to 15 after the start: 368
frames for an MM-1 at 128 (256 before), 287 for an HDA codec at 2 x 256
(512), 64 for a BRIO capture with MM-1 playback at 2 x 128, where playback
is only written once capture data arrives (256).

Linking capture and playback of different cards stays: it only groups
start and stop, but starting them together is what lets BRIO + MM-1 run on
2 periods at 128 frames; unlinked they under-ran with 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked against miniaudio 0.11.25's ALSA backend:
- periods are set as for PortAudio (4 when shorter than 2 ms, else 3)
  rather than left to MA_DEFAULT_PERIODS
- MA_PTHREAD_REALTIME_THREAD_PRIORITY 95: without it the worker is created
  at sched_get_priority_max (99), which the rtprio limit refuses, and
  miniaudio silently falls back to a normal thread
- the performance profile only matters without a period size: the retry
  with the conservative profile repeated the same request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reads a mapped range in from its file now (MADV_POPULATE_READ on Linux,
PrefetchVirtualMemory on Windows, one read per page otherwise), so that a
real-time thread reading it later does not wait for the disk; with
lock_process_memory() on, every page it reads stays resident. Measured on
XFS: a 256 MB file evicted from the page cache is 100 % resident and 256 MB
locked after it, before anything reads it. MADV_WILLNEED was not a
substitute: it only read the 256 kB readahead window.

PrefetchVirtualMemory is declared from _WIN32_WINNT_WIN8, which ossia pins
to 0x0A00 (mingw-w64 / llvm-mingw default: 0x601); checked with llvm-mingw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:45 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:45 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:45 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:45 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:45 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:45 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:45 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:47 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:47 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:47 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate October 26, 2026 17:47 — with GitHub Actions Inactive
@jcelerier
jcelerier merged commit a02ede2 into master Oct 28, 2026
46 of 47 checks passed

This branch was previously deployed

1 inactive deployment
Apple Certificate — f667e24b Deployed Oct 26, 2026 by jcelerier via build-macos (1, Release) #1310
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.

1 participant