The Linux capture sink drives its own PipeWire group — and says so when something else does #305

Merged
enricobuehler merged 5 commits from worktree-audio-sink-clock-isolation into main 2026-08-18 16:00:11 +00:00
Owner

🛑 Not for merge until the on-glass gate passes (below). The change is default-on, which is why the gate is the merge condition rather than a follow-up.

Closes the 2026-08-14 audio-stutter case. Design and reasoning: punktfunk-planning/design/audio-host-sink-clock-isolation{,-implementation-plan}.md (§1 = the PipeWire/WirePlumber source read, §9 = as-built deltas).

The defect

Our desktop-audio sink was a pw_stream wearing media.class=Audio/Sink. A stream is structurally a follower — it never drives — so the group it forms with the game's output streams has no driver of its own, and PipeWire's recalc assigns every such group to the highest-priority running driver anywhere on the box.

On a reporter's host that was a DualSense forwarded over VirtualHere: its USB-Audio card sat in the Pro Audio profile (priority.driver=2600, never suspends), nothing was linked to it, and it clocked our capture and the game's four streams for a whole 15-minute session. Its frame counter is a kernel stub — vhci_get_frame_number() logs "not yet implemented" and returns 0, ~1900×/s — so snd-usb-audio could not recover a device clock for an async endpoint arriving over the network.

Not xruns: every cycle that happened was healthy (ERR 11 in 15 min, WAIT never past 111 µs). The loss was in the interval between cycles — 3.9 holes/second, worst 142 ms, and 15.4 % of the audio that user heard was silence the host synthesized over the gaps. The same mechanism hit .21 in-house a day later, from the usbip pad's own card.

The fix

Mint a real sink: a support.null-audio-sink adapter created on our own connection, captured through its monitor. That node is a driver — the null sink publishes node.driver=true, the audio adapter forwards it — with a timerfd inside the daemon's realtime data loop, so the group carries its own clock and no hardware (or network-attached) device can be elected for it. It is the same object pactl load-module module-null-sink creates.

Three properties carry the design:

  • node.passive on the monitor tap. A passive link makes neither end runnable by itself, so between sessions the group is idle and the null sink's timer parks with it — the "200 callbacks a second forever on a parked host" objection (#258's R5) answered by construction rather than by a knob. While a game plays, its own non-passive link makes the sink runnable and the graph walks that through the monitor to us, so pause/resume accounting keeps today's meaning exactly.
  • node.force-quantum, not node.latency. A driver's quantum is the smallest node.latency among its followers, clamped, then rounded down to a power of two (default.clock.power-of-two-quantum is true by default). That is why our 240-frame ask has silently been served as 128 on every stock Linux host since this capture was written — 2.67 ms, not the 5 ms it is designed around. force-quantum skips the rounding, and because this sink drives only its own group it forces nothing on anyone else's device — which is exactly why the same key would have been the wrong answer while we were borrowing somebody's hardware clock.
  • node.dont-fallback WITH node.linger, never one alone. WirePlumber 0.5 reads dont-fallback on its own as licence to destroy the stream the moment its target is not visible ("defined target not found"); linger turns that into "wait for it". Together they mean the tap can only ever record our sink — never, not even for the moment before ours registers, a hardware sink's monitor.

PUNKTFUNK_STREAM_SINK grows a third value: unset = the new topology (default), stream = the 0.30 one (one-release escape hatch, so a field A/B needs no build), 0 = the legacy default-sink-monitor follower. The routing claim, the capture callback, the stats line and everything downstream are untouched — the sink node changed owner, not the pipeline.

And a tripwire, so this is never a four-log investigation again

node.driver-id on our own node names the driver of the group we are scheduled in. It is deliberately not in the registry's announce set, so it takes a bind and the node's info event — and then the host can simply say it: INFO when the driver is our own sink, WARN naming the culprit when it isn't, with what that means (the holes are that node's scheduling, not ours). ⚠ pw_impl_node_set_driver marks the props changed but leaves the flush to the node's next info emission, so read it as the last driver the daemon told us about; the code comment says so.

