Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
454531030d | ||
|
|
a64a22ccfc | ||
|
|
cfbde6aec7 | ||
|
|
0eb8f2d0f1 | ||
|
|
59cc234055 | ||
|
|
242292528c | ||
|
|
0f7d724154 | ||
|
|
475ff70a2a | ||
|
|
d4db2e3331 | ||
|
|
c49b648032 | ||
|
|
c94dafd4be | ||
|
|
abb084aac3 | ||
|
|
3eede724d1 | ||
|
|
4b5a37dae2 | ||
|
|
f24eb02692 | ||
|
|
e053292a80 | ||
|
|
ed075b98dd | ||
|
|
ec44079db4 | ||
|
|
b81aee6821 | ||
|
|
412991f6a3 | ||
|
|
e43d67c721 | ||
|
|
0a6a49a9aa | ||
|
|
e356e354f2 | ||
|
|
f737414949 | ||
|
|
dff2769ba9 | ||
|
|
20f766799c | ||
|
|
62119e553a | ||
|
|
5aebb1ace4 | ||
|
|
d5462d6d3d | ||
|
|
8cff5bda6b |
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// 15–60 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` (12–20 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 10–20 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
|
||||
/// 53–74 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 — 15–60 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
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.4–8.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). |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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…_
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user