audio: make the PipeWire backend robust to quantum/rate changes and unscheduled graphs - #927
Merged
Conversation
…stead of skipping cycles
The process callback rejected any cycle whose clock.duration differed
from the configured buffer size. But the graph quantum is a global
negotiation the client does not control (verified in pipewire 1.6.8
src/pipewire/context.c):
- node.force-quantum is last-stamp-wins between all follower nodes of a
driver, so any later-joining client that also forces a quantum (a
pipewire-jack app, another score instance) flips the graph;
- the global clock.force-quantum setting overrides every node force;
- clock.quantum-floor / clock.quantum-limit clamp the result, and the
stock config raises min-quantum to 1024 inside VMs;
- the first cycles after joining run at the previous quantum until the
driver picks up clock.target_duration;
- node.lock-quantum / node.lock-rate cannot help: the driver cancels the
lock whenever any follower forces the value (context.c), and this node
always forces both.
Returning without dequeuing the output buffers leaves them in NEED_DATA,
so a persistent mismatch meant permanent silence plus one warning per
cycle from the RT thread ('unexpected block size 512 (expected 128),
skipping cycle').
Instead, process every cycle, slicing it into chunks of at most the
configured block size (the engine's buffers are sized for it; ticks are
variable-size downstream). Buffers are fetched exactly once per port per
cycle, through pw_filter_dequeue_buffer so the write is clamped to the
buffer's real capacity: the mmapped size comes from this client's
clock.quantum-limit while clock.duration is bounded by the daemon's,
and the two can be configured apart.
The same applies to the sample rate: node.force-rate can lose to the
global clock.force-rate or to a competing client's newer stamp, and the
DSP ports then carry audio at the graph rate — the engine used to keep
claiming the requested rate, so hosts resampled soundfiles for a rate
that was not being played. The constructor now waits for the first
cycles and publishes the rate the graph actually granted in
effective_sample_rate; a mid-run rate change cannot be adapted to
client-side and is reported once.
Also:
- tear down the filter from stop() whenever it exists, not only when
fully activated: the constructor's failure paths after a successful
filter_connect used to leave a connected filter whose process callback
kept firing over freed members once the object was destroyed, and the
filter leaked on the cannot-connect throw path;
- replace the per-cycle alloca()s with constructor-sized pod_vectors,
and split the null-port fallback into distinct silence (input) and
discard (output) buffers - the old shared dummy fed each cycle's
discarded output back into missing inputs;
- log once per reconfiguration instead of once per cycle, ignore
zero-duration idle cycles, and stop logging entirely after 16
transitions so a flapping graph cannot re-create per-cycle RT logging;
- publish effective_* before filter_connect: cycles arrive while the
constructor is still synchronizing.
The cycle policy lives in pipewire_quantum.hpp, free of pipewire types,
so it is unit-testable without a daemon.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A node can end up scheduled by nothing, silently: when pw_context_recalc_graph finds no active driving node with a positive priority.driver (Dummy-Driver missing from the daemon configuration, a session-manager race that left things inactive, a device driver that vanished mid-flight), its unassigned-node pass calls remove_from_driver and the node just stops - no error reaches the client, playback simply never starts. This matches the long-standing 'no soundcard: sometimes nothing ever plays' reports. Count process cycles from the RT callback and watch them from a dedicated thread: if the engine is activated but no cycle arrives within stall_timeout_ms (default 3 s), warn precisely about what is happening and re-export the node (filter_disconnect + filter_connect), which re-runs activation and driver assignment - the effective fix for the race-shaped causes. Attempts are bounded (5), after which the watchdog reports that the daemon itself is not scheduling audio and gives up. The stall/attempt counters are public so hosts can surface the condition in their UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behavior verified from the pipewire sources at 0.3.48 (Ubuntu 22.04), 0.3.65 (Debian 12), 1.0.5 (Ubuntu 24.04), 1.2.8, 1.4.2/1.4.9 (Debian 13), 1.6.2 (Ubuntu 26.04) and 1.6.8: the quantum/rate negotiation (global force override, last-stamp-wins between node forces, lock cancellation under force, silent driverless stop), the NEED_DATA-on-skip behavior, the buffer sizing and the legality of disconnect/connect reuse all hold across the range. The differences (power-of-two rounding and max-latency shrink of forced quantums before 1.0.5, no target_duration before 1.0.5, fixed 8192-float buffers before 1.0.5) all land on the adaptive paths this file already implements. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
had a problem deploying
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Failure
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
jcelerier
temporarily deployed
to
Apple Certificate
August 16, 2026 21:03 — with
GitHub Actions
Inactive
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.
Problem
The PipeWire process callback rejected any cycle whose
clock.durationdiffered from the configured buffer size. The graph quantum is a global negotiation the client does not control (verified in the PipeWire sources, 0.3.48 through 1.6.8):node.force-quantumis last-stamp-wins between all follower nodes of a driver — any later-joining client that also forces (a pipewire-jack app, another score instance) flips the graph;clock.force-quantumsetting overrides every node force;node.lock-quantum/node.lock-ratecannot help: the driver cancels the lock whenever any follower forces the value, and this node always forces both.Returning without dequeuing the output buffers leaves them in
NEED_DATA, so a persistent mismatch meant permanent silence plus one warning per cycle from the RT thread (unexpected block size 512 (expected 128), skipping cycle).Changes
pw_filter_dequeue_buffer, with writes clamped to the buffer's real capacity (client and daemonclock.quantum-limitcan differ; no PipeWire version checksn_samplesagainstmaxsize).node.force-ratecan lose to the globalclock.force-rateor a competing client; the constructor now observes the rate the graph actually granted and publishes it ineffective_sample_rate, so hosts stop resampling soundfiles for a rate that is not being played. Mid-run rate changes are reported once (live adaptation deferred to a future PR).priority.driver > 0exists (missing Dummy-Driver, session-manager race, vanished device),pw_context_recalc_graphsilently stops the node — no error reaches the client and playback never starts. A watchdog counts process cycles and, after a bounded stall, re-exports the node (disconnect + connect, legal on all validated versions), with bounded per-outage attempts and public counters for host UIs.filter_connectused to leave a live RT callback over freed members), removal of per-cycleallocas, distinct silence/discard scratch buffers, once-per-reconfiguration logging with a hard cap.pipewire_quantum.hpp, free of PipeWire types, unit-tested without a daemon.Validation
PIPEWIRE_RUNTIME_DIR, driven viapw-metadataforces; the old code fails them with the exact reported symptoms).🤖 Generated with Claude Code