Also here

  • 60-punktfunk-dualsense.conf (f24eb026, droppable on its own). The shipped rule says priority.driver = 1 "keeps the pad from ever driving the graph" — it does not: priority_driver is unsigned and the recalc skips a driver only at <= 0. At 1 the pad is merely last, and last is still elected whenever nothing above it qualifies, which is the ordinary in-session state here because claiming our own sink leaves the box's real card idle. Zero is the value that means excluded (a driver still drives its own linked streams, so GE-Proton's haptics are unaffected). Plus a second rule for the alsa_input node — the one that actually clocked the reporter's session, and which the alsa_output matches never touched.
  • Docs. PUNKTFUNK_STREAM_SINK had no documentation at all; it now has a configuration row, and troubleshooting gains an "audio stutters, and only the audio (Linux)" section built around the new WARN. The CHANGELOG entry is deliberately not here — docs/releases/README.md puts release notes and CHANGELOG sections in the version-bump commit, and v0.30.0 is tagged.

Gates

punktfunk-rust-ci:latest amd64: cargo fmt --all --check; cargo clippy --all-targets -p punktfunk-host -- -D warnings, non-vacuous (Checking punktfunk-host present, sources touched first); cargo test -p punktfunk-host = 607 (604 + 3 new pure tests: the mode grammar, the channel map's two views agreeing, and the created sink's invariants). Plus bun run build on docs-site.

🛑 Reviewer note: the host suite is flaky in that container — 3 of 4 runs on clean main fail, rotating between gamestream::stream::tests::sender_delivers_batches (an unretried recv that panics on EINTR), the two mgmt::tests::local_summary_* tests (a session registry a sibling has not finished releasing) and library::plugin_launch::tests::asks_the_registered_plugin_and_takes_its_answer. Measured 4 runs each side of a stash. This branch reaches 607/607 on a clean run. Don't attribute a red one here to the diff without re-running main.

What still has to happen (the merge gate)

No unit test can prove clock isolation; the acceptance is on glass, on .181 (PipeWire 1.4) and .138/.21 (1.6), in both attach orders:

  1. pw-top shows our sink at the top of its own group, the game's streams and our tap indented under it, QUANT 240, ERR 0 — while a hardware sink is also running. Playing something to the speakers is the trigger; without it the test proves nothing.
  2. Five minutes of loud audio at desktop audio capture … delivered_pct=100 gaps=0.
  3. Between sessions: our sink idle in pw-top, no capture callbacks (proves node.passive).
  4. Volume slider on "Punktfunk Stream Speaker" still affects the stream; kill -9 leaves no ghost sink; both escape hatches reproduce their old topologies.

.21 with PUNKTFUNK_DUALSENSE_USBIP=1 and the shipped conf is the in-house Skynet replay — an always-processing USB card in the graph, no VirtualHere needed. Ideal third leg: the reporter's box with the DS5 card re-enabled.

