Compare commits

...
Author SHA1 Message Date
enricobuehler 454531030d fix(android): a console that cannot draw yields to the touch UI instead of a gray screen
Connecting a controller could swap in the console shell over a SurfaceView
nothing would ever paint: the native create failing, the render thread dying,
or a GL context Android reclaimed all left the app on a gray screen for the
rest of the process — Kotlin only logged the Dead event.

SkiaConsole now exposes an observable [healthy] flag (false on create failure
or a Dead event) and App folds it into the gamepad-UI gate, so the touch UI
takes over. On the native side, a run of consecutive GL setup failures
(window surface / Skia wrap) — previously logged and retried forever, a hot
spin with a live surface — now ends the render thread through the same
release order as Quit, which raises Dead and hands the screen back.
2026-08-18 22:34:25 +02:00
enricobuehler a64a22ccfc fix(android): the console's pad probes survive the Controllers/Licenses pages
The MainActivity pad probes were one last-writer-wins slot. The Skia shell
installs its probes once (its effect keys never change); a Compose screen the
console opens over itself (Controllers, Licenses) overwrote that slot, and on
its way out nulled it — the shell never re-installed, so every gamepad press
after closing the page was silently dropped until the process died.

The slot is now a stack: each holder pushes its claim on install and removes
it BY IDENTITY on dispose, and dispatch consults the top. Whatever ordering
Compose produces — cross-fades composing both screens at once, non-LIFO
disposal — a leaving screen takes only its own entry, and the one underneath
resurfaces the moment it pops.
2026-08-18 22:34:10 +02:00
enricobuehler cfbde6aec7 Merge pull request 'Tell the agents where the issues live: AGENTS.md and docs/agents/' (#310) from worktree-agents-md-setup into main
ci / bun-nix (push) Successful in 33s
ci / web (push) Successful in 1m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
ci / docs-site (push) Successful in 3m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 4m17s
docker / deploy-docs (push) Successful in 38s
ci / rust (push) Successful in 8m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 13m29s
docker / builders-arm64cross (push) Skipped
Reviewed-on: #310
2026-08-18 19:33:35 +00:00
enricobuehler 0eb8f2d0f1 docs(agents): AGENTS.md — where the issues live, what the labels mean, which docs to read first
ci / bun-nix (pull_request) Successful in 36s
ci / docs-site (pull_request) Successful in 1m25s
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m18s
ci / rust (pull_request) Successful in 8m53s
The mattpocock engineering skills (/triage, /to-tickets, /to-spec, /wayfinder,
/diagnosing-bugs, /code-review) all assume a per-repo configuration that this
repo never had: they need to know which tracker to talk to, which label strings
carry the five triage roles, and which docs to read before exploring. Without it
each of them guesses, and the guess is GitHub.

Which is wrong here in a way that fails quietly. This repo's remote is
git.unom.io — Gitea. `gh` is installed on the machine and will happily run
against github.com and find nothing; `glab` and `tea` are not installed at all.
So the tracker doc points at the connected `gitea` MCP server instead, and says
so in the first line, because the failure mode is an agent confidently reporting
an empty issue list.

Two traps in that MCP surface are worth the words they take:

  - `issue_write` applies labels by numeric ID and `remove_label` takes a
    `label_id`, while `list_issues` filters by label *name*. Applying a label
    therefore needs a `label_read` lookup first — passing the name silently
    gets you nowhere.

  - unom/punktfunk has no labels defined at all, on the repo or on the org
    (checked both). The first triage run has to create the five before it can
    apply any of them.

Gitea writes are outward-facing — a shared instance that mails on activity — so
the doc encodes the standing rule as a gate: reads are free, every write waits
for a go-ahead, subagents included.

Domain docs are single-context: one CONTEXT.md and one docs/adr/ at the root.
Neither exists yet and neither should be created pre-emptively; /domain-modeling
writes them when a term or a decision actually gets resolved. Twenty-seven
crates and nine client platforms is the shape of a repo that eventually wants a
CONTEXT-MAP.md, so the switch path is written down — but they serve one domain
today, and a glossary split four ways before it has a single entry is just four
empty files.

AGENTS.md rather than CLAUDE.md because neither existed, and the cross-tool
convention costs nothing here.
2026-08-18 21:31:48 +02:00
enricobuehler 59cc234055 Merge pull request 'Clicking a host connects to it again; the library goes back to the card menu' (#309) from worktree-revert-host-card-primary-connect into main
ci / bun-nix (push) Successful in 28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
ci / rust-arm64 (push) Successful in 1m26s
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 34s
apple / swift (push) Successful in 2m11s
docker / builders-arm64cross (push) Successful in 46s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m10s
docker / deploy-docs (push) Successful in 36s
ci / rust (push) Successful in 6m16s
apple / distribute (push) Successful in 11m21s
apple / screenshots (push) Successful in 10m18s
Reviewed-on: #309
2026-08-18 19:20:30 +00:00
enricobuehler 242292528c fix(clients/apple): clicking a host connects to it again; the library goes back to the menu
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m20s
ci / rust-arm64 (pull_request) Successful in 1m32s
apple / swift (pull_request) Successful in 2m19s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 5m10s
Reverts the primary-action swap from 22fdea66: a host card's tap ran
`onBrowseLibrary ?? onConnect`, so on Mac, iPad and Apple TV clicking a paired
machine dropped you on its game shelf and streaming the desktop had moved into
the context menu. That inverted the pattern every other surface still uses.

Back to: tap the card = connect, "Browse Library…" = a context-menu action
(both on the host's own card and on a pinned host+profile card, where its shelf
still launches with that card's profile).

No other client had drifted — the Rust console shell (Linux + the Android Skia
port) keeps A = connect and Y = library, the GTK card's activate emits
`CardOutput::Connect` with "Browse library…" in the overflow menu, Android's
Compose grid taps to connect with the same menu item, and the Apple console
shell's tiles activate into connect. Only the touch/desktop `HostCardView` had
the swap, which is why one fix covers all three Apple platforms.

Everything else the library round-trip landed stays: the wake-on-open retry, the
per-host cached catalog, the Resume badges, the scroll memory.

Verified on the Mac: swift build clean, swift test 375 tests / 0 failures.
2026-08-18 20:09:08 +02:00
enricobuehler 0f7d724154 Merge pull request 'The virtual DualSense wore a placeholder USB serial no real pad has' (#307) from worktree-usbip-dualsense-fix into main
ci / web (push) Successful in 1m49s
ci / rust-arm64 (push) Successful in 2m48s
ci / docs-site (push) Successful in 2m38s
ci / bun-nix (push) Successful in 50s
deb / build-publish-gamescope (push) Successful in 42s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 1m41s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 19s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 22s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m4s
deb / build-publish-host (push) Successful in 5m31s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 39s
deb / build-publish (push) Successful in 6m43s
android / android (push) Successful in 13m24s
windows-host / package (push) Successful in 13m13s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 41s
arch / build-publish (push) Successful in 11m15s
deb / smoke-install (push) Successful in 4m25s
ci / rust (push) Successful in 19m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m52s
Reviewed-on: #307
2026-08-18 17:50:00 +00:00
enricobuehler 475ff70a2a fix(pad): the virtual DualSense wore a placeholder USB serial no real pad has
ci / bun-nix (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m36s
ci / rust-arm64 (pull_request) Successful in 1m44s
android / android (pull_request) Successful in 5m10s
ci / rust (pull_request) Successful in 7m6s
`UsbDevice::default` in the vendored server fills iSerialNumber with the string
"Serial". A real DualSense reports none — this file's own doc comments assert that
twice — and ALSA bakes the value into the card id, so the pad presented as
`…DualSense_Wireless_Controller_Serial-00` where the hardware gives
`…DualSense_Wireless_Controller-00`. PipeWire then carries the difference into
every node name and into `device.serial`.

Clearing it makes every name a matcher can key on byte-identical to a physical
pad's.

Scope, honestly: this is fidelity, not a fix for anything currently broken. It was
written when the `_Serial` infix was a suspect for the dead haptics; it is not the
cause. GE-Proton's winepulse leg has since been observed matching
`alsa_output.usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller_Serial-00.Default__Speaker__sink`
with the placeholder present, so nothing depends on this today. It removes a
gratuitous difference from real hardware rather than fixing a symptom.

Also NOT a fix for UCM profile selection, though that was my first reading:
`alsa-ucm-conf` keys on `${CardComponents}` (`USB054c:0ce6`), so `DualSense-PS5.conf`
matched with the placeholder still present. Which profile a card lands on is decided
by verb priority, not by its name. The comment records that so it is not re-derived.

Gated with `cargo clippy -p pf-inject --all-targets -- -D warnings` in
`punktfunk-rust-ci:latest` (linux/amd64): exit 0.
2026-08-18 19:48:13 +02:00
enricobuehler d4db2e3331 Merge pull request 'The RPM %install tried to execute a %files path, so main builds no RPMs' (#306) from worktree-rpm-spec-stray-install into main
ci / bun-nix (push) Successful in 23s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 36s
ci / web (push) Successful in 1m29s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 54s
ci / docs-site (push) Successful in 1m31s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 16s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 25s
ci / rust-arm64 (push) Successful in 2m5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / builders-arm64cross (push) Successful in 38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m58s
docker / deploy-docs (push) Successful in 40s
ci / rust (push) Successful in 7m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m11s
Reviewed-on: #306
2026-08-18 17:18:01 +00:00
enricobuehler c49b648032 fix(packaging/rpm): drop the stray %files path that %install tried to execute
ci / bun-nix (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / rust (pull_request) Successful in 6m22s
The WirePlumber DualSense policy landed with its %files line duplicated into
%install, one line above the real install. rpm ran it as a command, and the
%install scriptlet died on 'No such file or directory' before it reached
anything else.
2026-08-18 19:13:59 +02:00
enricobuehler c94dafd4be Merge pull request 'The Linux capture sink drives its own PipeWire group — and says so when something else does' (#305) from worktree-audio-sink-clock-isolation into main
ci / web (push) Successful in 1m12s
ci / bun-nix (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m46s
ci / rust-arm64 (push) Successful in 2m36s
deb / build-publish-gamescope (push) Successful in 39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 26s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
deb / build-publish-client-arm64 (push) Successful in 1m26s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
deb / build-publish (push) Successful in 5m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m27s
deb / build-publish-host (push) Successful in 7m10s
android / android (push) Successful in 10m32s
arch / build-publish (push) Successful in 10m56s
docker / builders-arm64cross (push) Successful in 23s
docker / deploy-docs (push) Successful in 41s
windows-host / package (push) Failing after 11m30s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / smoke-install (push) Successful in 3m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 14m2s
ci / rust (push) Successful in 22m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 12m44s
Reviewed-on: #305
2026-08-18 16:00:06 +00:00
enricobuehler abb084aac3 Merge pull request 'topology: exclusive was echoed back by the API and dropped on Hyprland and sway' (#304) from worktree-vdisplay-topology-wlroots-hyprland into main
ci / bun-nix (push) Successful in 1m29s
ci / web (push) Successful in 2m6s
ci / docs-site (push) Successful in 2m9s
ci / rust-arm64 (push) Successful in 3m55s
deb / build-publish-gamescope (push) Successful in 48s
android / android (push) Canceled after 5m54s
arch / build-publish (push) Canceled after 3m21s
ci / rust (push) Canceled after 0s
deb / build-publish (push) Canceled after 3m27s
deb / build-publish-host (push) Canceled after 3m13s
deb / build-publish-client-arm64 (push) Canceled after 53s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 1s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 24s
windows-host / package (push) Canceled after 5m55s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #304
2026-08-18 15:54:11 +00:00
enricobuehler 3eede724d1 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
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.
2026-08-18 17:49:48 +02:00
enricobuehler 4b5a37dae2 feat(vdisplay): topology: exclusive was echoed back by the API and dropped on Hyprland and sway
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m18s
ci / docs-site (pull_request) Successful in 1m43s
ci / rust-arm64 (pull_request) Successful in 2m8s
android / android (pull_request) Successful in 7m40s
ci / rust (pull_request) Successful in 8m5s
Both wlr-family backends accepted the topology axis, the management API reported
it as the session's effective topology, and the backend logged a warning and did
nothing (sweep 13.18 shipped the warning, never the behaviour). Because
`resolve_topology` sends `auto` — the default — to `Exclusive` on any host without
a `PUNKTFUNK_COMPOSITOR` pin, and both compositors are auto-detected, the default
policy on every such box was an Exclusive that behaved as Extend. Closes #284.

`exclusive` now disables the operator's heads for the session and restores them
when the display GROUP's last member is torn down, via the same
`take_topology_restore` hand-off KWin uses — so the registry runs the restore
before the last output is reclaimed and the compositor never sees zero enabled
outputs, and a sibling session never has the desk re-enabled under it.

The disable filter is group-aware (design §6.1): enabled, not ours, and not
managed. On Hyprland `managed` is `PF-<pid>-<n>`, which covers a second host's
outputs too; on sway it is the `HEADLESS-` prefix, which also spares a headless
sway's own bootstrap output — the harmless failure, versus blacking out a live
sibling.

`primary` stays treated as extend, which is the honest answer rather than a gap:
Wayland has no primary-output concept and these compositors have only a focused
output, which the streamed head already holds since #283. It now says so
distinctly instead of sharing a warning with `exclusive`.

🛑 The Hyprland restore is `hyprctl reload`, and that is measured, not chosen.
Re-applying the head's own mode/position/scale — what design §5.2 and the issue
both assume — does NOT undo a disable: it answers `ok` and leaves `disabled:
true`. Probed 2026-08-18 against 0.56.2 (hyprlang) and 0.55.4 (Lua); every
targeted form was accepted and changed nothing, including `,enable` (answers
`invalid resolution`), `preferred,auto,1`, `monitorv2 disabled=false`, `keyword
unset monitor`, the Lua `disabled = false`, `dispatch dpms on` and
`forcerendererreload`. A runtime monitor rule is additive and the `disable` keeps
winning; only re-reading the config clears it. The headless output survives the
reload, so the issue's worry about losing it does not hold. Side effects are
documented at the call site and in the docs: other runtime `keyword` overrides are
dropped, and a hyprlang config re-runs its `exec =` lines. It runs only when a
session actually disabled something.

Disable is spelled per config era and confirmed by read-back, mirroring
`set_monitor_rule`: `keyword monitor <n>,disable` under hyprlang, `hl.monitor{
output = "<n>", disabled = true }` under Lua. Both eras reject the other's form at
exit 0, so the read-back — not the exit status, not the `ok` — is what decides.

Also fixes a marker gap that made one of those rejections read as success:
`hyprctl keyword` under the Lua config manager answers "keyword can't work with
non-legacy parsers. Use eval.", and `hyprctl_dispatch` matched "couldn't" but not
"can't". `set_monitor_rule` was covered by its own mode verification; nothing else
was.

⚠ The sway half is NOT exercised on a live sway — no box in the fleet runs one,
the same gap #283's `focus output` shipped with. The argv is sway's documented
surface, both shapes are pinned by tests (this file uses `output <name> <verb>`
AND `focus output <name>`, so getting one backwards is the live risk), and the
read-back turns a wrong guess into a warning naming the outputs rather than a
screen that silently stays dark.

Six new unit tests cover the group-aware filter on both backends, the headless
no-op case, both disable spellings and the marker set. 246 pass on Linux.
2026-08-18 17:49:45 +02:00
enricobuehler f24eb02692 fix(packaging): the DualSense driver-priority guard has to be 0, and cover the capture node
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.
2026-08-18 17:47:00 +02:00
enricobuehler e053292a80 docs: the host's own audio output, and what to do when another device clocks it
`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.
2026-08-18 17:46:38 +02:00
enricobuehler ed075b98dd feat(host/audio): the capture line says which node is clocking it
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'`).
2026-08-18 17:43:28 +02:00
enricobuehler ec44079db4 fix(host/audio): the Linux capture sink drives its own graph group
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.
2026-08-18 17:40:35 +02:00
enricobuehler b81aee6821 Merge pull request 'Audio follow-ups: the jitter ring deepens by inserting instead of de-priming, client audio threads get real priority, fs-change syncs are rate-capped' (#303) from worktree-audio-followups into main
audit / bun-audit (sdk) (push) Successful in 41s
apple / swift (push) Successful in 2m13s
audit / bun-audit (plugin-kit) (push) Successful in 43s
audit / cargo-audit (push) Failing after 46s
audit / pnpm-audit (push) Successful in 14s
audit / bun-audit (web) (push) Successful in 37s
audit / docs-site-audit (push) Successful in 36s
audit / license-gate (push) Successful in 5m21s
ci / web (push) Successful in 1m12s
ci / bun-nix (push) Successful in 22s
ci / docs-site (push) Successful in 1m16s
audit / c-abi-asan (push) Successful in 6m46s
audit / miri (push) Successful in 7m17s
deb / build-publish-gamescope (push) Successful in 44s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 7m51s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m18s
deb / build-publish-client-arm64 (push) Successful in 3m45s
apple / distribute (push) Successful in 11m56s
deb / build-publish-host (push) Successful in 8m3s
deb / build-publish (push) Successful in 8m19s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m5s
flatpak / build-publish (push) Successful in 7m12s
apple / screenshots (push) Successful in 10m44s
android / android (push) Successful in 10m27s
docker / builders-arm64cross (push) Successful in 18s
docker / deploy-docs (push) Successful in 1m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 17m54s
nix / flake (push) Successful in 17m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 17m40s
windows-host / package (push) Successful in 13m51s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 44s
arch / build-publish (push) Successful in 13m32s
deb / smoke-install (push) Successful in 5m48s
ci / rust (push) Canceled after 19m8s
Reviewed-on: #303
2026-08-18 15:05:19 +00:00
enricobuehler 412991f6a3 Merge pull request 'DualSense haptics + speaker work in-game: four fixes between the game and the pad' (#302) from worktree-pad-audio-fidelity into main
android / android (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 1s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
nix / flake (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #302
2026-08-18 15:02:16 +00:00
enricobuehler e43d67c721 Merge pull request 'The Android client adopts the Skia console — one gamepad UI on three platforms, the Compose mirror deleted' (#301) from worktree-android-skia-console into main
android / android (push) Canceled after 17s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
nix / flake (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
audit / cargo-audit (push) Failing after 56s
audit / bun-audit (sdk) (push) Successful in 27s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / bun-audit (web) (push) Successful in 26s
audit / docs-site-audit (push) Successful in 20s
audit / pnpm-audit (push) Successful in 18s
apple / swift (push) Successful in 2m9s
apple / distribute (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
audit / license-gate (push) Canceled after 1m50s
audit / miri (push) Canceled after 1m30s
audit / c-abi-asan (push) Canceled after 1m16s
flatpak / build-publish (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
Reviewed-on: #301
2026-08-18 15:01:49 +00:00
enricobuehler 0a6a49a9aa feat(packaging): a WirePlumber policy holds a DualSense's sound card for GE-Proton
ci / rust-arm64 (pull_request) Successful in 2m34s
ci / web (pull_request) Successful in 2m38s
android / android (pull_request) Successful in 5m32s
ci / docs-site (pull_request) Successful in 1m26s
ci / bun-nix (pull_request) Successful in 52s
ci / rust (pull_request) Successful in 8m25s
nix / flake (pull_request) Failing after 14m42s
GE-Proton's DS5 haptic router opens the pad sink's backing hw: device RAW whenever it is
free — then its own path re-probe EBUSYs against its own handle, invalidates the stream,
and spins a 100 Hz "device generation" refresh loop: haptics dead, speaker dead, and in
one game a buffer race in the same machinery crashed the title outright. On SteamOS,
where that code was developed, PipeWire always holds the device, so GE lands on its
well-tested Pulse-routing fallback immediately and none of this fires.

Ship the SteamOS-shaped environment: node.always-process + no suspend keeps PipeWire
holding the device from the moment the card appears, and priority.driver = 1 keeps the
pad — whose USB audio clock (virtual or physical) is nobody's idea of a house clock —
from ever driving the graph. Installed by rpm/deb/arch/nix into
/usr/share/wireplumber/wireplumber.conf.d/. Matches both DS5 product-string spellings;
covers physically plugged pads on a headless host identically.
2026-08-18 16:42:33 +02:00
enricobuehler e356e354f2 fix(pad-audio): the speaker lane gets the music coder, the haptics lane keeps LowDelay
Both 0xD1 lanes encoded with Application::LowDelay at 64 kbps CBR — right for voice-coil
rumble (felt latency, band-limited content), audibly wrong for the pad speaker, which
carries real programme audio: on glass it "sounded insanely compressed". The speaker lane
now uses the full Application::Audio coder at 96 kbps (~120 bytes per 10 ms frame, still
far under one MTU); its few ms of extra algorithmic delay are inaudible on a speaker.
Haptics unchanged.
2026-08-18 16:42:31 +02:00
enricobuehler f737414949 fix(usbip): pace ISO completions against an absolute deadline, not relative sleeps
The simulator slept `service_interval × packets` per URB, measured from "now" at handling
time — so tokio timer slop, socket I/O and handler lock waits all added ON TOP of the
nominal period, every URB. The virtual pad's audio clock ran measurably slow under load
(~26 %: hw_ptr advanced ~35.7k frames/s against a 48 kHz stream), the PCM backed up into
xruns, and — because snd-usb-audio's clock IS URB completion — anything clocked off the
device dragged with it. On the test box the pad sink became the PipeWire graph driver and
pulled the desktop capture down to 50 % delivery ("insanely distorted" stream audio).

Pace against a per-endpoint absolute deadline ledger instead: each URB advances the
endpoint's deadline by exactly its nominal duration and sleeps until that instant, so
overhead eats into the next sleep rather than accumulating. A stall beyond 20 ms
re-anchors to now instead of fast-forwarding a burst. Measured after: 48005 frames/s.
Two paused-clock tests pin the exact rate and the re-anchor.
2026-08-18 16:42:29 +02:00
enricobuehler dff2769ba9 fix(pad): the usbip capture forwarded the pad's hardware quad as the wire's speaker pair
The isochronous endpoint carries the DualSense's own channel map — ch0 = headphone LEFT,
ch1 = headphone RIGHT and the built-in mono speaker, ch2/3 = the voice coils — because
everything a game writes has already been folded to hardware channels by the host's UCM
split (or written there directly) before it reaches the endpoint. The 0xD1 wire contract
instead puts the *speaker pair* on ch0/1. Forwarding the quad verbatim shipped headphone-
left (silence) as wire speaker-left and the actual speaker as wire speaker-right, which
the client renders onto the one split-sink channel that current PipeWire never wires to
the physical speaker. Field-diagnosed on glass end-to-end: haptics felt, speaker dead,
a test tone measured on exactly one channel at each hop.

Duplicate the hardware speaker channel across the wire's speaker pair; pass the coils
through; drop headphone-left deliberately (a remote pad's jack is not a wire surface).
The stream-sink capture path (uhid pads) already emits the logical layout and is
unchanged.
2026-08-18 16:42:27 +02:00
enricobuehler 20f766799c feat(plugin-kit): cap fs-change syncs to one per interval — a debounce cannot bound a launcher that never stops writing
apple / swift (pull_request) Successful in 2m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 51s
ci / docs-site (pull_request) Successful in 1m33s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m59s
ci / rust-arm64 (pull_request) Successful in 7m52s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m45s
android / android (pull_request) Successful in 8m39s
ci / rust (pull_request) Successful in 19m10s
nix / flake (pull_request) Failing after 16m14s
The Skynet field log carried `plugin:steam sync (fs-change): reconciled
16 entries` 102 times in 27 minutes: Steam writes to its dirs the whole
time a game runs, and every write re-walked the library. The 3 s
`Stream.debounce` collapses a BURST, but a debounce extends on every
event and so cannot bound the RATE under sustained churn.

`SyncSettings.minInterval` (default `DEFAULT_FS_CHANGE_MIN_INTERVAL` =
30 s; `LibraryPluginDef.minInterval` to override) sits on top of the
debounce: debounced events land in a sliding queue of one, and a drain
loop syncs, then holds for the interval. Changes inside the hold coalesce
into exactly one trailing sync, so nothing is lost and a launcher in a
writing frenzy costs one re-walk per interval instead of one per quiet
gap. Optional on `SyncSettings`, so plugins built against the older kit
keep compiling.

Test drives real `fs.watch` on a temp dir: 28 writes at 25 ms clear a 20 ms
debounce every time and would be ~28 syncs; with a 400 ms interval they
are three, at +32 / +433 / +835 ms.

Not here: narrowing the Steam plugin's watch set (`steamapps/` +
`libraryfolders.vdf`, not `userdata/`/`logs/`) lives in the steam plugin
repo; and the host-side reconcile coalescing during a session is optional
— the kit cap is the lever.
2026-08-18 14:15:41 +02:00
enricobuehler 62119e553a feat(client): give the audio decode, pad-audio and WASAPI threads real priority — the Realtime portal in a flatpak, rtkit outside, MMCSS on Windows
The device callbacks already run where the OS puts realtime audio (the
PipeWire playback callback on the graph's data loop since #292, WASAPI's
event-driven loop woken by the engine). The threads that FEED them were
plain: the decode leg (`punktfunk-audio-rx`), the pad-audio renderer and
its PipeWire loop, the Linux mic loop, and on Windows the render and mic
loops themselves. On a Steam Deck the same four cores decode 1440p120 and
present it, and a decode thread descheduled past the ring depth is a
drought the callback then has to conceal. #292's `setpriority(-10)` was
a no-op there: the user's RLIMIT_NICE is 0.

New `audio_rt` module, one call at the top of each of those threads:

- Linux: `setpriority` where RLIMIT_NICE allows; else, INSIDE A FLATPAK,
  the xdg-desktop-portal Realtime portal (`org.freedesktop.portal.Realtime`
  on the session bus); else rtkit (`MakeThreadHighPriorityWithPID` on the
  system bus). The split is PipeWire `module-rt`'s, and it is not
  optional: verified on the Deck (rtkit 0.14) that rtkit-daemon has NO
  PID-namespace translation — it looks up `/proc/<pid>/task/<tid>/stat`
  with the numbers it is given — so a direct call from a sandbox is
  answered with ENOENT (and there is no `busctl` in the GNOME 50 runtime
  either, which rules out the shell-out). The portal maps the sandboxed
  pid/tid and calls rtkit on the app's behalf, and portals need no
  `--talk-name`. polkit gates both with the TARGET process as subject:
  verified on the Deck by renicing a live active-session thread and a
  `steam` user-service thread (what a Steam-launched client is) through
  both rungs, and restoring them; only remote (ssh) sessions are refused.
  Via zbus, exactly as `pf_frame::thread_qos` does on the host (same
  version, features and backend — one lock line). Never setcap/SCHED_RR:
  the cap_sys_nice route is the one that killed KDE sessions in the field.
- Windows: MMCSS "Pro Audio" + THREAD_PRIORITY_HIGHEST, raw `extern
  "system"` declarations as `pf_frame::session_tuning` spells them; the
  MMCSS handle is leaked (thread-lifetime, the OS reverts it).

Every rung is best-effort and logs at debug what it got (`audio thread
priority raised via=…` / `refused why=…`); a refusal leaves the thread
exactly as it was. Acceptance on the Deck: `ps -eLo cls,rtprio,ni,comm |
grep punktfunk` shows the decode thread at nice −10 after connect.
2026-08-18 14:15:00 +02:00
enricobuehler 5aebb1ace4 fix(client/apple): port the jitter ring's insert — a sync request for more depth deepens the ring instead of de-priming it
The Swift mirror of core's `JitterPolicy` change, line for line: `underRun`
and `insertOneFrame()` next to `overRun` and `shedOneFrame()`, `hollow`
judged against the ADAPTIVE target (`adaptiveTarget(lift:)`, never the
sync-inflated one), `syncWantsMore` arming the insert, and the same
constants (`insertSustainMS = shedSustainMS`, `insertMarginMS =
AvSync.deadbandMS / 2`). `Stats` gains `inserts` and the 1 Hz `audio:`
line logs `drift_inserts=` next to `drift_sheds=`.

`insertFront` is index-based where core's is a `VecDeque`: the copy lands
in the slots just before `readIdx` (free exactly when the ring has that
much spare capacity) and `readIdx` steps back over it; both offsets are
shifted forward by one capacity first when `readIdx` is too small, so
neither can go negative into `%`.

`dropFront` gets the same seam fix as core's `crossfade_drop`: the
fade-out source is now the head of the discarded region — the
continuation of the sample just played — not its tail. Driven through the
hard-cap trim, the old fade stepped by 2 688 samples on a ramp where the
new one stays under 17.

`AudioRingDriftTests` carries the same vectors as core (`…NeverDeprimes`,
`…DeepensWithoutADeprimeOnACleanLink`, the insert seam heard end to end,
the drop seam against the sample played before it); both regression tests
were run against the backed-out fixes and fail there. 42/42 on macOS.
2026-08-18 14:00:52 +02:00
enricobuehler d5462d6d3d feat(client): apply the jitter policy's insert on the PipeWire, WASAPI and AAudio rings, and log it as drift_inserts
The three `VecDeque<f32>` rings apply `JitterStep::insert_front` with
`crossfade_insert` right where they apply `drop_front` today. The PipeWire
callback runs on the graph's realtime loop; the insert stays inside the
ring's reserve (hard cap plus 64 frames) because the policy only inserts
below its target. Same on AAudio (`hard_cap_max + RING_CHUNKS × frame`).

Concealment must stay visible in both directions: `PlaybackVitals` gains
`inserts`, the 10 s `audio playback` line gains `drift_inserts=` next to
`drift_sheds=`, and the Android 10 s line gets the same field. On glass
the tell that this works is `underruns` no longer stepping in lockstep
with `av_offset` swings while `drift_inserts` climbs instead.
2026-08-18 13:52:44 +02:00
enricobuehler 8cff5bda6b fix(core/audio): the jitter ring could only get deeper by de-priming, so a sync request for more depth was a silence gap
`JitterPolicy` could lower its depth gently — one crossfaded frame per
sustain window — but could only RAISE it by de-priming: a full
`target − depth` of inserted silence plus the priming wait. `hollow` was
judged against the sync-inflated effective target, so the moment the A/V
sync loop asked for ≥ 10 ms more depth (audio early against a picture
whose latency wandered — a 53–74 fps KWin source, an ABR retarget, a
keyframe burst) the ring read as hollow on the very next callback, and
the next single late packet cost a 15–60 ms gap. Every client runs this
policy; it is the "started at 0.24/0.25" shape.

Now the ring moves toward its target in BOTH directions with the same
instrument:

- `JitterStep::insert_front` mirrors `drop_front`: when the sync loop
  wants more than the adaptive target and the depth EWMA has sat more
  than `INSERT_MARGIN_MS` below the request for `INSERT_SUSTAIN_MS` of
  consumed audio, duplicate ONE frame at the front, crossfaded. Sync-only
  (an un-wired ring is bit-identical to before), primed-only, and
  below-target-only, so it can never fight the trim.
- `hollow` is judged against the ADAPTIVE target — the one underrun
  evidence proved — never the sync request. Growth that was never banked
  still re-primes on the click it already paid; sync never de-primes.
- `crossfade_insert` is the RT-safe twin of `crossfade_drop` (push_front
  inside reserved capacity, seam blended in place).

The margin is HALF the sync loop's ±10 ms deadband, not the shed's
`shed_excess_ms`: the loop only speaks once the offset has left the
deadband, so a margin at or above it would leave every request it is
allowed to make permanently unanswered.

Also fixes `crossfade_drop`'s seam, found while mirroring it: the
fade-out source was the LAST `fade` discarded samples, which is adjacent
to the survivors — but the sample the device had just played was
adjacent to `ring[0]`, so the seam still opened with a step of
`drop − fade` samples of waveform (3 ms of a 5 ms shed). The old test
only bounded steps INSIDE the faded region. Both helpers now fade out
from the continuation of what was just played, and the tests check the
seam against that sample.

Simulated (the bunching harness now applies inserts and counts
re-primes): a clean link asked for +20 ms deepens in 6 s with 3 inserts,
zero audible, zero re-primes; a −50 ppm host clock over ten minutes is
absorbed by 9 inserts instead of a re-anchor click; the bunching link
asked for +25 ms lands at 4 audible / 0 in the tail / 1 re-prime where
the unsynced run pays 7 / 2 / 4. The two existing convergence tests keep
their bounds. No new `pub const` (cbindgen); the C header is unchanged.
2026-08-18 13:50:36 +02:00
49 changed files with 3226 additions and 290 deletions
+22
View File
@@ -0,0 +1,22 @@
# AGENTS.md
Guidance for coding agents working in this repository.
## Agent skills
### Issue tracker
Issues live as Gitea issues in `unom/punktfunk` on `git.unom.io`, driven by the `gitea` MCP server
(`gh`/`glab`/`tea` do not work here), and every write needs the user's go-ahead first.
See `docs/agents/issue-tracker.md`.
### Triage labels
The five canonical roles, each label string equal to its name — `needs-triage`, `needs-info`,
`ready-for-agent`, `ready-for-human`, `wontfix` — none of which exist in the tracker yet.
See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: one `CONTEXT.md` and one `docs/adr/` at the repo root, covering the whole
workspace. See `docs/agents/domain.md`.
Generated
+1
View File
@@ -3027,6 +3027,7 @@ dependencies = [
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
"x11rb",
"zbus",
]
[[package]]
@@ -94,7 +94,10 @@ fun App(forceGamepadUi: Boolean = false) {
// `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage). Without a
// console to draw, a controller drives the touch UI through Compose's own focus.
val skiaConsole = remember { SkiaConsole.wanted() }
val gamepadUi = skiaConsole && gamepadUiActive(
// …AND it actually came up: a console whose native create failed or whose render thread died
// ([SkiaConsole.healthy], observable) would front a SurfaceView nothing ever paints — a gray
// screen with a working pad probe, which is worse than the touch UI it replaced.
val gamepadUi = skiaConsole && SkiaConsole.healthy && gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
@@ -255,11 +255,11 @@ private fun ControllersBody(
val haptics by rememberUpdatedState(rememberConsoleHaptics())
DisposableEffect(observeInput) {
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
// here would kill the pad navigation the arriving screen had just installed. The same
// teardown also runs when this screen hands the pad to its own input test and back.
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
// composed, and only the identity removal keeps this screen's teardown from taking the
// arriving screen's claim with it. The same teardown also runs when this screen hands the
// pad to its own input test and back.
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Read ONCE, up front: the test can end inside this very event, and the release that
@@ -317,15 +317,10 @@ private fun ControllersBody(
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
}
if (observeInput) {
activity?.padKeyProbe = keyProbe
activity?.padMotionProbe = motionProbe
}
val probes = if (observeInput) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity?.pushPadProbes(it) }
onDispose {
activity?.let { a ->
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
}
probes?.let { activity?.removePadProbes(it) }
}
}
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
@@ -82,8 +82,9 @@ fun GamepadNavEffect(
val currentOnOptions by rememberUpdatedState(onOptions)
DisposableEffect(active) {
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
// One entry on the MainActivity probe stack (see GamepadNavEffect2D), removed by identity on
// dispose — a cross-fading-out screen must take only its OWN claim, never the incoming
// screen's, and never the console shell's underneath.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -113,13 +114,10 @@ fun GamepadNavEffect(
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
probes?.let { activity.removePadProbes(it) }
state.reset()
}
}
@@ -186,9 +184,11 @@ fun GamepadNavEffect2D(
val currentOnShoulder by rememberUpdatedState(onShoulder)
DisposableEffect(active) {
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
// One entry on the MainActivity probe stack, removed by identity on dispose — during a
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
// teardown must not null out the incoming screen's just-installed probes.
// teardown must take only its own claim. On the console this effect sits OVER the Skia
// shell's probes: pushing (not overwriting) is what lets the shell's pad input resurface
// the moment this screen pops, instead of dying with a nulled slot.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -220,13 +220,10 @@ fun GamepadNavEffect2D(
else -> false // B → MainActivity (remapped to BACK → BackHandler)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
probes?.let { activity.removePadProbes(it) }
state.reset()
}
}
@@ -109,12 +109,29 @@ class MainActivity : ComponentActivity() {
var gamepadRouter: GamepadRouter? = null
/**
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
* Called for every key/motion event while not streaming; a `true` return consumes the event
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
* One screen's claim on the pad while not streaming: its key/motion observers, consulted for
* every event before the focus-navigation fallbacks below; a `true` return consumes the event.
* Holders are the Skia console shell, [GamepadNavEffect2D] on the Compose screens the console
* opens over itself, and the Controllers screen's input test.
*/
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
class PadProbes(val key: (KeyEvent) -> Boolean, val motion: (MotionEvent) -> Boolean)
/**
* The pad-probe claims, a STACK — only the top entry sees events. A single last-writer-wins
* slot is how the console shell used to lose the pad for good: a screen composed over it
* (Controllers/Licenses) overwrote the slot, then nulled it on its way out, and the shell —
* whose install effect had no reason to re-run — never got it back. Pushing on install and
* removing BY IDENTITY on dispose survives every ordering Compose produces (cross-fades
* compose both screens at once, and dispose is not always LIFO): whatever leaves takes only
* its own entry, and whatever is left on top resumes seeing the pad.
*/
private val padProbes = mutableListOf<PadProbes>()
fun pushPadProbes(p: PadProbes) { padProbes += p }
fun removePadProbes(p: PadProbes) { padProbes.remove(p) }
private val padKeyProbe: ((KeyEvent) -> Boolean)? get() = padProbes.lastOrNull()?.key
private val padMotionProbe: ((MotionEvent) -> Boolean)? get() = padProbes.lastOrNull()?.motion
/**
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
@@ -8,6 +8,9 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
import io.unom.punktfunk.ConnectErrors
import io.unom.punktfunk.ProfileStore
@@ -67,6 +70,17 @@ object SkiaConsole {
private const val PREFS = "punktfunk_console_settings"
private var handle = 0L
/**
* False once the console has proven it cannot draw — the native create failed, or the render
* thread died (a GL context that never came up, or one Android reclaimed and that would not
* come back). Compose observes it: `App` folds it into the gamepad-UI gate, so the answer to a
* dead console is the touch UI — not the gray, never-painted `SurfaceView` the shell would
* otherwise sit on for the rest of the process.
*/
var healthy by mutableStateOf(true)
private set
private var appContext: Context? = null
private val main = Handler(Looper.getMainLooper())
private val ioPool = Executors.newCachedThreadPool { r -> Thread(r, "pf-console-io").apply { isDaemon = true } }
@@ -149,6 +163,7 @@ object SkiaConsole {
handle = runCatching { NativeBridge.nativeConsoleCreate(opts.toString()) }.getOrDefault(0L)
if (handle == 0L) {
Log.e(TAG, "console: native create failed")
healthy = false // see [healthy] — the touch UI fronts everything from here
return 0L
}
Log.i(TAG, "console: created (gpu cache ${gpuCacheBytes(app) shr 20} MB)")
@@ -386,7 +401,10 @@ object SkiaConsole {
ev.has("editing") -> {} // the shell draws its own keyboard; nothing to raise here
ev.has("settings") -> onSettingsSaved(ev.getJSONObject("settings"))
ev.has("gles") -> Log.i(TAG, "console: GLES ${ev.optInt("gles")}")
ev.has("dead") -> Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
ev.has("dead") -> {
Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
healthy = false // the touch UI takes over; only a process restart tries again
}
}
}
@@ -228,13 +228,13 @@ fun SkiaConsoleShell(
padState.push(handle)
true
}
activity.padKeyProbe = keyProbe
activity.padMotionProbe = motionProbe
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
activity.pushPadProbes(probes)
SkiaConsole.padsChanged(Gamepad.firstPad())
onDispose {
// Only clear what is still ours: a screen composed after us must not lose its probes.
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
// Remove OUR claim only — a platform screen pushed over us keeps its own, and when it
// pops, this one resurfaces (the stack is what fixed the pad dying after Controllers).
activity.removePadProbes(probes)
padState.reset()
if (handle != 0L) padState.push(handle)
}
+14 -1
View File
@@ -336,6 +336,10 @@ struct Counters {
pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling)
underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained)
target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns)
/// Sync-driven inserts: one duplicated, crossfaded frame each (`JitterStep::insert_front`).
/// Concealment must be visible next to the underruns it prevents — a ring that is quietly
/// being deepened is a link whose picture keeps moving away from its audio.
inserts: AtomicU64,
/// Data callbacks since the process started, primed or not. Distinct from `pcm_written`
/// (which only counts SERVED reads) because that is exactly the distinction the start
/// watchdog needs: a device that is pulling but un-primed still ticks this, a stream that
@@ -1046,6 +1050,14 @@ fn try_open(rung: OpenRung, ctx: &OpenCtx) -> ndk::audio::Result<LiveStream> {
if step.drop_front > 0 {
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
}
// The mirror: the sync loop asked for a DEEPER ring, answered with one duplicated,
// crossfaded frame instead of a de-prime (see `JitterStep::insert_front`). Stays inside
// the ring's reserve on this RT thread — `with_capacity` above leaves `RING_CHUNKS`
// frames past the hard cap, and the policy only inserts BELOW its target.
if step.insert_front > 0 {
punktfunk_core::audio::crossfade_insert(&mut ring, step.insert_front, step.crossfade);
cb_counters.inserts.fetch_add(1, Ordering::Relaxed);
}
let mut ran_short = false;
if !step.silence {
for slot in out.iter_mut() {
@@ -1355,7 +1367,7 @@ fn decode_loop(
// `underruns` bought with a climbing `plc_ms` is a link in trouble,
// not a link that is fine.
log::info!(
"audio: {}={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} plc_ms={} peak={window_peak:.3}",
"audio: {}={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} av_ms={} plc_ms={} drift_inserts={} peak={window_peak:.3}",
plane_counter_key(fmt),
counters.pcm_written.load(Ordering::Relaxed),
counters.underruns.load(Ordering::Relaxed),
@@ -1363,6 +1375,7 @@ fn decode_loop(
counters.target_ms.load(Ordering::Relaxed),
av.offset_ms(),
drought.total_ms(),
counters.inserts.load(Ordering::Relaxed),
);
window_peak = 0.0;
}
+31 -4
View File
@@ -11,7 +11,7 @@
use super::egl::{EglContext, EglSurface, GlesVersion};
use super::gpu::Gpu;
use anyhow::Result;
use anyhow::{bail, Result};
use ndk::native_window::NativeWindow;
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
@@ -267,6 +267,13 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
let mut was_editing = console.editing();
let mut saved_gen = store.saved_gen();
let mut menu_out: Vec<MenuEvent> = Vec::new();
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
// torn down mid-create); a run of them is a context that is not coming back — most likely
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
// failure alone is logged, the loop retries, and the screen stays a gray never-painted
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
let mut gl_failures = 0u32;
const GL_FAILURE_LIMIT: u32 = 3;
loop {
// Take everything queued. With no surface up, block until something arrives.
@@ -347,11 +354,15 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
surface = Some(s);
window = Some(w);
gl_failures = 0;
// A fresh surface is a fresh entry: snapshot the pad so a button
// still held from before does not fire into the first frame.
nav.reset();
}
Err(e) => log::error!("console: window surface: {e:#}"),
Err(e) => {
log::error!("console: window surface: {e:#}");
gl_failures += 1;
}
}
}
Cmd::SurfaceChanged => {
@@ -411,8 +422,14 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
if need_wrap {
skia = None;
match g.wrap_window(&egl, w, h) {
Ok(surf) => skia = Some((surf, w, h)),
Err(e) => log::error!("console: {e:#}"),
Ok(surf) => {
skia = Some((surf, w, h));
gl_failures = 0;
}
Err(e) => {
log::error!("console: {e:#}");
gl_failures += 1;
}
}
}
if let Some((surf, _, _)) = skia.as_mut() {
@@ -441,6 +458,16 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
}
if gl_failures >= GL_FAILURE_LIMIT {
// Same release order as `Cmd::Quit`: the Skia surface, the current binding, then (on
// return) the EGL surface + window + context drop.
drop(skia.take());
if surface.is_some() {
egl.release_current();
}
bail!("GL surface failed {gl_failures} times in a row — giving the screen back");
}
// Publish what the console raised.
while let Some(a) = console.take_action() {
shared.emit(HostEvent::Action(a));
@@ -126,13 +126,11 @@ struct HostCardView: View {
let onSpeedTest: () -> Void
let onForget: () -> Void
let onRemove: () -> Void
/// Open this host's game library. `nil` no library affordance at all when the setting is
/// off or the host is unpaired (the library plane needs the pinned identity).
/// Open this host's game library a MENU action. `nil` no library affordance at all when
/// the setting is off or the host is unpaired (the library plane needs the pinned identity).
///
/// When present this is the card's **primary** action: tapping a machine you play games on
/// should offer the games, not drop you on its desktop. Streaming the desktop is still one tap
/// away, in the menu, and remains primary for a host with no library. Field note, 2026-08-16:
/// "clicking the PC opening the library directly".
/// Never the card's primary tap: tapping a host connects to it, on every surface. Browsing is
/// one step further in, exactly where the console shell keeps it (Y on a tile).
var onBrowseLibrary: (() -> Void)? = nil
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
@@ -152,13 +150,9 @@ struct HostCardView: View {
}
}
/// What tapping the card does: open the library where the host has one, else connect. The
/// menu carries whichever of the two this isn't, so neither is ever more than one press away.
private var primaryAction: () -> Void { onBrowseLibrary ?? onConnect }
var body: some View {
let m = CardMetrics.current
return Button(action: primaryAction) {
return Button(action: onConnect) {
HStack(spacing: m.spacing) {
monogramTile(monogram(host.displayName), osChain: host.osChain,
m: m, connecting: isConnecting, filled: true)
@@ -230,12 +224,11 @@ struct HostCardView: View {
// the host's default binding.
connectWithMenu(menu)
// Browsing IS a connect-shaped action it is this card's connect with a title picked
// first and it is now what TAPPING the card does, so the menu carries the other
// half instead: streaming the machine itself. (Pair / speed test / wake / forget stay
// on the host's card: those are about the machine, and a shortcut has no business
// claiming them.)
if onBrowseLibrary != nil {
Button("Stream the Desktop", systemImage: "display", action: onConnect)
// first so a pinned card offers it and opens its own shelf, whose launches carry the
// pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
// are about the machine, and a shortcut has no business claiming them.)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
@@ -256,11 +249,8 @@ struct HostCardView: View {
}
Button("Pair with PIN…", action: onPair)
Button("Test Network Speed…", action: onSpeedTest)
// The inverse of the card's primary tap see `onBrowseLibrary`. Absent for a host
// with no library, where connecting IS the primary tap and a menu row for it would
// just be the same action twice.
if onBrowseLibrary != nil {
Button("Stream the Desktop", systemImage: "display", action: onConnect)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
Button("Wake Host", systemImage: "power", action: onWake)
@@ -158,6 +158,17 @@ final class AudioRing: @unchecked Sendable {
/// and must stay there for this much consumed audio. Long, because a shed is the only thing
/// here a listener could notice; it must never fire on a transient.
private static let shedSustainMS = 2_000
/// The mirror for the sync-driven INSERT: the depth average must sit below the requested
/// target for this much consumed audio before one frame is duplicated. Equal to the shed's,
/// so the two corrections are the same instrument in both directions and cannot fight; kept
/// separate so the insert can be sped up alone if a listen test proves it inaudible. Mirrors
/// `INSERT_SUSTAIN_MS`.
private static let insertSustainMS = shedSustainMS
/// How far below the sync-requested target the depth average must sit before the insert
/// arms. NOT `shedExcessMS`: the sync loop only asks for more depth once the offset has left
/// its ±`AvSync.deadbandMS`, so a margin at or above the deadband would leave every request it
/// is allowed to make permanently unanswered. Half the deadband. Mirrors `INSERT_MARGIN_MS`.
private static let insertMarginMS = AvSync.deadbandMS / 2
private static let crossfadeMS = 2
/// Time constant of the depth average.
private static let ewmaTauMS = 1_000
@@ -206,6 +217,9 @@ final class AudioRing: @unchecked Sendable {
private var emptyRun = 0
private var depthAvg: Double = 0
private var overRun = 0
/// The mirror: consumed samples for which the average has sat more than `insertMarginMS`
/// below the sync-requested target (see the insert branch in `read`).
private var underRun = 0
/// The live target in interleaved samples `targetMS` grown by underrun pressure
/// (`noteRead`), never below the base. Set in `init` (needs the rate).
private var targetLive = 0
@@ -219,6 +233,9 @@ final class AudioRing: @unchecked Sendable {
/// which is a different problem from the depth being wrong.
private var underrunCount = 0
private var shedCount = 0
/// Sync-driven inserts: one duplicated, crossfaded frame each. Concealment in BOTH directions
/// must be visible a ring being quietly deepened is a picture moving away from its audio.
private var insertCount = 0
/// The depth the A/V sync loop would like, in interleaved samples (`AvSync.desiredDepth`).
/// `nil` the default, and what an un-wired session keeps reproduces the pre-sync
/// behaviour exactly, so this ring could adopt sync without the other three diverging.
@@ -230,8 +247,9 @@ final class AudioRing: @unchecked Sendable {
/// episode (a RUN of consecutive near-misses while the ring refills) buys one measured
/// step, not a sprint to the ceiling.
private var nearMissGrown = false
/// The depth average runs a `deprimeDebtMS` debt against the target (set in `read`): an
/// underrun should re-prime at once instead of waiting out the hysteresis.
/// The depth average runs a `deprimeDebtMS` debt against the ADAPTIVE target the one
/// underrun pressure grew, never the sync-inflated one (set in `read`): an underrun should
/// re-prime at once instead of waiting out the hysteresis.
private var hollow = false
/// Interleaved samples left in the current shrink-probe window (0 = no probe outstanding).
private var probeRun = 0
@@ -378,12 +396,20 @@ final class AudioRing: @unchecked Sendable {
/// oversized read would otherwise inflate the debt threshold forever and turn the very next
/// late packet into a full re-prime.
private func target(lift quantum: Int) -> Int {
let floor = max(targetLive, quantum + frameSamples)
let floor = adaptiveTarget(lift: quantum)
guard let want = syncTarget else { return floor }
let cap = max(msSamples(Self.hardCapMS), floor)
return min(max(want, floor), cap)
}
/// The ADAPTIVE target: the live target underrun pressure has grown, lifted so it can always
/// serve one quantum plus a packet. The floor the sync request is clamped against, and
/// because it is what underrun evidence has PROVEN this link needs what `hollow` is judged
/// against. Mirrors `JitterPolicy::adaptive_target`.
private func adaptiveTarget(lift quantum: Int) -> Int {
max(targetLive, quantum + frameSamples)
}
/// The sync loop is asking to run shallower than the adaptive target has grown to the
/// evidence `noteRead` relaxes a grown target on. Compared against the LIVE target, not the
/// effective one: it is the underrun-driven growth that a sync request is evidence against,
@@ -393,6 +419,15 @@ final class AudioRing: @unchecked Sendable {
return want < targetLive
}
/// The sync loop is asking to run DEEPER than the adaptive target audio is early against
/// the picture. This is what arms the insert in `read`; without a sync request the ring never
/// adds depth by itself, so an un-wired ring behaves exactly as it did before the insert
/// existed. Mirrors `JitterPolicy::sync_wants_more`.
private var syncWantsMore: Bool {
guard let want = syncTarget else { return false }
return want > targetLive
}
/// Hand the ring the depth the A/V sync loop wants (`AvSync.desiredDepth`), in interleaved
/// samples, or `nil` to run unsynchronised. Called from the drain thread.
///
@@ -460,6 +495,7 @@ final class AudioRing: @unchecked Sendable {
dropFront(writeIdx - readIdx - cap)
depthAvg = Double(writeIdx - readIdx)
overRun = 0
underRun = 0
}
}
@@ -496,20 +532,51 @@ final class AudioRing: @unchecked Sendable {
// this instant: a single late packet empties the ring for a callback without making it
// hollow, and must keep the consecutive-empties hysteresis. Lifted by THIS callback's
// size, not the high-water quantum see `target(lift:)`.
hollow = depthAvg + Double(msSamples(Self.deprimeDebtMS)) < Double(target(lift: count))
//
// Judged against the ADAPTIVE target, never the sync-inflated one. The debt this exists
// to call in is GROWTH that was never banked underrun evidence raised the promise and
// only a re-prime cashes that. A sync request is not evidence of starvation; it is a
// request for alignment, and it has its own gentle instrument (the insert below).
// Measured against the effective target, a request for `deprimeDebtMS` more depth made
// the ring hollow on the very next callback and turned the next single late packet into a
// full re-prime. The effective target is never below the adaptive one, so this can only be
// LESS hollow. Mirrors `JitterPolicy::step`.
hollow = depthAvg + Double(msSamples(Self.deprimeDebtMS)) < Double(adaptiveTarget(lift: count))
// Drift correction: shed exactly one frame, crossfaded, once the AVERAGE has sat above
// the threshold for the sustain window. Anything shorter is jitter and must be left alone.
if depthAvg > Double(target + msSamples(Self.shedExcessMS)) {
overRun += count
underRun = 0
if overRun >= msSamples(Self.shedSustainMS) {
overRun = 0
shedOneFrame()
shedCount += 1
depthAvg = Double(writeIdx - readIdx)
}
} else if syncWantsMore, depthAvg + Double(msSamples(Self.insertMarginMS)) < Double(target) {
// The mirror of the shed. The sync loop has asked for a DEEPER ring than the adaptive
// target (audio is early against the picture) and the AVERAGE has sat more than the
// margin below what it asked for, for the sustain window: duplicate ONE frame at the
// front, crossfaded. Below-target-only, so it can never fight the trim; sync-only, so
// an un-wired ring never adds depth by itself and the hollow re-prime keeps its job
// for growth that was never banked. (Primed-only comes free: an un-primed read
// returned above.) The ring must hold a whole frame to duplicate if it does not it
// is running dry, and the drought path is the tool for that. Mirrors the insert
// branch in `JitterPolicy::step`.
overRun = 0
underRun += count
if underRun >= msSamples(Self.insertSustainMS), writeIdx - readIdx >= frameSamples {
underRun = 0
insertOneFrame()
insertCount += 1
// Whatever we duplicated is buffered now reflect it at once so the next
// callbacks don't re-fire on a stale average.
depthAvg += Double(frameSamples)
}
} else {
overRun = 0
underRun = 0
}
let n = min(writeIdx - readIdx, count)
@@ -660,15 +727,21 @@ final class AudioRing: @unchecked Sendable {
///
/// The fade is `crossfadeSamples` capped at half a frame then clamped again to what this
/// particular drop can actually spare on either side of the seam.
///
/// The fade-OUT source is the HEAD of what is discarded the continuation of the sample the
/// device just played blending into the head of what survives, so both ends of the seam are
/// continuous. (It used to fade out from the discarded region's TAIL, which is adjacent to the
/// survivors but not to the sample just played, so the seam still opened with a step of
/// `drop fade` samples of waveform. Core's `crossfade_drop` had the same defect and the same
/// fix; `AudioRingDriftTests` now checks the seam against the sample played before it.)
private func dropFront(_ drop: Int) {
let available = writeIdx - readIdx
guard drop > 0, available > drop else { return }
let fade = min(crossfadeSamples, min(drop, available - drop))
let capacity = buf.count
if fade > 0 {
// The tail of what we discard fades out into the head of what survives.
for i in 0..<fade {
let old = buf[(readIdx + drop - fade + i) % capacity]
let old = buf[(readIdx + i) % capacity]
let new = buf[(readIdx + drop + i) % capacity]
let t = Float(i + 1) / Float(fade + 1)
buf[(readIdx + drop + i) % capacity] = old * (1 - t) + new * t
@@ -677,6 +750,47 @@ final class AudioRing: @unchecked Sendable {
readIdx += drop
}
/// Duplicate one audio frame at the front the sync-driven deepening, the mirror of
/// `shedOneFrame`. The session's REAL frame (`setFrameUs`).
private func insertOneFrame() { insertFront(frameSamples) }
/// Duplicate the first `insert` interleaved samples at the front the ring plays them, then
/// plays them again linearly crossfading the seam so the correction is continuous rather
/// than a click. Mirrors `punktfunk_core::audio::crossfade_insert`; caller holds the lock.
///
/// Index-based where core's is a `VecDeque`: the copy lands in the `insert` slots just BEFORE
/// `readIdx`, which are free exactly when the ring has that much spare capacity (they hold
/// audio already consumed), and `readIdx` steps back over it. `readIdx`/`writeIdx` are plain
/// offsets reduced modulo the capacity wherever they touch `buf`, so when `readIdx` is too
/// small to step back both are shifted forward by one whole capacity first every position
/// they name is unchanged, and neither can go negative (which `%` would turn into a negative
/// index).
///
/// The seam: what would have followed the copy's last sample is the original's `insert`-th
/// sample onward, so THAT fades out into the original's head, in place. The copy is written
/// before the seam is blended, so it is verbatim; the fade-out reads sit `insert` past every
/// write, so one ascending pass is safe.
private func insertFront(_ insert: Int) {
let available = writeIdx - readIdx
let capacity = buf.count
guard insert > 0, available >= insert, available + insert <= capacity else { return }
let fade = min(crossfadeSamples, min(insert, available - insert))
if readIdx < insert {
readIdx += capacity
writeIdx += capacity
}
for i in 0..<insert {
buf[(readIdx - insert + i) % capacity] = buf[(readIdx + i) % capacity]
}
for i in 0..<fade {
let old = buf[(readIdx + insert + i) % capacity]
let new = buf[(readIdx + i) % capacity]
let t = Float(i + 1) / Float(fade + 1)
buf[(readIdx + i) % capacity] = old * (1 - t) + new * t
}
readIdx -= insert
}
/// Current buffered depth in milliseconds for the stats overlay and the drain thread's
/// periodic log.
var bufferedMS: Int {
@@ -692,6 +806,9 @@ final class AudioRing: @unchecked Sendable {
let targetMS: Int
let underruns: Int
let sheds: Int
/// Sync-driven inserts one duplicated, crossfaded frame each (`insertOneFrame`). Read
/// next to `sheds`: the same correction, the other direction.
let inserts: Int
/// The A/V sync loop's smoothed offset (ms): **positive = audio playing BEHIND the
/// picture**, negative = ahead of it. `0` before the loop has evidence, or with sync off.
///
@@ -712,6 +829,7 @@ final class AudioRing: @unchecked Sendable {
targetMS: samplesMs(target),
underruns: underrunCount,
sheds: shedCount,
inserts: insertCount,
avOffsetMS: avOffsetMS,
plcMS: plcMS)
}
@@ -751,7 +869,7 @@ struct AvSync {
/// discontinuity and buys nothing a listener can perceive detectability for A/V misalignment
/// sits an order of magnitude above it. The deadband is what keeps the loop from hunting
/// forever around zero, which would be audible in a way the misalignment it chased was not.
private static let deadbandMS = 10
static let deadbandMS = 10
/// Observations folded before the first correction is offered. The offset is derived from a
/// clock skew estimate and a video figure that both need a moment to settle after connect;
/// acting on the first sample would chase the handshake, not the stream.
@@ -1229,7 +1229,7 @@ public final class SessionAudio {
if drained % 2_000 == 0 {
let s = ring.stats
log.info(
"audio: rate_hz=\(rateHz) frame_us=\(frameUs) buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) av_offset_ms=\(s.avOffsetMS) plc_ms=\(s.plcMS)"
"audio: rate_hz=\(rateHz) frame_us=\(frameUs) buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds) drift_inserts=\(s.inserts) av_offset_ms=\(s.avOffsetMS) plc_ms=\(s.plcMS)"
)
}
return true
@@ -684,6 +684,173 @@ final class AudioRingDriftTests: XCTestCase {
XCTAssertTrue(scratch.contains { $0 != 0 }, "refilled to target — playback resumes")
}
// MARK: - Sync-driven DEEPENING: the insert, the mirror of the shed
//
// The Swift half of core's insert tests (`a_sync_request_for_more_depth_*`,
// `growth_not_banked_still_re_primes`, `crossfade_insert_*`). Same vectors, same bounds: the
// ring could lower its depth gently but could only RAISE it by de-priming, and a sync request
// for a deeper ring made it `hollow` at once so the next single late packet was a full
// re-prime's worth of silence. Now one crossfaded frame per sustain window, both directions.
/// A primed ring asked for +30 ms is NOT hollow (`hollow` is judged against the adaptive
/// target), so one short read leaves it primed; and once the average has sat below the request
/// for `insertSustainMS` of consumed audio, exactly one frame is duplicated. Mirrors
/// `a_sync_request_for_more_depth_never_de_primes`.
func testASyncRequestForMoreDepthNeverDeprimes() {
let ring = AudioRing(seconds: 1, channels: channels, rateHz: 48_000)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 60 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
}
// Prime at the 20 ms base and hold the depth there for 100 ms.
write(ms: 25); read()
XCTAssertTrue(scratch.contains { $0 != 0 }, "25 ms primes the base")
ring.setSyncTarget(50 * perMS)
for _ in 0..<20 { write(ms: 5); read() }
// ONE late packet: four reads drain the 20 ms exactly, the fifth runs short. Before the fix
// the sync-inflated target made the ring hollow and this single click re-primed it.
for _ in 0..<5 { read() }
XCTAssertEqual(ring.stats.underruns, 1, "exactly one short read")
// Refill to the base. A de-primed ring would need the full 50 ms request before it
// played again and would answer this with silence.
write(ms: 25); read()
XCTAssertTrue(
scratch.contains { $0 != 0 },
"a single short read on a sync-deepened ring must keep the hysteresis, not de-prime")
// Steady at 20 ms again; the insert arms once the sustain window (counted since the
// request, ~130 ms of it already spent above) is full, and adds exactly one frame.
let before = ring.bufferedSamples
var firstInsertAtMS: Int?
for step in 0..<800 { // 4 s
write(ms: 5); read()
if firstInsertAtMS == nil, ring.stats.inserts > 0 { firstInsertAtMS = step * 5 }
}
guard let first = firstInsertAtMS else { return XCTFail("the insert never armed") }
XCTAssertGreaterThanOrEqual(first, 2_000 - 200, "armed before the sustain window")
XCTAssertLessThanOrEqual(first, 2_000 + 500, "armed long after the sustain window")
XCTAssertEqual(ring.stats.underruns, 1, "the deepening cost no clicks")
// One frame per sustain window: two of them in four seconds, each exactly a frame deep.
XCTAssertEqual(ring.stats.inserts, 2)
XCTAssertEqual(ring.bufferedSamples - before, 2 * ring.frameGeometry.frame)
}
/// The clean-link half of core's `a_sync_request_for_more_depth_deepens_without_a_de_prime_
/// on_a_clean_link`: sync asks for +20 ms, and the answer is a few inserts over a few seconds
/// with NO silent callback at all.
func testASyncRequestForMoreDepthDeepensWithoutADeprimeOnACleanLink() {
let ring = AudioRing(seconds: 1, channels: channels, rateHz: 48_000)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
let feed = [Float](repeating: 0.5, count: 60 * perMS)
func write(ms: Int) {
feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: ms * perMS) }
}
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
}
write(ms: 25); read()
ring.setSyncTarget(40 * perMS)
var silent = 0
var settledAtMS: Int?
for step in 0..<12_000 { // 60 s
write(ms: 5); read()
if scratch.allSatisfy({ $0 == 0 }) { silent += 1 }
// Settled once the ring holds the request minus the margin (5 ms), post-read.
if settledAtMS == nil, ring.bufferedMS >= 40 - 5 - 5 { settledAtMS = step * 5 }
}
XCTAssertEqual(silent, 0, "a clean link must stay silence-free")
XCTAssertEqual(ring.stats.underruns, 0)
XCTAssertGreaterThan(ring.stats.inserts, 0, "the deepening has to come from somewhere")
XCTAssertLessThanOrEqual(ring.stats.inserts, 8, "the insert kept firing once deep enough")
if let settledAtMS {
XCTAssertLessThanOrEqual(settledAtMS, 20_000, "deepening by 20 ms took \(settledAtMS) ms")
} else {
XCTFail("the ring never reached the sync target")
}
}
/// The seam of the insert, heard end to end: fill the ring with a ramp (any splice is a
/// visible jump), let one insert fire, and check every step of the PLAYED stream including
/// the one into the duplicated frame and the one out of it stays inside the fade's slope.
/// Mirrors `crossfade_insert_adds_exactly_one_frame_and_the_seam_is_continuous`.
func testTheInsertSeamIsContinuousInWhatIsPlayed() {
let ring = AudioRing(seconds: 1, channels: channels, rateHz: 48_000)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
var next: Float = 1_000 // the ramp: +1 per interleaved sample
func write(ms: Int) {
var chunk = [Float](repeating: 0, count: ms * perMS)
for i in 0..<chunk.count { chunk[i] = next; next += 1 }
chunk.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: chunk.count) }
}
var played: [Float] = []
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
played.append(contentsOf: scratch)
}
write(ms: 25); read()
ring.setSyncTarget(35 * perMS)
// Run until exactly one insert has happened, then a little past it.
var steps = 0
while ring.stats.inserts < 1, steps < 1_000 { write(ms: 5); read(); steps += 1 }
XCTAssertEqual(ring.stats.inserts, 1, "expected exactly one insert by now")
for _ in 0..<10 { write(ms: 5); read() }
XCTAssertEqual(ring.stats.underruns, 0)
// A duplicated 5 ms frame with a 2 ms fade: the seam smears 480 samples of ramp over
// 192, so |step| 480/192 + 1 3.5. A hard splice would step by 480.
let (frame, fade) = ring.frameGeometry
let maxSlope = Float(frame) / Float(fade) + 2
var worst: Float = 0
for i in 1..<played.count { worst = max(worst, abs(played[i] - played[i - 1])) }
XCTAssertLessThanOrEqual(worst, maxSlope, "a step of \(worst) is a splice, not a fade")
// And exactly one frame was added: everything written is either played or still buffered,
// plus the one duplicated frame.
let written = Int(next - 1_000)
XCTAssertEqual(played.count, written + frame - ring.bufferedSamples, "not exactly +1 frame")
}
/// The DROP's seam, checked the same way against the sample the device played just before
/// it. This is the check the fade never had, and the one the old tail-sourced fade-out failed
/// by a step of `drop fade` samples. Driven through the hard-cap trim, which is the drop
/// that actually fires in the field. Mirrors `crossfade_drop_is_continuous_with_what_was_just_
/// played`.
func testTheDropSeamIsContinuousWithWhatWasJustPlayed() {
let ring = AudioRing(seconds: 1, channels: channels, rateHz: 48_000)
let want = 5 * perMS
var scratch = [Float](repeating: 0, count: want)
var next: Float = 1_000
func write(ms: Int) {
var chunk = [Float](repeating: 0, count: ms * perMS)
for i in 0..<chunk.count { chunk[i] = next; next += 1 }
chunk.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: chunk.count) }
}
var played: [Float] = []
func read() {
scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) }
played.append(contentsOf: scratch)
}
// Prime and play one callback, so "the sample just played" is a real one.
write(ms: 25); read()
// A 60 ms burst lands on the 20 ms left: 80 ms > the 50 ms cap, so 30 ms is trimmed off
// the FRONT right behind the sample just played with a 2 ms fade.
write(ms: 60)
read(); read()
let (_, fade) = ring.frameGeometry
let drop = 30 * perMS
let maxSlope = Float(drop) / Float(fade) + 2
var worst: Float = 0
for i in 1..<played.count { worst = max(worst, abs(played[i] - played[i - 1])) }
XCTAssertLessThanOrEqual(
worst, maxSlope,
"a step of \(worst) across the trim is a splice, not a fade (the old fade-out source "
+ "would step by \(drop - fade))")
}
/// The four client rings adopt sync one at a time; an un-wired one must behave exactly as it
/// did. `nil` is the default, so this pins the initializer too and every other test in this
/// file runs without a sync target, which is the real guard that nothing moved underneath them.
+7
View File
@@ -150,6 +150,13 @@ rand = "0.9"
# (SteamOS, flatpak runtimes, Arch, Ubuntu ≥ 22.10) clears.
pipewire = { version = "0.9", features = ["v0_3_49"] }
sdl3 = { version = "0.18", features = ["hidapi"] }
# Audio-thread priority (`audio_rt`): one blocking D-Bus call per boosted thread — the Realtime
# PORTAL (session bus) inside a flatpak, where the sandbox's PID namespace makes a direct rtkit
# call unresolvable, and rtkit (system bus) outside one. The same zbus, features and backend
# choice as `pf-frame`'s `thread_qos` (which the host uses for the same purpose): `tokio` because
# core's `quic` already resolves tokio for this crate, `blocking-api` because the callers are
# plain worker threads, no default `async-io`.
zbus = { version = "5", default-features = false, features = ["tokio", "blocking-api"] }
# Native VAAPI decode (M6 of the native-decode program): the hand-declared libva buffer
# layouts, the profile/format/surface decisions, the AuPlan → picparams/IQ/slice
# conversion and the DRM-PRIME export descriptor that `video_vaapi_native` marshals.
+15
View File
@@ -503,6 +503,17 @@ fn pw_thread(
step.crossfade,
);
}
// The mirror: the sync loop asked for a DEEPER ring, and the policy answers
// with one duplicated, crossfaded frame instead of a de-prime. Allocation-free
// on this realtime loop: the ring is reserved for the hard cap plus slack and
// the policy only inserts below its target.
if step.insert_front > 0 {
punktfunk_core::audio::crossfade_insert(
&mut ud.ring,
step.insert_front,
step.crossfade,
);
}
let mut ran_short = false;
let n_frames = if let Some(slice) = data.data() {
@@ -529,6 +540,7 @@ fn pw_thread(
ud.vitals.note_callback(
ran_short,
step.drop_front > 0,
step.insert_front > 0,
ud.policy.avg_depth_ms(),
ud.policy.target_ms(),
);
@@ -611,6 +623,9 @@ impl MicStreamer {
let thread = std::thread::Builder::new()
.name("punktfunk-mic".into())
.spawn(move || {
// The capture stream's `process` runs on THIS thread (no RT_PROCESS): capture,
// encode and send are all here, and a late tick is mic latency. Best-effort.
crate::audio_rt::boost_and_log("punktfunk-mic");
if let Err(e) = mic_thread(&connector, quit_rx, muted, echo_cancel) {
tracing::warn!(error = %e, "mic uplink thread ended");
}
+202
View File
@@ -0,0 +1,202 @@
//! Best-effort scheduling priority for the client's audio threads.
//!
//! The device callbacks already run where the OS puts realtime audio: the PipeWire playback
//! callback is on the graph's data loop (`RT_PROCESS`), and WASAPI's event-driven render loop
//! is woken by the engine. The threads that FEED them are ordinary threads: the decode leg
//! (`punktfunk-audio-rx` — receive, conceal, decode, queue), the pad-audio renderer, and on
//! Windows the render/mic loops themselves. Their lateness is absorbed by the jitter ring — a
//! decode thread descheduled past the ring depth is a drought the callback conceals — but on a
//! Steam Deck the same four cores decode 1440p120 and present it, and on a loaded Windows box
//! the render loop competes with the game and the compositor. This module is the one place that
//! asks the OS for priority for those threads, on the sanctioned unprivileged paths only.
//!
//! **Linux.** Three rungs, first one that works wins:
//! 1. `setpriority(-10)` — honoured wherever `RLIMIT_NICE` allows (a developer's shell, most
//! desktops). On SteamOS the user's `RLIMIT_NICE` is 0 and this is a no-op.
//! 2. Inside a flatpak (`/.flatpak-info` exists): the **Realtime portal**
//! (`org.freedesktop.portal.Realtime` on the session bus). The sandbox has its own PID
//! namespace, and rtkit-daemon (0.14 verified on the Deck) does NOT translate — it looks up
//! `/proc/<pid>/task/<tid>/stat` with the numbers it is given, so a direct call from a
//! sandbox is answered with ENOENT. The portal maps the sandboxed pid/tid to the host's and
//! calls rtkit on the app's behalf; portals are reachable from every sandbox without a
//! `--talk-name`. This is the same split PipeWire's own `module-rt` makes.
//! 3. Otherwise **rtkit** directly (`org.freedesktop.RealtimeKit1` on the system bus,
//! `MakeThreadHighPriorityWithPID`) — what gives PipeWire's data loop its priority on the
//! Deck, and what `pf_frame::thread_qos` uses on the host.
//!
//! Both bus rungs are gated by polkit's `acquire-high-priority` action with the TARGET process
//! as the subject — allowed for the user's active session and for their session-less user
//! services (a client launched by Steam is one), refused for a remote (ssh) session. Verified on
//! the Deck 2026-08-18 by renicing a live active-session thread and a `steam` user-service thread
//! through both rungs, and restoring them.
//!
//! **Never** `setcap`/`SCHED_RR` here — the `cap_sys_nice` route is the one that killed KDE
//! sessions in the field, and a nice level is all the decode leg needs.
//!
//! **Windows.** MMCSS "Pro Audio" + `THREAD_PRIORITY_HIGHEST` for the calling thread — the
//! same pair every audio engine on the platform uses; the MMCSS handle is intentionally leaked
//! (thread-lifetime; the OS reverts it at exit), as `pf_frame::session_tuning::on_hot_thread`
//! does on the host.
//!
//! Every path is best-effort and logs at debug what it got; a refusal is exactly what the thread
//! had before this existed.
/// Nice level asked for on Linux. `-10` is comfortably inside rtkit's default `MinNiceLevel`
/// (15 on the Deck) and what the host's own hot threads ask for.
#[cfg(target_os = "linux")]
const NICE: i32 = -10;
/// Raise the CALLING thread's priority for audio work. Call at the top of the thread, before
/// any audio state is touched, from a plain worker thread (the bus calls block); returns what
/// happened for the caller's log line.
#[cfg(target_os = "linux")]
pub fn boost_current_thread() -> Boost {
// SAFETY: three by-value integers, no pointers; `PRIO_PROCESS` with `who == 0` targets the
// calling thread on Linux and only adjusts its nice value.
if unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, NICE) } == 0 {
return Boost::Setpriority;
}
// SAFETY: `gettid` takes no arguments, touches no memory, and returns the calling thread's
// kernel tid — always valid on Linux.
let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u64;
let pid = u64::from(std::process::id());
if std::path::Path::new("/.flatpak-info").exists() {
match linux_bus::portal_high_priority(pid, tid, NICE) {
Ok(()) => Boost::Portal,
Err(e) => Boost::Refused(format!("realtime portal: {e}")),
}
} else {
match linux_bus::rtkit_high_priority(pid, tid, NICE) {
Ok(()) => Boost::Rtkit,
Err(e) => Boost::Refused(format!("rtkit: {e}")),
}
}
}
#[cfg(target_os = "linux")]
mod linux_bus {
/// One-shot blocking system-bus call to rtkit. Per-call connection rather than cached: this
/// runs a handful of times per session (thread starts), and holding a bus connection for the
/// session's lifetime to save microseconds is a bad trade against a wedged bus daemon
/// pinning a socket in every session forever. Mirrors `pf_frame::thread_qos`.
pub(super) fn rtkit_high_priority(pid: u64, tid: u64, nice: i32) -> Result<(), zbus::Error> {
let conn = zbus::blocking::Connection::system()?;
// `MakeThreadHighPriorityWithPID(u64 process, u64 thread, i32 priority)` — priority is a
// nice level, floored by rtkit's MinNiceLevel. The WithPID variant with our own pid is
// the explicit spelling of "this thread of this process"; rtkit still authenticates the
// caller via the bus and hands the target process to polkit.
conn.call_method(
Some("org.freedesktop.RealtimeKit1"),
"/org/freedesktop/RealtimeKit1",
Some("org.freedesktop.RealtimeKit1"),
"MakeThreadHighPriorityWithPID",
&(pid, tid, nice),
)?;
Ok(())
}
/// The same request through the Realtime portal on the SESSION bus — the sandbox's own
/// pid/tid, which the portal maps before it calls rtkit. Same method name and signature
/// (`tti`), on `org.freedesktop.portal.Desktop` at `/org/freedesktop/portal/desktop`.
pub(super) fn portal_high_priority(pid: u64, tid: u64, nice: i32) -> Result<(), zbus::Error> {
let conn = zbus::blocking::Connection::session()?;
conn.call_method(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
Some("org.freedesktop.portal.Realtime"),
"MakeThreadHighPriorityWithPID",
&(pid, tid, nice),
)?;
Ok(())
}
}
/// Raise the CALLING thread's priority for audio work: MMCSS "Pro Audio" plus the highest
/// normal-class thread priority. Returns what happened for the caller's log line.
#[cfg(windows)]
pub fn boost_current_thread() -> Boost {
// Declared here rather than through the `windows` crate's feature list: two calls, both
// stable Win32 exports, and `pf_frame::session_tuning` already spells
// `AvSetMmThreadCharacteristicsW` this way. A raw HANDLE is a pointer-sized integer; NULL
// means failure for AvSet…, and SetThreadPriority returns a BOOL.
#[link(name = "avrt")]
unsafe extern "system" {
fn AvSetMmThreadCharacteristicsW(task_name: *const u16, task_index: *mut u32) -> isize;
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentThread() -> isize;
fn SetThreadPriority(thread: isize, priority: i32) -> i32;
}
const THREAD_PRIORITY_HIGHEST: i32 = 2;
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. `task` is a local
// NUL-terminated UTF-16 buffer alive for the whole call, so `task.as_ptr()` is a valid
// LPCWSTR; `&mut idx` is a live local u32 the call writes the task index into. The returned
// MMCSS handle is intentionally leaked — the OS reverts the characteristics at thread exit —
// so there is nothing to free. `GetCurrentThread` returns a pseudo-handle that needs no
// closing; `SetThreadPriority` takes only that handle and a flag.
let (mmcss, prio) = unsafe {
let task: Vec<u16> = "Pro Audio\0".encode_utf16().collect();
let mut idx: u32 = 0;
let h = AvSetMmThreadCharacteristicsW(task.as_ptr(), &mut idx);
let p = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);
(h != 0, p != 0)
};
match (mmcss, prio) {
(true, true) => Boost::Mmcss,
(true, false) => Boost::Refused("MMCSS ok, SetThreadPriority refused".into()),
(false, true) => Boost::Refused("SetThreadPriority ok, MMCSS refused".into()),
(false, false) => Boost::Refused("MMCSS and SetThreadPriority refused".into()),
}
}
/// What [`boost_current_thread`] managed. Logged, never acted on: every path is best-effort.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Boost {
/// Linux: `setpriority` was honoured (RLIMIT_NICE allowed it).
#[cfg(target_os = "linux")]
Setpriority,
/// Linux, sandboxed: the Realtime portal granted the nice level.
#[cfg(target_os = "linux")]
Portal,
/// Linux: rtkit granted the nice level after `setpriority` was refused (SteamOS).
#[cfg(target_os = "linux")]
Rtkit,
/// Windows: MMCSS "Pro Audio" plus `THREAD_PRIORITY_HIGHEST`.
#[cfg(windows)]
Mmcss,
/// Nothing was granted; the thread runs exactly as it did before. The string says why.
Refused(String),
}
impl Boost {
/// The one-word tag for a log line.
pub fn as_str(&self) -> &'static str {
match self {
#[cfg(target_os = "linux")]
Boost::Setpriority => "setpriority",
#[cfg(target_os = "linux")]
Boost::Portal => "portal",
#[cfg(target_os = "linux")]
Boost::Rtkit => "rtkit",
#[cfg(windows)]
Boost::Mmcss => "mmcss",
Boost::Refused(_) => "refused",
}
}
}
/// Boost the calling thread and log the outcome under `what` — the shape every audio thread
/// start uses.
pub fn boost_and_log(what: &'static str) {
match boost_current_thread() {
Boost::Refused(why) => {
tracing::debug!(thread = what, why = %why, "audio thread priority refused");
}
got => tracing::debug!(
thread = what,
via = got.as_str(),
"audio thread priority raised"
),
}
}
+25 -7
View File
@@ -7,7 +7,7 @@
//! missed graph cycle, which is a click. So the callback publishes numbers into these atomics
//! and the decode thread, an ordinary thread that already wakes every frame, prints them at the
//! old cadence with the old field names (`audio playback buffer_ms= target_ms= underruns=
//! drift_sheds= plc_ms=`), so a field-log grep keeps working. The WASAPI twin runs its render
//! drift_sheds= drift_inserts= plc_ms=`), so a field-log grep keeps working. The WASAPI twin runs its render
//! loop on a plain thread and could log in place, but publishes here too: one logging site,
//! one line shape, on both platforms.
@@ -23,6 +23,10 @@ pub struct PlaybackVitals {
pub underruns: AtomicU64,
/// Drops the policy asked for: drift sheds and hard trims together.
pub sheds: AtomicU64,
/// Inserts the policy asked for: sync-driven deepening, one duplicated crossfaded frame each
/// (`JitterStep::insert_front`). Logged next to `sheds` so concealment in BOTH directions
/// stays visible — a ring being quietly deepened is a picture moving away from its audio.
pub inserts: AtomicU64,
/// The policy's smoothed ring depth, ms — what drift correction reacts to.
pub buffer_ms: AtomicU32,
/// The policy's LIVE target depth, ms (grows under underrun pressure, follows A/V sync).
@@ -36,8 +40,15 @@ pub struct PlaybackVitals {
impl PlaybackVitals {
/// Callback side: one callback done. `ran_short` = it could not be filled from the ring;
/// `shed` = the policy dropped something this callback.
pub fn note_callback(&self, ran_short: bool, shed: bool, buffer_ms: u32, target_ms: u32) {
/// `shed` = the policy dropped something this callback; `insert` = it duplicated a frame.
pub fn note_callback(
&self,
ran_short: bool,
shed: bool,
insert: bool,
buffer_ms: u32,
target_ms: u32,
) {
self.callbacks.fetch_add(1, Ordering::Relaxed);
if ran_short {
self.underruns.fetch_add(1, Ordering::Relaxed);
@@ -45,6 +56,9 @@ impl PlaybackVitals {
if shed {
self.sheds.fetch_add(1, Ordering::Relaxed);
}
if insert {
self.inserts.fetch_add(1, Ordering::Relaxed);
}
self.buffer_ms.store(buffer_ms, Ordering::Relaxed);
self.target_ms.store(target_ms, Ordering::Relaxed);
}
@@ -68,6 +82,7 @@ impl PlaybackVitals {
callbacks: self.callbacks.load(Ordering::Relaxed),
underruns: self.underruns.load(Ordering::Relaxed),
sheds: self.sheds.load(Ordering::Relaxed),
inserts: self.inserts.load(Ordering::Relaxed),
buffer_ms: self.buffer_ms.load(Ordering::Relaxed),
target_ms: self.target_ms.load(Ordering::Relaxed),
requested_frames: self.requested_frames.load(Ordering::Relaxed),
@@ -83,6 +98,7 @@ pub struct Snapshot {
pub callbacks: u64,
pub underruns: u64,
pub sheds: u64,
pub inserts: u64,
pub buffer_ms: u32,
pub target_ms: u32,
pub requested_frames: u32,
@@ -98,14 +114,16 @@ mod tests {
fn counters_accumulate_and_gauges_overwrite() {
let v = PlaybackVitals::default();
assert!(!v.quantum_known());
v.note_callback(false, false, 15, 15);
v.note_callback(true, true, 9, 25);
v.note_callback(true, false, 12, 25);
v.note_callback(false, false, false, 15, 15);
v.note_callback(true, true, false, 9, 25);
v.note_callback(true, false, false, 12, 25);
v.note_callback(false, false, true, 12, 25);
v.note_quantum(240, 8192, 240);
let s = v.snapshot();
assert_eq!(s.callbacks, 3);
assert_eq!(s.callbacks, 4);
assert_eq!(s.underruns, 2);
assert_eq!(s.sheds, 1);
assert_eq!(s.inserts, 1);
assert_eq!(
(s.buffer_ms, s.target_ms),
(12, 25),
+16
View File
@@ -403,6 +403,10 @@ fn render_thread(
let _ = ready.send(Err(e));
return Ok(());
}
// Event-driven at the endpoint's period on a plain thread until now: MMCSS "Pro Audio" +
// THREAD_PRIORITY_HIGHEST, what every audio engine on the platform gives its render loop.
// A missed period here is a click the ring cannot help with. Best-effort (`audio_rt`).
crate::audio_rt::boost_and_log("wasapi-render");
let res = (|| -> Result<Option<u32>> {
let channels = fmt.channels.clamp(1, 8) as u8;
// 32-bit float interleaved: channels × 4 bytes/sample, at EVERY rate and depth this client
@@ -538,6 +542,15 @@ fn render_thread(
if step.drop_front > 0 {
punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade);
}
// The mirror: the sync loop asked for a DEEPER ring, answered with one duplicated,
// crossfaded frame instead of a de-prime (see `JitterStep::insert_front`).
if step.insert_front > 0 {
punktfunk_core::audio::crossfade_insert(
&mut ring,
step.insert_front,
step.crossfade,
);
}
out.clear();
out.resize(avail_frames * block_align, 0);
@@ -559,6 +572,7 @@ fn render_thread(
vitals.note_callback(
ran_short,
step.drop_front > 0,
step.insert_front > 0,
policy.avg_depth_ms(),
policy.target_ms(),
);
@@ -638,6 +652,8 @@ fn mic_thread(
wasapi::initialize_mta()
.ok()
.context("CoInitializeEx (MTA)")?;
// Same treatment for the capture loop: capture, encode and send all run here.
crate::audio_rt::boost_and_log("wasapi-mic");
let mut encoder = opus::Encoder::new(
SAMPLE_RATE as u32,
+4
View File
@@ -30,6 +30,10 @@ pub mod audio;
// — atomics only, because the PipeWire callback runs on the graph's realtime loop.
#[cfg(any(target_os = "linux", windows))]
pub mod audio_vitals;
// Best-effort priority for the threads that FEED the device callbacks (decode leg, pad-audio
// renderer, the WASAPI loops): rtkit / the Realtime portal on Linux, MMCSS on Windows.
#[cfg(any(target_os = "linux", windows))]
pub mod audio_rt;
#[cfg(any(target_os = "linux", windows))]
pub mod discovery;
#[cfg(any(target_os = "linux", windows))]
+6
View File
@@ -1326,6 +1326,9 @@ pub(crate) fn spawn(
}
fn run(connector: &NativeClient, stop: &AtomicBool, haptics: bool, speaker: bool) {
// The pad's audio is a haptic: a late decode is a rumble that lands after the hit. Same
// best-effort priority as the main decode leg (`audio_rt`).
crate::audio_rt::boost_and_log("pf-pad-audio");
// Per-kind decode state for the ONE rendered pad (v1: the first pad that streams; the
// spec's per-(pad, kind) fan-out degenerates to per-kind once the pad is latched).
let mut streams: [Option<KindStream>; 2] = [None, None];
@@ -1480,6 +1483,9 @@ impl PadOut {
let thread = std::thread::Builder::new()
.name("pf-pad-audio-out".into())
.spawn(move || {
// The pad stream's `process` runs on THIS thread (no RT_PROCESS), so this is
// the thread that has to make the pad's device cycles. Best-effort.
crate::audio_rt::boost_and_log("pf-pad-audio-out");
if let Err(e) = pad_pw_thread(pcm_rx, recycle_tx, quit_rx, target) {
tracing::warn!(error = %format!("{e:#}"), "pad-audio playback thread ended");
}
+7 -10
View File
@@ -2091,16 +2091,10 @@ fn spawn_audio(
// the ring (target 15 ms and up), so it is not the callback's problem in kind — but
// on a Steam Deck the same four cores decode 1440p120 and present it, and a decode
// thread descheduled past the ring depth is a drought the callback then has to
// conceal. A plain `setpriority` is honoured wherever RLIMIT_NICE allows (rtkit is
// the sanctioned unprivileged path and a follow-up); where it is refused this is a
// no-op, which is exactly what it was before.
#[cfg(target_os = "linux")]
{
// SAFETY: three by-value integers, no pointers; `PRIO_PROCESS` with `who == 0`
// targets the calling thread on Linux and only adjusts its nice value.
let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, -10) };
tracing::debug!(raised = rc == 0, "audio decode thread priority");
}
// conceal. `setpriority` where RLIMIT_NICE allows, else the Realtime portal (in a
// flatpak) or rtkit — the sanctioned unprivileged paths; a refusal leaves the thread
// exactly as it was. See `audio_rt`.
crate::audio_rt::boost_and_log("punktfunk-audio-rx");
let mut pcm = vec![0f32; scratch];
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
// Interleaved samples in the last decoded frame — the unit concealment is produced in.
@@ -2152,6 +2146,9 @@ fn spawn_audio(
target_ms = v.target_ms,
underruns = v.underruns,
drift_sheds = v.sheds,
// The other direction of the same correction: sync-driven deepening,
// one duplicated crossfaded frame each. Concealment must stay visible.
drift_inserts = v.inserts,
callbacks = v.callbacks,
// Concealment must be visible next to the underruns it prevented: a
// healthy `underruns` bought with a climbing `plc_ms` is a link in
@@ -522,6 +522,17 @@ fn build_device(
dev.configuration_max_power = 250; // 500 mA in 2 mA units
dev.set_manufacturer_name("Sony Interactive Entertainment");
dev.set_product_name("DualSense Wireless Controller");
// A real DualSense reports **no iSerialNumber**, but the vendored server's `UsbDevice::default`
// fills in the placeholder string "Serial" — which ALSA bakes into the card id and PipeWire into
// the node names: `…DualSense_Wireless_Controller_Serial-00` where the hardware gives
// `…DualSense_Wireless_Controller-00`. Clear it so every name a matcher can key on is
// byte-identical to a physical pad's.
//
// ⚠ This is fidelity, NOT a fix for UCM selection — measured on .41 2026-08-18, `alsa-ucm-conf`
// keys on `${CardComponents}` (`USB054c:0ce6`), so the DualSense UCM matched with the
// placeholder still present. Do not re-derive that: the profile a card lands on is chosen by
// verb priority, not by its name.
dev.unset_serial_number();
dev
// Interface 0 — Audio Control (no endpoints).
+373 -17
View File
@@ -143,6 +143,23 @@ pub struct HyprlandDisplay {
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
/// overlay is never coming instead of inferring it from an absence.
last_cursor_mode: Option<crate::portal_cursor::Mode>,
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
/// physical head is never left dark. Mirrors `kwin.rs`'s field of the same name.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
impl Drop for HyprlandDisplay {
fn drop(&mut self) {
// Backstop only: the registry takes the restore right after `create` (moving it into the
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
// than strand the operator's heads dark.
if let Some(restore) = self.pending_restore.take() {
restore();
}
}
}
impl HyprlandDisplay {
@@ -150,8 +167,32 @@ impl HyprlandDisplay {
Ok(HyprlandDisplay {
hw_cursor: false,
last_cursor_mode: None,
pending_restore: None,
})
}
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
/// the restore for the registry (see [`Self::pending_restore`]).
///
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
/// after it, so there is no path that disables the operator's heads and then unwinds past the
/// point where the restore is handed over. The cost is that the physical heads stay lit for the
/// duration of the portal handshake, which is the pre-existing `extend` behaviour anyway.
fn apply_topology(&mut self, ours: &str) {
use crate::policy::Topology;
match crate::effective_topology() {
// Nothing to do — the headless output joins the desk as one more head, which is what
// `create` has already built.
Topology::Extend | Topology::Auto => {}
Topology::Primary => warn_primary_is_not_expressible(),
Topology::Exclusive => {
let disabled = disable_other_heads(ours);
self.pending_restore = (!disabled.is_empty()).then(|| {
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
});
}
}
}
}
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by
@@ -220,12 +261,15 @@ impl VirtualDisplay for HyprlandDisplay {
self.last_cursor_mode
}
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
self.pending_restore.take()
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Log the permission-system caveat once per process (silent black frames otherwise).
preflight_once();
// Remove any output a PREVIOUS host left in this compositor, before we mint our first.
reclaim_leftovers_once();
warn_topology_is_extend_only();
let name = next_output_name();
hyprctl_dispatch(&["output", "create", "headless", &name]).with_context(|| {
@@ -264,6 +308,9 @@ impl VirtualDisplay for HyprlandDisplay {
cursor = cursor_mode.name(),
"hyprland headless output ready"
);
// Display-management topology (design §5.2). Last, so no failure path unwinds past the
// hand-off of the restore — see [`HyprlandDisplay::apply_topology`].
self.apply_topology(&name);
Ok(VirtualOutput {
node_id,
remote_fd: Some(fd),
@@ -423,11 +470,20 @@ fn reclaim_leftovers_once() {
/// remote mouse motion can focus-follows-mouse its way over, and no window rule names our output.
/// So without this, every app the host launches for the session — the whole game library — opens on
/// a monitor the client cannot see, and the stream shows a bare desktop. This is the EXTEND-topology
/// answer to that: it steers window placement without touching the operator's heads (which is what
/// [`warn_topology_is_extend_only`] is still telling the truth about).
/// answer to that: it steers window placement without touching the operator's heads which is also
/// the whole of what `topology: primary` can mean here (see [`warn_primary_is_not_expressible`]).
/// Under `exclusive` it is called a second time, after the heads are disabled, because that moves
/// focus (see [`disable_other_heads`]).
///
/// Best-effort by construction: a failure costs window placement, not the session, and a box with no
/// physical head was already placing windows correctly.
///
/// ⚠️ **This is a no-op under the Lua config manager.** Measured on .138 (0.55.4, Lua) 2026-08-18:
/// `hyprctl dispatch focusmonitor <name>` is parsed as Lua (`hl.dispatch(focusmonitor <name>)`) and
/// rejected, and `hl.dsp.focusmonitor` does not exist either — so the #283 window-placement fix
/// does not reach a Lua-configured box. Both rejections carry "error", so [`hyprctl_dispatch`]
/// reports them and this warns rather than failing silently; the gap itself is unfixed and belongs
/// to the #283 follow-up, not to the topology work here.
pub(crate) fn focus_output(name: &str) {
match hyprctl_dispatch(&focus_argv(name)) {
Ok(()) => tracing::info!(output = %name, "focused the streamed headless output"),
@@ -450,21 +506,233 @@ fn focus_argv(name: &str) -> [&str; 3] {
["dispatch", "focusmonitor", name]
}
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
/// (sweep 13.18). The Hyprland headless output is always an EXTENSION: [`focus_output`] steers new
/// windows onto it, but nothing here promotes it to primary or disables the operator's heads.
fn warn_topology_is_extend_only() {
let topology = crate::effective_topology();
if !matches!(
topology,
crate::policy::Topology::Extend | crate::policy::Topology::Auto
) {
/// `topology: primary` has no expression on this compositor, and saying so once per create is the
/// honest implementation (design §5.2 gives the whole wlr family "**unsupported** (no primary
/// concept) → log + treat as extend").
///
/// Wayland has no primary-output concept at all, and Hyprland's nearest equivalent is the *focused*
/// monitor — which [`focus_output`] already points at the streamed head for every session, whatever
/// the topology says. So `primary` is not silently dropped so much as already granted, as far as
/// this compositor can express it; what an operator does NOT get is a persistent designation other
/// clients can read. Distinct from the `exclusive` path, which really does change the desk.
fn warn_primary_is_not_expressible() {
tracing::info!(
"hyprland: `topology: primary` has no equivalent here — Wayland has no primary output and \
Hyprland has only a FOCUSED monitor, which the streamed head already holds. Treating it \
as `extend`; use `exclusive` to actually disable the operator's heads."
);
}
/// Which heads an `exclusive` topology should disable: enabled, not ours, and **not managed**.
///
/// Pure so the group-awareness rule (design §6.1 — "exclusive means the *managed virtual displays*
/// are the only enabled outputs; never disable a sibling slot") is unit-testable without a
/// compositor. `managed` comes from [`list_monitors`], i.e. [`is_managed_output`]: `PF-<pid>-<n>`,
/// which covers a SECOND host's outputs as well as our own, so a concurrent session's screen can
/// never be blacked out by ours. `ours` is excluded by name too — belt and braces, since our own
/// output is managed by construction and the one head that must survive.
fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> Vec<String> {
heads
.iter()
.filter(|h| h.enabled && !h.managed && h.connector != ours)
.map(|h| h.connector.clone())
.collect()
}
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
/// that screen, not the session.
fn disable_other_heads(ours: &str) -> Vec<String> {
let heads = match list_monitors() {
Ok(h) => h,
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
"hyprland: could not enumerate monitors for `topology: exclusive` — leaving the \
operator's heads enabled (the session still streams, as `extend`)"
);
return Vec::new();
}
};
let targets = heads_to_disable(&heads, ours);
if targets.is_empty() {
tracing::info!(
"hyprland: `topology: exclusive` had nothing to disable — no enabled head besides the \
managed ones (a headless box, or a sibling session already took the desk)"
);
return Vec::new();
}
let mut disabled = Vec::new();
for name in targets {
match disable_head(&name) {
Ok(()) => disabled.push(name),
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
"hyprland: could not disable this head for `topology: exclusive` — it stays lit"
),
}
}
if !disabled.is_empty() {
tracing::info!(
?disabled,
"hyprland: `topology: exclusive` — the streamed output is now the desk"
);
// Disabling heads re-homes their workspaces, and the compositor picks the replacement
// focus itself. Re-assert ours so window placement still lands on the stream (the #283
// contract) rather than on whichever head Hyprland happened to choose.
focus_output(ours);
}
disabled
}
/// Disable one head, supporting **both config eras** and confirming by read-back.
///
/// Same two-era shape as [`set_monitor_rule`], and for the same reason: `hyprctl keyword` is the
/// hyprlang form and is *rejected outright* under the Lua config manager ("keyword can't work with
/// non-legacy parsers. Use eval."), while `hyprctl eval` is rejected under hyprlang ("eval is only
/// supported with the lua config manager"). Both rejections come back at **exit 0**, so the read-back
/// — not the exit status, and not the `ok` — is what decides. Measured 2026-08-18 on Hyprland
/// 0.56.2 (hyprlang, `.21`) and 0.55.4 (Lua, `.138`); both spellings disable, both verified by
/// `disabled: true` in `hyprctl -j monitors all`.
fn disable_head(name: &str) -> Result<()> {
let spec = disable_rule_spec(name);
let lua = disable_lua_expr(name);
let keyword: Vec<&str> = vec!["keyword", "monitor", &spec];
let eval: Vec<&str> = vec!["eval", &lua];
let mut attempts: Vec<String> = Vec::new();
for a in [&keyword, &eval] {
if let Err(e) = hyprctl_dispatch(a) {
let said = format!("{e:#}");
tracing::debug!(output = %name, cmd = ?a, error = %said, "hyprctl rejected this disable form — trying the other config era");
attempts.push(said);
continue;
}
if wait_head_disabled(name, DISABLE_BUDGET) {
return Ok(());
}
attempts.push(format!(
"hyprctl {a:?} was accepted but the head never went disabled"
));
}
bail!("no hyprctl form disabled {name}: {}", attempts.join("; "))
}
/// The **hyprlang** disable rule for `name` (`hyprctl keyword monitor <this>`), split out so a test
/// pins its shape. `disable` is a whole-rule verb and replaces the resolution field — there is no
/// `<name>,<mode>,disable`, and (measured) no `<name>,enable` to undo it.
fn disable_rule_spec(name: &str) -> String {
format!("{name},disable")
}
/// The **Lua** disable rule for `name` (`hyprctl eval <this>`), split out so a test pins its shape.
///
/// The field is `disabled` (past tense) and takes a boolean. Measured on .138: `disable = true` is
/// rejected with "unknown field 'disable'", and `mode = "disable"` with "error applying field
/// 'mode'" — the hyprlang spelling does not carry over, so this is not a place to guess.
fn disable_lua_expr(name: &str) -> String {
format!("hl.monitor{{ output = \"{name}\", disabled = true }}")
}
/// Poll until `name` reports `disabled: true` (the rule applies asynchronously), up to `timeout`.
fn wait_head_disabled(name: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if matches!(head_is_enabled(name), Ok(Some(false))) {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(Duration::from_millis(50));
}
}
/// Is head `name` currently enabled? `None` if it is not present at all. Reads `-j monitors all`,
/// which is the only listing that includes a DISABLED head (the plain `-j monitors` drops it, so
/// asking that one "is it disabled?" cannot distinguish disabled from unplugged).
fn head_is_enabled(name: &str) -> Result<Option<bool>> {
let out = hyprctl(&["-j", "monitors", "all"])?;
let monitors: serde_json::Value =
serde_json::from_str(&out).context("parse hyprctl -j monitors all")?;
let Some(arr) = monitors.as_array() else {
return Ok(None);
};
for m in arr {
if m.get("name").and_then(|n| n.as_str()) == Some(name) {
return Ok(Some(
!m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
));
}
}
Ok(None)
}
/// How long a `disable` (or the `reload` that undoes it) has to show up in `hyprctl -j monitors
/// all`. Generous next to the measured near-instant apply; a miss is reported, never assumed.
const DISABLE_BUDGET: Duration = Duration::from_secs(3);
/// Re-enable the heads an `exclusive` session disabled. Run by the REGISTRY when the display
/// group's last member is torn down (design §6.1) and, critically, **before** that member's output
/// is removed — so Hyprland never sees zero enabled outputs.
///
/// 🛑🛑 **`hyprctl reload` is the only thing that re-enables a disabled head, and that is measured,
/// not chosen.** The obvious restore — re-apply the head's own mode/position/scale, which is what
/// `design/display-management.md` §5.2 assumes and what the issue (#284) proposed — **does not
/// work**: it answers `ok` and leaves `disabled: true`. Probed 2026-08-18 against Hyprland 0.56.2
/// (hyprlang) and 0.55.4 (Lua); every one of these was accepted and changed nothing:
///
/// * `keyword monitor <name>,<W>x<H>@<Hz>,<x>x<y>,<scale>` (the exact pre-disable rule)
/// * `keyword monitor <name>,preferred,auto,1`
/// * `keyword monitor <name>,enable` — not a verb: answers `invalid resolution`
/// * `keyword monitorv2 output=<name>,…,disabled=false`
/// * `keyword unset monitor`
/// * `eval 'hl.monitor{ output = "<name>", disabled = false, … }'` (the Lua twin)
/// * `dispatch dpms on <name>` — DPMS is a different axis; the head stays disabled
/// * `dispatch forcerendererreload`
///
/// A runtime `monitor` rule is additive, and the `disable` in it keeps winning; only re-reading the
/// config clears the runtime rules. So the restore is the operator's own config, re-applied — which
/// for a config-driven compositor is exactly what "put it back how it was" means.
///
/// ⚠️ The side effects are real and worth knowing: a reload drops **every** runtime `hyprctl
/// keyword`/`eval` override, including our own monitor rule for the streamed output (harmless — the
/// output is removed moments later by the same teardown) and any the operator set by hand; and on a
/// hyprlang config it re-runs `exec =` lines (`exec-once` is not re-run, and a Lua config's
/// `hl.on("hyprland.start", …)` autostart does not re-fire either). This runs ONLY when we actually
/// disabled something, so a box that never used `exclusive` never pays it.
fn restore_heads(disabled: &[String]) {
if let Err(e) = hyprctl_dispatch(&["reload"]) {
tracing::error!(
?disabled, error = %format!("{e:#}"),
"hyprland: `hyprctl reload` failed — the heads this session disabled are still dark. \
Re-run `hyprctl reload` by hand to get them back."
);
return;
}
// Report the OUTCOME, not the request: `reload` answers `ok` for "config parsed", which is not
// the same as "the head came back" (a head the operator's own config disables stays disabled,
// correctly). Read it back so a field report says which screens actually returned.
let deadline = Instant::now() + DISABLE_BUDGET;
let still_dark = loop {
let dark: Vec<&String> = disabled
.iter()
.filter(|n| matches!(head_is_enabled(n), Ok(Some(false))))
.collect();
if dark.is_empty() || Instant::now() >= deadline {
break dark;
}
thread::sleep(Duration::from_millis(50));
};
if still_dark.is_empty() {
tracing::info!(
?disabled,
"hyprland: re-enabled the heads `topology: exclusive` disabled"
);
} else {
tracing::warn!(
?topology,
"hyprland: this backend implements EXTEND only — the headless output is added beside \
the operator's heads and nothing is promoted or disabled. Configure `topology: extend` \
to stop the console promising otherwise."
?disabled, ?still_dark,
"hyprland: `hyprctl reload` ran but these heads are still disabled — the operator's own \
config may disable them, otherwise they need a manual `hyprctl reload`"
);
}
}
@@ -686,6 +954,13 @@ fn hyprctl_dispatch(args: &[&str]) -> Result<()> {
// config manager" — a rejection hyprctl reports with exit 0 and no other marker.
|| lc.contains("only supported")
|| lc.contains("not supported")
// The MIRROR rejection, and it matched none of the markers above: `hyprctl keyword` on a
// Lua config answers "keyword can't work with non-legacy parsers. Use eval." — note
// "can't", not the "couldn't" that was already covered. Measured on .138 (0.55.4, Lua).
// Without this the wrong-era `keyword` read as SUCCESS, and every caller then had to
// notice the miss for itself by reading the state back.
|| lc.contains("can't")
|| lc.contains("cannot")
{
bail!("hyprctl {:?} rejected: {t}", args);
}
@@ -1172,4 +1447,85 @@ mod tests {
fn picker_line_is_the_shared_selection_format() {
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]/screen:PF-1\n");
}
fn head(connector: &str, enabled: bool) -> crate::monitors::PhysicalMonitor {
crate::monitors::PhysicalMonitor {
connector: connector.to_string(),
description: connector.to_string(),
width: 1920,
height: 1080,
refresh_mhz: 60_000,
x: 0,
y: 0,
scale: 1.0,
primary: false,
enabled,
// The real `list_monitors` derives this with `is_managed_output`; mirror it here so the
// fixture can't drift into asserting a rule the backend doesn't actually apply.
managed: is_managed_output(connector),
}
}
/// The group-awareness rule (design §6.1): `exclusive` disables the operator's heads and
/// **only** those. A sibling session's output — ours or another host's, both `PF-<pid>-<n>` —
/// must survive, or the second exclusive session blacks out the first one's screen, which is
/// the exact bug KWin's Stage 3 shipped and Stage 5 fixed.
#[test]
fn exclusive_disables_the_operators_heads_and_never_a_managed_sibling() {
let ours = "PF-4242-1";
let heads = [
head("DP-1", true),
head("HDMI-A-1", true),
head(ours, true),
// A concurrent session's output, and one from a second host — both managed.
head("PF-4242-2", true),
head("PF-99-1", true),
// Already off: nothing to disable, and it must NOT end up in the restore list, or
// teardown would switch on a head the operator had deliberately left dark.
head("DP-3", false),
];
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
}
/// A box with no physical head (the CI/headless posture) has nothing to disable, so no restore
/// is prepared and teardown never runs a `hyprctl reload` — the reload's side effects are paid
/// only by a session that actually took a screen.
#[test]
fn exclusive_on_a_headless_box_disables_nothing() {
let ours = "PF-4242-1";
assert!(heads_to_disable(&[head(ours, true)], ours).is_empty());
}
/// Both config eras, pinned. These two strings are the whole contract with the compositor and
/// neither is guessable: `hyprctl` answers a wrong-era or malformed rule at **exit 0**, so a
/// typo here reads as success and the operator's screen simply stays lit under `exclusive`.
#[test]
fn disable_rules_are_pinned_for_both_config_eras() {
assert_eq!(disable_rule_spec("DP-1"), "DP-1,disable");
assert_eq!(
disable_lua_expr("DP-1"),
r#"hl.monitor{ output = "DP-1", disabled = true }"#
);
}
/// `hyprctl keyword` under the Lua config manager answers "keyword can't work with non-legacy
/// parsers. Use eval." at exit 0 — the one rejection shape the marker list used to miss, so the
/// wrong-era form reported success. (Its mirror, `eval` under hyprlang, was already covered.)
#[test]
fn a_wrong_era_rejection_is_an_error_not_a_success() {
for said in [
"keyword can't work with non-legacy parsers. Use eval.",
"eval is only supported with the lua config manager",
"invalid resolution ",
] {
let lc = said.to_ascii_lowercase();
assert!(
lc.contains("can't")
|| lc.contains("cannot")
|| lc.contains("only supported")
|| lc.contains("invalid"),
"{said:?} must match a marker in hyprctl_dispatch"
);
}
}
}
+278 -16
View File
@@ -73,6 +73,23 @@ pub struct WlrootsDisplay {
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
/// overlay is never coming instead of inferring it from an absence.
last_cursor_mode: Option<crate::portal_cursor::Mode>,
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
/// physical head is never left dark. Mirrors `kwin.rs` and the Hyprland twin.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
impl Drop for WlrootsDisplay {
fn drop(&mut self) {
// Backstop only: the registry takes the restore right after `create` (moving it into the
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
// than strand the operator's heads dark.
if let Some(restore) = self.pending_restore.take() {
restore();
}
}
}
impl WlrootsDisplay {
@@ -80,8 +97,32 @@ impl WlrootsDisplay {
Ok(WlrootsDisplay {
hw_cursor: false,
last_cursor_mode: None,
pending_restore: None,
})
}
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
/// the restore for the registry (see [`Self::pending_restore`]).
///
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
/// after it, so there is no path that disables the operator's heads and then unwinds past the
/// point where the restore is handed over. The cost is that the physical heads stay lit for the
/// duration of the portal handshake, which is the pre-existing `extend` behaviour anyway.
fn apply_topology(&mut self, ours: &str) {
use crate::policy::Topology;
match crate::effective_topology() {
// Nothing to do — the headless output joins the desk as one more head, which is what
// `create` has already built.
Topology::Extend | Topology::Auto => {}
Topology::Primary => warn_primary_is_not_expressible(),
Topology::Exclusive => {
let disabled = disable_other_heads(ours);
self.pending_restore = (!disabled.is_empty()).then(|| {
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
});
}
}
}
}
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK`
@@ -113,8 +154,11 @@ impl VirtualDisplay for WlrootsDisplay {
self.last_cursor_mode
}
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
self.pending_restore.take()
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
warn_topology_is_extend_only();
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
// itself (`HEADLESS-N`), so the only way to know which one is ours is "the name that was not
// there before" — and two concurrent creates each picking the other's output is a silent
@@ -180,6 +224,9 @@ impl VirtualDisplay for WlrootsDisplay {
cursor = cursor_mode.name(),
"sway headless output ready"
);
// Display-management topology (design §5.2). Last, so no failure path unwinds past the
// hand-off of the restore — see [`WlrootsDisplay::apply_topology`].
self.apply_topology(&name);
Ok(VirtualOutput {
node_id,
remote_fd: Some(fd),
@@ -342,22 +389,182 @@ fn focus_argv(name: &str) -> [&str; 3] {
["focus", "output", name]
}
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
/// (sweep 13.18). sway's virtual output is always an EXTENSION: [`focus_output`] steers new windows
/// onto it, but nothing here promotes it to primary or disables the operator's heads.
fn warn_topology_is_extend_only() {
let topology = crate::effective_topology();
if !matches!(
topology,
crate::policy::Topology::Extend | crate::policy::Topology::Auto
) {
tracing::warn!(
?topology,
"wlroots: this backend implements EXTEND only — the headless output is added beside the \
operator's heads and nothing is promoted or disabled. Configure `topology: extend` to \
stop the console promising otherwise."
/// `topology: primary` has no expression on this compositor, and saying so once per create is the
/// honest implementation — design §5.2 spells this row out: "**unsupported** (no primary concept)
/// → log + treat as extend".
///
/// Wayland has no primary-output concept, and sway's nearest equivalent is the *focused* output —
/// which [`focus_output`] already points at the streamed head for every session, whatever the
/// topology says. So `primary` is not silently dropped so much as already granted, as far as this
/// compositor can express it; what an operator does NOT get is a persistent designation other
/// clients can read. Distinct from the `exclusive` path, which really does change the desk.
fn warn_primary_is_not_expressible() {
tracing::info!(
"wlroots: `topology: primary` has no equivalent here — Wayland has no primary output and \
sway has only a FOCUSED output, which the streamed head already holds. Treating it as \
`extend`; use `exclusive` to actually disable the operator's heads."
);
}
/// Which heads an `exclusive` topology should disable: enabled, not ours, and **not managed**.
///
/// Pure so the group-awareness rule (design §6.1 — "exclusive means the *managed virtual displays*
/// are the only enabled outputs; never disable a sibling slot") is unit-testable without a
/// compositor. `managed` comes from [`list_monitors`], i.e. the `HEADLESS-` prefix, so a concurrent
/// session's output is never blacked out by ours.
///
/// ⚠️ That prefix is [deliberately blunt](is_managed_output): sway names its OWN headless outputs
/// the same way we do, so a sway started on the headless backend has a `HEADLESS-1` of its own that
/// this filter also spares. The failure that buys is the harmless one — a bootstrap head stays lit
/// on a box that has no physical screen anyway — whereas the alternative is disabling a live
/// sibling's output. `ours` is excluded by name too, belt and braces.
fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> Vec<String> {
heads
.iter()
.filter(|h| h.enabled && !h.managed && h.connector != ours)
.map(|h| h.connector.clone())
.collect()
}
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
/// that screen, not the session.
fn disable_other_heads(ours: &str) -> Vec<String> {
let heads = match list_monitors() {
Ok(h) => h,
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
"wlroots: could not enumerate outputs for `topology: exclusive` — leaving the \
operator's heads enabled (the session still streams, as `extend`)"
);
return Vec::new();
}
};
let targets = heads_to_disable(&heads, ours);
if targets.is_empty() {
tracing::info!(
"wlroots: `topology: exclusive` had nothing to disable — no enabled output besides the \
headless ones (a headless box, or a sibling session already took the desk)"
);
return Vec::new();
}
let mut disabled = Vec::new();
for name in targets {
match disable_head(&name) {
Ok(()) => disabled.push(name),
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
"wlroots: could not disable this output for `topology: exclusive` — it stays lit"
),
}
}
if !disabled.is_empty() {
tracing::info!(
?disabled,
"wlroots: `topology: exclusive` — the streamed output is now the desk"
);
// Disabling outputs moves their workspaces, and sway picks the replacement focus itself.
// Re-assert ours so window placement still lands on the stream (the #283 contract).
focus_output(ours);
}
disabled
}
/// Disable one head: `swaymsg output <name> disable`, confirmed by read-back.
///
/// The read-back is not ceremony. `swaymsg` does report a rejected command with a non-zero exit
/// (unlike `hyprctl`, which answers at exit 0 — see the Hyprland twin), so a bad *command* is
/// caught by [`swaymsg`] itself; what the read-back adds is proof the output actually went
/// inactive, which is the state teardown will have to undo.
fn disable_head(name: &str) -> Result<()> {
swaymsg(&disable_argv(name)).with_context(|| format!("swaymsg output {name} disable"))?;
if wait_head_enabled_is(name, false, DISABLE_BUDGET) {
return Ok(());
}
bail!("swaymsg accepted `output {name} disable` but the output never went inactive")
}
/// The `swaymsg` argv that disables `name`, split out so a test pins its SHAPE — the noun comes
/// FIRST here (`output <name> disable`), the opposite of [`focus_argv`]'s `focus output <name>`.
fn disable_argv(name: &str) -> [&str; 3] {
["output", name, "disable"]
}
/// The `swaymsg` argv that re-enables `name`. sway keeps a disabled output's configuration, so a
/// bare `enable` restores the mode/position/scale it had — there is no need to replay the rule the
/// way the Hyprland twin's `reload` does.
fn enable_argv(name: &str) -> [&str; 3] {
["output", name, "enable"]
}
/// How long a `disable`/`enable` has to show up in `swaymsg -t get_outputs`. Generous next to a
/// healthy IPC round trip; a miss is reported, never assumed.
const DISABLE_BUDGET: Duration = Duration::from_secs(3);
/// Poll until `name`'s enabled state equals `want`, up to `timeout`. `false` on timeout.
fn wait_head_enabled_is(name: &str, want: bool, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if matches!(head_is_enabled(name), Ok(Some(got)) if got == want) {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(Duration::from_millis(50));
}
}
/// Is output `name` currently enabled (sway's `active`)? `None` if it is not present at all.
/// A disabled output is still listed by `get_outputs`, with `"active": false` — which is what makes
/// this a usable read-back rather than a presence check.
fn head_is_enabled(name: &str) -> Result<Option<bool>> {
let parsed = swaymsg_query("get_outputs")?;
let Some(arr) = parsed.as_array() else {
return Ok(None);
};
for o in arr {
if o.get("name").and_then(|n| n.as_str()) == Some(name) {
return Ok(Some(
o.get("active").and_then(|v| v.as_bool()).unwrap_or(true),
));
}
}
Ok(None)
}
/// Re-enable the outputs an `exclusive` session disabled. Run by the REGISTRY when the display
/// group's last member is torn down (design §6.1) and, critically, **before** that member's output
/// is unplugged — so sway never sees zero enabled outputs.
///
/// ⚠️ **Not exercised on a live sway.** No box in the fleet runs one (the 2026-08-18 probes had
/// Hyprland only), which is the same gap PR #283's `focus output` half shipped with and which
/// `design/display-management.md` records as "wlroots `exclusive` (needs a Sway box)". The argv is
/// sway's documented command surface and is pinned by [`enable_argv`]'s test; the read-back below
/// turns a wrong guess into a logged warning naming the outputs, rather than a screen that silently
/// stays dark. Unlike Hyprland — where re-applying a rule provably does NOT undo a disable and only
/// `hyprctl reload` does — sway's `enable` is the documented inverse of `disable`.
fn restore_heads(disabled: &[String]) {
for name in disabled {
match swaymsg(&enable_argv(name)) {
Ok(_) => {
if wait_head_enabled_is(name, true, DISABLE_BUDGET) {
tracing::info!(output = %name, "wlroots: re-enabled the output `topology: exclusive` disabled");
} else {
tracing::warn!(
output = %name,
"wlroots: `output enable` was accepted but the output is still inactive — \
re-enable it by hand with `swaymsg output {name} enable`"
);
}
}
Err(e) => tracing::error!(
output = %name, error = %format!("{e:#}"),
"wlroots: could not re-enable this output — it is still dark. Run \
`swaymsg output {name} enable` by hand."
),
}
}
}
@@ -805,4 +1012,59 @@ mod tests {
fn focus_names_the_output_after_the_verb() {
assert_eq!(focus_argv("HEADLESS-2"), ["focus", "output", "HEADLESS-2"]);
}
/// The topology pair takes the OTHER shape — `output <name> <verb>`, noun first, like `mode` /
/// `unplug` and unlike [`focus_argv`]. Both are pinned because this file legitimately uses both
/// orders, which is exactly the condition under which one gets written the wrong way round.
#[test]
fn disable_and_enable_name_the_output_before_the_verb() {
assert_eq!(disable_argv("DP-1"), ["output", "DP-1", "disable"]);
assert_eq!(enable_argv("DP-1"), ["output", "DP-1", "enable"]);
}
fn head(connector: &str, enabled: bool) -> crate::monitors::PhysicalMonitor {
crate::monitors::PhysicalMonitor {
connector: connector.to_string(),
description: connector.to_string(),
width: 1920,
height: 1080,
refresh_mhz: 60_000,
x: 0,
y: 0,
scale: 1.0,
primary: false,
enabled,
// The real `list_monitors` derives this from the `HEADLESS-` prefix; mirror it here so
// the fixture can't drift into asserting a rule the backend doesn't actually apply.
managed: connector.starts_with("HEADLESS-"),
}
}
/// The group-awareness rule (design §6.1): `exclusive` disables the operator's outputs and
/// **only** those. A sibling session's `HEADLESS-N` must survive, or the second exclusive
/// session blacks out the first one's screen — the exact bug KWin's Stage 3 shipped.
#[test]
fn exclusive_disables_the_operators_outputs_and_never_a_headless_sibling() {
let ours = "HEADLESS-2";
let heads = [
head("DP-1", true),
head("HDMI-A-1", true),
head(ours, true),
// A concurrent session's output — and, indistinguishably, a headless sway's own
// bootstrap output. Both are spared; see `heads_to_disable`.
head("HEADLESS-1", true),
// Already off: nothing to disable, and it must NOT end up in the restore list, or
// teardown would switch on an output the operator had deliberately left dark.
head("DP-3", false),
];
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
}
/// A box with no physical output (the CI/headless posture) has nothing to disable, so no
/// restore is prepared and teardown touches nothing.
#[test]
fn exclusive_on_a_headless_box_disables_nothing() {
let ours = "HEADLESS-1";
assert!(heads_to_disable(&[head(ours, true)], ours).is_empty());
}
}
+604 -22
View File
@@ -675,8 +675,22 @@ impl DroughtConceal {
pub struct JitterStep {
/// Interleaved samples to discard from the FRONT of the ring before reading.
pub drop_front: usize,
/// Interleaved samples of linear crossfade to apply across the seam left by `drop_front`
/// ([`crossfade_drop`] does it for a `VecDeque<f32>` ring). Zero only when nothing is dropped.
/// Interleaved samples to DUPLICATE at the front of the ring before reading — the mirror of
/// `drop_front` ([`crossfade_insert`] does it for a `VecDeque<f32>` ring). Never set in the
/// same step as `drop_front`. Zero when nothing is inserted.
///
/// This is how the ring gets DEEPER without a re-prime. Before it existed the policy could
/// lower depth gently (one crossfaded frame per sustain window) but could only raise it by
/// de-priming — a whole `target depth` of inserted silence plus the priming wait — and the
/// A/V sync loop asking for a deeper ring made that happen on the very next late packet: a
/// 1560 ms gap, repeated every time a wandering video reference asked again. Now a sync
/// request for more depth is answered the way a request for less is: one crossfaded frame at
/// a time, and the de-prime is reserved for genuine starvation and for growth that was never
/// banked (see `hollow`).
pub insert_front: usize,
/// Interleaved samples of linear crossfade to apply across the seam left by `drop_front` or
/// `insert_front` ([`crossfade_drop`] / [`crossfade_insert`] do it for a `VecDeque<f32>`
/// ring). Zero only when nothing is dropped or inserted.
///
/// BOTH kinds of drop are faded. The hard-cap trim used to splice raw, on the reasoning that a
/// ring which blew its ceiling "is already a discontinuity" — but that is a statement about the
@@ -700,6 +714,27 @@ const EWMA_TAU_MS: u32 = 1_000;
/// The depth EWMA must stay above the shed threshold for this much CONSUMED AUDIO. Deliberately long: a shed is the only
/// thing here a listener could ever notice, so it must never fire on a transient.
const SHED_SUSTAIN_MS: u32 = 2_000;
/// The mirror for the sync-driven INSERT: the depth EWMA must sit below the target for this much
/// consumed audio before one frame is duplicated. Starts equal to the shed's — the two corrections
/// are then the same instrument in both directions (2.5 ms of correction per second at a 5 ms
/// frame; ~8 s to answer a 20 ms request), and equal time constants are the safest thing against
/// the pair ever fighting. Kept as its own constant so the insert can be sped up (a crossfaded
/// 5 ms repeat every 500 ms is a 1 % time-stretch, which is what RFC 3550 playout adaptation
/// does) without touching the shed, IF a listen test proves it inaudible. Not `pub` — cbindgen.
const INSERT_SUSTAIN_MS: u32 = SHED_SUSTAIN_MS;
/// How far below the sync-requested target the depth AVERAGE must sit before the insert arms.
///
/// NOT the shed's `shed_excess_ms` (1220 ms across the presets), though the branch is otherwise
/// its mirror: the sync loop only ever asks for more depth once the offset has left its
/// ±[`AV_DEADBAND_MS`] deadband, so a margin at or above the deadband would leave every request
/// the loop is allowed to make — "audio is 1020 ms early, deepen by that" — permanently
/// unanswered, and the insert would be dead code for exactly the field shape it exists to fix.
/// Half the deadband: small enough that every request is acted on, and — with the shed's
/// threshold on the other side — a settling zone at least `shed_excess + this` wide, so a ring
/// parked anywhere inside it is touched by neither. The insert never chases itself, either: the
/// loop asks for `depth offset`, and one inserted frame moves both by the same amount.
const INSERT_MARGIN_MS: u32 = AV_DEADBAND_MS / 2;
const _: () = assert!(INSERT_MARGIN_MS < AV_DEADBAND_MS);
/// Linear crossfade applied across a drift shed's seam.
///
/// Sized against the protocol's 5 ms Opus frame, where 2 ms is a comfortable fraction of what a
@@ -817,6 +852,14 @@ fn samples_to_ms(rate_hz: u32, channels: u8, samples: usize) -> u32 {
/// [`SHED_SUSTAIN_MS`] of consumed audio sheds ONE 5 ms frame with a crossfade, so latency returns
/// to target instead of ratcheting.
///
/// **And the mirror.** When the A/V sync loop asks for a DEEPER ring than the adaptive target,
/// the depth is raised the same way — a depth EWMA that sits [`INSERT_MARGIN_MS`] below the
/// request for [`INSERT_SUSTAIN_MS`] duplicates ONE frame with a crossfade
/// ([`JitterStep::insert_front`]). Until that existed the only way UP was a de-prime, and a
/// sync request for more depth made the ring `hollow` at once, so the next single late packet
/// bought a full `target depth` of silence: the "audio gaps track the A/V offset" field
/// shape, on every client, since sync steering shipped.
///
/// **Driven by the audio clock, not the wall clock**: every duration is measured in samples
/// consumed. That makes it allocation-free, syscall-free (safe in a realtime callback) and
/// deterministic under test.
@@ -845,6 +888,9 @@ pub struct JitterPolicy {
depth_avg: f32,
/// Consumed samples for which the EWMA has stayed above the shed threshold.
over_run: usize,
/// The mirror: consumed samples for which the EWMA has stayed below the sync-requested
/// target by more than [`INSERT_MARGIN_MS`] — see the insert branch in [`step`](Self::step).
under_run: usize,
/// Underruns seen in the current growth window, and the window's consumed-sample count.
underruns: u32,
window_run: usize,
@@ -866,7 +912,8 @@ pub struct JitterPolicy {
/// buys one measured step, not a sprint to the ceiling.
near_miss_grown: bool,
/// Set by [`step`](Self::step): the depth average sits more than [`DEPRIME_DEBT_MS`] below
/// the target, so an underrun should re-prime at once instead of waiting out the hysteresis.
/// the ADAPTIVE target (the one underrun pressure grew — never the sync-inflated one), so an
/// underrun should re-prime at once instead of waiting out the hysteresis.
hollow: bool,
/// Consumed samples left in the current shrink-probe window (0 = no probe outstanding).
probe_run: usize,
@@ -918,6 +965,7 @@ impl JitterPolicy {
empties_run: 0,
depth_avg: 0.0,
over_run: 0,
under_run: 0,
underruns: 0,
window_run: 0,
quiet_run: 0,
@@ -999,6 +1047,14 @@ impl JitterPolicy {
self.sync_target.is_some_and(|s| s < self.target)
}
/// The sync loop is asking to run DEEPER than the adaptive target — audio is playing early
/// against the picture. This is what arms the insert branch in [`step`](Self::step); without
/// a sync request the policy never adds depth on its own, so an un-wired ring (`sync_target
/// == None`) behaves exactly as it did before the insert existed.
fn sync_wants_more(&self) -> bool {
self.sync_target.is_some_and(|s| s > self.target)
}
/// The live target depth in ms (grows under underrun pressure; never below the base).
pub fn target_ms(&self) -> u32 {
self.samples_ms(self.target)
@@ -1019,12 +1075,21 @@ impl JitterPolicy {
self.primed
}
/// The effective target for a device asking for `want` samples per callback. A ring can never
/// sustain a target below one device quantum, so a large-buffer device (a 20 ms PipeWire graph
/// quantum, a legacy AAudio path) lifts it to `want` plus one protocol frame rather than
/// oscillating prime → dropout → re-prime forever.
/// The ADAPTIVE target for a device asking for `want` samples per callback: the live target
/// underrun pressure has grown, lifted so it can always serve one quantum plus a packet. A
/// ring can never sustain a target below one device quantum, so a large-buffer device (a
/// 20 ms PipeWire graph quantum, a legacy AAudio path) is lifted to `want` plus one protocol
/// frame rather than oscillating prime → dropout → re-prime forever. This is the floor the
/// sync loop's request is clamped against, and — because it is what underrun evidence has
/// PROVEN this link needs — the depth `hollow` is judged against.
fn adaptive_target(&self, want: usize) -> usize {
self.target.max(want + self.frame_samples())
}
/// The effective target: [`adaptive_target`](Self::adaptive_target), or the sync loop's
/// request clamped into `[adaptive, hard_cap]`. Never below the adaptive one.
fn effective_target(&self, want: usize) -> usize {
let floor = self.target.max(want + self.frame_samples());
let floor = self.adaptive_target(want);
match self.sync_target {
// Continuity outranks sync — see `set_sync_target`. The loop may pull the ring
// shallower to catch the picture up, or push it deeper when audio runs early, but
@@ -1074,8 +1139,10 @@ impl JitterPolicy {
.crossfade_samples()
.min(depth.saturating_sub(out.drop_front));
self.over_run = 0;
self.under_run = 0;
} else if self.depth_avg > (target + self.ms_samples(self.tuning.shed_excess_ms())) as f32 {
self.over_run += want;
self.under_run = 0;
if self.over_run >= self.ms_samples(SHED_SUSTAIN_MS) {
out.drop_front = self.frame_samples().min(depth);
out.crossfade = self
@@ -1083,12 +1150,36 @@ impl JitterPolicy {
.min(depth.saturating_sub(out.drop_front));
self.over_run = 0;
}
} else if self.primed
&& self.sync_wants_more()
&& (self.depth_avg as usize + self.ms_samples(INSERT_MARGIN_MS)) < target
{
// The mirror of the shed. The sync loop has asked for a DEEPER ring than the adaptive
// target (audio is early against the picture), and the depth AVERAGE has sat more
// than the margin below what it asked for, for the sustain window of consumed audio:
// duplicate ONE frame at the front, crossfaded. Below-target-only, so it can never
// fight the trim; sync-only, so an un-wired ring never adds depth by itself and the
// hollow re-prime keeps its job for growth that was never banked; primed-only, so a
// ring filling from silence is not padded with copies of what little it holds.
//
// The ring must hold a whole frame to duplicate. If it does not, it is not "a little
// shallow", it is running dry — and the drought/PLC path is the tool for that.
self.over_run = 0;
self.under_run += want;
if self.under_run >= self.ms_samples(INSERT_SUSTAIN_MS) && depth >= self.frame_samples()
{
out.insert_front = self.frame_samples();
out.crossfade = self.crossfade_samples();
self.under_run = 0;
}
} else {
self.over_run = 0;
self.under_run = 0;
}
// Whatever we shed is no longer buffered — reflect it immediately so the next callbacks
// don't re-fire on a stale average.
self.depth_avg = (self.depth_avg - out.drop_front as f32).max(0.0);
// Whatever we shed is no longer buffered, and whatever we duplicated now is — reflect
// both immediately so the next callbacks don't re-fire on a stale average.
self.depth_avg =
(self.depth_avg - out.drop_front as f32 + out.insert_front as f32).max(0.0);
if !self.primed && depth.saturating_sub(out.drop_front) >= target {
self.primed = true;
@@ -1104,7 +1195,7 @@ impl JitterPolicy {
// Near-miss: this read will be served, but with less than one frame left over — the
// next callback starves unless a packet lands within one frame time. Unconditional
// assignment, so a stale flag can never survive a de-prime into the next primed read.
let after = depth.saturating_sub(out.drop_front);
let after = depth.saturating_sub(out.drop_front) + out.insert_front;
self.near_miss = self.primed
&& after >= want
// Post-read depth below which a served callback counts as a NEAR-MISS: the device got
@@ -1122,8 +1213,18 @@ impl JitterPolicy {
// but the depth was never re-banked (see `DEPRIME_DEBT_MS`). Judged on the average, not
// this instant: a single late packet empties the ring for a callback without making it
// hollow, and must keep the consecutive-empties hysteresis.
//
// Judged against the ADAPTIVE target, never the sync-inflated one. The debt this exists
// to call in is GROWTH that was never banked — underrun evidence raised the promise —
// and only a re-prime cashes that. A sync request is not evidence of starvation; it is a
// request for alignment, and it now has its own gentle instrument (the insert above).
// Measured against the effective target, a request for ≥ `DEPRIME_DEBT_MS` more depth
// made the ring hollow on the very next callback and turned the next single late packet
// into a full re-prime: the field's "audio gaps track the A/V offset" shape. The
// effective target is never below the adaptive one, so this can only be LESS hollow.
let adaptive = self.adaptive_target(want);
self.hollow =
self.primed && (self.depth_avg as usize + self.ms_samples(DEPRIME_DEBT_MS)) < target;
self.primed && (self.depth_avg as usize + self.ms_samples(DEPRIME_DEBT_MS)) < adaptive;
out
}
@@ -1272,16 +1373,25 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
ring.drain(..drop);
return;
}
// The last `fade` samples of what we are about to discard are the fade-OUT source; they blend
// into the first `fade` samples of what survives.
// The FIRST `fade` samples of what we are about to discard are the fade-OUT source they are
// the continuation of the sample the device just played — and they blend into the first
// `fade` samples of what survives, whose own continuation the stream then follows. Both ends
// of the seam are continuous: the splice smears `drop` samples of waveform advance over the
// fade instead of stepping.
//
// Blended in place and BEFORE the drain, with no scratch buffer: a value written at `drop + i`
// can never be read again as a fade-OUT source, because those sources are `drop - fade + j` for
// `j < fade`, i.e. strictly below `drop`. One ascending pass is therefore safe — and this runs
// inside realtime audio callbacks, where the `Vec` this used to allocate had no business being.
// It now runs on every hard-cap trim too, which is the common case on a bunching link.
// (It used to fade out from the LAST `fade` discarded samples, `drop - fade + i`. That end is
// adjacent to the survivors, so the fade-in side was smooth — but the sample the device had
// just played was adjacent to `ring[0]`, not to `ring[drop - fade]`, so the seam still opened
// with a step of `drop - fade` samples of waveform: 3 ms of a 5 ms shed. The old test only
// bounded steps INSIDE the faded region and never looked at the one before it.)
//
// Blended in place and BEFORE the drain, with no scratch buffer: the fade-OUT sources are
// `i < fade <= drop`, strictly below every write at `drop + i`, so a written value is never
// read again. One ascending pass is therefore safe — and this runs inside realtime audio
// callbacks, where the `Vec` this used to allocate had no business being. It runs on every
// hard-cap trim too, which is the common case on a bunching link.
for i in 0..fade {
let old = ring[drop - fade + i];
let old = ring[i];
let new = ring[drop + i];
let t = (i + 1) as f32 / (fade + 1) as f32;
ring[drop + i] = old * (1.0 - t) + new * t;
@@ -1289,6 +1399,47 @@ pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, f
ring.drain(..drop);
}
/// The mirror of [`crossfade_drop`]: DUPLICATE the first `insert` interleaved samples of `ring`
/// at its front — the ring plays them, then plays them again — linearly crossfading the seam over
/// `fade` samples so the sync-driven deepening ([`JitterStep::insert_front`]) is a continuous
/// waveform rather than a click. Net length change is exactly `+insert`.
///
/// Allocation-free when the ring has `insert` samples of spare capacity, which the three
/// `VecDeque<f32>` rings that call this reserve up front (they are sized for the hard cap plus
/// slack, and the policy only inserts BELOW its target): the copy is built with `push_front`,
/// which never reallocates inside capacity, and the seam is blended in place. It runs inside
/// realtime audio callbacks, like its twin. The Apple ring is index-based and mirrors this in
/// Swift (`AudioRing.insertOneFrame`).
///
/// A no-op on `insert == 0` or a ring shorter than `insert` (nothing to duplicate); `fade` is
/// clamped to `insert` and to what the ring holds beyond the copy, and `fade == 0` splices hard.
pub fn crossfade_insert(ring: &mut std::collections::VecDeque<f32>, insert: usize, fade: usize) {
if insert == 0 || ring.len() < insert {
return;
}
let fade = fade.min(insert).min(ring.len() - insert);
// Build the copy at the front, last sample first. Before iteration `k` the front `k`
// samples of the ring are `orig[insert-k .. insert]`, so `orig[insert-1-k]` — the sample
// to push next — always sits at index `insert - 1`. After `insert` pushes the ring reads
// `orig[0..insert] ++ orig`, and `orig[j]` sits at `insert + j`.
for _ in 0..insert {
let s = ring[insert - 1];
ring.push_front(s);
}
// Seam: the device plays the copy — `orig[0..insert]` — and then `orig[0..]` again. What
// would have followed the copy's last sample is `orig[insert..]`, so THAT is the fade-out
// source (at `2·insert + i`), blending into the original's head `orig[i]` (at
// `insert + i`), in place. Both ends of the seam are continuous, exactly as in the drop.
// The fade-out reads sit at or above `2·insert`, above every write, and the fade-in read is
// the very cell about to be written — one ascending pass is safe.
for i in 0..fade {
let old = ring[2 * insert + i];
let new = ring[insert + i];
let t = (i + 1) as f32 / (fade + 1) as f32;
ring[insert + i] = old * (1.0 - t) + new * t;
}
}
/// Where [`apply_gain`]'s soft knee begins, in linear amplitude (≈ 3.1 dBFS). Below this the
/// gained signal is passed through EXACTLY — a boost whose peaks never reach the knee is plain
/// multiplication, sample for sample, so the limiter costs nothing on material that does not need
@@ -2084,6 +2235,9 @@ mod tests {
);
depth -= s.drop_front.min(depth);
}
// No sync target here, so the insert must never fire: an un-wired ring adds no
// depth by itself. Pinned on every drift run rather than in one test.
assert_eq!(s.insert_front, 0, "an unsynced ring inserted");
if s.silence {
p.note_read(false);
continue;
@@ -3164,6 +3318,13 @@ mod tests {
/// Audible reads in the second half of the run: non-zero means the policy never
/// converged and the user hears it forever.
audible_tail: u32,
/// Sync-driven inserts (one crossfaded frame each) — the gentle deepening.
inserts: u32,
/// Times the ring de-primed AFTER its first prime: each is a `target` worth of silence.
reprimes: u32,
/// Simulated ms at which the depth AVERAGE first came within `INSERT_MARGIN_MS` of the
/// sync target — how long the deepening took. `None` = never (or no sync target).
settle_ms: Option<u32>,
}
/// Drive a policy over a link that BUNCHES: delivery pauses for `gap_ms` every `period_ms`,
@@ -3183,11 +3344,17 @@ mod tests {
let pm = per_ms(2);
let want = 5 * pm;
let mut p = JitterPolicy::new(tuning, 2);
p.set_sync_target(sync_target);
let mut depth = 0usize;
let mut withheld = 0usize;
let mut carry: i64 = 0;
let mut out = BunchSim::default();
let mut was_primed = false;
// The sync loop speaks only once it has evidence (`AV_MIN_OBSERVATIONS`), which is
// always after the ring has primed at its own base — so the request lands on a PRIMED
// ring, never on one still filling. Modelled the same way here: a request for less is
// clamped at the base anyway, and a request for more must be answered by the insert,
// not by the ring happening to prime straight to it.
let mut sync_pending = sync_target;
for cb in 0..(ms / 5) {
// The host keeps producing (want ± drift per callback); the link decides delivery.
carry += want as i64 * drift_ppm;
@@ -3201,7 +3368,30 @@ mod tests {
depth += produced + std::mem::take(&mut withheld);
}
let s = p.step(depth, want);
if p.is_primed() {
if let Some(t) = sync_pending.take() {
p.set_sync_target(Some(t));
}
}
depth -= s.drop_front.min(depth);
if s.insert_front > 0 {
assert!(s.crossfade > 0, "every insert must be faded");
assert_eq!(s.drop_front, 0, "a step never drops AND inserts");
assert!(s.insert_front <= depth, "inserted more than the ring holds");
depth += s.insert_front;
out.inserts += 1;
}
if let Some(t) = sync_target {
if out.settle_ms.is_none()
&& p.depth_avg as usize + p.ms_samples(INSERT_MARGIN_MS) >= t
{
out.settle_ms = Some(cb * 5);
}
}
if was_primed && !p.is_primed() {
out.reprimes += 1;
}
was_primed = p.is_primed();
if s.silence {
p.note_read(false);
continue;
@@ -3259,6 +3449,398 @@ mod tests {
let s = simulate_bunching(JitterTuning::COREAUDIO, None, 600_000, 25, 300, -50);
assert!(s.audible_tail <= 4, "{s:?}");
assert!(s.audible <= 12, "{s:?}");
assert_eq!(s.inserts, 0, "an unsynced ring must never insert: {s:?}");
}
// ---- sync-driven DEEPENING: the insert, the mirror of the shed -----------------------
/// THE field shape this exists for ("started at 0.24/0.25", every client). The sync loop
/// asks for a DEEPER ring — audio is early against a picture whose latency wandered (a
/// 5374 fps KWin source, an ABR retarget, a keyframe burst). Before the insert existed the
/// policy could only raise depth by de-priming: the sync-inflated target made the ring
/// `hollow` on the very next callback, and the next single late packet bought a full
/// `target depth` of silence plus the priming wait — 1560 ms of gap, repeated every time
/// the reference asked again. Now the request is answered the way a request for LESS is:
/// one crossfaded frame per sustain window, and no de-prime at all on a clean link.
#[test]
fn a_sync_request_for_more_depth_deepens_without_a_de_prime_on_a_clean_link() {
// Ring primes at PIPEWIRE's 15 ms base; sync asks for 35 ms — 20 ms deeper. No gaps.
let s = simulate_bunching(
JitterTuning::PIPEWIRE,
Some(per_ms(2) * 35),
60_000,
0,
300,
0,
);
assert_eq!(s.audible, 0, "a clean link must stay silent-free: {s:?}");
assert_eq!(s.reprimes, 0, "sync must never cause a de-prime: {s:?}");
assert!(
s.inserts > 0,
"the deepening has to come from somewhere: {s:?}"
);
// 20 ms at one 5 ms frame per INSERT_SUSTAIN_MS, plus the EWMA's settling — well
// inside the bound the handoff set.
let settle = s.settle_ms.expect("the ring never reached the sync target");
assert!(
settle <= 20_000,
"deepening by 20 ms took {settle} ms — too slow to track a wandering reference: {s:?}"
);
// …and, having settled, it STOPS: the insert is below-target-only and must not keep
// duplicating once the average sits inside the margin. Four frames cover 20 ms; allow
// the EWMA a couple more, not a stream of them.
assert!(
s.inserts <= 8,
"the insert kept firing after the ring was deep enough: {s:?}"
);
}
/// The same request on the bunching link the two convergence tests above use — sync asking
/// for MORE where they ask for less. The insert must not make a bunching link worse than
/// the unsynced case (same "handful over ten minutes" bound), and the deepening must not be
/// paid for in re-primes.
#[test]
fn a_sync_request_for_more_depth_stays_clean_on_a_bunching_link() {
let s = simulate_bunching(
JitterTuning::COREAUDIO,
Some(per_ms(2) * 45),
600_000,
25,
300,
-50,
);
assert!(s.audible_tail <= 4, "{s:?}");
assert!(s.audible <= 12, "{s:?}");
assert!(
s.settle_ms.is_some(),
"the ring must reach the requested depth on a link that delivers: {s:?}"
);
}
/// Unit pin of the two mechanisms in isolation: a primed ring asked for +30 ms is NOT hollow
/// (`hollow` is judged against the adaptive target), so one short read leaves it primed; and
/// once the average has sat below the request for `INSERT_SUSTAIN_MS` of consumed audio the
/// step carries `insert_front` of exactly one frame, faded, and the average reflects it.
#[test]
fn a_sync_request_for_more_depth_never_de_primes() {
let pm = per_ms(2);
let want = 5 * pm;
let mut p = JitterPolicy::new(JitterTuning::PIPEWIRE, 2);
let mut depth = 15 * pm;
assert!(
!p.step(depth, want).silence,
"15 ms primes the PIPEWIRE base"
);
depth -= want;
p.note_read(false);
p.set_sync_target(Some(45 * pm));
// Hold the depth flat at ~15 ms while the sync target sits 30 ms above it, and count
// consumed audio until the insert arms. 100 ms in, ONE late packet: the read runs short.
// Before the fix that de-primed at once (the sync-inflated target made the ring hollow).
let mut consumed = 0usize;
let mut short_read_done = false;
let mut first = None;
for _ in 0..2_000 {
if !short_read_done && consumed >= 100 * pm {
short_read_done = true;
assert!(
!p.hollow,
"a sync request is not growth debt — the ring must not read as hollow"
);
let s = p.step(want / 2, want);
assert!(!s.silence);
p.note_read(true);
assert!(
p.is_primed(),
"a single short read on a sync-deepened ring must keep the hysteresis, not de-prime"
);
consumed += want;
continue;
}
depth += want;
let before = p.depth_avg;
let s = p.step(depth, want);
if s.insert_front > 0 {
assert_eq!(s.insert_front, p.frame_samples(), "one frame, no more");
assert_eq!(s.crossfade, p.crossfade_samples(), "faded");
assert_eq!(s.drop_front, 0);
assert!(
p.depth_avg >= before + s.insert_front as f32 - 1.0,
"the average must reflect the inserted frame at once: {before} -> {}",
p.depth_avg
);
// The arming step's own `want` is part of the sustain (the policy counts it
// when it decides, before the read).
first = Some(consumed + want);
break;
}
depth -= want;
consumed += want;
p.note_read(false);
}
assert!(short_read_done, "the short read never happened");
let first = first.expect("the insert never armed");
// At least the sustain window; the EWMA is already settled at 15 ms so not much more.
assert!(
first >= p.ms_samples(INSERT_SUSTAIN_MS),
"armed after {} ms — before the sustain window",
first / pm
);
assert!(
first <= p.ms_samples(INSERT_SUSTAIN_MS + 500),
"armed after {} ms — long after the sustain window",
first / pm
);
}
/// The surviving `hollow` path: growth that was never banked STILL re-primes on the click it
/// already paid — that is what the hollow re-prime is for, and it must not be lost in
/// moving its yardstick from the effective target to the adaptive one. No sync target here.
#[test]
fn growth_not_banked_still_re_primes() {
let pm = per_ms(2);
let want = 5 * pm;
let mut p = JitterPolicy::new(JitterTuning::PIPEWIRE, 2);
// Prime at the 15 ms base and settle the average there.
let mut depth = 15 * pm;
assert!(!p.step(depth, want).silence);
depth -= want;
p.note_read(false);
for _ in 0..400 {
depth += want;
let s = p.step(depth, want);
depth -= s.drop_front + want;
p.note_read(false);
}
// Grow the target twice (two windows of three underruns) WITHOUT letting the depth
// follow: the average stays ~15 ms while the promise climbs to 35.
for _round in 0..2 {
for _ in 0..GROW_UNDERRUNS {
// A short read: the device asked for `want`, the ring had less.
let s = p.step(want / 2, want);
assert!(
!s.silence,
"the hysteresis must hold through a single short read"
);
p.note_read(true);
// Refill to the base depth so the average is not dragged down by the run.
for _ in 0..8 {
let s = p.step(15 * pm + want, want);
p.note_read(s.silence);
}
}
// Roll the growth window over so the next three count as a fresh window.
let mut consumed = 0;
while consumed < p.ms_samples(GROW_WINDOW_MS) {
let s = p.step(15 * pm + want, want);
p.note_read(s.silence);
consumed += want;
}
}
// Growth may already have de-primed the ring in the loop above via exactly the path
// under test; either way, by now the target is grown and the ring, if primed, is
// hollow against it.
assert!(
p.target_ms() >= 25,
"the target must have grown, got {} ms",
p.target_ms()
);
// Re-prime at the grown target if the loop above already spent the click, and drain
// the average back down to the base without an underrun — the ring stays PRIMED (no
// short read) while its promise runs 20 ms above what it holds.
let target = p.effective_target(want);
let s = p.step(target + want, want);
assert!(!s.silence);
p.note_read(false);
for _ in 0..600 {
let s = p.step(15 * pm + want, want);
assert!(!s.silence, "no starvation here — the depth is only shallow");
p.note_read(false);
}
assert!(p.is_primed());
assert!(p.hollow, "a grown promise the depth never banked is hollow");
// ONE short read: the click has been paid; the hollow ring cashes the refill at once.
let s = p.step(want / 2, want);
assert!(!s.silence);
p.note_read(true);
assert!(
!p.is_primed(),
"growth that was never banked must still re-prime on its first click"
);
}
/// A ring at the hard cap being asked deeper: `effective_target` clamps the request at the
/// cap, the insert is below-target-only, and so it can never fight the trim. Pinned, since
/// the trim and the insert both move the depth and a fight between them would be a
/// continuous stream of faded corrections.
#[test]
fn the_insert_never_fights_the_trim() {
let pm = per_ms(2);
let want = 5 * pm;
let t = JitterTuning::PIPEWIRE;
let mut p = JitterPolicy::new(t, 2);
// Ask for far more than the cap; sit the ring right at the cap.
p.set_sync_target(Some(usize::MAX / 2));
let cap = t.hard_cap_ms as usize * pm;
let mut depth = cap;
assert!(!p.step(depth, want).silence);
depth -= want;
p.note_read(false);
let (mut trims, mut inserts) = (0, 0);
for _ in 0..4_000 {
depth += want; // producer keeps pace exactly
let s = p.step(depth, want);
if s.hard_trim {
trims += 1;
}
if s.insert_front > 0 {
inserts += 1;
}
depth = depth + s.insert_front - s.drop_front.min(depth) - want;
p.note_read(false);
}
assert_eq!(
trims, 0,
"a ring holding exactly the cap must not be trimmed"
);
assert_eq!(
inserts, 0,
"a ring at the cap is at its (clamped) target — nothing to insert"
);
}
/// The insert on the lossless plane: 2 ms frames at 96 kHz/24-bit. `frame_samples` follows
/// `set_frame_us`, and the seam fade is capped at half a frame (1 ms) — a fade as long as
/// the material it fades is not a crossfade.
#[test]
fn the_insert_follows_the_negotiated_frame_length() {
let rate = 96_000;
let mut p = JitterPolicy::new_at_rate(JitterTuning::PIPEWIRE, 2, rate);
p.set_frame_us(2_000);
let frame = p.frame_samples();
assert_eq!(frame, 96 * 2 * 2, "2 ms at 96 kHz stereo is 384 samples");
let want = frame; // a 2 ms device quantum
let base = ms_to_samples(rate, 2, JitterTuning::PIPEWIRE.base_target_ms);
let mut depth = base;
assert!(!p.step(depth, want).silence);
depth -= want;
p.note_read(false);
p.set_sync_target(Some(base * 3));
let mut got = None;
for _ in 0..20_000 {
depth += want;
let s = p.step(depth, want);
if s.insert_front > 0 {
got = Some(s);
break;
}
depth -= want;
p.note_read(false);
}
let s = got.expect("the insert never armed at 96 kHz");
assert_eq!(s.insert_front, frame, "insert exactly one 2 ms frame");
assert_eq!(s.crossfade, frame / 2, "the fade is capped at half a frame");
}
/// Mirror of `crossfade_drop_splices_without_a_step`, and stricter: BOTH ends of the seam are
/// checked, including against the sample the device played just before the ring's head —
/// which is where the old drop stepped (see `crossfade_drop`).
#[test]
fn crossfade_insert_adds_exactly_one_frame_and_the_seam_is_continuous() {
use std::collections::VecDeque;
// A slow ramp starting at 1000 — "the device just played 999".
let mut ring: VecDeque<f32> = (1000..2000).map(|i| i as f32).collect();
let (insert, fade) = (240, 96);
crossfade_insert(&mut ring, insert, fade);
assert_eq!(
ring.len(),
1000 + insert,
"net length change is exactly +insert"
);
// The copy is verbatim: the device plays the head once…
for (i, &s) in ring.iter().take(insert).enumerate() {
assert_eq!(s, (1000 + i) as f32, "copy sample {i}");
}
// …and the whole played sequence — including the step from the previously played
// sample (999) into the ring — never jumps by more than the fade's slope. The seam sits
// at `insert`: what the copy's last sample (1239) leads into is blended from 1240… down
// toward the replayed 1000… over the fade, so the local slope is at most
// (insert / fade + 1) per sample and no sample steps by anything like `insert`.
let max_slope = (insert as f32 / fade as f32) + 2.0;
let mut prev = 999.0f32;
for (i, &s) in ring.iter().enumerate() {
let step = (s - prev).abs();
assert!(
step <= max_slope,
"sample {i}: step {step} from {prev} to {s} is a splice, not a fade"
);
prev = s;
}
// Past the fade the original is untouched, and the tail is intact.
for (i, &s) in ring.iter().enumerate().skip(insert + fade) {
assert_eq!(s, (1000 + i - insert) as f32, "original sample {i}");
}
assert_eq!(ring[ring.len() - 1], 1999.0);
}
#[test]
fn crossfade_insert_handles_degenerate_inputs() {
use std::collections::VecDeque;
let mut ring: VecDeque<f32> = (0..10).map(|i| i as f32).collect();
crossfade_insert(&mut ring, 0, 4); // nothing to insert
assert_eq!(ring.len(), 10);
crossfade_insert(&mut ring, 99, 4); // more than we hold — refuse
assert_eq!(ring.len(), 10);
crossfade_insert(&mut ring, 10, 4); // exactly all of it: no room to fade, hard splice
assert_eq!(ring.len(), 20);
let v: Vec<f32> = ring.iter().copied().collect();
let mut want: Vec<f32> = (0..10).map(|i| i as f32).collect();
want.extend((0..10).map(|i| i as f32));
assert_eq!(v, want, "a hard splice is a verbatim repeat");
// A fade longer than the insert is clamped to it, not read out of bounds.
let mut ring: VecDeque<f32> = (0..100).map(|i| i as f32).collect();
crossfade_insert(&mut ring, 8, 50);
assert_eq!(ring.len(), 108);
}
/// The RT-safety claim: with the spare capacity the client rings reserve, an insert must not
/// reallocate. `VecDeque::push_front` never grows inside capacity; pin that the helper does
/// nothing else that would.
#[test]
fn crossfade_insert_does_not_reallocate_inside_capacity() {
use std::collections::VecDeque;
let mut ring: VecDeque<f32> = VecDeque::with_capacity(4096);
ring.extend((0..1000).map(|i| i as f32));
let cap = ring.capacity();
crossfade_insert(&mut ring, 240, 96);
assert_eq!(ring.capacity(), cap, "the insert reallocated the ring");
assert_eq!(ring.len(), 1240);
}
/// The drop's seam, checked the way the insert's is: against the sample played just BEFORE
/// the ring's head. This is the check the original test lacked, and the one the old
/// `drop - fade + i` fade-out source failed by a step of `drop - fade` samples.
#[test]
fn crossfade_drop_is_continuous_with_what_was_just_played() {
use std::collections::VecDeque;
let mut ring: VecDeque<f32> = (1000..2000).map(|i| i as f32).collect();
let (drop, fade) = (240, 96);
crossfade_drop(&mut ring, drop, fade);
assert_eq!(ring.len(), 1000 - drop);
let max_slope = (drop as f32 / fade as f32) + 2.0;
let mut prev = 999.0f32; // the device just played 999; the ring's head was 1000
for (i, &s) in ring.iter().enumerate() {
let step = (s - prev).abs();
assert!(
step <= max_slope,
"sample {i}: step {step} from {prev} to {s} is a splice, not a fade"
);
prev = s;
}
// Past the fade the survivors are untouched.
for (i, &s) in ring.iter().enumerate().skip(fade) {
assert_eq!(s, (1000 + drop + i) as f32, "survivor {i}");
}
}
/// Unity must be bit-exact. The callers gate on `gain != 1.0` anyway, but if this ever stopped
+590 -96
View File
@@ -1,31 +1,46 @@
//! PipeWire desktop-audio capture — via a **host-owned stream sink** (default), or the legacy
//! default-sink-monitor follower (`PUNKTFUNK_STREAM_SINK=0`).
//! PipeWire desktop-audio capture — through a **host-owned virtual sink** (default), the 0.30
//! stream-sink node (`PUNKTFUNK_STREAM_SINK=stream`), or the legacy default-sink-monitor
//! follower (`PUNKTFUNK_STREAM_SINK=0`).
//!
//! **Stream-sink mode.** The capture stream registers itself as an `Audio/Sink` node
//! ("Punktfunk Stream Speaker", unique `node.name` per capturer): host apps play *into* it,
//! PipeWire mixes them, and our `process()` callback receives the mix directly — the same
//! stream-node architecture as [`PwMicSource`] below (inverted), and the documented
//! `pw-loopback --capture-props='media.class=Audio/Sink'` virtual-sink recipe. A session-scoped
//! [`stream_sink`] claim makes it the *default* sink so apps route to it (and back) with the
//! session. Why: capture no longer depends on any hardware sink, whose availability is display
//! hardware state — live-diagnosed 2026-07-14 on a bazzite/TV host, every gamescope modeset
//! dropped the HDMI audio endpoint, WirePlumber ping-ponged the default HDMI↔auto_null ~8×/s,
//! and the old monitor-follower relinked on every flip (Paused→renegotiate→Streaming storms =
//! client crackle). Bonus: the sink advertises the session's true channel count, so games can
//! produce real 5.1/7.1 even when the local hardware is stereo.
//! **Null-sink mode (the default).** The host creates a real `support.null-audio-sink` adapter
//! ("Punktfunk Stream Speaker", unique `node.name` per capturer) and captures its **monitor**;
//! host apps play into it, PipeWire mixes them, and our `process()` callback receives the mix. A
//! session-scoped [`stream_sink`] claim makes it the *default* sink so apps route to it (and
//! back) with the session.
//!
//! **Legacy mode** connects an input stream with `stream.capture.sink=true`, which routes the
//! *default* sink's monitor into us — no portal needed (unlike screen capture), but coupled to
//! hardware-default churn as above.
//! Why a node we create rather than our own capture stream wearing `media.class=Audio/Sink`:
//! **a stream is structurally a follower and never drives**, so PipeWire schedules the resulting
//! driver-less group — {game streams -> our sink} — on the highest-priority *running* driver
//! anywhere on the box. Field-diagnosed 2026-08-18: on a host with a DualSense forwarded over
//! VirtualHere, our capture group was clocked for a whole 15-minute session by that pad's
//! USB-over-IP sound card — a device nothing was linked to, whose frame counter is a kernel stub
//! (`vhci_get_frame_number()` logs and returns 0) — giving 3.9 delivery holes a second, and
//! **15.4 % of the audio the user heard was silence this host synthesized** over them. A
//! `support.null-audio-sink` is a **driver**: it carries its own `timerfd` inside the daemon's
//! realtime data loop, so our group owns its clock and no hardware (or network-attached) device
//! can be elected to schedule it. It is also exactly the node `pactl load-module
//! module-null-sink` creates — the most exercised virtual-sink recipe on Linux.
//!
//! In both modes the (`!Send`) MainLoop/Stream live on a dedicated thread; interleaved `f32`
//! chunks leave over a bounded channel (dropped if the encoder falls behind, never blocking
//! the PipeWire loop). The stream is opened at the *session's* channel count (2/6/8); in
//! legacy mode PipeWire's channel-mixer fills missing positions with silence (zero upmix).
//! Dropping the capturer quits the loop thread (via a `pipewire::channel` Terminate message),
//! tearing the stream — and in stream-sink mode the sink node itself — down promptly, so a
//! surround session can replace a stereo capturer without leaking a PipeWire consumer (see
//! CLAUDE.md: a wedged link head-blocks the daemon).
//! **`=stream` (one-release escape hatch).** The 0.30 topology: the capture stream itself is the
//! `Audio/Sink` node — same routing, same claim, but the group borrows a driver as above.
//!
//! **`=0` (legacy).** An input stream with `stream.capture.sink=true` and no target, which
//! PipeWire routes to whatever the *default* sink is — so it is coupled to hardware-default
//! churn: live-diagnosed 2026-07-14 on a bazzite/TV host, every gamescope modeset dropped the
//! HDMI audio endpoint, WirePlumber ping-ponged the default HDMI<->auto_null ~8x/s, and the
//! monitor follower relinked on every flip (Paused->renegotiate->Streaming storms = client
//! crackle). Both sink modes are immune — nothing about a host-owned sink depends on display
//! hardware — and both advertise the session's true channel count, so games can produce real
//! 5.1/7.1 even when the local hardware is stereo.
//!
//! In every mode the (`!Send`) MainLoop/Stream live on a dedicated thread; interleaved `f32`
//! chunks leave over a bounded channel (dropped if the encoder falls behind, never blocking the
//! PipeWire loop). The stream is opened at the *session's* channel count (2/6/8); in legacy mode
//! PipeWire's channel-mixer fills missing positions with silence (zero upmix). Dropping the
//! capturer quits the loop thread (via a `pipewire::channel` Terminate message), tearing the
//! stream — and in the sink modes the sink node itself — down promptly, so a surround session
//! can replace a stereo capturer without leaking a PipeWire consumer (see CLAUDE.md: a wedged
//! link head-blocks the daemon).
mod monitor_rate;
pub(crate) mod pad_sink;
@@ -34,7 +49,7 @@ mod stream_sink;
use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
use std::sync::Arc;
@@ -44,24 +59,82 @@ use std::time::Duration;
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
struct Terminate;
/// Whether the host-owned stream sink is active. **Default ON** — decouples capture (and app
/// routing) from hardware-sink availability; see the module docs for the live-diagnosed
/// crackle this fixes. `PUNKTFUNK_STREAM_SINK=0` (also `false`/`no`/`off`) is the escape hatch
/// back to capturing the default sink's monitor.
fn stream_sink_enabled() -> bool {
std::env::var("PUNKTFUNK_STREAM_SINK")
.map(|v| !matches!(v.trim(), "0" | "false" | "no" | "off"))
.unwrap_or(true)
/// Which topology this host captures desktop audio through — see the module docs for what each
/// one costs and why the default moved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CaptureMode {
/// Create a `support.null-audio-sink` — a node that DRIVES its own graph group — and capture
/// its monitor. **Default.**
NullSink,
/// The capture stream itself is the `Audio/Sink` node (the 0.30 topology), so the group it
/// forms with its producers borrows a driver from elsewhere on the box. One-release escape
/// hatch, kept so a field A/B needs no build.
StreamSink,
/// No host-owned sink at all: follow whatever the default sink is and tap its monitor.
Monitor,
}
impl CaptureMode {
/// Both sink modes mint a sink node and [`claim`](stream_sink::claim) it as the default
/// output; only [`Monitor`](Self::Monitor) does not.
fn owns_sink(self) -> bool {
!matches!(self, CaptureMode::Monitor)
}
/// For the line that says which topology is live — the first thing to read in a log where
/// audio arrives in a shape nobody expected.
fn as_str(self) -> &'static str {
match self {
CaptureMode::NullSink => "null-sink",
CaptureMode::StreamSink => "stream-sink",
CaptureMode::Monitor => "monitor",
}
}
}
/// `PUNKTFUNK_STREAM_SINK`: `stream` = [`StreamSink`](CaptureMode::StreamSink),
/// `0`/`false`/`no`/`off` = [`Monitor`](CaptureMode::Monitor), anything else (including unset) =
/// [`NullSink`](CaptureMode::NullSink).
///
/// An unrecognised value resolves to the default rather than failing: this is a field-debugging
/// lever, and a typo in it must not cost a session its audio.
fn capture_mode() -> CaptureMode {
capture_mode_from(std::env::var("PUNKTFUNK_STREAM_SINK").ok().as_deref())
}
/// [`capture_mode`] without the environment, so the grammar is testable without a process-global
/// mutation (and so the three modes each have a test at all).
fn capture_mode_from(value: Option<&str>) -> CaptureMode {
match value.map(str::trim) {
Some("0" | "false" | "no" | "off") => CaptureMode::Monitor,
Some("stream") => CaptureMode::StreamSink,
_ => CaptureMode::NullSink,
}
}
/// The graph identity of one capturer: which topology, and the `node.name`s it owns.
#[derive(Debug, Clone)]
struct CaptureNodes {
mode: CaptureMode,
/// The `Audio/Sink` node's name — `Some` in both sink modes, and what the [`stream_sink`]
/// default-sink claim points at. In [`StreamSink`](CaptureMode::StreamSink) mode this IS the
/// capture stream; in [`NullSink`](CaptureMode::NullSink) mode it is the adapter the host
/// creates, whose monitor [`capture`](Self::capture) taps.
sink: Option<String>,
/// The capture stream's own `node.name`. Aliases [`sink`](Self::sink) only in
/// [`StreamSink`](CaptureMode::StreamSink) mode, where they are one node.
capture: String,
}
/// §8.4 condition 4 on Linux (`design/hi-res-audio.md` §4.4 / §8.3). The two capture modes give
/// structurally different answers, and that difference is the whole content of §4.4:
///
/// * **Stream-sink mode (the default).** We register the `Audio/Sink` node ourselves and
/// [`pw_thread`] declares its format, so applications render into it at that rate natively.
/// The rate we claim is the rate we get, by construction — there is no upstream resampler in
/// the path to lie about it, so the answer is yes for every rate the plane supports, and no
/// probe of any kind is needed to say so.
/// * **Both sink modes (the default `null-sink`, and `stream`).** The `Audio/Sink` node is ours
/// and we declare its format — as the created adapter's `audio.rate` in null-sink mode, as the
/// stream's own negotiated format in stream-sink mode — so applications render into it at that
/// rate natively. The rate we claim is the rate we get, by construction: there is no upstream
/// resampler in the path to lie about it, so the answer is yes for every rate the plane
/// supports, and no probe of any kind is needed to say so.
/// * **`PUNKTFUNK_STREAM_SINK=0` (monitor mode).** We capture somebody else's sink through
/// PipeWire's resampler, which reports a clean rate whatever the node upstream of it really
/// runs at — the same blindness WASAPI's autoconvert has. So the answer cannot come from our
@@ -83,7 +156,7 @@ fn stream_sink_enabled() -> bool {
/// only the monitor mode does, because in the default mode the host is the one declaring the
/// format.
pub(super) fn probe_capture_rate() -> super::CaptureRate {
if stream_sink_enabled() {
if capture_mode().owns_sink() {
return super::CaptureRate::Declared;
}
match monitor_rate::monitored_sink_rate() {
@@ -110,7 +183,8 @@ pub struct PwAudioCapturer {
chunks: Receiver<Vec<f32>>,
channels: u32,
quit: pipewire::channel::Sender<Terminate>,
/// `Some(node.name)` in stream-sink mode; `None` = legacy monitor follower.
/// `Some(node.name)` in both sink modes — the created null sink, or the capture stream
/// itself; `None` = legacy monitor follower.
sink_name: Option<String>,
/// Whether this capturer currently holds a [`stream_sink`] default-sink claim (session
/// active). Toggled by open/[`drain`](AudioCapturer::drain) (claim) and
@@ -125,13 +199,13 @@ pub struct PwAudioCapturer {
/// field host log carried ten such warnings, up to `dropped_chunks=11251` (= 30 s × 375
/// chunks/s, i.e. every single chunk), each one straddling a session boundary and each one
/// meaningless. Distinct from `claimed`, which tracks the sink-routing claim and only
/// exists when the stream sink is enabled at all.
/// exists when this host owns a sink at all.
active: Arc<AtomicBool>,
/// The rate the graph actually NEGOTIATED, written by the format callback on the PipeWire
/// thread and read back by [`AudioCapturer::sample_rate`].
///
/// Seeded with the rate we asked for, because that is the honest answer until the graph has
/// said otherwise — and in stream-sink mode it is nearly always the final one, since the
/// said otherwise — and in both sink modes it is nearly always the final one, since the
/// host owns the sink and declares its format (`design/hi-res-audio.md` §4.4). In legacy
/// monitor mode the value is a weaker claim: it is the rate of the resampled stream we are
/// handed, not of the node upstream of it, which is why the §8.3 gate reads the monitored
@@ -146,26 +220,37 @@ impl PwAudioCapturer {
"unsupported audio channel count {channels} (want 2, 6 or 8)"
);
anyhow::ensure!(rate_hz > 0, "audio capture rate must be positive");
let mode = capture_mode();
// Unique per capturer: overlapping instances (mid-session reopen, concurrent sessions)
// must never alias in metadata claims, and a fresh name gets fresh (unity) WirePlumber
// volume state instead of whatever a previous run left behind.
let sink_name = stream_sink_enabled().then(|| {
use std::sync::atomic::AtomicU64;
// must never alias in metadata claims or in a `target.object` lookup, and a fresh name
// gets fresh (unity) WirePlumber volume state instead of whatever a previous run left
// behind. ONE sequence number for both names, so a line about the tap and a line about
// its sink are visibly the same capturer.
let seq = {
static SEQ: AtomicU64 = AtomicU64::new(0);
format!(
"{}-{}-{}",
stream_sink::SINK_NAME_PREFIX,
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
)
});
SEQ.fetch_add(1, Ordering::Relaxed)
};
let pid = std::process::id();
let sink_node = format!("{}-{pid}-{seq}", stream_sink::SINK_NAME_PREFIX);
let nodes = CaptureNodes {
mode,
// In stream-sink mode the capture stream IS the sink, so it wears the sink's name.
// Otherwise it is a tap of its own and gets a name that can never be mistaken for a
// sink: `stream_sink`'s crash-staleness rule matches on the speaker prefix, and the
// graph-driver diagnostic finds our node by exactly this string.
capture: match mode {
CaptureMode::StreamSink => sink_node.clone(),
_ => format!("punktfunk-audio-{pid}-{seq}"),
},
sink: mode.owns_sink().then_some(sink_node),
};
let (tx, rx) = sync_channel::<Vec<f32>>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
// Bring-up handshake (mirrors the virtual mic): a PipeWire that isn't running must
// surface as an open ERROR — engaging the callers' reopen backoff — and in stream-sink
// mode the sink node must exist before we claim the default to its name.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
let thread_sink_name = sink_name.clone();
let sink_name = nodes.sink.clone();
// Opens at session start (see the routing claim below), so the consumer is live from
// the first chunk.
let active = Arc::new(AtomicBool::new(true));
@@ -180,7 +265,7 @@ impl PwAudioCapturer {
quit_rx,
channels,
rate_hz,
thread_sink_name,
nodes,
ready_tx,
thread_active,
thread_rate,
@@ -277,33 +362,104 @@ impl AudioCapturer for PwAudioCapturer {
}
}
/// SPA channel position array for the GameStream surround order FL FR FC LFE RL RR [SL SR]
/// (= the PipeWire/PulseAudio default map for 6/8 channels, and the order Moonlight's
/// renderers expect — moonlight-common-c: "we use FL FR C LFE RL RR SL SR"). Values are
/// `enum spa_audio_channel` (spa/param/audio/raw.h): FL=3 FR=4 FC=5 LFE=6 SL=7 SR=8 RL=12
/// RR=13.
fn spa_positions(channels: u32) -> [u32; 64] {
const FL: u32 = 3;
const FR: u32 = 4;
const FC: u32 = 5;
const LFE: u32 = 6;
const SL: u32 = 7;
const SR: u32 = 8;
const RL: u32 = 12;
const RR: u32 = 13;
const MONO: u32 = 2;
let mut pos = [0u32; 64];
let order: &[u32] = match channels {
/// The GameStream surround order FL FR FC LFE RL RR [SL SR] (= the PipeWire/PulseAudio default
/// map for 6/8 channels, and the order Moonlight's renderers expect — moonlight-common-c: "we
/// use FL FR C LFE RL RR SL SR"), as `(enum spa_audio_channel, name)` pairs.
///
/// Two things need this order in two spellings — a format pod ([`spa_positions`]) for a stream,
/// and an `audio.position` string ([`spa_position_names`]) for a node we create by properties —
/// and a channel map that disagrees with itself between them would mean the sink accepts audio
/// in one layout and hands it on in another. They are two views of THIS one list, so they
/// cannot drift.
///
/// Values are `enum spa_audio_channel` (spa/param/audio/raw.h): MONO=2 FL=3 FR=4 FC=5 LFE=6
/// SL=7 SR=8 RL=12 RR=13; the names are the spellings `spa_audio_parse_position` accepts.
fn channel_order(channels: u32) -> &'static [(u32, &'static str)] {
const MONO: (u32, &str) = (2, "MONO");
const FL: (u32, &str) = (3, "FL");
const FR: (u32, &str) = (4, "FR");
const FC: (u32, &str) = (5, "FC");
const LFE: (u32, &str) = (6, "LFE");
const SL: (u32, &str) = (7, "SL");
const SR: (u32, &str) = (8, "SR");
const RL: (u32, &str) = (12, "RL");
const RR: (u32, &str) = (13, "RR");
match channels {
1 => &[MONO],
2 => &[FL, FR],
6 => &[FL, FR, FC, LFE, RL, RR],
8 => &[FL, FR, FC, LFE, RL, RR, SL, SR],
_ => unreachable!("validated in open()"),
};
pos[..order.len()].copy_from_slice(order);
}
}
/// [`channel_order`] as the SPA position array a format pod carries.
fn spa_positions(channels: u32) -> [u32; 64] {
let mut pos = [0u32; 64];
for (slot, (id, _)) in pos.iter_mut().zip(channel_order(channels)) {
*slot = *id;
}
pos
}
/// [`channel_order`] as `audio.position` spells it (`"[ FL FR ]"`) — the
/// `support.null-audio-sink` adapter is configured by properties, not by a format pod.
fn spa_position_names(channels: u32) -> String {
let names: Vec<&str> = channel_order(channels).iter().map(|(_, n)| *n).collect();
format!("[ {} ]", names.join(" "))
}
/// The property set of the host-owned `support.null-audio-sink` — the sink apps play into in
/// [`NullSink`](CaptureMode::NullSink) mode.
///
/// A pure function returning `(key, value)` pairs rather than a built `Properties`, because the
/// invariants below are the whole design and none of them is checkable at run time on the
/// developer's machine — the tests at the bottom of this file are:
///
/// * `factory.name` + no `object.linger`: the adapter recipe pipewire-pulse's own
/// `module-null-sink` uses (`pactl load-module module-null-sink`), and a node whose lifetime is
/// this connection's — a host that crashes leaves no ghost sink behind, and WirePlumber falls
/// back to automatic election for local audio.
/// * `audio.rate`/`audio.channels`/`audio.position`: the sink is created at the *session's*
/// format, which is what makes [`probe_capture_rate`]'s `Declared` answer honest and lets a
/// game render real 5.1/7.1 into a host whose own hardware is stereo.
/// * **`node.force-quantum`, not `node.latency`**: a driver's quantum is the smallest
/// `node.latency` among its followers, clamped — and then, because PipeWire's
/// `default.clock.power-of-two-quantum` defaults to *true*, rounded DOWN to a power of two.
/// That is why our 240-frame (5 ms) ask has been silently served as 128 on every stock Linux
/// host. `node.force-quantum` skips the rounding, and because this sink drives only its own
/// group it forces nothing on anybody else's device — which is exactly why the same key would
/// have been the wrong answer while we were borrowing somebody's hardware clock.
/// * `priority.session = 50`: LOW on purpose. Between sessions the sink stays alive (the
/// capturer is parked, not torn down) and must never win WirePlumber's *automatic* default
/// election against real hardware; routing comes from the [`stream_sink`] claim.
/// * **No `priority.driver`**: 0 means the graph never elects this node to clock somebody else's
/// driver-less group. It drives ours because our stream is linked to it, and nothing else.
/// * `session.suspend-timeout-seconds = 0`: Wine churns its audio device through a game's first
/// minute; each suspend/resume round trip is a real hole in a stream someone is listening to.
/// * `monitor.*`: pipewire-pulse's own defaults for a null sink, so the volume slider on
/// "Punktfunk Stream Speaker" keeps behaving the way it does on the 0.30 stream sink.
fn null_sink_props(name: &str, channels: u32, rate_hz: u32) -> Vec<(&'static str, String)> {
vec![
("factory.name", "support.null-audio-sink".to_string()),
("node.name", name.to_string()),
("node.description", "Punktfunk Stream Speaker".to_string()),
("media.class", "Audio/Sink".to_string()),
("node.virtual", "true".to_string()),
("audio.rate", rate_hz.to_string()),
("audio.channels", channels.to_string()),
("audio.position", spa_position_names(channels)),
("priority.session", "50".to_string()),
("session.suspend-timeout-seconds", "0".to_string()),
(
"node.force-quantum",
capture_quantum_frames(rate_hz).to_string(),
),
("monitor.channel-volumes", "true".to_string()),
("monitor.passthrough", "true".to_string()),
]
}
/// Virtual microphone: a PipeWire `Audio/Source` node host apps can record from. The host pushes
/// decoded client-mic PCM in; the loop thread's producer callback drains it (silence on
/// underrun) into PipeWire buffers. Mirrors [`PwAudioCapturer`] but inverted (Direction::Output).
@@ -318,6 +474,12 @@ fn spa_positions(channels: u32) -> [u32; 64] {
/// validated working on PipeWire 1.4 (Bazzite) and 1.6 (this box) in both attach orderings.
/// Do not "modernize" this to the adapter recipe without re-running that validation.
///
/// ⚠ The desktop **sink** now IS an adapter (`null_sink_props`), and that is not a contradiction:
/// this result is about an `Audio/Source/Virtual` adapter — the direction WirePlumber has no
/// monitor path for and reroutes feeders away from — while a null-sink adapter captured through
/// its monitor is the direction every virtual-sink recipe uses. The two were validated
/// separately, and neither result transfers to the other.
///
/// **Liveness contract** (see [`VirtualMic`]): the loop thread exits on a core error (PipeWire
/// daemon restart — the node is gone) or a stream error, which flips `alive` — `push` then
/// returns `false` and the owning pump reopens against the new daemon, recreating the node.
@@ -772,15 +934,23 @@ fn pw_thread(
quit_rx: pipewire::channel::Receiver<Terminate>,
channels: u32,
rate_hz: u32,
sink_name: Option<String>,
nodes: CaptureNodes,
ready: std::sync::mpsc::SyncSender<Result<()>>,
active: Arc<AtomicBool>,
negotiated_rate: Arc<AtomicU32>,
) -> Result<()> {
use pipewire as pw;
use pw::proxy::ProxyT;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
use std::cell::RefCell;
use std::rc::Rc;
let CaptureNodes {
mode,
sink: sink_name,
capture: capture_name,
} = nodes;
// ⚠ This boosts the MAINLOOP thread, which is NOT where the capture callback runs.
//
// The previous comment here asserted the opposite ("we never hand PipeWire a separate data
@@ -827,20 +997,237 @@ fn pw_thread(
})
.register();
// Which source the negotiated format below actually describes — see the note there.
let sink_mode = sink_name.is_some();
// In null-sink mode the sink apps play into is a REAL node this host creates: a
// `support.null-audio-sink` adapter, the same object `pactl load-module module-null-sink`
// makes. Unlike a stream node it is a **driver** — the null sink publishes
// `node.driver=true` and the adapter forwards it — carrying its own `timerfd` inside the
// daemon's realtime data loop, so the group {game streams → this sink → our monitor tap}
// owns its clock and PipeWire never borrows one from an unrelated device (module docs).
//
// Created BEFORE the capture stream connects, on the same connection, so the sink is
// registered first and the tap's `target.object` resolves without waiting. It is
// destroyed with this connection (no `object.linger`), which is what the loop thread's
// exit relies on.
let _sink_node = match mode {
CaptureMode::NullSink => {
let name = sink_name
.as_deref()
.context("null-sink mode without a sink name")?;
let mut props = pw::properties::PropertiesBox::new();
for (key, value) in null_sink_props(name, channels, rate_hz) {
props.insert(key, value);
}
let node = core
.create_object::<pw::node::Node>("adapter", &props)
.context("create the punktfunk stream sink (support.null-audio-sink)")?;
// The server answers asynchronously: `bound` is the sink existing (and its graph
// id, which the driver diagnostic compares against), `error` is a daemon without
// the adapter factory or the null-sink plugin — rare, and today's only new way to
// have no audio at all, so it says what to set instead of dying quietly. The
// core-error listener above ends the thread either way, which puts the session's
// reopen-with-backoff in charge, exactly as a stream error does.
let listener = node
.upcast_ref()
.add_listener_local()
.bound(|id| {
tracing::debug!(node_id = id, "punktfunk stream sink registered");
})
.error(|_seq, res, message| {
tracing::warn!(
res,
message,
"the punktfunk stream sink could not be created — this host cannot \
capture desktop audio until it can. Set PUNKTFUNK_STREAM_SINK=stream \
for the 0.30 topology (no created sink) and please report it"
);
})
.register();
Some((node, listener))
}
_ => None,
};
// The `NODE_LATENCY` ask, built at run time because the rate is now a session value:
// `<quantum frames>/<rate>` is how PipeWire spells a latency, and both halves move
// together so the ask stays 5 ms at 48 kHz and at 96 kHz alike. Formatted once here
// rather than at each use so the two property arms cannot drift apart.
let node_latency = format!("{}/{}", capture_quantum_frames(rate_hz), rate_hz);
let props = match &sink_name {
// ── Which node is clocking us ────────────────────────────────────────────────────
// `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 (`pw_impl_node_register`'s key
// list), so it takes a bind and the node's `info` event.
//
// ⚠ `pw_impl_node_set_driver` writes the key and marks the props changed, but leaves
// the flush to the node's next info emission — which in practice is the state change
// that accompanies the same graph recalculation. So read this as *the last driver the
// daemon told us about*, which is what a diagnostic wants, and not as a real-time
// signal: a driver change with no state change anywhere would reach us late or not
// at all.
//
// This line exists because on 2026-08-14 the question "what is clocking desktop audio?"
// cost four field logs, a bespoke probe script and a `pw-top` DRIVER column to answer —
// and the answer was a sound card attached over the network that nothing was linked to.
// Whatever the next such box is, it now says so itself, in the log the reporter already
// sends. Reported on CHANGE, not per window: it moves a handful of times a session, and
// the capture summary is written from the RT callback while this arrives on the main
// loop.
struct GraphDriver {
/// `node.name` of every Node global, so the driver can be NAMED and not just
/// numbered. Pruned on removal — a host runs for days and streams come and go.
names: HashMap<u32, String>,
/// Our own node, bound so that its `info` — and with it `node.driver-id` — arrives.
ours: Option<(pw::node::Node, pw::node::NodeListener)>,
/// The last driver reported, so only changes are logged.
driver: Option<u32>,
}
// In null-sink mode there is exactly one right answer and it is ours; the legacy
// topologies borrow a driver by design, so there the line names it without judging it.
let expected_driver = match mode {
CaptureMode::NullSink => sink_name.clone(),
_ => None,
};
let watch = Rc::new(RefCell::new(GraphDriver {
names: HashMap::new(),
ours: None,
driver: None,
}));
let registry = core.get_registry_rc().context("pw audio registry")?;
let _registry_listener = registry
.add_listener_local()
.global({
let watch = watch.clone();
let registry = registry.clone();
let capture_name = capture_name.clone();
move |global| {
if global.type_ != pw::types::ObjectType::Node {
return;
}
let Some(props) = global.props else { return };
let Some(name) = props.get("node.name") else {
return;
};
watch.borrow_mut().names.insert(global.id, name.to_string());
if name != capture_name.as_str() || watch.borrow().ours.is_some() {
return;
}
let Ok(node) = registry.bind::<pw::node::Node, _>(global) else {
return;
};
let listener = node
.add_listener_local()
.info({
let watch = watch.clone();
let expected = expected_driver.clone();
move |info| {
let Some(props) = info.props() else { return };
// Absent = we are between drivers (the daemon drops the key from
// a node that has none), which is not worth a line: the next
// assignment reports itself.
let Some(id) = props
.get("node.driver-id")
.and_then(|v| v.parse::<u32>().ok())
else {
return;
};
let mut w = watch.borrow_mut();
if w.driver == Some(id) {
return;
}
w.driver = Some(id);
let named = w.names.get(&id).cloned();
let driver = named.as_deref().unwrap_or("<unnamed>");
match expected.as_deref() {
Some(sink) if driver == sink => tracing::info!(
driver,
driver_id = id,
"audio capture graph driver"
),
Some(sink) => tracing::warn!(
driver,
driver_id = id,
expected = sink,
"our audio capture group is being clocked by another \
node every hole in this stream is that node's \
scheduling, not ours. Something has linked our sink to \
it (a loopback from its monitor is the usual cause); a \
USB or USB-over-IP sound card here is the 2026-08-18 \
defect"
),
// Both legacy topologies have no driver of their own, so
// borrowing one is the design and not a fault — but WHICH one
// is still the first thing anybody investigating wants.
None => tracing::info!(
driver,
driver_id = id,
"audio capture graph driver (borrowed — this topology \
has none of its own)"
),
}
}
})
.register();
watch.borrow_mut().ours = Some((node, listener));
}
})
.global_remove({
let watch = watch.clone();
move |id| {
watch.borrow_mut().names.remove(&id);
}
})
.register();
let props = match mode {
// Null-sink mode: the sink is the adapter created above and this stream is a MONITOR
// TAP of it — the same `stream.capture.sink=true` recipe as the legacy arm below,
// except aimed by name so it can only ever be ours.
CaptureMode::NullSink => {
let name = sink_name
.as_deref()
.context("null-sink mode without a sink name")?;
let mut p = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Music",
*pw::keys::STREAM_CAPTURE_SINK => "true",
// A passive link does not, on its own, make either end runnable. Between
// sessions — parked capturer, nothing playing into the sink — the group is
// therefore idle and the null sink's timer parks with it, so this topology
// costs nothing while nobody is streaming. That is the objection (R5) which
// kept `node.always-process` off the 0.30 stream sink, 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 nothing about capture changes.
*pw::keys::NODE_PASSIVE => "true",
// Wait for OUR sink; never fall back to a hardware sink's monitor, not even
// for the moment before ours registers — recording the box's real output
// would be the wrong audio, and briefly rejoining a hardware driver's group
// is the defect this whole mode exists to remove.
//
// ⚠ These two are a PAIR. WirePlumber (0.5 `find-defined-target.lua`) reads
// `node.dont-fallback` alone as licence to DESTROY this stream the moment the
// target is not visible ("defined target not found"); `node.linger` is what
// turns that into "wait for it". Never ship one without the other.
"node.dont-fallback" => "true",
"node.linger" => "true",
};
p.insert(*pw::keys::NODE_NAME, capture_name.as_str());
// Spelled out because pipewire-rs only exposes `TARGET_OBJECT` behind its
// `v0_3_44` feature, and a key constant is not worth widening the API surface
// this crate compiles against. WirePlumber matches this value against
// `node.name` (or `object.serial`) — 0.5 `find-defined-target.lua`.
p.insert("target.object", name);
p.insert(*pw::keys::NODE_LATENCY, node_latency.as_str());
p
}
// Stream-sink mode: this stream IS the sink (media.class + Direction::Input). Apps
// play into it, PipeWire mixes them, process() receives the mix. Mirrors the
// validated PwMicSource recipe (stream node + RT_PROCESS; see its property
// comments) — do NOT "modernize" either into a `support.null-audio-sink` adapter
// without re-running that validation.
Some(name) => {
// comments). Kept as the escape hatch from the mode above for one release.
CaptureMode::StreamSink => {
let name = sink_name
.as_deref()
.context("stream-sink mode without a sink name")?;
let mut p = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CLASS => "Audio/Sink",
@@ -864,7 +1251,7 @@ fn pw_thread(
// suspend keeps the node available without asking anyone to drive it.
"session.suspend-timeout-seconds" => "0",
};
p.insert(*pw::keys::NODE_NAME, name.as_str());
p.insert(*pw::keys::NODE_NAME, name);
// Ask for a ~5 ms quantum (= one protocol audio frame) so buffers arrive
// smoothly rather than in bursts the client's jitter buffer would hear as
// glitching. Inserted rather than written in the `properties!` literal because
@@ -873,13 +1260,14 @@ fn pw_thread(
p
}
// Legacy: capture the default sink's monitor (system output), not a microphone.
None => {
CaptureMode::Monitor => {
let mut p = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Music",
*pw::keys::STREAM_CAPTURE_SINK => "true",
};
p.insert(*pw::keys::NODE_NAME, capture_name.as_str());
p.insert(*pw::keys::NODE_LATENCY, node_latency.as_str());
p
}
@@ -1020,12 +1408,12 @@ fn pw_thread(
ud.rate_hz = now.1;
ud.negotiated_rate.store(now.1, Ordering::Relaxed);
}
// `stream_sink` says WHICH source this format describes, and that changes how
// much it is worth. In stream-sink mode the host owns the sink, so this IS the
// format apps render into and the desktop mix cannot have been narrowed before
// we saw it. In LEGACY monitor mode we are capturing someone else's sink
// through PipeWire's resampler: a 16 kHz Bluetooth headset upstream would
// still be reported here as a clean 48 kHz, exactly the way WASAPI's
// `mode` says WHICH source this format describes, and that changes how much
// it is worth. In both sink modes the host owns the sink, so this IS the
// format apps render into and the desktop mix cannot have been narrowed
// before we saw it. In LEGACY monitor mode we are capturing someone else's
// sink through PipeWire's resampler: a 16 kHz Bluetooth headset upstream
// would still be reported here as a clean 48 kHz, exactly the way WASAPI's
// autoconvert hid the same thing on Windows (the 2026-08-03 report). So this
// line is a fact about OUR stream and never about the content in legacy mode
// — the monitored node's own rate is a registry lookup, and it lives in
@@ -1034,7 +1422,7 @@ fn pw_thread(
format = ?info.format(),
rate = info.rate(),
channels = info.channels(),
stream_sink = sink_mode,
mode = mode.as_str(),
"audio format negotiated"
);
}
@@ -1227,7 +1615,8 @@ fn pw_thread(
// Request F32LE at the session's rate + channel count with explicit positions. In
// legacy mode PipeWire's channel-mixer up/downmixes the sink monitor to this layout;
// in stream-sink mode this IS the sink's advertised layout (apps mix/route to it)
// in stream-sink mode this IS the sink's advertised layout (apps mix/route to it), and
// in null-sink mode it is the monitor of a sink we created at this very layout —
// which is exactly why hi-res is structurally honest there and has to be PROVEN in
// monitor mode (`design/hi-res-audio.md` §4.4): a sink we OWN renders at the rate we
// declare, while a monitor tap is handed a resampled copy that reports a clean rate
@@ -1253,12 +1642,14 @@ fn pw_thread(
.into_inner();
let mut params = [Pod::from_bytes(&values).context("audio pod from bytes")?];
// RT_PROCESS in stream-sink mode for the same reason as the mic: the sink must be a
// *synchronous* graph node that joins its producers' driver group and is actually
// RT_PROCESS in both sink modes for the same reason as the mic: the node must be a
// *synchronous* graph node that joins the driver group it belongs to and is actually
// driven (see the mic's connect comment — async device-class stream nodes on a busy
// graph never acquire a driver and their process() never fires).
// graph never acquire a driver and their process() never fires). It also puts the
// callback on a data loop libpipewire schedules at SCHED_RR, which is where a capture
// callback belongs and where the mainloop boost above can never reach.
let mut flags = pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS;
if sink_name.is_some() {
if mode.owns_sink() {
flags |= pw::stream::StreamFlags::RT_PROCESS;
}
stream
@@ -1275,6 +1666,12 @@ fn pw_thread(
// default-sink claim lands a few ms before the node registers, WirePlumber simply
// keeps the configured value and elects it the moment the node appears — verified
// live: configured values persist unelected while their target is absent.)
tracing::info!(
mode = mode.as_str(),
sink = sink_name.as_deref().unwrap_or("<the default sink>"),
capture = capture_name.as_str(),
"desktop audio capture topology"
);
let _ = ready.send(Ok(()));
mainloop.run();
tracing::debug!("pipewire audio loop exited (capturer dropped)");
@@ -1285,3 +1682,100 @@ fn pw_thread(
}
result
}
#[cfg(test)]
mod tests {
use super::*;
/// The three spellings of `PUNKTFUNK_STREAM_SINK`, and the rule that anything else is the
/// default: a typo in a field-debugging variable must never be the reason a session has no
/// audio.
#[test]
fn capture_mode_grammar() {
assert_eq!(capture_mode_from(None), CaptureMode::NullSink);
for off in ["0", "false", "no", "off", " off "] {
assert_eq!(
capture_mode_from(Some(off)),
CaptureMode::Monitor,
"{off:?} selects the legacy monitor follower"
);
}
assert_eq!(capture_mode_from(Some("stream")), CaptureMode::StreamSink);
assert_eq!(capture_mode_from(Some(" stream ")), CaptureMode::StreamSink);
for junk in ["1", "yes", "null", "STREAM", ""] {
assert_eq!(
capture_mode_from(Some(junk)),
CaptureMode::NullSink,
"{junk:?} is not a mode and must fall to the default"
);
}
assert!(CaptureMode::NullSink.owns_sink() && CaptureMode::StreamSink.owns_sink());
assert!(!CaptureMode::Monitor.owns_sink());
}
/// The pod form and the property form of the channel map describe the SAME layout. They are
/// consumed by different things (a stream's format vs a created node's `audio.position`) and
/// a disagreement would mean the sink takes audio in one order and hands it on in another —
/// silently, as a channel swap nobody can see in a log.
#[test]
fn channel_map_views_agree() {
for ch in [1u32, 2, 6, 8] {
let ids = spa_positions(ch);
let order = channel_order(ch);
assert_eq!(order.len(), ch as usize, "{ch} channels");
for (i, (id, _)) in order.iter().enumerate() {
assert_eq!(ids[i], *id, "channel {i} of {ch}");
}
assert!(
ids[ch as usize..].iter().all(|&p| p == 0),
"positions past the channel count stay unset"
);
}
// Spelled out, because these exact strings are what PipeWire parses.
assert_eq!(spa_position_names(2), "[ FL FR ]");
assert_eq!(spa_position_names(6), "[ FL FR FC LFE RL RR ]");
assert_eq!(spa_position_names(8), "[ FL FR FC LFE RL RR SL SR ]");
}
/// The invariants of the created sink, each of which is a decision that cost a field
/// investigation to reach (see [`null_sink_props`]) and none of which fails loudly if it
/// silently changes.
#[test]
fn null_sink_props_hold_their_invariants() {
let props = null_sink_props("punktfunk-speaker-42-0", 6, 48_000);
let get = |k: &str| {
props
.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(get("factory.name"), Some("support.null-audio-sink"));
assert_eq!(get("media.class"), Some("Audio/Sink"));
assert_eq!(get("node.name"), Some("punktfunk-speaker-42-0"));
assert!(
get("node.name").is_some_and(|n| n.starts_with(stream_sink::SINK_NAME_PREFIX)),
"the claim's staleness rule matches this prefix"
);
assert_eq!(get("audio.channels"), Some("6"));
assert_eq!(get("audio.rate"), Some("48000"));
assert_eq!(get("audio.position"), Some("[ FL FR FC LFE RL RR ]"));
// The 5 ms ask, stated in the one form PipeWire will not round down to 128.
assert_eq!(get("node.force-quantum"), Some("240"));
assert_eq!(get("session.suspend-timeout-seconds"), Some("0"));
assert_eq!(get("priority.session"), Some("50"));
// A sink that outlives its creator would wedge routing on a node nothing owns; a sink
// with a driver priority would be elected to clock OTHER people's driver-less groups,
// which is the very defect this mode exists to end.
assert_eq!(get("object.linger"), None);
assert_eq!(get("priority.driver"), None);
// Hi-res: the quantum is a LATENCY, so it scales with the rate (5 ms either way).
let hi = null_sink_props("punktfunk-speaker-42-1", 2, 96_000);
let hi_get = |k: &str| {
hi.iter()
.find(|(key, _)| *key == k)
.map(|(_, v)| v.as_str())
};
assert_eq!(hi_get("audio.rate"), Some("96000"));
assert_eq!(hi_get("node.force-quantum"), Some("480"));
}
}
@@ -34,6 +34,29 @@ pub(crate) struct PadUsbCapturer {
pad: u8,
}
/// Map the pad's **hardware** quad onto the wire's **logical** layout.
///
/// The isochronous endpoint carries the DualSense's own channel map — `ch0` = headphone LEFT,
/// `ch1` = headphone RIGHT *and* the built-in mono speaker, `ch2`/`ch3` = the voice coils
/// (confirmed twice independently: the UCM split positions `[AUX1,AUX1,AUX2,AUX3]` and the
/// on-glass channel sweep). The 0xD1 wire contract instead puts the *speaker pair* on ch0/1.
/// Forwarding the hardware quad verbatim therefore ships headphone-left (silence, or content no
/// remote pad can render — the jack is on the other end of the stream) as wire speaker-left, and
/// the actual speaker channel as wire speaker-right — which the client then plays into the ONE
/// split-sink channel that a current PipeWire never wires to the physical speaker.
/// Field-diagnosed 2026-08-18: haptics felt, speaker dead, the tone measured on exactly one
/// channel at each hop.
///
/// So: duplicate the hardware speaker channel (`ch1`) across the wire's speaker pair, pass the
/// coils through. Headphone-left is dropped deliberately — the remote pad's jack is not a wire
/// surface, and a game that routes to the jack has the pad's audio *off* the speaker anyway.
fn normalize_hw_quad(mut chunk: Vec<f32>) -> Vec<f32> {
for frame in chunk.chunks_exact_mut(4) {
frame[0] = frame[1];
}
chunk
}
impl PadUsbCapturer {
/// Claim wire pad `pad`'s USB audio stream.
///
@@ -55,7 +78,7 @@ impl AudioCapturer for PadUsbCapturer {
fn next_chunk_within(&mut self, budget: Duration) -> Result<Vec<f32>> {
match self.rx.recv_timeout(budget.min(IDLE_TIMEOUT)) {
Ok(chunk) => Ok(chunk),
Ok(chunk) => Ok(normalize_hw_quad(chunk)),
// Nothing arrived in the budget. The game isn't writing (or the stream is stopped) —
// a quiet pad, not a dead one, exactly as the sink capturer reports it.
Err(RecvTimeoutError::Timeout) => Ok(Vec::new()),
@@ -110,15 +133,29 @@ mod tests {
assert!(c.next_chunk_within(Duration::from_millis(10)).is_err());
}
/// Samples pass through untouched the handler already produced interleaved `f32`.
/// The hardware quad is normalized to the wire layout: hw ch1 (the pad's one real speaker
/// channel) is duplicated across the wire speaker pair, the coils pass through, and hw ch0
/// (headphone-left — not a wire surface) is dropped. Forwarding the quad verbatim shipped
/// the speaker on wire ch1 only, which the client's split-sink render never got to the
/// physical speaker (field, 2026-08-18: haptics felt, speaker dead).
#[test]
fn delivers_the_published_chunk_verbatim() {
fn normalizes_the_hardware_quad_to_the_wire_layout() {
let (tx, mut c) = capturer();
tx.send(vec![0.5, -0.5, 0.25, -0.25]).expect("send");
tx.send(vec![0.9, 0.5, 0.25, -0.25, 0.8, 0.4, 0.2, -0.2])
.expect("send");
assert_eq!(
c.next_chunk_within(Duration::from_millis(50))
.expect("chunk"),
vec![0.5, -0.5, 0.25, -0.25]
vec![0.5, 0.5, 0.25, -0.25, 0.4, 0.4, 0.2, -0.2]
);
}
/// The normalizer itself, on one frame: `[hpL, spk, coilA, coilB]` → `[spk, spk, coilA, coilB]`.
#[test]
fn normalize_duplicates_the_speaker_channel() {
assert_eq!(
normalize_hw_quad(vec![0.9, 0.5, 0.25, -0.25]),
vec![0.5, 0.5, 0.25, -0.25]
);
}
}
+24 -15
View File
@@ -2,7 +2,7 @@
//! Windows: WASAPI loopback of a pre-provisioned endpoint ([`crate::audio::pad_endpoint`]);
//! Linux: the per-pad PipeWire sink we mint (`crate::audio::pad_sink`) — → 4-ch de-interleave
//! into the speaker (front) and voice-coil haptics (back) pairs → per-kind silence gate →
//! stereo Opus (48 kHz, CBR, LowDelay)
//! stereo Opus (48 kHz, CBR; LowDelay for haptics, Audio for the speaker)
//! → [`PAD_AUDIO_MAGIC`](punktfunk_core::quic::PAD_AUDIO_MAGIC) datagrams. One thread per
//! arriving pad, spawned/reaped by the input thread ([`super::input`]) as arrivals declare
//! renderers and pads leave. Modeled on the session audio thread ([`super::audio`]): the same
@@ -49,10 +49,16 @@ const GATE_OPEN_PEAK: f32 = 1e-3;
#[cfg(any(target_os = "windows", target_os = "linux", test))]
const GATE_HANGOVER_MS: u32 = 250;
/// Per-kind Opus bitrate — a stereo voice-coil / pad-speaker pair needs far less than the
/// session plane's 128 kbps; 64 kbps CBR keeps every frame comfortably under one MTU.
/// The haptics lane's Opus bitrate — voice-coil content is band-limited rumble; 64 kbps CBR
/// keeps every frame comfortably under one MTU.
#[cfg(any(target_os = "windows", target_os = "linux"))]
const PAD_AUDIO_BITRATE: i32 = 64_000;
const HAPTICS_BITRATE: i32 = 64_000;
/// The speaker lane's Opus bitrate. The pad speaker carries real programme audio (voice lines,
/// effects), and 64 kbps CELT-only in 10 ms frames is audibly artifacty there — field report
/// 2026-08-18: "sounds insanely compressed". 96 kbps CBR is still ~120 bytes per frame, far
/// under one MTU.
#[cfg(any(target_os = "windows", target_os = "linux"))]
const SPEAKER_BITRATE: i32 = 96_000;
/// The per-kind silence gate — the steady-state-cost feature: an idle pad endpoint (games
/// rarely render pad audio) must cost ZERO encodes and ZERO datagrams, not a permanent 200 Hz
@@ -471,32 +477,35 @@ struct Lane {
encode_errs: u64,
}
/// Build one stereo encoder per enabled kind: 48 kHz LowDelay hard-CBR like the session audio
/// plane ([`super::audio`]), at the pad plane's 64 kbps.
/// Build one stereo encoder per enabled kind: 48 kHz hard-CBR like the session audio plane
/// ([`super::audio`]), each lane tuned to its content. Haptics are felt latency — LowDelay
/// (CELT-only, 2.5 ms lookahead) at 64 kbps. The speaker is programme audio — the full
/// `Application::Audio` coder at 96 kbps; its ~4 ms of extra algorithmic delay is inaudible on
/// a speaker but the CELT-only artifacts were not (field, 2026-08-18).
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn build_lanes(kinds: u8) -> Result<Vec<Lane>, opus::Error> {
let mut lanes = Vec::new();
for (bit, kind, frame_ms) in [
for (bit, kind, frame_ms, app, bitrate) in [
(
KIND_BIT_HAPTICS,
punktfunk_core::quic::PAD_AUDIO_KIND_HAPTICS,
HAPTICS_FRAME_MS,
opus::Application::LowDelay,
HAPTICS_BITRATE,
),
(
KIND_BIT_SPEAKER,
punktfunk_core::quic::PAD_AUDIO_KIND_SPEAKER,
SPEAKER_FRAME_MS,
opus::Application::Audio,
SPEAKER_BITRATE,
),
] {
if kinds & bit == 0 {
continue;
}
let mut enc = opus::Encoder::new(
crate::audio::SAMPLE_RATE,
opus::Channels::Stereo,
opus::Application::LowDelay,
)?;
enc.set_bitrate(opus::Bitrate::Bits(PAD_AUDIO_BITRATE)).ok();
let mut enc = opus::Encoder::new(crate::audio::SAMPLE_RATE, opus::Channels::Stereo, app)?;
enc.set_bitrate(opus::Bitrate::Bits(bitrate)).ok();
enc.set_vbr(false).ok();
lanes.push(Lane {
kind,
@@ -537,7 +546,7 @@ fn pad_audio_thread<C: crate::audio::AudioCapturer>(
return; // spawn() refuses kinds == 0 — belt and braces
}
let mut framer = PadFramer::new(kinds);
// One Opus frame per datagram; 64 kbps CBR at ≤10 ms is ~80 bytes — sized with the session
// One Opus frame per datagram; 96 kbps CBR at ≤10 ms is ~120 bytes — sized with the session
// plane's slack.
let mut opus_buf = vec![0u8; 1500];
// Reopen-with-backoff (the audio.rs discipline): a capture death (endpoint invalidated,
@@ -546,7 +555,7 @@ fn pad_audio_thread<C: crate::audio::AudioCapturer>(
let mut capturer: Option<C> = None;
let mut last_failed: Option<std::time::Instant> = None;
// Datagrams the wire refused as oversized (`design/hi-res-audio.md` §4.8). Vanishingly
// unlikely on this plane — a 64 kbps CBR Opus frame at ≤10 ms is ~80 bytes — but it used to
// unlikely on this plane — a ≤96 kbps CBR Opus frame at ≤10 ms is ≤~120 bytes — but it used to
// be indistinguishable from the connection ending, which is the actual defect being fixed.
let mut oversized_drops: u64 = 0;
tracing::info!(
+6
View File
@@ -28,10 +28,16 @@ num-derive = "0.4"
num-traits = "0.2"
# `time` is for the interrupt-IN pacing added in device.rs (punktfunk modification — see NOTICE).
tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time"] }
# Upstream gated its struct derives behind a `serde` feature; kept (off by default) so the
# `#[cfg(feature = "serde")]` attributes stay valid and the vendored diff stays minimal.
serde = { version = "1", features = ["derive"], optional = true }
[dev-dependencies]
# `#[tokio::test(start_paused = true)]` for the ISO pacing tests — paused virtual time is the
# only way to pin an absolute-deadline pacer exactly.
tokio = { version = "1", features = ["rt", "macros", "test-util", "time"] }
[features]
default = []
serde = ["dep:serde"]
+7
View File
@@ -36,6 +36,13 @@ Modifications by the punktfunk project:
`actual_length = 0`; `vhci_hcd` copies that field into the URB verbatim, so
every synchronous writer (`write()` on hidraw, `HIDIOCSFEATURE`) was told it
transferred 0 bytes and treated the write as failed.
- Isochronous completion is paced against an absolute per-endpoint deadline
ledger (`UsbDevice::iso_deadlines`) rather than a relative
`sleep(interval × packets)` per URB. The relative sleep added scheduling
overhead on top of every period, so the simulated device's audio clock ran
measurably slow (~26 % under load) — the PCM backed up into xruns and
anything clocked off the device dragged. Late completions now catch up;
a stall beyond 20 ms re-anchors instead of fast-forwarding.
Only the USB/IP server *simulation* path is retained: the device model, the
USB/IP wire protocol, and the `UsbInterfaceHandler` trait. The original MIT
+134 -1
View File
@@ -52,6 +52,13 @@ pub struct UsbDevice {
#[cfg_attr(feature = "serde", serde(skip))]
pub device_handler: Option<Arc<Mutex<Box<dyn UsbDeviceHandler + Send>>>>,
/// Per-endpoint isochronous completion deadlines (punktfunk addition) — the absolute-time
/// ledger [`handle_iso_urb`](Self::handle_iso_urb) paces against. Keyed by endpoint address.
/// Shared across clones because the clones all present the same device: whoever services the
/// endpoint advances the one clock.
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) iso_deadlines: Arc<Mutex<HashMap<u8, tokio::time::Instant>>>,
pub usb_version: Version,
pub(crate) ep0_in: UsbEndpoint,
@@ -321,6 +328,17 @@ impl UsbDevice {
/// above is paced), so completing instantly would both spin the loopback link and tell the
/// kernel the device consumed a whole URB's worth of samples in no time, running the stream's
/// clock away and xrunning it continuously.
///
/// **Paced against an absolute per-endpoint deadline, not relative sleeps.** A plain
/// `sleep(interval × packets)` per URB adds every source of slop — tokio timer granularity,
/// socket I/O, handler lock waits — ON TOP of the nominal period, so the device's clock runs
/// systematically slow (measured ~26 % slow on a busy graph, 2026-08-18: `hw_ptr` advanced
/// ~35.7 k frames/s against a 48 kHz stream — the PCM backs up, latency grows into xruns,
/// and anything clocked off this device drags). The ledger makes late completions *catch up*:
/// each URB advances the endpoint's deadline by exactly its nominal duration and sleeps until
/// that absolute instant, so overhead eats into the next sleep instead of accumulating. If
/// the stream stalls long enough that the ledger is far behind (stop/start, unlink storm),
/// it re-anchors to now rather than fast-forwarding a burst of instant completions.
pub(crate) async fn handle_iso_urb(
&self,
ep: UsbEndpoint,
@@ -331,7 +349,22 @@ impl UsbDevice {
// ISO on ep0 is not a thing; treat it as an unsupported transfer rather than panicking.
return Err(std::io::Error::other("isochronous transfer to ep0"));
};
tokio::time::sleep(self.service_interval(ep) * packets.len() as u32).await;
// Allow this much catch-up before deciding the stream stalled and re-anchoring. Two USB
// frames of slack keeps ordinary scheduling jitter inside the ledger (where it averages
// out) without letting a restarted stream burn through a stale deadline backlog.
const RESYNC_SLACK: std::time::Duration = std::time::Duration::from_millis(20);
let step = self.service_interval(ep) * packets.len() as u32;
let deadline = {
let mut ledger = self.iso_deadlines.lock().unwrap();
let now = tokio::time::Instant::now();
let due = ledger.entry(ep.address).or_insert(now);
if *due + RESYNC_SLACK < now {
*due = now;
}
*due += step;
*due
};
tokio::time::sleep_until(deadline).await;
let mut handler = intf.handler.lock().unwrap();
handler.handle_iso_urb(intf, ep, packets)
}
@@ -717,6 +750,106 @@ mod pacing_tests {
);
}
/// A no-op ISO handler so the pacing tests can drive `handle_iso_urb` without a device model.
#[derive(Debug)]
struct NullIso;
impl crate::UsbInterfaceHandler for NullIso {
fn handle_urb(
&mut self,
_interface: &UsbInterface,
_ep: UsbEndpoint,
_transfer_buffer_length: u32,
_setup: crate::SetupPacket,
_req: &[u8],
) -> Result<Vec<u8>> {
Ok(Vec::new())
}
fn handle_iso_urb(
&mut self,
_interface: &UsbInterface,
_ep: UsbEndpoint,
packets: &[IsoPacket<'_>],
) -> Result<Vec<Vec<u8>>> {
Ok(vec![Vec::new(); packets.len()])
}
fn get_class_specific_descriptor(&self) -> Vec<u8> {
Vec::new()
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
fn null_intf() -> UsbInterface {
UsbInterface {
interface_class: 1,
interface_subclass: 2,
interface_protocol: 0,
endpoints: vec![iso_ep(4)],
string_interface: 0,
class_specific_descriptor: Vec::new(),
alt_settings: Vec::new(),
handler: Arc::new(Mutex::new(
Box::new(NullIso) as Box<dyn crate::UsbInterfaceHandler + Send>
)),
}
}
/// The completion pace must hold the NOMINAL rate over many URBs — a relative
/// `sleep(interval × packets)` per URB adds scheduling overhead on top of every period and
/// the device's audio clock runs measurably slow (~26 % on a busy graph, field 2026-08-18).
/// Under tokio's paused clock the ledger's `sleep_until` deadlines auto-advance with zero
/// slop, so 50 URBs × 8 packets × 1 ms must take exactly 400 ms of virtual time — and the
/// deadline arithmetic (not per-call `now()`) is what guarantees the same under real slop.
#[tokio::test(start_paused = true)]
async fn iso_pacing_holds_the_nominal_rate_across_urbs() {
let d = dev(UsbSpeed::High);
let intf = null_intf();
let buf = [0u8; 392];
let start = tokio::time::Instant::now();
for _ in 0..50 {
let packets: Vec<IsoPacket<'_>> = (0..8)
.map(|_| IsoPacket {
data: &buf,
requested_len: 392,
})
.collect();
d.handle_iso_urb(iso_ep(4), Some(&intf), &packets)
.await
.expect("iso urb");
}
assert_eq!(
start.elapsed(),
std::time::Duration::from_millis(400),
"50 URBs × 8 packets × 1 ms must complete in exactly their nominal duration"
);
}
/// After a stall longer than the resync slack, the ledger re-anchors to now instead of
/// fast-forwarding a burst of instant completions through the stale backlog.
#[tokio::test(start_paused = true)]
async fn iso_pacing_reanchors_after_a_stall() {
let d = dev(UsbSpeed::High);
let intf = null_intf();
let buf = [0u8; 392];
let one = |d: &UsbDevice, intf: &UsbInterface| {
let packets = vec![IsoPacket {
data: &buf,
requested_len: 392,
}];
let d = d.clone();
let intf = intf.clone();
async move { d.handle_iso_urb(iso_ep(4), Some(&intf), &packets).await }
};
one(&d, &intf).await.expect("prime the ledger");
// Stall well past the slack, then resume: the next URB must take ~its nominal 1 ms from
// NOW, not complete instantly against the stale deadline.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let start = tokio::time::Instant::now();
one(&d, &intf).await.expect("resumed urb");
assert_eq!(start.elapsed(), std::time::Duration::from_millis(1));
}
/// `bmAttributes` carries the synchronisation and usage type above the transfer type, so a real
/// UAC endpoint is `0x05`/`0x09` rather than a bare `0x01`. Decoding the whole byte returns
/// `None` for those and used to reach `unimplemented!()`; only bits 1..0 may be decoded.
+1
View File
@@ -160,6 +160,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_AUDIO_REDUNDANCY` | `1` · `0` *(default: automatic)* | Send audio packets redundantly so a lossy link doesn't crackle. Leave it unset: the host turns redundancy on by itself, only toward clients that support it and only while the link is actually losing packets. `1` forces it on for the whole session, `0` never sends it. |
| `PUNKTFUNK_AUDIO_HIRES` | `0` · `1` *(default: allowed)* | Whether this host will serve the **lossless** audio plane — uncompressed PCM (44.1 / 48 / 88.2 / 96 / 176.4 kHz, 16 or 24-bit, stereo through 7.1) instead of Opus. **You do not need to set this** — since 0.30 the host allows it and the *client's* audio-format setting is the opt-in, which is the switch belonging to the person whose bandwidth it spends. `0` refuses the plane on this host no matter what any client asks for. It was an operator opt-in until 2026-08-17, and what that produced was users picking "Lossless 96 kHz / 24-bit" in a client, silently getting Opus, and the reason existing only as one `INFO` line in the host's journal. What still protects the link is mechanical rather than a pre-agreement: the plane costs **1.48.5 Mbps** in stereo (up to 33.9 for 176.4 kHz/24-bit 7.1) against Opus's 256 kbps, it rides QUIC datagrams outside the adaptive-bitrate loop — off the top of the link, where ABR can neither see it nor claw it back — and so a session gets it only if the cost fits **a quarter of that session's video bitrate**. A 5 Mbps session can afford no rung of it at all. Be clear about what it buys: on game content it is very unlikely to be *audible* (256 kbps Opus is already effectively transparent, and nothing above 24 kHz is hearable at all), so the real win is **bit-exactness** — no lossy stage anywhere, and no resample for a host whose interface genuinely runs at 96 kHz. If any condition fails — the client didn't ask, this variable is `0`, the capture device can't genuinely deliver the rate, the link can't spare the bandwidth, or one frame of that format won't fit a datagram at that channel count — the session quietly stays on Opus and the host log names which one lost. ⚠️ **The desktop clients read a variable of this same name with a richer grammar** (see [Client-side](#client-side-native-clients) below), so on a box that is both host and client, one line configures both ends. `0` is now the interesting shared spelling, and it means *off* to each of them; this host gate reads anything that isn't `0`/`false`/`off`/`no` as *allow*, so a client-style `96000/24` leaves this half permissive. |
| `PUNKTFUNK_AUDIO_GAIN` | float (default `1.0`) | Gain applied to captured desktop audio — bump it for a quiet source. Applies to **both** the native `punktfunk/1` and Moonlight/GameStream paths. Peaks are rounded off by a soft limiter rather than clipped, so a boost distorts gracefully instead of abruptly; values above `8.0` (+18 dB) are capped, and a non-positive value is ignored. Note this buys **headroom, not loudness** — it cannot make a desktop mix as loud as already-limited streaming-app audio, and pushing it hard to try will audibly squash the signal. On Windows this is the only host-side control that works at all: loopback capture is tapped upstream of the endpoint's master volume, so the speaker slider does not affect what a client receives. |
| `PUNKTFUNK_STREAM_SINK` | *(unset — a host-owned virtual output)* · `stream` · `0` | **(Linux)** Where desktop audio is captured from. **Leave it unset.** The host creates its own virtual output — "Punktfunk Stream Speaker" — makes it the default while a session runs, and records that, so capture never depends on your speakers existing, on HDMI audio surviving a mode change, or on anything else the desktop does with its output devices; and because the host declares that output's format, a game can render real 5.1/7.1 into it even when this box's own hardware is stereo. Since 0.31 the output is a **real** PipeWire node (a `null-audio-sink`) rather than the capture stream wearing a sink's clothes, and the difference is which clock the graph runs on: a stream cannot drive, so PipeWire had to borrow a clock from whatever sound card happened to be running on the box. On one host that was a controller's sound card attached over the network, and **15 % of the audio that user heard was silence the host invented** over the gaps it left. `stream` restores the 0.30 arrangement for one release (a field A/B without a rebuild); `0` records whatever your current default output is playing instead — which follows the default around, so it hiccups every time that changes. While a session runs you will see both the output and a recording stream named `punktfunk-audio-…` in pavucontrol or KDE's audio settings: that is the capture, not a leak. The host log names the live topology (`desktop audio capture topology mode=…`) and, on every change, which node is clocking it (`audio capture graph driver`). |
| `PUNKTFUNK_MIC_DEVICE` | name substring | **(Windows)** Target mic-uplink device by friendly-name substring (first match wins). |
| `PUNKTFUNK_MIC_LEGACY_BUFFER` | `1` | Restore the fixed pre-adaptive mic buffering (a ~48 ms prime and ~120 ms cap on Windows; a buffer scaled to the recording app's audio quantum on Linux) instead of the adaptive per-client jitter target. One-release escape hatch: if the microphone coming out of the host only sounds right *with* this set, that's a bug — please report it. |
| `PUNKTFUNK_NO_MIC_INSTALL` | set | **(Windows)** Skip installing the virtual-mic driver (e.g. when the host runs as SYSTEM). |
+9 -2
View File
@@ -47,11 +47,18 @@ See [Configuration](/docs/configuration) for the full reference.
portal. To pick the output without a GUI on a headless host, the host writes a managed
`~/.config/hypr/xdph.conf` pointing xdph's `custom_picker_binary` at a small shim that selects the
new output automatically — no interactive picker dialog to answer.
- **Window placement**the headless output is an *extension*: it sits beside your real monitors and
nothing promotes it or turns them off. Hyprland opens a new window on the **focused** monitor, so
- **Window placement**under the default *extend* topology the headless output sits beside your
real monitors and nothing promotes it. Hyprland opens a new window on the **focused** monitor, so
the host runs `hyprctl dispatch focusmonitor PF-…` — once when the output is ready, and again right
before it launches anything from your library. Without that, games open on whichever physical
monitor had focus and the stream shows a bare desktop.
- **Exclusive topology** — if you set it, the host disables your physical monitors for the session
(`monitor <name>,disable`, or the Lua `hl.monitor{ …, disabled = true }` if you use a Lua config)
and brings them back with a **`hyprctl reload`** at teardown. The reload is not a shortcut: a
disabled Hyprland monitor cannot be re-enabled by re-applying its rule — every targeted form is
accepted and does nothing — so re-reading your config is the only way back. It also drops other
runtime `hyprctl keyword` changes and re-runs `exec =` lines in a non-Lua config, and it runs only
when a session actually disabled a monitor.
- **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual
keyboard** protocols (Hyprland kept them). Gamepads and audio are compositor-independent.
+6 -2
View File
@@ -54,11 +54,15 @@ See [Configuration](/docs/configuration) for the full reference.
- **Capture** — it captures that output through the **xdg-desktop-portal-wlr (xdpw)** ScreenCast
portal. The host writes a managed chooser config so the output pick is automatic — no interactive
picker dialog to answer.
- **Window placement**the headless output is an *extension*: it sits beside your real monitors and
nothing promotes it or turns them off. sway opens a new window on the focused workspace, so the host
- **Window placement**under the default *extend* topology the headless output sits beside your
real monitors and nothing promotes it. sway opens a new window on the focused workspace, so the host
runs `swaymsg focus output HEADLESS-…` — once when the output is ready, and again right before it
launches anything from your library. Without that, games open on whichever physical monitor had
focus and the stream shows a bare desktop.
- **Exclusive topology** — if you set it, the host runs `swaymsg output <name> disable` for each of
your physical outputs at session start and `swaymsg output <name> enable` when the last streaming
display is torn down. Outputs named `HEADLESS-*` are never disabled, so a second streaming client
(and a headless sway's own bootstrap output) is left alone.
- **Input** — mouse and keyboard are injected via the wlroots **virtual pointer** and **virtual
keyboard** protocols.
+28 -2
View File
@@ -239,8 +239,10 @@ If it still happens on a host that has the fix:
this is your normal way to play, set **Virtual displays → Dedicated game sessions** to **Dedicated**
— every launch then gets its own headless gamescope with only the game inside, and placement stops
being a question of focus at all (needs `gamescope` installed).
- **Setting the topology to Primary or Exclusive won't do it.** Neither is implemented on these two
backends — the console accepts the setting and the host logs that it dropped it. See
- **Setting the topology to Primary won't do it.** Wayland has no primary output for these two
backends to set, so Primary behaves as Extend and the host says so in the log. **Exclusive** *is*
implemented here — it switches your physical monitors off for the session and back on afterwards,
which does put every window on the stream. See
[Virtual displays → Topology](/docs/virtual-displays#topology).
## The screen stays black after switching to Game Mode (Nobara)
@@ -535,6 +537,30 @@ told your client so. [When the client and the host
disagree](/docs/client-settings#when-the-client-and-the-host-disagree) lists what it does with each
one.
## Audio stutters, and only the audio (Linux)
Video steady, sound broken up: on a Linux host, look for this line in the host log.
```
WARN our audio capture group is being clocked by another node — every hole in this stream is
that node's scheduling, not ours … driver="alsa_input.usb-…" expected="punktfunk-speaker-…"
```
PipeWire schedules audio in groups, and each group runs on one node's clock. The host brings its
own — the virtual output it records — so this warning means something has linked that output to
another device and handed it the clock instead. Whatever the named device does with its timing,
your stream now does too: if it stalls for 30 ms, so does the audio, and the host fills the hole
with silence.
The usual cause is a loopback from the host's virtual output to a real one (some "listen to this
device" setups create exactly that). The pathological case is a **sound card reached over the
network** — a controller forwarded with VirtualHere or USB/IP presents one, and its clock cannot be
recovered across the link at all; a host in that state synthesized 15 % of everything the user
heard. Remove the loopback, or turn off that card's audio profile (KDE → Audio → the device →
Profile → *Off*), and the group goes back to the host's own clock.
Without the warning, audio stutter is the same problem as any other stutter — see above.
## Streamed audio sounds worse than the host does
The host does not capture "the sound card" — it captures a **render endpoint**, and by default it
+24 -14
View File
@@ -29,8 +29,8 @@ different setting and it turns most of this page off — see
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows, KDE/KWin
> *and* GNOME/Mutter), and **multi-monitor layout** (several clients as monitors of one desktop) are
> all enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
> `exclusive`, and multi-display for a *single* client (that last is the next stage).
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, and
> multi-display for a *single* client (that last is the next stage).
## Stream a real monitor instead
@@ -212,20 +212,30 @@ Per-backend support:
|---|---|---|---|---|
| Extend | ✅ | ✅ | ✅ | ✅ |
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
| Exclusive | ✅ | ✅ | ⏳ following release | ✅ |
| Exclusive | ✅ | ✅ | | ✅ |
On **Sway/wlroots and Hyprland** the virtual display is always an *extend* output — it is added
beside your physical monitors and neither promoted nor allowed to disable them, whatever the topology
says. So that "treated as Extend" doesn't leave your games on the wrong screen, the host **claims the
compositor's focus for the streamed display**: once at session start, and again immediately before it
launches anything from your library. Both compositors open a new window on the focused monitor, so
that is what puts the game on the display you're streaming.
**Primary** has no equivalent on **Sway/wlroots and Hyprland**, and that is a Wayland fact rather
than a missing feature: there is no primary-output concept to set. What these compositors do have is
a *focused* output, and the host already points that at the streamed display — once at session start,
and again immediately before it launches anything from your library. Both open a new window on the
focused monitor, so that is what puts the game on the display you're streaming. Choosing Primary
therefore behaves as Extend, and the host says so in the log.
Two things follow from it being focus rather than promotion. Your physical monitors stay lit and
usable — this is Extend, not Exclusive. And a window that opens *later* (a launcher that spawns a
second window, a game that re-parents itself) follows whatever has focus at that moment, so if you're
also sitting at the machine, clicking on a physical monitor mid-launch can still pull a window over
to it.
One thing follows from it being focus rather than promotion: a window that opens *later* (a launcher
that spawns a second window, a game that re-parents itself) follows whatever has focus at that
moment, so if you're also sitting at the machine, clicking on a physical monitor mid-launch can still
pull a window over to it.
**Exclusive** does disable your physical monitors on both, and switches them back on when the last
streaming display is torn down. Two details are specific to these compositors:
- Punktfunk only ever disables monitors it did not create, so a second client streaming at the same
time never goes dark.
- On Hyprland the restore is a `hyprctl reload`, because nothing else re-enables a monitor that a
rule disabled — a re-applied monitor rule is accepted and ignored. That re-reads your Hyprland
config, which is what puts your monitors back; the side effect is that any settings you changed at
runtime with `hyprctl keyword` are dropped too, and a non-Lua config re-runs its `exec =` lines
(`exec-once` is not re-run). This only happens if a session actually disabled something.
### Conflict handling · identity · layout
+58
View File
@@ -0,0 +1,58 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the
codebase.
This is a **single-context** repo: one `CONTEXT.md` and one `docs/adr/` at the root, covering the
whole workspace.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest
creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and
`/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
Neither file exists yet — that is expected, and not something to fix pre-emptively.
## File structure
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-....md
│ └── 0002-....md
├── crates/ ← Rust workspace members (host, capture, encode, decode, presenter, …)
├── clients/ ← per-platform clients (android, apple, linux, cli, decky, …)
├── web/ ← web console
├── sdk/
└── plugin-kit/
```
The code is split across many crates and client platforms, but they serve one domain — a host
captures, encodes, and streams a session to a client that decodes and presents it. Keep the
glossary unified across them rather than splitting per directory. If a genuinely separate domain
appears later, switch to a root `CONTEXT-MAP.md` pointing at per-context `CONTEXT.md` files and
update this file.
`docs/` already holds release notes (`docs/releases/`) — those are not domain docs, and ADRs sit
alongside them in `docs/adr/`, not inside them.
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a
test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary
explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing
language the project doesn't use (reconsider) or there's a real gap (note it for
`/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
+97
View File
@@ -0,0 +1,97 @@
# Issue tracker: Gitea (`git.unom.io`)
Issues and specs for this repo live as **Gitea issues** on the self-hosted instance at
`git.unom.io`, in the repo **`unom/punktfunk`** (owner `unom`, repo `punktfunk`).
Confirm with `git remote -v` if in doubt — but note that a worktree of this repo has the same
remote, so `unom/punktfunk` holds regardless of which checkout you are in.
## Use the `gitea` MCP server — not `gh`, `glab`, or `tea`
This is **not** GitHub and **not** GitLab. `gh` is installed on this machine but is bound to
github.com and will not see these issues; `glab` and `tea` are not installed at all. Every issue
operation goes through the connected **`gitea` MCP server**.
Its tools are *deferred* — the names are visible but the schemas are not loaded, so calling one
straight away fails with `InputValidationError`. Load what you need first:
```
ToolSearch("select:mcp__gitea__issue_write,mcp__gitea__issue_read,mcp__gitea__list_issues,mcp__gitea__label_read")
```
## Conventions
Every call takes `owner: "unom"`, `repo: "punktfunk"`.
- **Create an issue**: `mcp__gitea__issue_write` with `method: "create"`, `title`, `body`.
- **Read an issue**: `mcp__gitea__issue_read` with `method: "get"` (details), `"get_comments"`
(discussion), or `"get_labels"`. Read all three when triaging — Gitea returns them separately.
- **List issues**: `mcp__gitea__list_issues` with `state` (`open`/`closed`/`all`), optional
`labels` (an array of label **names** here), `since`/`before`, `page`/`per_page`.
- **Search across repos**: `mcp__gitea__search_issues` with `query`, optional `owner`, `labels`
(comma-separated string), `state`, `type`.
- **Comment**: `issue_write` with `method: "add_comment"`, `issue_number`, `body`.
- **Apply labels**: `issue_write` with `method: "add_labels"` / `"replace_labels"` /
`"remove_label"` / `"clear_labels"`.
- **Close**: `issue_write` with `method: "update"`, `issue_number`, `state: "closed"` — comment
first if you have something to say, since `update` takes no comment.
### Trap: labels are written by numeric ID, read by name
`issue_write` takes `labels` as an **array of numeric label IDs**, and `remove_label` takes a
single `label_id`. It will not accept label names. `list_issues`, by contrast, filters on label
**names**. So before applying a label, resolve the name to its ID:
```
mcp__gitea__label_read { method: "list_repo_labels", owner: "unom", repo: "punktfunk", per_page: 100 }
```
and match on `.name` to get `.id`. If the label does not come back, it does not exist yet — see
`triage-labels.md`; the repo currently has **no labels defined at all**, on the repo or the org.
## Ask before writing — this tracker is outward-facing
Reads (`issue_read`, `list_issues`, `search_issues`, `label_read`) are free; run them whenever you
need context.
**Writes are outward-facing and require the user's go-ahead each time.** Creating an issue,
commenting, applying labels, closing, and creating labels all publish to a shared instance other
people watch, and Gitea emails on activity. Draft the full text, show it to the user, and file it
only once they say to. This applies to subagents too — a subagent may not file on your behalf.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo should treat external PRs as feature
requests; `/triage` reads this flag.)_
If it is ever set to `yes`, the PR equivalents are `mcp__gitea__list_issues` with
`type: "pulls"`, plus `mcp__gitea__pull_request_read` and `mcp__gitea__pull_request_write`. Gitea
shares one number space across issues and PRs, so a bare `#42` may be either — resolve with
`pull_request_read` and fall back to `issue_read`.
## When a skill says "publish to the issue tracker"
Create a Gitea issue in `unom/punktfunk` — after asking (see above).
## When a skill says "fetch the relevant ticket"
`issue_read` with `method: "get"`, then `method: "get_comments"`.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue; **child** issues are the tickets.
- **Map**: an issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body.
- **Child ticket**: Gitea has no sub-issue relationship over this MCP surface. Add each child to a
task list in the map body (`- [ ] #<child>`) and put `Part of #<map>` at the top of the child
body. Label with `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed,
set `assignees` to the driving dev.
- **Blocking**: Gitea has native issue dependencies in its web UI, but no MCP method reaches them.
Use a `Blocked by: #<n>, #<n>` line at the top of the child body instead. A ticket is unblocked
when every issue named there is closed — check with `issue_read`.
- **Frontier query**: `list_issues` with `state: "open"`, narrowed to the map's task-list children;
drop any with an open blocker or an assignee; first in map order wins.
- **Claim**: `issue_write` with `method: "update"` and `assignees` — the session's first write, so
ask first.
- **Resolve**: `add_comment` with the answer, then `update` to `state: "closed"`, then append a
context pointer to the map's Decisions-so-far.
+29
View File
@@ -0,0 +1,29 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual
label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label
string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.
## These labels do not exist yet
As of setup, `unom/punktfunk` has **no labels defined** — not on the repo, not on the `unom` org.
The first triage run has to create them with `mcp__gitea__label_write`
(`method: "create_repo_label"`, `name`, `color` as `#RRGGBB`, optional `description`).
Creating labels is a write to a shared instance, so it falls under the ask-first rule in
`issue-tracker.md` — propose the five, then create them once the user agrees.
Remember the ID trap from `issue-tracker.md`: applying a label needs its **numeric ID** from
`label_read`, not the name in the table above.
+2
View File
@@ -213,6 +213,8 @@ package_punktfunk-host() {
install -Dm0755 "$T/punktfunk-encode-worker" "$pkgdir/usr/bin/punktfunk-encode-worker"
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
install -Dm0644 "$R/scripts/60-punktfunk-dualsense.conf" \
"$pkgdir/usr/share/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf"
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
# stop/restore the display manager for the stream. Arch has no /usr/libexec — install under
# /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both).
+1
View File
@@ -82,6 +82,7 @@ install -Dm0644 packaging/linux/punktfunk-update.service \
install -Dm0644 packaging/linux/49-punktfunk-update.rules \
"$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-update.rules"
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
install -Dm0644 scripts/60-punktfunk-dualsense.conf "$STAGE/usr/share/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf"
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
# stop/restore the display manager for the stream (the helper derives the DM unit itself).
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
+2
View File
@@ -176,6 +176,8 @@ in
# udev: /dev/uinput + /dev/uhid (virtual gamepads) + the vhci sysfs perms for the virtual Deck.
install -Dm0644 scripts/60-punktfunk.rules "$out/lib/udev/rules.d/60-punktfunk.rules"
# WirePlumber: hold a DualSense's sound card open + keep it off the graph clock.
install -Dm0644 scripts/60-punktfunk-dualsense.conf "$out/share/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf"
# KWin Desktop-mode authorization (zkde_screencast + fake_input). Point Exec at the store binary.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
+5
View File
@@ -324,6 +324,10 @@ install -Dm0755 target/release/punktfunk-encode-worker %{buildroot}%{_bindir}/pu
# udev rule — /dev/uinput access for virtual gamepads (input group).
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
# WirePlumber policy — hold a DualSense's sound card open (GE-Proton's raw-open self-race) and
# keep it from driving the graph clock. See the file's own comments.
install -Dm0644 scripts/60-punktfunk-dualsense.conf %{buildroot}%{_datadir}/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf
# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit
# action let the host stop/restore the display manager for the stream without a hand-installed
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
@@ -577,6 +581,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_unitdir}/user@.service.d/50-punktfunk-nice.conf
%{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules
%{_datadir}/wireplumber/wireplumber.conf.d/60-punktfunk-dualsense.conf
%dir %{_libexecdir}/punktfunk
%{_libexecdir}/punktfunk/pf-dm-helper
%{_libexecdir}/punktfunk/pf-update
+1
View File
@@ -37,6 +37,7 @@ export {
} from "./runtime.js";
export { type SseRouteOptions, sseRoute } from "./sse.js";
export {
DEFAULT_FS_CHANGE_MIN_INTERVAL,
type LastSync,
makeSyncEngine,
type SyncEngine,
+13 -1
View File
@@ -15,7 +15,10 @@ import { type ConfigService, makeConfigService } from "../config.js";
import { HostClient, type PluginInfo } from "../host-client.js";
import { ProviderClient, type ProviderClientService } from "../reconcile.js";
import { definePluginKit, type PluginKitDef } from "../runtime.js";
import { makeSyncEngine } from "../sync-engine.js";
import {
DEFAULT_FS_CHANGE_MIN_INTERVAL,
makeSyncEngine,
} from "../sync-engine.js";
import { serveUi } from "../ui-server.js";
import type { ProviderEntry } from "../wire.js";
import {
@@ -77,6 +80,13 @@ export interface LibraryPluginDef<S extends Schema.Top> {
readonly pollInterval?: Duration.Duration;
/** Debounce on filesystem events. Default `Duration.seconds(3)`. */
readonly debounce?: Duration.Duration;
/**
* Floor between two filesystem-triggered syncs, on top of the debounce: a debounce collapses a
* burst, this caps the rate under sustained churn (a launcher writing to its dirs while a game
* runs). Changes inside the interval coalesce into one trailing sync. Default
* `Duration.seconds(30)`.
*/
readonly minInterval?: Duration.Duration;
/** Display title (the console's sources row falls back to the scanner label). Defaults to `name`. */
readonly title?: string;
/** Extra CLI verbs beyond the standard `detect` / `scan` / `uninstall` set. */
@@ -106,6 +116,7 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
const store = def.store === null ? undefined : (def.store ?? def.name);
const poll = def.pollInterval ?? Duration.minutes(15);
const debounce = def.debounce ?? Duration.seconds(3);
const minInterval = def.minInterval ?? DEFAULT_FS_CHANGE_MIN_INTERVAL;
/** The config service, built fresh wherever it is needed (it only requires `PluginInfo`). */
const config: Effect.Effect<
@@ -196,6 +207,7 @@ export const defineLibraryPlugin = <S extends Schema.Top>(
pollInterval: poll,
watch: true,
debounce,
minInterval,
watchDirs,
})),
),
+32 -4
View File
@@ -13,7 +13,7 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import {
type Duration,
Duration,
Effect,
Exit,
PubSub,
@@ -58,8 +58,21 @@ export interface SyncSettings {
readonly watch: boolean;
readonly debounce: Duration.Duration;
readonly watchDirs: ReadonlyArray<string>;
/**
* Floor between two `fs-change` syncs. The debounce collapses a BURST of events into one
* sync, but it extends on every event and so cannot bound how often a busy launcher makes
* us re-walk the library Steam writes to its dirs the whole time a game runs, and one
* field log carried 102 `fs-change` syncs in 27 minutes. This caps the RATE: at most one
* fs-change sync per interval, and every change that lands inside it coalesces into exactly
* one trailing sync. Default `30 s` (see `DEFAULT_FS_CHANGE_MIN_INTERVAL`).
*/
readonly minInterval?: Duration.Duration;
}
/** `SyncSettings.minInterval` when a plugin does not set one. */
export const DEFAULT_FS_CHANGE_MIN_INTERVAL: Duration.Duration =
Duration.seconds(30);
export interface SyncEngineOptions<
Report,
Entries extends ReadonlyArray<unknown>,
@@ -265,11 +278,26 @@ export const makeSyncEngine = <
}),
),
);
const watchLoop = watchStream.pipe(
// Debounce collapses a burst; the sliding queue of ONE plus the hold below caps the
// rate (see `SyncSettings.minInterval`). Debounced events land in the queue and
// coalesce there while a sync runs or the hold sleeps, so a launcher that never
// stops writing costs one sync per interval — and the change it made is never
// lost, because the queue is drained by exactly one trailing sync.
const minInterval =
settings.minInterval ?? DEFAULT_FS_CHANGE_MIN_INTERVAL;
const kick = yield* Queue.sliding<void>(1);
const feed = watchStream.pipe(
Stream.debounce(settings.debounce),
Stream.runForEach(() => safeSync("fs-change")),
Stream.runForEach(() => Queue.offer(kick, undefined)),
);
yield* Effect.forkIn(watchLoop, scope);
yield* Effect.forkIn(feed, scope);
const drain = Effect.forever(
Queue.take(kick).pipe(
Effect.andThen(safeSync("fs-change")),
Effect.andThen(Effect.sleep(minInterval)),
),
);
yield* Effect.forkIn(drain, scope);
}
});
+61
View File
@@ -124,3 +124,64 @@ describe("SyncEngine", () => {
expect(statuses[1]?.lastSync?.count).toBe(2);
});
});
// The fs-change RATE cap (`SyncSettings.minInterval`). A debounce collapses a burst but extends
// on every event, so a launcher that keeps writing (Steam, while a game runs) drove one field log
// to 102 fs-change syncs in 27 minutes. With the cap, sustained churn costs one sync per interval
// and still lands one trailing sync for whatever changed inside it.
describe("SyncEngine fs-change min-interval", () => {
test("sustained churn is capped to one fs-change sync per interval, plus one trailing", async () => {
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-sync-cap-"));
try {
const computes = await run(
Effect.gen(function* () {
const computed = yield* Ref.make(0);
const last = yield* Ref.make<LastSync | undefined>(undefined);
const engine = yield* makeSyncEngine<
Report,
ReadonlyArray<string>,
never
>({
compute: () =>
Ref.updateAndGet(computed, (n) => n + 1).pipe(
// Distinct content every time, so nothing is fingerprint-skipped
// and every sync is a real re-walk — the cost being capped.
Effect.map((n) => ({
entries: [`e${n}`],
report: { included: 1 },
})),
),
apply: () => Effect.void,
lastSync: { get: Ref.get(last), set: (l) => Ref.set(last, l) },
settings: Effect.succeed({
pollInterval: Duration.minutes(60),
watch: true,
debounce: Duration.millis(20),
minInterval: Duration.millis(400),
watchDirs: [dir],
}),
});
yield* engine.start; // startup sync (1) + the watch loops
// Churn: a write every 25 ms for 700 ms — each one clears the 20 ms debounce,
// so without the cap this is ~28 syncs.
for (let i = 0; i < 28; i++) {
fs.writeFileSync(path.join(dir, `f${i % 3}.txt`), String(i));
yield* Effect.sleep("25 millis");
}
// Past the last hold, so the trailing sync has happened.
yield* Effect.sleep("600 millis");
return yield* Ref.get(computed);
}),
);
// startup + first fs-change + one per 400 ms hold (+ trailing): well under the ~29 an
// uncapped engine would run, and more than the startup alone (the watch works).
expect(computes).toBeGreaterThanOrEqual(2);
expect(computes).toBeLessThanOrEqual(6);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
+62
View File
@@ -0,0 +1,62 @@
# WirePlumber policy for DualSense sound cards on a punktfunk host (virtual usbip pads AND
# physically plugged pads — the failure modes are identical).
#
# 1. `node.always-process` + no suspend: PipeWire must HOLD the pad's ALSA device open at all
# times. GE-Proton's DS5 haptic router opens the sink's backing `hw:` device RAW whenever it
# is free — and then its own path re-probe EBUSYs against its own handle, invalidates the
# stream, and spins a refresh loop at 100 Hz (haptics dead, speaker dead). On SteamOS, where
# that code was developed, PipeWire always holds the device, so GE lands on its well-tested
# Pulse-routing fallback immediately. This rule reproduces that environment. Field-diagnosed
# 2026-08-18 (Spider-Man Remastered, GE-Proton 11-5).
#
# 2. `priority.driver = 0`: an always-processing node is a permanent graph-driver candidate, and
# a USB pad's audio clock (virtual or real) must never clock somebody else's graph — the day
# this was diagnosed, the virtual pad's clock drove the desktop capture to 50 % delivery, and
# a reporter's pad forwarded over VirtualHere did the same to a whole 15-minute session.
#
# ⚠ It must be ZERO, not 1. PipeWire's `pw_context_recalc_graph` skips a driver only when its
# `priority.driver` is `<= 0` (`priority_driver` is unsigned); at 1 the node is merely LAST in
# the ordering, and last is still elected whenever nothing above it qualifies — which is the
# normal in-session state on a punktfunk host, because claiming our own stream sink as the
# default output leaves the box's real card idle. Zero keeps the pad driving its own linked
# streams (a driver always drives its own group) while removing it from the election for
# everyone else's.
#
# The `alsa_input` rule below is the same key on the pad's capture side. That node is what
# clocked the reporter's desktop audio for a whole session: in the Pro Audio profile it
# carries `priority.driver = 2600` and never suspends, and nothing was linked to it at all —
# its only function on that machine was to clock other people's graphs.
#
# Install: /usr/share/wireplumber/wireplumber.conf.d/ (the user instance reads the shared dirs).
monitor.alsa.rules = [
{
matches = [
# Both product-string spellings a DS5 family pad ships with: newer firmware / Edge say
# "DualSense[ Edge] Wireless Controller", earlier firmware says just "Wireless Controller".
{ node.name = "~alsa_output.usb-Sony_Interactive_Entertainment_DualSense.*" }
{ node.name = "~alsa_output.usb-Sony_Interactive_Entertainment_Wireless_Controller.*" }
]
actions = {
update-props = {
session.suspend-timeout-seconds = 0
node.pause-on-idle = false
node.always-process = true
priority.driver = 0
}
}
}
{
matches = [
# The capture side of the same cards. Only the driver priority: holding the device open is
# about the playback node GE-Proton opens raw, and an always-processing microphone is not
# something this host has any business asking for.
{ node.name = "~alsa_input.usb-Sony_Interactive_Entertainment_DualSense.*" }
{ node.name = "~alsa_input.usb-Sony_Interactive_Entertainment_Wireless_Controller.*" }
]
actions = {
update-props = {
priority.driver = 0
}
}
}
]