Skip to content

audio: make the PipeWire backend robust to quantum/rate changes and unscheduled graphs - #927

Merged
jcelerier merged 3 commits into
masterfrom
fix/pipewire-quantum
Aug 16, 2026
Merged

audio: make the PipeWire backend robust to quantum/rate changes and unscheduled graphs#927
jcelerier merged 3 commits into
masterfrom
fix/pipewire-quantum

Conversation

@jcelerier

Copy link
Copy Markdown
Member

Problem

The PipeWire process callback rejected any cycle whose clock.duration differed 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-quantum is 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;
  • the global clock.force-quantum setting overrides every node force;
  • quantum-floor/limit clamp the result (and pre-1.0.5 servers additionally round forced quantums to powers of two and let follower max-latency shrink them);
  • the first cycles after joining run at the previous quantum;
  • node.lock-quantum/node.lock-rate cannot 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

  • Process every cycle, sliced into chunks of at most the configured block size; buffers fetched exactly once per port per cycle through pw_filter_dequeue_buffer, with writes clamped to the buffer's real capacity (client and daemon clock.quantum-limit can differ; no PipeWire version checks n_samples against maxsize).
  • Truthful sample rate: node.force-rate can lose to the global clock.force-rate or a competing client; the constructor now observes the rate the graph actually granted and publishes it in effective_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).
  • Stall watchdog: when no active driver with priority.driver > 0 exists (missing Dummy-Driver, session-manager race, vanished device), pw_context_recalc_graph silently 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.
  • Teardown fixes (a constructor failure after a successful filter_connect used to leave a live RT callback over freed members), removal of per-cycle allocas, distinct silence/discard scratch buffers, once-per-reconfiguration logging with a hard cap.
  • The cycle policy lives in pipewire_quantum.hpp, free of PipeWire types, unit-tested without a daemon.

Validation

  • Unit + integration tests live in the matching ossia/score PR (private daemons in a scratch PIPEWIRE_RUNTIME_DIR, driven via pw-metadata forces; the old code fails them with the exact reported symptoms).
  • Behavior validated against 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), 1.6.8.
  • Two adversarial review passes; final verdict: no critical/major findings, net RT improvement over the previous code.

🤖 Generated with Claude Code

jcelerier and others added 3 commits August 16, 2026 16:10
…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
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier temporarily deployed to Apple Certificate August 16, 2026 21:03 — with GitHub Actions Inactive
@jcelerier
jcelerier merged commit cd4f31a into master Aug 16, 2026
31 of 37 checks passed
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