🛑 **Not for merge until the on-glass gate passes** (below). The change is default-on, which is why the gate is the merge condition rather than a follow-up. Closes the 2026-08-14 audio-stutter case. Design and reasoning: `punktfunk-planning/design/audio-host-sink-clock-isolation{,-implementation-plan}.md` (§1 = the PipeWire/WirePlumber source read, §9 = as-built deltas). ## The defect Our desktop-audio sink was a `pw_stream` wearing `media.class=Audio/Sink`. A stream is structurally a **follower** — it never drives — so the group it forms with the game's output streams has no driver of its own, and PipeWire's recalc assigns every such group to the highest-priority *running* driver anywhere on the box. On a reporter's host that was a DualSense forwarded over VirtualHere: its USB-Audio card sat in the Pro Audio profile (`priority.driver=2600`, never suspends), **nothing was linked to it**, and it clocked our capture and the game's four streams for a whole 15-minute session. Its frame counter is a kernel stub — `vhci_get_frame_number()` logs "not yet implemented" and returns 0, ~1900×/s — so `snd-usb-audio` could not recover a device clock for an async endpoint arriving over the network. Not xruns: every cycle that happened was healthy (ERR 11 in 15 min, `WAIT` never past 111 µs). The loss was in the interval *between* cycles — 3.9 holes/second, worst 142 ms, and **15.4 % of the audio that user heard was silence the host synthesized** over the gaps. The same mechanism hit `.21` in-house a day later, from the usbip pad's own card. ## The fix Mint a real sink: a `support.null-audio-sink` adapter created on our own connection, captured through its monitor. That node **is** a driver — the null sink publishes `node.driver=true`, the audio adapter forwards it — with a `timerfd` inside the daemon's realtime data loop, so the group carries its own clock and no hardware (or network-attached) device can be elected for it. It is the same object `pactl load-module module-null-sink` creates. Three properties carry the design: - **`node.passive` on the monitor tap.** A passive link makes neither end runnable by itself, so between sessions the group is idle and the null sink's timer parks with it — the "200 callbacks a second forever on a parked host" objection (#258's R5) answered by construction rather than by a knob. While a game plays, its own non-passive link makes the sink runnable and the graph walks that through the monitor to us, so pause/resume accounting keeps today's meaning exactly. - **`node.force-quantum`, not `node.latency`.** A driver's quantum is the smallest `node.latency` among its followers, clamped, then rounded **down to a power of two** (`default.clock.power-of-two-quantum` is true by default). That is why our 240-frame ask has silently been served as 128 on every stock Linux host since this capture was written — 2.67 ms, not the 5 ms it is designed around. `force-quantum` skips the rounding, and because this sink drives only its own group it forces nothing on anyone else's device — which is exactly why the same key would have been the wrong answer while we were borrowing somebody's hardware clock. - **`node.dont-fallback` WITH `node.linger`, never one alone.** WirePlumber 0.5 reads `dont-fallback` on its own as licence to destroy the stream the moment its target is not visible (`"defined target not found"`); `linger` turns that into "wait for it". Together they mean the tap can only ever record our sink — never, not even for the moment before ours registers, a hardware sink's monitor. `PUNKTFUNK_STREAM_SINK` grows a third value: unset = the new topology (**default**), `stream` = the 0.30 one (one-release escape hatch, so a field A/B needs no build), `0` = the legacy default-sink-monitor follower. The routing claim, the capture callback, the stats line and everything downstream are untouched — the sink node changed owner, not the pipeline. ## And a tripwire, so this is never a four-log investigation again `node.driver-id` on our own node names the driver of the group we are scheduled in. It is deliberately not in the registry's announce set, so it takes a bind and the node's `info` event — and then the host can simply say it: INFO when the driver is our own sink, **WARN naming the culprit** when it isn't, with what that means (the holes are that node's scheduling, not ours). ⚠ `pw_impl_node_set_driver` marks the props changed but leaves the flush to the node's next info emission, so read it as *the last driver the daemon told us about*; the code comment says so. ## Also here - **`60-punktfunk-dualsense.conf` (`f24eb026`, droppable on its own).** The shipped rule says `priority.driver = 1` "keeps the pad from ever driving the graph" — it does not: `priority_driver` is unsigned and the recalc skips a driver only at `<= 0`. At 1 the pad is merely *last*, and last is still elected whenever nothing above it qualifies, which is the ordinary in-session state here because claiming our own sink leaves the box's real card idle. Zero is the value that means excluded (a driver still drives its own linked streams, so GE-Proton's haptics are unaffected). Plus a second rule for the `alsa_input` node — the one that actually clocked the reporter's session, and which the `alsa_output` matches never touched. - **Docs.** `PUNKTFUNK_STREAM_SINK` had no documentation at all; it now has a configuration row, and troubleshooting gains an "audio stutters, and only the audio (Linux)" section built around the new WARN. The CHANGELOG entry is deliberately **not** here — `docs/releases/README.md` puts release notes and CHANGELOG sections in the version-bump commit, and v0.30.0 is tagged. ## Gates `punktfunk-rust-ci:latest` amd64: `cargo fmt --all --check`; `cargo clippy --all-targets -p punktfunk-host -- -D warnings`, non-vacuous (`Checking punktfunk-host` present, sources touched first); `cargo test -p punktfunk-host` = **607** (604 + 3 new pure tests: the mode grammar, the channel map's two views agreeing, and the created sink's invariants). Plus `bun run build` on docs-site. 🛑 **Reviewer note: the host suite is flaky in that container — 3 of 4 runs on clean `main` fail**, rotating between `gamestream::stream::tests::sender_delivers_batches` (an unretried `recv` that panics on EINTR), the two `mgmt::tests::local_summary_*` tests (a session registry a sibling has not finished releasing) and `library::plugin_launch::tests::asks_the_registered_plugin_and_takes_its_answer`. Measured 4 runs each side of a stash. This branch reaches 607/607 on a clean run. Don't attribute a red one here to the diff without re-running main. ## What still has to happen (the merge gate) No unit test can prove clock isolation; the acceptance is on glass, on `.181` (PipeWire 1.4) and `.138`/`.21` (1.6), in **both attach orders**: 1. `pw-top` shows our sink at the top of its own group, the game's streams and our tap indented under it, `QUANT 240`, `ERR 0` — while a hardware sink is *also running*. **Playing something to the speakers is the trigger; without it the test proves nothing.** 2. Five minutes of loud audio at `desktop audio capture … delivered_pct=100 gaps=0`. 3. Between sessions: our sink idle in `pw-top`, no capture callbacks (proves `node.passive`). 4. Volume slider on "Punktfunk Stream Speaker" still affects the stream; `kill -9` leaves no ghost sink; both escape hatches reproduce their old topologies. `.21` with `PUNKTFUNK_DUALSENSE_USBIP=1` and the shipped conf is the in-house Skynet replay — an always-processing USB card in the graph, no VirtualHere needed. Ideal third leg: the reporter's box with the DS5 card re-enabled.
enricobuehler added 5 commits 2026-08-18 15:59:35 +00:00
Our desktop-audio sink was a pw_stream wearing media.class=Audio/Sink. A stream is
structurally a follower — it never drives — so the group it forms with the game's output
streams has no driver of its own, and PipeWire's recalc assigns every such group to the
highest-priority *running* driver anywhere on the box.

