Skip to content

audio: test what each card sends to the host, end to end - #617

Open
jamesbraid wants to merge 2 commits into
mainfrom
ci/audio-real-device
Open

jamesbraid wants to merge 2 commits into
mainfrom
ci/audio-real-device

Conversation

@jamesbraid

@jamesbraid jamesbraid commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Nothing in CI hears what a sound card sends to the host, so a card that plays nothing, or comes back from an underrun a sample late (which swaps its channels for good), stays green. This tests every card through the [audio] file backend from the PR below, and runs the real output path in CI on Linux.

Two commits:

  1. Per-card tests for HAL2, O2 audio and RAD1, and the bare host output: the card built as a machine builds it, a tone from guest memory, then a tone with a different frequency per channel across a 0.3 s stop. Each runs into the WAV file and through the host's real default output recorded back from a loopback named by IRIS_AUDIO_LOOPBACK (SKIPPED when unset). The real-device tests run one at a time: a real-audio nextest group, a lock under plain cargo test. main's nextest profiles are untouched.
  2. scripts/audio-loopback-test.sh sets up a PulseAudio null sink and checks it before running anything, and the Linux x86-64 Test job runs the real-device tests against it (ALSA host, same build and ${{ matrix.features }} as the main run). TESTING.md says how to run it and what it does not cover.

The PulseAudio host stalled on a dev build with unoptimised dependencies; main's opt-level = 1 for dependencies avoids it (root cause: the pulseaudio crate zero-fills 1 MiB per socket read: 19 ms at opt-level 0, 0.06 ms at opt-level 3). The PulseAudio host stays out of the required CI step until the roughly 2-in-85 intermittent underrun seen at opt-level 3 is re-measured at opt-level 1.

Verification: with IRIS_AUDIO_LOOPBACK unset the four real-device tests print SKIPPED and pass; in a container with a PulseAudio 17 null sink they pass through the ALSA host. Deliberate breaks (stopped clock, zeroed samples, swapped channels, ignored mute, never-draining file, resuming one sample late) failed them on the original build of these commits and were not repeated after the rebase. The CI step has not run on a hosted runner.

@jamesbraid
jamesbraid added this pull request to stack #618 November 12, 2026 02:26
@jamesbraid jamesbraid changed the title ci/audio real device host_audio, ci: test host audio end to end through a real output device Nov 12, 2026
@jamesbraid
jamesbraid marked this pull request as ready for review November 12, 2026 02:26
@jamesbraid
jamesbraid removed this pull request from stack #618 November 12, 2026 02:27
@jamesbraid
jamesbraid added this pull request to stack #619 November 12, 2026 02:27
@jamesbraid
jamesbraid removed this pull request from stack #619 November 12, 2026 04:29
@jamesbraid
jamesbraid added this pull request to stack #623 November 12, 2026 04:31
@jamesbraid
jamesbraid removed this pull request from stack #623 November 12, 2026 06:17
@jamesbraid jamesbraid changed the title host_audio, ci: test host audio end to end through a real output device audio: test what each card sends to the host, end to end Nov 12, 2026
@jamesbraid
jamesbraid changed the base branch from audio/host-audio to audio/onyx2-mediaio November 12, 2026 06:17
@jamesbraid
jamesbraid added this pull request to stack #641 November 12, 2026 06:18
@jamesbraid
jamesbraid removed this pull request from stack #641 November 12, 2026 07:16
@jamesbraid
jamesbraid added this pull request to stack #667 November 12, 2026 07:17
@jamesbraid
jamesbraid removed this pull request from stack #667 November 12, 2026 09:12
@jamesbraid
jamesbraid changed the base branch from audio/onyx2-mediaio to audio/file-backend November 12, 2026 09:12
@jamesbraid
jamesbraid added this pull request to stack #675 November 12, 2026 09:13
Base automatically changed from audio/file-backend to main November 12, 2026 14:49
The cards had tests of their registers and of the frames they handed a
test tap. None ran the way a machine does: the clock was set by hand,
the host output was put in place by the test, nothing exercised
Device::start opening the stream or the card's own timer moving the
samples, and none stopped the producer to run the host stream dry and
check what came after. A card could pass all of its tests and play
nothing, or come back from an underrun a sample late, which swaps its
channels for good.

HAL2, O2 audio and RAD1 each get a test that builds the card as a
machine does: on the host's clock, with the machine's timers, its output
opened through enable_output and Device::start into an `[audio] file`.
It plays a tone from memory laid out the way the driver lays it out
(HPC3 words for HAL2, the O2's big-endian ring kept full, RAD1's output
ring), with no register access while it plays. The capture, less the
underrun silence, must be the guest's samples in order with each channel
on its own side. The clock must have moved at least half the frames the
wall time allows: a busy host holds a timer back. For RAD1 and the O2
the mute word must silence what follows. Where the card's rate is 48 kHz
(HAL2, O2) nothing is resampled and the comparison is exact; RAD1 at
44.1 kHz is checked by frequency and level.

The same three cards and the bare host output each get a second test
that plays a tone with a different frequency on each channel, stops the
producer for 0.3 s (the guest falls behind for HAL2, the guest stops the
output for the others) and plays again. On both sides of the gap each
channel must keep its frequency within 1%, reach half the tone's level,
and last no more than 110% of what the card played and at least half of
it. A tighter lower bound failed one run in three on a loaded host: the
card's service pass caught up in a burst that overflowed the host ring.
The card's underrun counter must move during the gap. Each runs on both
backends: the `[audio] file`, and the host's real default output
recorded back from the loopback IRIS_AUDIO_LOOPBACK names
(`<output host>:<PulseAudio source>`, for example
`alsa:iris_sink.monitor`), which sees cpal, the sound server and its
mixer. With the variable unset a real-device test prints SKIPPED and
passes, so plain `cargo test` stays green. With it set, a missing source
or silence fails. The recording runs `parec`: cpal's own PulseAudio
record stream did not start until a default fragment of about two
seconds had filled. The real-device tests run one at a time, in a
nextest group and behind a lock, since two cards on one output would
mix.

Each of these deliberate breaks makes at least one test fail: a clock
that stands still; RAD1's and HAL2's samples zeroed; channels swapped
where frames enter the host ring; the O2's channels swapped; RAD1's and
the O2's mute ignored; the file output never draining or failing to
open; resuming the consumer one sample late after an underrun (seven of
the eight underrun tests); a HAL2 guest resuming one word late (both
HAL2 underrun tests, 880 Hz on the left after the pause). In a container
with a PulseAudio 17 null sink all four underrun tests pass through the
ALSA host.
The hosted runners have no sound device, so the real-device audio tests
skip in the test run, and a card that plays nothing through cpal and the
sound server stays green. Setting up a null sink by hand is also easy to
get subtly wrong: cpal's PulseAudio client finds the server only through
PULSE_SERVER or XDG_RUNTIME_DIR, which a container may not set, and
parec loses what it buffered when a signal stops it. A loopback that
records only silence would look exactly like the bug the tests look for.

scripts/audio-loopback-test.sh starts a PulseAudio server if none
answers, makes a null sink the default output and its monitor the default
input, names the server's socket, and checks that a second of noise
played into the sink comes back from the monitor before it runs
anything. It then runs the given command once per output host in
IRIS_AUDIO_HOSTS: ALSA through the pulse plugin by default.

The Test job on x86-64 Linux installs PulseAudio, runs the script and
runs those tests against the null sink, reusing the run's build and its
feature set. The step gates. arm64 and the other platforms are left out
until a run has shown the setup works there. TESTING.md says how to run
the same thing locally and what the tests do not cover.
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