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