On a reporter's host that was the DualSense forwarded over VirtualHere: its USB-Audio card
sat in the Pro Audio profile (priority.driver=2600, never suspends), nothing was linked to
it, and it clocked our capture and the game's four streams for a whole 15-minute session.
Its frame counter is a kernel stub — vhci_get_frame_number() logs "not yet implemented" and
returns 0, ~1900 times a second — so snd-usb-audio could not recover a device clock for an
async endpoint arriving over the network. Not xruns: every cycle that happened was healthy
(ERR 11 in 15 min, WAIT never past 111 µs); the loss was in the interval *between* cycles.
3.9 delivery holes a second, worst 142 ms, and 15.4 % of the audio that user heard was
silence this host synthesized over the gaps.

So mint a real sink instead: a support.null-audio-sink adapter, created on our own
connection, captured through its monitor. That node IS a driver — the null sink publishes
node.driver=true and the audio adapter forwards its props — with a timerfd inside the
daemon's realtime data loop, so the group carries its own clock and no hardware (or
network-attached) device can be elected for it. It is the same object pactl load-module
module-null-sink creates, which is the most exercised virtual-sink path on Linux.

Three properties carry the design, and each is a decision rather than a default:

  * node.passive on the monitor tap. A link is passive when the input port is passive and
    the output node can suspend (any Audio/Sink), and a passive link makes neither end
    runnable by itself — so between sessions the group is idle and the null sink's timer
    parks with it. That answers, by construction, the "200 callbacks a second forever on a
    parked host" objection that kept node.always-process off the old stream sink. While a
    game plays, its own non-passive link makes the sink runnable and run_nodes() walks that
    through the monitor to us, so pause/resume accounting keeps today's meaning exactly.

  * node.force-quantum, not node.latency. A driver's quantum is the smallest node.latency
    among its followers, clamped — and then rounded DOWN to a power of two, because
    default.clock.power-of-two-quantum is true by default. That is why our 240-frame ask has
    silently been served as 128 on every stock Linux host since the capture was written: the
    callback runs at 2.67 ms, not the 5 ms it is designed around. force-quantum skips the
    rounding, and because this sink drives only its own group it forces nothing on anyone
    else's device — which is exactly why the same key would have been the wrong answer while
    we were borrowing somebody's hardware clock.

  * node.dont-fallback WITH node.linger, never one alone. WirePlumber 0.5 reads
    dont-fallback on its own as licence to destroy the stream the moment its target is not
    visible ("defined target not found"); linger turns that into "wait for it". Together
    they mean the tap can only ever record our sink — never, not even for the moment before
    ours registers, a hardware sink's monitor.

PUNKTFUNK_STREAM_SINK grows a third value: unset is the new topology, =stream is the 0.30
one (a one-release escape hatch, so a field A/B needs no build), =0 is still the legacy
default-sink-monitor follower. The routing claim, the capture callback, the stats line and
everything downstream are untouched — the sink node changed owner, not the pipeline.

The channel map now has one source and two views (pod and audio.position string), because a
created node is configured by properties while a stream is configured by a format pod, and a
map that disagreed with itself between them would swap channels silently.

