From d0b4a6ce764ea4273e77210fd31a5d4b16e3658b Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Wed, 7 Oct 2026 01:18:03 +0300 Subject: [PATCH] fix(audio): run the guest graph at 240 frames, and account for missed cycles The guest's audio stream ran about 3.5% short of realtime: 193 packets a second where 200 were due, with no network loss at all. The client plays at its own 48 kHz, so its buffer drained and underran every few seconds, idle or loaded. The graph was running 128-frame (2.67 ms) cycles. The 256 floor for VMs matched on `cpu.vm.name`, which PipeWire reads from DMI and nesbox does not provide, so it never applied, and neswire's 240-frame request was rounded down to a power of two. A busy guest missed ~20 of those cycles a second (pw-top ERR on neswire), and each missed cycle's audio never reached neswire while the graph clock moved on. - PipeWire is fixed at 240 frames, power-of-two rounding off: one cycle, one 5 ms Opus packet, twice the headroom of before. - neswire reads the graph position each cycle and fills exactly the frames a missed cycle skipped, as silence, so the stream stays at 48 kHz whatever the scheduler does. Co-Authored-By: Claude Opus 5.5 --- apps/neswire/src/sink.rs | 72 +++++++++++++++++++ .../pipewire/pipewire.conf.d/50-nestri.conf | 41 +++++++---- build/kernel/nestri.fragment | 4 +- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/apps/neswire/src/sink.rs b/apps/neswire/src/sink.rs index 0f976bee..bbce068a 100644 --- a/apps/neswire/src/sink.rs +++ b/apps/neswire/src/sink.rs @@ -3,6 +3,19 @@ use crossbeam_channel::Sender; use pipewire::{self as pw, loop_::Signal, spa, stream::*}; use spa::param::audio::{AudioFormat, AudioInfoRaw}; +/// Frames the graph clock moved past without this stream receiving them. +/// +/// `expected` is where the previous cycle's data ended, `now` where this +/// cycle's begins. Zero on the first cycle, when either position is unknown, +/// and when the clock went backwards -- a restart, which is followed rather +/// than filled. +fn missed_frames(expected: Option, now: Option) -> u64 { + match (expected, now) { + (Some(expected), Some(now)) => now.saturating_sub(expected), + _ => 0, + } +} + pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender>) -> Result<()> { pw::init(); tracing::info!("pipewire init ok"); @@ -44,12 +57,20 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender tx: Sender>, accum: Vec, samples_per_frame: usize, + channels: usize, + sample_rate: u32, + /// The graph position the next cycle's data starts at, if the stream + /// has run a cycle yet. See `process` for what it is for. + next_ticks: Option, } let state = State { tx, accum: Vec::with_capacity(samples_per_frame * 2), samples_per_frame, + channels: channels as usize, + sample_rate, + next_ticks: None, }; let _listener = stream @@ -65,6 +86,13 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender } }) .process(move |stream, state| { + // Where the graph clock is this cycle, in frames. Read before the + // buffer: it is this cycle's position whatever the buffer holds. + let ticks = stream + .time() + .ok() + .filter(|t| t.rate().denom == state.sample_rate) + .map(|t| t.ticks()); // Dequeue the buffer from PipeWire if let Some(mut buffer) = stream.dequeue_buffer() { let datas = buffer.datas_mut(); @@ -79,6 +107,31 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender // Reinterpret as f32 samples (we requested F32LE) let samples: &[f32] = bytemuck::cast_slice(audio_bytes); + // **A cycle this stream missed is time the stream + // still has to account for.** When the graph runs a + // cycle and neswire does not get to it in time -- an + // xrun -- that cycle's audio never arrives here, but + // the graph clock moves on. Sending only what arrived + // made the stream run short of realtime by exactly the + // missed cycles, about 3.5% measured, and the client, + // which plays at its own sound card's 48 kHz, drained + // its buffer and underran every few seconds. + // + // So the gap is filled with exactly the frames the clock + // says were missed, as silence: an xrun is a hole of the + // size it really was, and the stream stays at 48 kHz. + // A position that went backwards is a clock restart, + // not a gap, and is simply followed. + let frames = (samples.len() / state.channels) as u64; + let missed = missed_frames(state.next_ticks, ticks) as usize; + if missed > 0 { + state + .accum + .resize(state.accum.len() + missed * state.channels, 0.0); + tracing::debug!(missed, "graph cycle missed; filled with silence"); + } + state.next_ticks = ticks.map(|t| t + frames); + state.accum.extend_from_slice(samples); tracing::trace!( @@ -181,3 +234,22 @@ pub fn run(sample_rate: u32, channels: u32, frame_size: u32, tx: Sender tracing::info!("shutting down"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::missed_frames; + + #[test] + fn a_gap_is_exactly_the_frames_the_clock_moved_past() { + // One 240-frame cycle delivered, the next started 480 later: one missed. + assert_eq!(missed_frames(Some(240), Some(480)), 240); + assert_eq!(missed_frames(Some(240), Some(240)), 0); + } + + #[test] + fn nothing_is_filled_without_two_positions_or_when_the_clock_restarts() { + assert_eq!(missed_frames(None, Some(10_000)), 0); + assert_eq!(missed_frames(Some(240), None), 0); + assert_eq!(missed_frames(Some(10_000), Some(0)), 0); + } +} diff --git a/build/etc/pipewire/pipewire.conf.d/50-nestri.conf b/build/etc/pipewire/pipewire.conf.d/50-nestri.conf index 2dda99c3..d19a504d 100644 --- a/build/etc/pipewire/pipewire.conf.d/50-nestri.conf +++ b/build/etc/pipewire/pipewire.conf.d/50-nestri.conf @@ -1,23 +1,38 @@ -# The graph's clock, floored where the audio sender needs it. +# The graph's clock, fixed at one of neswire's packets per cycle. # -# Inside a virtual machine, pipewire.conf raises the graph's minimum quantum to -# 1024 frames (21.3 ms), because a VM's timers jitter and small quanta then -# underrun on real audio hardware. A box is a VM, so that rule applies, and it -# silently overrides neswire's request for its 5 ms packet size. Every sample -# then waits for a 21 ms cycle before it can be encoded. +# 240 frames is neswire's 5 ms Opus packet, and the graph runs exactly that: +# one cycle, one packet, nothing re-cut and nothing carried over. # -# The floor comes down to 256 frames (5.3 ms), the smallest power of two that -# holds one of neswire's packets. It is not lower than that on purpose: nothing -# here needs less, and the reason for the rule, a VM's timer driving the graph, -# still holds, because the Dummy-Driver is exactly that. +# Fixed rather than floored, because a floor never applied. The previous rule +# set min-quantum to 256 for virtual machines, matched on `cpu.vm.name`, which +# PipeWire fills in from DMI -- and nesbox exposes no DMI, so the rule never +# matched. The graph then took neswire's 240-frame request and rounded it down +# to a power of two, 128 frames: a 2.67 ms cycle that a busy guest's timer and +# scheduler miss about twenty times a second. Each missed cycle is audio that +# never reaches neswire, and the stream ran about 3.5% short of realtime -- +# measured 2026-10-07 as 193 packets a second where 200 were due, with +# `pw-top` counting the xruns on neswire. # -# A drop-in appends to a rules array, and every matching rule applies in order, -# so this one runs after the shipped one and wins. +# `clock.power-of-two-quantum = false` is what lets 240 stand: with it on, +# PipeWire rounds every quantum down to a power of two. min and max are both +# 240 so no client can move the graph off it -- a game asking for 480 runs at +# 240 like everything else. +context.properties = { + clock.power-of-two-quantum = false + default.clock.rate = 48000 + default.clock.quantum = 240 + default.clock.min-quantum = 240 + default.clock.max-quantum = 240 +} + +# pipewire.conf ships a rule raising min-quantum to 1024 on any VM it can +# recognise. nesbox is not one it recognises today; if that changes, the rule +# would put min-quantum above max-quantum. This one runs after it and holds 240. context.properties.rules = [ { matches = [ { cpu.vm.name = !null } ] actions = { update-props = { - default.clock.min-quantum = 256 + default.clock.min-quantum = 240 } } } diff --git a/build/kernel/nestri.fragment b/build/kernel/nestri.fragment index c0fdc05b..0065e818 100644 --- a/build/kernel/nestri.fragment +++ b/build/kernel/nestri.fragment @@ -32,8 +32,8 @@ CONFIG_HIDRAW=y # ── Timers ─────────────────────────────────────────────── # The one that cost a day of silent audio. The guest has no sound hardware, so -# PipeWire drives its whole graph off a timerfd at a 2.67ms cycle, which a -# HZ_PERIODIC/250 kernel cannot express. Symptom: everything reports healthy, +# PipeWire drives its whole graph off a timerfd at a 5 ms cycle (240 frames, +# see etc/pipewire), which a HZ_PERIODIC/250 kernel cannot express. Symptom: everything reports healthy, # neswire sends a steady ~3 kbps of Opus, and every sample is zero. CONFIG_HIGH_RES_TIMERS=y CONFIG_NO_HZ_IDLE=y