Repository navigation
Audio: fix the Linux ALSA backends and real-time threads - #966
Merged
Merged
Conversation
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>
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
jcelerier
had a problem deploying
to
Apple Certificate
October 26, 2026 15:21 — with
GitHub Actions
Error
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
temporarily deployed
to
Apple Certificate
October 26, 2026 17:45 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:45 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:45 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:45 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:45 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:45 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:45 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:47 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:47 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:47 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
October 26, 2026 17:47 — with
GitHub Actions
Inactive
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/usband miniaudio 0.11.25 sources.Used by ossia/score#2363, which bumps this submodule.
Root causes found
set_thread_realtime()always asked for SCHED_FIFO 99 and ignored the error. With the usualRLIMIT_RTPRIOof 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.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.stop()did not close the stream, so reopening the samehw:device failed with "Device unavailable" until the engine was destroyed.Changes
thread: the priority is honoured and clamped toRLIMIT_RTPRIO(Linux), a refusal is logged once, andset_current_thread_realtime()serves library-created threads.lock_process_memory()usesmlockall(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):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.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, followingpa_linux_alsa.c's period computation.failed()includes a callback watchdog. xruns are counted once per host period.miniaudio_engine: a share mode (exclusive with a shared fallback), explicit periods,MA_PTHREAD_REALTIME_THREAD_PRIORITY,ma_device_uniniton start failure, and real latencies.audio_engine:failed(), anxrunscounter, and input/output latencies.pulseaudio_engine:running()was inverted (true while corked);stop()now corks;pa_operationleaks fixed.libasound: card enumeration andhw_paramstest functions.Verification
🤖 Generated with Claude Code