Gated in punktfunk-rust-ci amd64: fmt, clippy --all-targets -D warnings (non-vacuous —
"Checking punktfunk-host" present), 607 tests. On-glass validation is still owed and is what
the plan gates the merge on: pw-top must show our sink at the top of its own group with the
game's streams and our tap under it, and 5 min of loud audio at delivered_pct=100 gaps=0 on
a box where a hardware sink is also running.
On 2026-08-14 a reporter's audio stuttered. Answering "what is clocking desktop audio on
that box?" took four field logs, a purpose-built probe script, and finally a pw-top DRIVER
column — and the answer was a DualSense's sound card, attached over the network, that
nothing was linked to.

Every part of that answer was already in the daemon, one bind away. `node.driver-id` on our
own node names the driver of the group we are scheduled in; it is deliberately not in the
registry's announce set, so it needs the node bound and its `info` event read, and the
daemon republishes the props whenever the graph is recalculated.

So bind our node, keep a map of node id to name from the registry, and log the driver by
name whenever it changes. In null-sink mode there is exactly one right answer — our own sink
— so anything else is a WARN that names the culprit and says what it means: the holes in
this stream are that node's scheduling, not ours. The legacy topologies have no driver of
their own and borrow one by design, so there the same fact is an INFO that names it without
judging it.

On change rather than per window, deliberately: the driver moves a handful of times in a
session, and the 30 s capture summary is written from the RT callback while this arrives on
the main loop — a shared field would need cross-thread state for a value that a line of its
own carries better (`grep 'graph driver'`).
`PUNKTFUNK_STREAM_SINK` has existed since 0.29 and appeared in no documentation at all, which
was tolerable while it had two values and one of them was "don't". It has three now, the
default changed shape, and the change is visible to anyone who opens their audio settings
mid-session: there is a virtual output *and* a recording stream named `punktfunk-audio-…`,
and someone will want to know whether that is a leak. It is not.

Troubleshooting gains the section the 2026-08-14 investigation should have been able to
start from: audio broken up while video is steady, one WARN line naming the node that took
our clock, what that means for the sound (its stalls are now our holes), and the two ways
out — remove the loopback, or turn off the profile of a sound card that is reached over the
network and cannot be clocked at all.
The shipped WirePlumber policy sets `priority.driver = 1` on a DS5's ALSA sink and says it
"keeps the pad from ever driving the graph". Read against PipeWire's own recalc, it does not:
`priority_driver` is unsigned and `pw_context_recalc_graph` skips a driver only when it is
`<= 0`. At 1 the pad is merely LAST in the ordering — and last is still elected whenever
nothing above it qualifies, which on a punktfunk host is the ordinary in-session state,
because claiming our own sink as the default output leaves the box's real card idle. With
`node.always-process` on the same node it is also permanently runnable, i.e. permanently
eligible.

Zero is the value that means excluded. The pad keeps driving the streams actually linked to
it — a driver always drives its own group, priority orders the election and nothing else —
so GE-Proton's haptics are unaffected.

The second rule covers the capture side of the same cards. That node is what clocked a
reporter's desktop audio for a whole session: in the Pro Audio profile it carries
`priority.driver = 2600`, never suspends, and had nothing linked to it at all — its only
function on that machine was to clock other people's graphs. The `alsa_output` matches never
touched it. Only the priority is set there; holding a device open is about the playback node
GE opens raw, and an always-processing microphone is not something this host should ask for.

Both of these are belt to the braces of the host-side fix — a capture group that carries its
own driver cannot be handed one — but they are worth having on their own: they are what stops
a pad from clocking anything else on the box, including a build that predates it.
docs(host/audio): say what the driver-id line can and cannot promise
ci / bun-nix (pull_request) Successful in 52s
ci / web (pull_request) Successful in 1m21s
ci / docs-site (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 2m5s
android / android (pull_request) Successful in 6m1s
ci / rust (pull_request) Successful in 17m38s
3eede724d1
The comment claimed the daemon republishes node props "whenever the graph is
recalculated". It does not, quite: pw_impl_node_set_driver writes the key and marks the
props changed, but leaves the flush to the node's next info emission — in practice the
state change that accompanies the same recalculation. Near enough for a diagnostic, and
exactly the kind of near-enough that a later reader deserves to be told about rather than
discover. Also retires four "in stream-sink mode" phrasings that now describe both sink
modes.
enricobuehler merged commit c94dafd4be into main 2026-08-18 16:00:11 +00:00
enricobuehler deleted branch worktree-audio-sink-clock-isolation 2026-08-18 16:00:18 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#305