Files
punktfunk/crates/punktfunk-core/src/audio.rs
T
enricobuehlerandClaude Opus 5 2cfc82e96c fix(audio): budget the audio plane against the link, and close the review's gaps
Findings from the post-implementation review of design/audio-quality-and-latency.md.

**The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were
added separately, each costed as "~1 % of the video budget", and nobody added them together:
256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio
rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a
constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed
the rest.

`plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved
video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio,
since the field report was about quality and redundancy only pays under loss, so `High` alone
outranks `Standard`+redundancy even though they cost the same. It can lower what the operator
asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is
worse than one spending a few percent more.

**The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` —
silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a
click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had
not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus
per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as
the Windows one was on 2026-08-03.

**Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread
now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the
numbers in a line describe the same instant.

Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that
changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have
been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be
reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid
it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does
not do; recorded as an open gap rather than implied to be covered.

Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still
described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:`
parameter, dead since the depth moved into the ring, is gone.

Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced
clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on
Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple
ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails
under qemu — the recorded environmental flake, unrelated to audio, green on the earlier
less-loaded run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:58:17 +02:00

1450 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shared audio layout: the single source of truth for Opus (multi)stream surround across the
//! host, the GameStream compatibility path, and every client decoder.
//!
//! **Canonical wire channel order** is `FL FR FC LFE RL RR SL SR` (the GameStream/Moonlight
//! order, and the PipeWire/PulseAudio default map for 6/8 channels). Every host capturer
//! delivers PCM in this order and every client decodes into it, so the Opus multistream
//! `mapping` is the **identity** (`[0, 1, …, channels-1]`) on both ends — punktfunk owns the
//! encoder and every decoder, so the GFE-style pre-rotation Moonlight needs over SDP
//! (`gamestream::audio::surround_params`) is a GameStream-only concern and never touches the
//! native `punktfunk/1` path.
//!
//! Channel counts the protocol negotiates: `2` (stereo), `6` (5.1) and `8` (7.1). Anything
//! else clamps to stereo ([`normalize_channels`]).
/// Canonical wire channel positions; the index is the channel's slot in the interleaved PCM
/// frame. A count of N uses positions `0..N` (always a prefix of this 8-channel order).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum WirePos {
FrontLeft = 0,
FrontRight = 1,
FrontCenter = 2,
Lfe = 3,
RearLeft = 4,
RearRight = 5,
SideLeft = 6,
SideRight = 7,
}
/// The full 8-channel wire order; the N-channel order is its first N entries.
pub const WIRE_ORDER_8: [WirePos; 8] = {
use WirePos::*;
[
FrontLeft,
FrontRight,
FrontCenter,
Lfe,
RearLeft,
RearRight,
SideLeft,
SideRight,
]
};
/// One Opus (multi)stream layout. `mapping` is the libopus multistream mapping we encode AND
/// decode with — identity, since punktfunk owns both ends. `streams`/`coupled` give the
/// normal-quality coupling (FL,FR)+(FC,LFE) [+(RL,RR) on 7.1] with the remaining channels as
/// mono streams; high quality is one mono stream per channel. Bitrates match Sunshine's
/// per-config values (stereo keeps punktfunk's live-validated 128 kbps).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OpusLayout {
/// Interleaved channel count (2, 6 or 8).
pub channels: u8,
/// Number of Opus streams in the multistream packet.
pub streams: u8,
/// How many of those streams are coupled (stereo) pairs.
pub coupled: u8,
/// libopus multistream channel mapping — identity `[0, 1, …, channels-1]`.
pub mapping: &'static [u8],
/// Target Opus bitrate in bits/sec at [`AudioTier::Standard`] — see
/// [`OpusLayout::bitrate_for`], which is what callers should use. These are the historical
/// values, kept exactly so `Standard` reproduces the pre-tier wire byte-for-byte.
///
/// The GameStream plane encodes hard-CBR from these (its audio FEC needs a constant packet
/// size); the native plane uses constrained VBR, where that constraint does not apply.
pub bitrate: i32,
}
/// Stereo: a plain coupled pair. The 128 kbps live-validated config.
pub const LAYOUT_STEREO: OpusLayout = OpusLayout {
channels: 2,
streams: 1,
coupled: 1,
mapping: &[0, 1],
bitrate: 128_000,
};
/// 5.1 normal quality: (FL,FR)+(FC,LFE) coupled, RL+RR mono.
pub const LAYOUT_51: OpusLayout = OpusLayout {
channels: 6,
streams: 4,
coupled: 2,
mapping: &[0, 1, 2, 3, 4, 5],
bitrate: 256_000,
};
/// 5.1 high quality: one mono stream per channel.
pub const LAYOUT_51_HQ: OpusLayout = OpusLayout {
channels: 6,
streams: 6,
coupled: 0,
mapping: &[0, 1, 2, 3, 4, 5],
bitrate: 1_536_000,
};
/// 7.1 normal quality: (FL,FR)+(FC,LFE)+(RL,RR) coupled, SL+SR mono.
pub const LAYOUT_71: OpusLayout = OpusLayout {
channels: 8,
streams: 5,
coupled: 3,
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
bitrate: 450_000,
};
/// 7.1 high quality: one mono stream per channel.
pub const LAYOUT_71_HQ: OpusLayout = OpusLayout {
channels: 8,
streams: 8,
coupled: 0,
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
bitrate: 2_048_000,
};
/// Encode bitrate tier for the desktop-audio downlink. The layout table's `bitrate` is the
/// [`AudioTier::Standard`] value, so `Standard` reproduces the pre-tier wire byte-for-byte.
///
/// **Why a tier at all.** 5 ms Opus frames are markedly less efficient than 20 ms ones (shorter
/// MDCT, a bigger per-packet overhead share), so the historical 128 kbps stereo buys roughly what
/// ~100 kbps buys at 20 ms — audible on music, and the 2026-08-03 field report said exactly that.
/// Meanwhile the same session carries tens of Mbps of video: at 256 kbps audio is ~1 % of the
/// budget. [`AudioTier::High`] is therefore the DEFAULT; the lower tiers exist for a genuinely
/// constrained link, not as the normal case.
///
/// Purely a host-side encoder knob: every client decodes whatever bitrate arrives (libopus reads
/// it from the packet), so changing tiers needs no protocol negotiation and no client change.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum AudioTier {
/// Constrained links — noticeably lossy on music, still fine for game/voice content.
Low,
/// The historical values (stereo 128 kbps). Kept exactly so the tier machinery is provably
/// non-regressive against every pre-tier build.
Standard,
/// The default: effectively transparent at 5 ms frames, for ~1 % of a normal video budget.
#[default]
High,
}
impl AudioTier {
/// Parse a config/CLI spelling (`low` / `standard` / `high`); `None` for anything else so the
/// caller can warn and fall back rather than silently downgrading someone's audio.
pub fn parse(s: &str) -> Option<AudioTier> {
match s.trim().to_ascii_lowercase().as_str() {
"low" => Some(AudioTier::Low),
"standard" | "normal" | "medium" => Some(AudioTier::Standard),
"high" => Some(AudioTier::High),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
AudioTier::Low => "low",
AudioTier::Standard => "standard",
AudioTier::High => "high",
}
}
}
impl OpusLayout {
/// This layout's target bitrate at `tier`. The uncoupled HIGH-QUALITY layouts
/// ([`LAYOUT_51_HQ`] / [`LAYOUT_71_HQ`]) are already far past transparency, so they are
/// tier-invariant — scaling 1.5 Mbps up would only waste wire.
pub fn bitrate_for(&self, tier: AudioTier) -> i32 {
// One mono stream per channel == the HQ layouts; nothing to gain from a tier there.
if self.coupled == 0 && self.streams == self.channels {
return self.bitrate;
}
match (self.channels, tier) {
(6, AudioTier::Low) => 192_000,
(6, AudioTier::High) => 448_000,
(8, AudioTier::Low) => 320_000,
(8, AudioTier::High) => 768_000,
(_, AudioTier::Low) => 96_000,
(_, AudioTier::High) => 256_000,
(_, AudioTier::Standard) => self.bitrate,
}
}
}
/// What the audio plane will actually cost this session: the tier to encode at, and whether the
/// redundant `0xD2` plane is affordable. Produced by [`plan_audio_budget`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AudioBudget {
pub tier: AudioTier,
pub redundancy: bool,
/// Total wire cost in kbps, redundancy included — what the decision was made against.
pub kbps: u32,
}
/// Share of the session's video bitrate the audio plane may spend. Audio rides QUIC datagrams,
/// OUTSIDE the ABR loop, so whatever it takes is taken off the top and adaptive bitrate can
/// neither see nor reclaim it — which is exactly why it needs a budget of its own.
const AUDIO_BUDGET_PCT: u32 = 5;
/// …but never squeeze audio below the Low tier. A stream with unintelligible audio is worse than
/// one that spends a few percent more, and the floor is what stops a very low video bitrate from
/// silently producing a useless audio plane.
const AUDIO_BUDGET_FLOOR_KBPS: u32 = 96;
/// Choose the encode tier and whether to send redundancy, given the session's resolved VIDEO
/// bitrate.
///
/// **Why this exists.** Tier `High` and the redundant plane were introduced separately, each
/// justified as "about 1 % of the video budget" — but they multiply: 256 kbps stereo sent twice is
/// 512 kbps, which is ~2.5 % of a 20 Mbps session and ~10 % of a 5 Mbps one. Nothing added the two
/// together, and nothing capped the total, so on a constrained link the audio plane quietly took a
/// tenth of the bandwidth that ABR was carefully managing the rest of.
///
/// The ladder is ordered by preference, not by cost: transparent audio beats redundant audio (the
/// complaint this whole program came from was quality, and the redundancy only pays off under
/// loss), so `High` alone outranks `Standard` + redundancy even though they cost the same.
/// `requested` lets an operator ask for a specific tier; the budget can lower it but never raises
/// it above what was asked.
pub fn plan_audio_budget(
video_kbps: u32,
channels: u8,
requested: AudioTier,
client_wants_redundancy: bool,
) -> AudioBudget {
let budget = (video_kbps.saturating_mul(AUDIO_BUDGET_PCT) / 100).max(AUDIO_BUDGET_FLOOR_KBPS);
let layout = layout_for(channels, false);
let cost = |tier: AudioTier, red: bool| -> u32 {
let one = (layout.bitrate_for(tier) / 1000).max(0) as u32;
if red {
one.saturating_mul(2)
} else {
one
}
};
// Preference order, best first. An operator asking for `Low` must not be handed `High`, so
// candidates above the request are filtered out.
let rank = |t: AudioTier| match t {
AudioTier::Low => 0,
AudioTier::Standard => 1,
AudioTier::High => 2,
};
let ladder = [
(AudioTier::High, true),
(AudioTier::High, false),
(AudioTier::Standard, true),
(AudioTier::Standard, false),
(AudioTier::Low, false),
];
for (tier, red) in ladder {
if rank(tier) > rank(requested) || (red && !client_wants_redundancy) {
continue;
}
let kbps = cost(tier, red);
if kbps <= budget {
return AudioBudget {
tier,
redundancy: red,
kbps,
};
}
}
// Nothing fit — take the cheapest thing that still works rather than muting audio.
AudioBudget {
tier: AudioTier::Low,
redundancy: false,
kbps: cost(AudioTier::Low, false),
}
}
/// Pick the layout for a negotiated channel count. Unknown counts fall back to stereo (clients
/// only ever request 2/6/8). `high_quality` selects the uncoupled high-bitrate config.
pub fn layout_for(channels: u8, high_quality: bool) -> &'static OpusLayout {
match (channels, high_quality) {
(6, false) => &LAYOUT_51,
(6, true) => &LAYOUT_51_HQ,
(8, false) => &LAYOUT_71,
(8, true) => &LAYOUT_71_HQ,
_ => &LAYOUT_STEREO,
}
}
/// Clamp an arbitrary (wire / requested) channel count to one the protocol negotiates. `0`,
/// absent, or any unsupported value becomes stereo.
pub fn normalize_channels(requested: u8) -> u8 {
match requested {
6 => 6,
8 => 8,
_ => 2,
}
}
/// Loss detector for the client audio plane, shared by every platform decoder.
///
/// The `0xC9` audio datagrams carry a per-packet sequence the host advances by 1 (wrapping), but
/// ride the lossy datagram plane with no FEC — a lost 5 ms Opus packet used to play out as a hard
/// gap (a click/pop; the jitter rings just emit silence). Feeding this tracker each received
/// packet's sequence tells the decoder how many packets went missing *immediately before it*, so
/// it can synthesize that many frames of libopus packet-loss concealment (`decode` with empty
/// input) before decoding the real one — turning clicks into an inaudible interpolation.
///
/// Reorders and duplicates conceal nothing (the plane has no reorder buffer; playing a late
/// packet where it lands is the existing behaviour), and a gap is capped at
/// [`MAX_CONCEAL_PACKETS`] (50 ms at the protocol's 5 ms frames) — libopus PLC fades to silence
/// after a few frames anyway, so past the cap the ring's underrun/re-prime path takes over as
/// before.
#[derive(Debug, Default)]
pub struct AudioGapTracker {
/// Sequence of the newest packet seen (`None` until the first).
last_seq: Option<u32>,
}
/// Most packets a single gap will ask concealment for (50 ms at the protocol's 5 ms frames).
/// Crate-internal: callers only ever see `missing_before`'s already-capped count (and cbindgen
/// must not export it — it's not part of the C ABI).
const MAX_CONCEAL_PACKETS: u32 = 10;
impl AudioGapTracker {
pub fn new() -> Self {
Self::default()
}
/// Feed the next received packet's sequence; returns how many packets are missing immediately
/// before it (`0` for in-order, the first packet, duplicates, and reorders), capped at
/// [`MAX_CONCEAL_PACKETS`]. Wrapping-safe: a sequence in the backward half of the u32 space is
/// a reorder, not a 2³¹-packet gap.
pub fn missing_before(&mut self, seq: u32) -> u32 {
let Some(last) = self.last_seq else {
self.last_seq = Some(seq);
return 0;
};
let delta = seq.wrapping_sub(last);
if delta == 0 || delta > u32::MAX / 2 {
return 0; // duplicate, or a reorder older than the newest — nothing to conceal
}
self.last_seq = Some(seq);
(delta - 1).min(MAX_CONCEAL_PACKETS)
}
}
/// Rebuilds the audio stream from the redundant `0xD2` plane, so a single lost datagram is
/// RECOVERED rather than concealed.
///
/// Deliberately lives in core, on the demux side, rather than in the four client decoders. The
/// recovered frame is re-inserted into the same queue in order, so every embedder — Linux,
/// Windows, Android, Apple, and any C-ABI consumer — gets a complete stream with no change at all,
/// and their [`AudioGapTracker`] simply stops seeing the gap.
///
/// **Only the immediately-preceding frame can be recovered**, because that is all the wire carries
/// (see [`crate::quic::encode_audio_red_datagram`]). A longer burst still falls through to
/// packet-loss concealment — but it falls through one frame shorter, which is strictly better.
#[derive(Debug, Default)]
pub struct AudioRedRecovery {
/// Sequence of the newest packet handed downstream.
last_seq: Option<u32>,
}
impl AudioRedRecovery {
pub fn new() -> Self {
Self::default()
}
/// Feed the arriving datagram's sequence and whether it carried a redundant copy. Returns
/// `true` when that copy should be emitted (as `seq - 1`) BEFORE the packet itself.
///
/// Wrapping-safe, and conservative in both directions: a reorder or duplicate recovers
/// nothing, and neither does the first packet of a session (nothing is known to be missing).
pub fn recover_before(&mut self, seq: u32, has_prev: bool) -> bool {
let recover = match self.last_seq {
// Nothing emitted yet: no evidence anything was lost, so inserting the predecessor
// would prepend audio the client never missed.
None => false,
Some(last) => {
let delta = seq.wrapping_sub(last);
// `delta == 1` is in-order; `delta >= 2` (forward half of the space only) means
// at least the predecessor is missing.
has_prev && (2..u32::MAX / 2).contains(&delta)
}
};
self.last_seq = Some(match self.last_seq {
// A reorder must not drag the anchor backwards.
Some(last) if seq.wrapping_sub(last) > u32::MAX / 2 => last,
_ => seq,
});
recover
}
}
// ---- the shared playback de-jitter policy -------------------------------------------------
/// The protocol's audio frame, in milliseconds — every host datagram carries exactly one
/// ([`crate::quic::encode_audio_datagram`]), so it is also the smallest useful shed unit.
pub const FRAME_MS: u32 = 5;
/// Tuning for [`JitterPolicy`], in MILLISECONDS.
///
/// Denominating the depth in time rather than in device quanta is the point. Every client used to
/// compute its target as `3 × quantum`, which is a sane 15 ms at a 5 ms quantum and a silent 64 ms
/// at a 20 ms one — the same source line meaning two very different latencies depending on what
/// else happened to be using the audio graph that day.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct JitterTuning {
/// Depth to prime to before the first sample plays, and the depth drift correction pulls
/// back toward. The adaptive floor may raise the live target above this; it never goes below.
pub base_target_ms: u32,
/// Ceiling for the adaptively-grown target (see [`JitterPolicy::note_read`]).
pub max_target_ms: u32,
/// Slack above the live target before drop-oldest trimming starts. Absorbs an arrival burst
/// without overflowing.
///
/// Drift correction sheds at the MIDDLE of this band (see [`JitterTuning::shed_excess_ms`]),
/// so the smooth correction always gets its chance before the hard trim. Setting this too
/// small is a real failure mode, not just a tuning choice: if the trim point sits below the
/// shed point, the ring is trimmed back before the depth average can ever reach the shed
/// threshold, drift correction becomes dead code, and every correction is once again the
/// audible drop it was supposed to replace.
pub headroom_ms: u32,
/// Absolute bound on buffered audio — the only hard guarantee on added latency.
pub hard_cap_ms: u32,
/// Consecutive short reads before the ring goes back to priming. `1` reproduces the old
/// `if ring.is_empty() { primed = false }`, where a single transient drain manufactured a
/// whole target's worth of fresh silence; every platform now uses hysteresis.
pub deprime_after: u32,
}
impl JitterTuning {
/// PipeWire adaptively rate-matches the stream to the graph clock and absorbs a shallow ring,
/// so Linux can run tight.
pub const PIPEWIRE: JitterTuning = JitterTuning {
base_target_ms: 15,
max_target_ms: 60,
headroom_ms: 25,
hard_cap_ms: 80,
deprime_after: 4,
};
/// WASAPI shared-mode event-driven render: the engine buffers for us, but nothing rate-matches.
pub const WASAPI: JitterTuning = JitterTuning {
base_target_ms: 20,
max_target_ms: 70,
headroom_ms: 30,
hard_cap_ms: 90,
deprime_after: 4,
};
/// CoreAudio via AVAudioEngine — comparable to WASAPI; the iOS IO buffer is already 5 ms.
pub const COREAUDIO: JitterTuning = JitterTuning {
base_target_ms: 20,
max_target_ms: 70,
headroom_ms: 30,
hard_cap_ms: 90,
deprime_after: 4,
};
/// AAudio hands us a raw realtime callback and makes us own the buffer, and Wi-Fi power-save
/// bunching lands as underruns = crackle. Android therefore starts DEEPER — but at 25 ms, not
/// the old fixed 40: the adaptive floor raises it only on the devices that actually underrun,
/// instead of every device pre-paying for the worst one.
pub const AAUDIO: JitterTuning = JitterTuning {
base_target_ms: 25,
max_target_ms: 90,
headroom_ms: 40,
hard_cap_ms: 120,
deprime_after: 5,
};
/// How far above the live target the depth average must sit before drift correction sheds:
/// the middle of the headroom band, but never less than two protocol frames (so it cannot be
/// hair-triggered by one quantum of normal swing). Deriving it from `headroom_ms` rather than
/// fixing it absolutely is what keeps the smooth shed strictly BELOW the hard trim on every
/// preset — see the field on `headroom_ms`.
pub const fn shed_excess_ms(&self) -> u32 {
let half = self.headroom_ms / 2;
if half > 2 * FRAME_MS {
half
} else {
2 * FRAME_MS
}
}
}
/// What one callback should do, from [`JitterPolicy::step`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct JitterStep {
/// Interleaved samples to discard from the FRONT of the ring before reading.
pub drop_front: usize,
/// When non-zero, `drop_front` is a smooth drift correction and this many interleaved samples
/// of linear crossfade should be applied across the seam ([`crossfade_drop`] does it for a
/// `VecDeque<f32>` ring). Zero means discard hard — either nothing is being dropped, or the
/// ring blew the hard cap and is already a discontinuity.
pub crossfade: usize,
/// Emit silence this callback: still priming, or re-priming after a sustained drain.
pub silence: bool,
}
/// EWMA time constant for the depth average, in ms. Long enough that a burst doesn't trigger a
/// shed, short enough to track real drift.
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;
/// Linear crossfade applied across a drift shed's seam.
const SHED_CROSSFADE_MS: u32 = 2;
/// Underruns inside [`GROW_WINDOW_MS`] before the live target grows.
const GROW_UNDERRUNS: u32 = 3;
const GROW_WINDOW_MS: u32 = 5_000;
const GROW_STEP_MS: u32 = 10;
/// Quiet time (no underrun) before a grown target relaxes one step back toward the base.
const SHRINK_QUIET_MS: u32 = 30_000;
/// The playback de-jitter state machine shared by every client's audio ring.
///
/// **The defect it exists to fix.** Every client's ring primed *up* to a target and clamped at a
/// ceiling, and none of them walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a
/// host stall, or plain host-DAC-vs-client-DAC clock skew of a few dozen ppm — therefore added
/// latency permanently, until an underrun happened to re-prime. Android, with no shed at all,
/// converged on its hard cap and stayed there; Apple shed 40 ms at once and its own comment called
/// that "one audible blip". Here, a depth EWMA that sits [`SHED_EXCESS_MS`] above target for
/// [`SHED_SUSTAIN_MS`] of consumed audio sheds ONE 5 ms frame with a crossfade, so latency returns
/// to target instead of ratcheting.
///
/// **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.
#[derive(Clone, Debug)]
pub struct JitterPolicy {
tuning: JitterTuning,
/// Interleaved samples per millisecond at the negotiated layout (48 × channels).
per_ms: usize,
/// The live target, in interleaved samples — `base_target_ms` grown by underrun pressure.
target: usize,
primed: bool,
/// Consecutive short reads (de-prime hysteresis).
empties: u32,
/// EWMA of ring depth, interleaved samples.
depth_avg: f32,
/// Consumed samples for which the EWMA has stayed above the shed threshold.
over_run: usize,
/// Underruns seen in the current growth window, and the window's consumed-sample count.
underruns: u32,
window_run: usize,
/// Consumed samples since the last underrun (drives the relax-back-down step).
quiet_run: usize,
/// `want` from the most recent [`step`](Self::step), so [`note_read`](Self::note_read) can
/// advance the sample-denominated timers without the caller repeating it.
last_want: usize,
}
impl JitterPolicy {
/// `channels` is the negotiated interleaved channel count (2/6/8).
pub fn new(tuning: JitterTuning, channels: u8) -> JitterPolicy {
let per_ms = (SAMPLE_RATE_HZ / 1000) as usize * channels.max(1) as usize;
JitterPolicy {
tuning,
per_ms,
target: tuning.base_target_ms as usize * per_ms,
primed: false,
empties: 0,
depth_avg: 0.0,
over_run: 0,
underruns: 0,
window_run: 0,
quiet_run: 0,
last_want: 0,
}
}
/// The live target depth in ms (grows under underrun pressure; never below the base).
pub fn target_ms(&self) -> u32 {
(self.target / self.per_ms) as u32
}
/// Convert a ring depth in interleaved samples to milliseconds — for stats/HUD reporting.
pub fn depth_ms(&self, depth: usize) -> u32 {
(depth / self.per_ms) as u32
}
/// Smoothed ring depth in ms — what drift correction actually reacts to, and the honest
/// number to publish as "audio buffer" (the instantaneous depth swings by a whole quantum).
pub fn avg_depth_ms(&self) -> u32 {
(self.depth_avg.max(0.0) as usize / self.per_ms) as u32
}
pub fn is_primed(&self) -> bool {
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.
fn effective_target(&self, want: usize) -> usize {
self.target.max(want + FRAME_MS as usize * self.per_ms)
}
/// Decide this callback: what to trim, and whether to play. Call BEFORE reading, with the
/// ring's current `depth` and the device's `want`, both in interleaved samples.
pub fn step(&mut self, depth: usize, want: usize) -> JitterStep {
self.last_want = want;
let target = self.effective_target(want);
// Track depth with a callback-rate-independent EWMA: weighting by `want` keeps the time
// constant at EWMA_TAU_MS whether the device pulls 5 ms or 20 ms at a time.
let alpha = (want as f32 / (EWMA_TAU_MS as usize * self.per_ms) as f32).clamp(0.0, 1.0);
self.depth_avg += (depth as f32 - self.depth_avg) * alpha;
// The hard cap must always leave room to serve this callback, or a large-quantum device
// would trim itself into a permanent underrun.
let cap = (target + self.tuning.headroom_ms as usize * self.per_ms)
.min(self.tuning.hard_cap_ms as usize * self.per_ms)
.max(target + want);
let mut out = JitterStep::default();
if depth > cap {
// Blew the ceiling: a burst arrived, or we were wedged. Already a discontinuity —
// discard hard, and reset the drift timer so the trim isn't double-counted as drift.
out.drop_front = depth - cap;
self.over_run = 0;
} else if self.depth_avg
> (target + self.tuning.shed_excess_ms() as usize * self.per_ms) as f32
{
self.over_run += want;
if self.over_run >= SHED_SUSTAIN_MS as usize * self.per_ms {
out.drop_front = (FRAME_MS as usize * self.per_ms).min(depth);
out.crossfade = (SHED_CROSSFADE_MS as usize * self.per_ms)
.min(depth.saturating_sub(out.drop_front));
self.over_run = 0;
}
} else {
self.over_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);
if !self.primed && depth.saturating_sub(out.drop_front) >= target {
self.primed = true;
self.empties = 0;
}
out.silence = !self.primed;
out
}
/// Report the outcome of the read `step` authorised. `ran_short` = the ring could not fill the
/// callback (a genuine underrun), which drives both the de-prime hysteresis and the adaptive
/// target floor.
///
/// A callback that `step` told to emit silence is NOT an underrun — the ring is deliberately
/// re-priming — so calls made while un-primed are ignored and callers need not special-case it.
pub fn note_read(&mut self, ran_short: bool) {
if !self.primed {
return;
}
let want = self.last_want.max(1);
self.window_run += want;
if self.window_run >= GROW_WINDOW_MS as usize * self.per_ms {
self.window_run = 0;
self.underruns = 0;
}
if ran_short {
self.quiet_run = 0;
self.empties += 1;
if self.empties >= self.tuning.deprime_after {
self.primed = false;
self.empties = 0;
}
self.underruns += 1;
if self.underruns >= GROW_UNDERRUNS {
// This device genuinely needs more slack than the base target. Grow ONCE per
// window, capped — the alternative (every device pre-paying the worst device's
// depth) is what the fixed 40 ms Android floor was.
self.underruns = 0;
self.window_run = 0;
let grown = self.target + GROW_STEP_MS as usize * self.per_ms;
self.target = grown.min(self.tuning.max_target_ms as usize * self.per_ms);
}
} else {
self.empties = 0;
self.quiet_run += want;
if self.quiet_run >= SHRINK_QUIET_MS as usize * self.per_ms {
// Long quiet spell: give a grown target one step back, so a single bad minute
// doesn't cost latency for the rest of the session.
self.quiet_run = 0;
let base = self.tuning.base_target_ms as usize * self.per_ms;
self.target = self
.target
.saturating_sub(GROW_STEP_MS as usize * self.per_ms)
.max(base);
}
}
}
}
/// Sample rate of every audio plane in the protocol.
pub const SAMPLE_RATE_HZ: u32 = 48_000;
/// Discard `drop` interleaved samples from the front of `ring`, linearly crossfading the seam over
/// `fade` samples so a drift correction is inaudible rather than a click.
///
/// The dropped region's tail fades out while the surviving head fades in, so the waveform is
/// continuous across the splice. `fade == 0` discards hard (what a hard-cap trim wants — that
/// backlog is already a discontinuity). Shared by the three `VecDeque<f32>` rings; the Apple ring
/// is index-based and mirrors this in Swift.
pub fn crossfade_drop(ring: &mut std::collections::VecDeque<f32>, drop: usize, fade: usize) {
if drop == 0 || ring.len() < drop {
return;
}
let fade = fade.min(drop).min(ring.len() - drop);
if fade == 0 {
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.
let mut faded = Vec::with_capacity(fade);
for i in 0..fade {
let old = ring[drop - fade + i];
let new = ring[drop + i];
let t = (i + 1) as f32 / (fade + 1) as f32;
faded.push(old * (1.0 - t) + new * t);
}
ring.drain(..drop);
for (i, v) in faded.into_iter().enumerate() {
ring[i] = v;
}
}
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
///
/// NB 7.1 == `0x63F` (FL FR FC LFE **BL BR SL SR**), NOT `0xFF` — `0xFF` selects the
/// front-of-center pair FLC/FRC, the wrong speakers. WASAPI delivers channels in ascending
/// mask-bit order, which equals the wire order, so the decoded PCM needs no permutation.
pub const fn wasapi_channel_mask(channels: u8) -> u32 {
const FL: u32 = 0x1;
const FR: u32 = 0x2;
const FC: u32 = 0x4;
const LFE: u32 = 0x8;
const BL: u32 = 0x10; // back left (wire RL)
const BR: u32 = 0x20; // back right (wire RR)
const SL: u32 = 0x200; // side left
const SR: u32 = 0x400; // side right
match channels {
6 => FL | FR | FC | LFE | BL | BR, // 0x3F
8 => FL | FR | FC | LFE | BL | BR | SL | SR, // 0x63F
_ => FL | FR, // 0x3 (stereo)
}
}
/// PipeWire / SPA `enum spa_audio_channel` positions in wire order — identical to the host
/// capture side (`punktfunk-host` `audio::linux::spa_positions`): FL=3 FR=4 FC=5 LFE=6 SL=7
/// SR=8 RL=12 RR=13. Identity routing: the client sets these on its playback node so PipeWire
/// maps each wire slot to the matching speaker (and downmixes when the sink has fewer).
pub fn spa_positions(channels: u8) -> &'static [u32] {
const STEREO: [u32; 2] = [3, 4]; // FL FR
const C51: [u32; 6] = [3, 4, 5, 6, 12, 13]; // FL FR FC LFE RL RR
const C71: [u32; 8] = [3, 4, 5, 6, 12, 13, 7, 8]; // FL FR FC LFE RL RR SL SR
match channels {
6 => &C51,
8 => &C71,
_ => &STEREO,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_table_is_consistent() {
for l in [
&LAYOUT_STEREO,
&LAYOUT_51,
&LAYOUT_51_HQ,
&LAYOUT_71,
&LAYOUT_71_HQ,
] {
// Mapping is identity and exactly `channels` entries long.
assert_eq!(l.mapping.len(), l.channels as usize);
for (i, &m) in l.mapping.iter().enumerate() {
assert_eq!(m as usize, i, "mapping must be identity for {l:?}");
}
// libopus invariant: total channels == coupled*2 + (streams - coupled).
assert_eq!(
l.coupled * 2 + (l.streams - l.coupled),
l.channels,
"stream/coupled accounting for {l:?}"
);
assert!(l.coupled <= l.streams);
assert!(l.bitrate > 0);
}
}
#[test]
fn layout_for_picks_expected() {
assert_eq!(layout_for(2, false), &LAYOUT_STEREO);
assert_eq!(layout_for(6, false), &LAYOUT_51);
assert_eq!(layout_for(6, true), &LAYOUT_51_HQ);
assert_eq!(layout_for(8, false), &LAYOUT_71);
assert_eq!(layout_for(8, true), &LAYOUT_71_HQ);
// Unknown / 0 → stereo.
assert_eq!(layout_for(0, false), &LAYOUT_STEREO);
assert_eq!(layout_for(3, false), &LAYOUT_STEREO);
assert_eq!(layout_for(7, true), &LAYOUT_STEREO);
}
#[test]
fn normalize_clamps_to_negotiable() {
assert_eq!(normalize_channels(2), 2);
assert_eq!(normalize_channels(6), 6);
assert_eq!(normalize_channels(8), 8);
for bad in [0u8, 1, 3, 4, 5, 7, 9, 255] {
assert_eq!(normalize_channels(bad), 2, "{bad} must clamp to stereo");
}
}
#[test]
fn gap_tracker_counts_only_forward_gaps() {
let mut t = AudioGapTracker::new();
assert_eq!(t.missing_before(100), 0, "first packet");
assert_eq!(t.missing_before(101), 0, "in order");
assert_eq!(t.missing_before(104), 2, "102+103 lost");
assert_eq!(t.missing_before(104), 0, "duplicate");
assert_eq!(t.missing_before(103), 0, "late reorder conceals nothing");
assert_eq!(t.missing_before(105), 0, "reorder didn't move the anchor");
// A huge gap is capped; the stream continues from the new anchor.
assert_eq!(t.missing_before(105 + 1000), MAX_CONCEAL_PACKETS);
assert_eq!(t.missing_before(105 + 1001), 0);
}
#[test]
fn gap_tracker_survives_seq_wraparound() {
let mut t = AudioGapTracker::new();
assert_eq!(t.missing_before(u32::MAX - 1), 0);
assert_eq!(t.missing_before(u32::MAX), 0, "in order at the edge");
assert_eq!(t.missing_before(1), 1, "seq 0 lost across the wrap");
assert_eq!(t.missing_before(0), 0, "pre-wrap reorder, not a 2^31 gap");
}
// ---- redundant-plane recovery ---------------------------------------------------------
#[test]
fn red_recovery_rebuilds_exactly_the_single_missing_frame() {
let mut r = AudioRedRecovery::new();
// First packet: nothing is known to be missing, so nothing is prepended.
assert!(!r.recover_before(10, true));
// In order.
assert!(!r.recover_before(11, true));
// 12 lost: 13 carries it.
assert!(r.recover_before(13, true));
// Back in order from the new anchor.
assert!(!r.recover_before(14, true));
}
#[test]
fn red_recovery_is_conservative() {
let mut r = AudioRedRecovery::new();
r.recover_before(10, true);
// A datagram with no redundant copy recovers nothing, however big the gap.
assert!(!r.recover_before(20, false));
// Duplicates and reorders recover nothing, and must not move the anchor backwards.
let mut r = AudioRedRecovery::new();
r.recover_before(10, true);
r.recover_before(11, true);
assert!(!r.recover_before(11, true), "duplicate");
assert!(!r.recover_before(9, true), "late reorder");
assert!(
!r.recover_before(12, true),
"the reorder must not have moved the anchor"
);
}
/// A longer burst still recovers its last frame — the gap the client has to conceal gets one
/// frame shorter, which is strictly better than concealing all of it.
#[test]
fn red_recovery_shortens_a_longer_burst() {
let mut r = AudioRedRecovery::new();
r.recover_before(100, true);
assert!(
r.recover_before(105, true),
"104 is recoverable even though 101-103 are not"
);
}
#[test]
fn red_recovery_survives_seq_wraparound() {
let mut r = AudioRedRecovery::new();
assert!(!r.recover_before(u32::MAX - 1, true));
assert!(
!r.recover_before(u32::MAX, true),
"in order across the edge"
);
assert!(r.recover_before(1, true), "seq 0 lost across the wrap");
assert!(!r.recover_before(2, true));
}
/// The two halves must agree: whatever `AudioRedRecovery` rebuilds, `AudioGapTracker` must
/// then see as no gap at all — that is the whole point of doing recovery on the demux side.
#[test]
fn recovery_and_the_gap_tracker_agree() {
let mut rec = AudioRedRecovery::new();
let mut gaps = AudioGapTracker::new();
let mut concealed = 0;
// Deliver 0..20 with 7 and 13 lost; each survivor carries its predecessor.
let mut emitted: Vec<u32> = Vec::new();
for seq in (0..20u32).filter(|s| *s != 7 && *s != 13) {
if rec.recover_before(seq, true) {
emitted.push(seq - 1);
}
emitted.push(seq);
}
for seq in &emitted {
concealed += gaps.missing_before(*seq);
}
assert_eq!(
concealed, 0,
"recovered stream must need no concealment: {emitted:?}"
);
assert_eq!(emitted.len(), 20, "every frame accounted for");
assert!(
emitted.windows(2).all(|w| w[1] == w[0] + 1),
"and in order: {emitted:?}"
);
}
// ---- bitrate tiers -------------------------------------------------------------------
/// `Standard` must reproduce the historical table EXACTLY — that is what makes the tier
/// machinery provably non-regressive against every pre-tier build.
#[test]
fn standard_tier_is_the_legacy_table() {
for l in [
&LAYOUT_STEREO,
&LAYOUT_51,
&LAYOUT_51_HQ,
&LAYOUT_71,
&LAYOUT_71_HQ,
] {
assert_eq!(l.bitrate_for(AudioTier::Standard), l.bitrate, "{l:?}");
}
}
#[test]
fn tiers_are_monotonic_and_hq_layouts_are_invariant() {
for l in [&LAYOUT_STEREO, &LAYOUT_51, &LAYOUT_71] {
let (lo, std, hi) = (
l.bitrate_for(AudioTier::Low),
l.bitrate_for(AudioTier::Standard),
l.bitrate_for(AudioTier::High),
);
assert!(lo < std && std < hi, "{l:?}: {lo} < {std} < {hi}");
}
// The uncoupled HQ layouts are already past transparency — no tier may move them.
for l in [&LAYOUT_51_HQ, &LAYOUT_71_HQ] {
for t in [AudioTier::Low, AudioTier::Standard, AudioTier::High] {
assert_eq!(l.bitrate_for(t), l.bitrate, "{l:?} at {t:?}");
}
}
}
#[test]
fn tier_default_is_high_and_parses() {
assert_eq!(AudioTier::default(), AudioTier::High);
for t in [AudioTier::Low, AudioTier::Standard, AudioTier::High] {
assert_eq!(AudioTier::parse(t.as_str()), Some(t));
}
assert_eq!(AudioTier::parse(" HIGH "), Some(AudioTier::High));
assert_eq!(AudioTier::parse("normal"), Some(AudioTier::Standard));
// Unknown spellings must be rejected, not silently downgraded.
assert_eq!(AudioTier::parse("transparent"), None);
assert_eq!(AudioTier::parse(""), None);
}
// ---- the audio bandwidth budget --------------------------------------------------------
/// THE regression this guards: `High` (256 kbps stereo) and the redundant plane (x2) were
/// each justified as "~1 % of the video budget" and nobody added them together. 512 kbps is
/// ~10 % of a 5 Mbps session — and audio is outside the ABR loop, so ABR cannot reclaim it.
#[test]
fn budget_steps_down_as_the_link_narrows() {
let plan = |kbps| plan_audio_budget(kbps, 2, AudioTier::High, true);
// Roomy link: everything on.
let b = plan(20_000);
assert_eq!((b.tier, b.redundancy), (AudioTier::High, true));
assert_eq!(b.kbps, 512);
// Halve it and redundancy is the first thing to go — quality is what the field report
// was about, and redundancy only pays under loss.
assert_eq!(plan(10_000).tier, AudioTier::High);
assert!(!plan(10_000).redundancy);
// Tighter still: down to Standard.
assert_eq!(plan(5_000).tier, AudioTier::Standard);
assert!(!plan(5_000).redundancy);
// A genuinely narrow link lands on Low, and never below it.
assert_eq!(plan(1_000).tier, AudioTier::Low);
assert_eq!(plan(1).tier, AudioTier::Low);
assert_eq!(
plan(0).kbps,
96,
"audio must survive an absurd video bitrate"
);
}
/// The budget must never spend more than its share, at any bitrate or channel count.
#[test]
fn budget_never_exceeds_its_share() {
for kbps in [0u32, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 100_000] {
for ch in [2u8, 6, 8] {
let b = plan_audio_budget(kbps, ch, AudioTier::High, true);
let allowed =
(kbps.saturating_mul(AUDIO_BUDGET_PCT) / 100).max(AUDIO_BUDGET_FLOOR_KBPS);
let floor = plan_audio_budget(0, ch, AudioTier::Low, false).kbps;
assert!(
b.kbps <= allowed || b.kbps == floor,
"{ch}ch at {kbps} kbps: spent {} of {allowed}",
b.kbps
);
}
}
}
/// Surround costs more per tier, so the same link must step it down sooner than stereo —
/// the budget is about total wire cost, not about the tier name.
#[test]
fn budget_accounts_for_the_channel_count() {
let stereo = plan_audio_budget(10_000, 2, AudioTier::High, true);
let surround = plan_audio_budget(10_000, 8, AudioTier::High, true);
assert_eq!(stereo.tier, AudioTier::High);
assert!(surround.kbps <= stereo.kbps.max(surround.kbps), "sanity");
// 7.1 at High is 768 kbps — far past a 500 kbps allowance, so it must have stepped down.
assert!(
surround.kbps < 768,
"7.1 High must not fit a 10 Mbps budget"
);
}
/// The budget may LOWER what was asked for, never raise it: an operator who set `low` gets
/// `low` on a 100 Mbps link, and a client that never asked for redundancy never gets it.
#[test]
fn budget_respects_the_request() {
let b = plan_audio_budget(100_000, 2, AudioTier::Low, true);
assert_eq!(b.tier, AudioTier::Low);
let b = plan_audio_budget(100_000, 2, AudioTier::Standard, true);
assert_eq!(b.tier, AudioTier::Standard);
assert!(b.redundancy, "Standard + redundancy fits a huge link");
let b = plan_audio_budget(100_000, 2, AudioTier::High, false);
assert_eq!(b.tier, AudioTier::High);
assert!(
!b.redundancy,
"a client that did not ask must never be sent 0xD2"
);
}
// ---- the de-jitter policy ------------------------------------------------------------
/// Interleaved samples per ms at `channels`.
fn per_ms(channels: u8) -> usize {
(SAMPLE_RATE_HZ / 1000) as usize * channels as usize
}
/// One simulated run's outcome.
#[derive(Debug, Default)]
struct Sim {
final_ms: u32,
peak_ms: u32,
/// Smooth drift corrections (crossfaded, one frame each) — the good kind.
soft_sheds: u32,
/// Hard-cap trims — the backstop. Any of these in a plain-drift run means the smooth
/// correction is not doing its job.
hard_trims: u32,
underruns: u32,
}
/// Drive a policy through `ms` of simulated audio at a `quantum_ms` device, where the producer
/// delivers `drift_ppm` more (or less) than the consumer takes — i.e. host-vs-client clock skew.
fn simulate(
tuning: JitterTuning,
channels: u8,
ms: u32,
quantum_ms: u32,
drift_ppm: i64,
start_ms: u32,
) -> Sim {
let pm = per_ms(channels);
let want = quantum_ms as usize * pm;
let mut p = JitterPolicy::new(tuning, channels);
let mut depth = start_ms as usize * pm;
let mut out = Sim::default();
// Fractional producer accumulator, so a sub-sample-per-callback drift still accumulates.
let mut carry: i64 = 0;
for _ in 0..(ms / quantum_ms) {
// Producer: one quantum of audio plus the drift.
carry += want as i64 * drift_ppm;
let extra = carry / 1_000_000;
carry -= extra * 1_000_000;
depth = (depth as i64 + want as i64 + extra).max(0) as usize;
let s = p.step(depth, want);
if s.drop_front > 0 {
if s.crossfade > 0 {
out.soft_sheds += 1;
} else {
out.hard_trims += 1;
}
depth -= s.drop_front.min(depth);
}
if s.silence {
p.note_read(false);
continue;
}
let short = depth < want;
depth -= want.min(depth);
if short {
out.underruns += 1;
}
p.note_read(short);
out.peak_ms = out.peak_ms.max((depth / pm) as u32);
}
out.final_ms = (depth / pm) as u32;
out
}
/// The invariant that makes drift correction real rather than decorative: on every preset the
/// smooth shed point must sit strictly BELOW the hard trim point. Invert it — by tuning
/// `headroom_ms` down — and the ring is trimmed back before the depth average can ever reach
/// the shed threshold, so the smooth path becomes dead code and every correction is the
/// audible drop it was meant to replace. (That inversion was present in the first draft of
/// this module and only surfaced because `a_transient_burst_does_not_shed` failed.)
#[test]
fn every_preset_sheds_before_it_trims() {
for (name, t) in [
("PIPEWIRE", JitterTuning::PIPEWIRE),
("WASAPI", JitterTuning::WASAPI),
("COREAUDIO", JitterTuning::COREAUDIO),
("AAUDIO", JitterTuning::AAUDIO),
] {
assert!(
t.shed_excess_ms() < t.headroom_ms,
"{name}: sheds at +{} ms but trims at +{} ms — drift correction can never fire",
t.shed_excess_ms(),
t.headroom_ms
);
assert!(
t.base_target_ms + t.headroom_ms <= t.hard_cap_ms,
"{name}: the headroom band is cut short by the hard cap"
);
assert!(t.max_target_ms >= t.base_target_ms, "{name}");
assert!(t.deprime_after >= 2, "{name}: needs real hysteresis");
}
}
/// THE headline behaviour, and the defect this policy exists for: with the host clock running
/// fast, the old rings grew to their ceiling and stayed pinned there for the rest of the
/// session. Drift correction must hold the depth near target — and must do it with the SMOOTH
/// crossfaded shed, never by letting the hard cap chop the backlog.
#[test]
fn drift_does_not_ratchet_latency_to_the_ceiling() {
// +200 ppm is a deliberately harsh skew (real DAC pairs are tens of ppm); 5 minutes.
let s = simulate(JitterTuning::AAUDIO, 2, 300_000, 5, 200, 25);
assert!(
s.soft_sheds > 0,
"drift must be shed, not accumulated: {s:?}"
);
assert_eq!(
s.hard_trims, 0,
"plain drift must never reach the hard cap: {s:?}"
);
assert_eq!(
s.underruns, 0,
"shedding must never cause an underrun: {s:?}"
);
// The old Android ring pinned at its 120 ms hard cap. Ours must stay inside the band.
let ceiling = JitterTuning::AAUDIO.base_target_ms + JitterTuning::AAUDIO.headroom_ms;
assert!(
s.peak_ms <= ceiling,
"peaked at {} ms (band ends at {ceiling}) — that is the ratchet, not a correction",
s.peak_ms
);
}
/// Same skew, every preset: none of them may ratchet.
#[test]
fn no_preset_ratchets_under_drift() {
for (name, t) in [
("PIPEWIRE", JitterTuning::PIPEWIRE),
("WASAPI", JitterTuning::WASAPI),
("COREAUDIO", JitterTuning::COREAUDIO),
("AAUDIO", JitterTuning::AAUDIO),
] {
let s = simulate(t, 2, 300_000, 5, 200, t.base_target_ms);
assert!(s.soft_sheds > 0, "{name}: {s:?}");
assert!(
s.peak_ms <= t.base_target_ms + t.headroom_ms,
"{name} peaked at {} ms: {s:?}",
s.peak_ms
);
}
}
/// The mirror case: a host clock running SLOW must not be "corrected" into permanent
/// underruns. The adaptive floor may grow the target, but nothing may be shed.
#[test]
fn negative_drift_grows_the_target_instead_of_stuttering() {
let s = simulate(JitterTuning::AAUDIO, 2, 120_000, 5, -200, 25);
assert_eq!(
s.soft_sheds, 0,
"nothing to shed when the ring is draining: {s:?}"
);
assert_eq!(s.hard_trims, 0, "{s:?}");
}
/// A shed must never fire on a transient — a burst that arrives and drains is normal jitter,
/// and shedding it would cost an audible artefact for nothing. The spike here sits ABOVE the
/// shed threshold but below the trim point, so only the sustain requirement can reject it.
#[test]
fn a_transient_burst_does_not_shed() {
let t = JitterTuning::AAUDIO;
let pm = per_ms(2);
let want = 5 * pm;
let spike_ms = t.base_target_ms + t.shed_excess_ms() + FRAME_MS; // inside the band
assert!(
spike_ms < t.base_target_ms + t.headroom_ms,
"test spike must not hit the trim"
);
let mut p = JitterPolicy::new(t, 2);
let mut sheds = 0;
// 300 ms spiked out of every 1 s, for 20 s.
for round in 0..20 {
for i in 0..200 {
let depth = if round > 0 && i < 60 {
spike_ms
} else {
t.base_target_ms
} as usize;
let s = p.step(depth * pm, want);
if s.drop_front > 0 {
sheds += 1;
}
p.note_read(false);
}
}
assert_eq!(
sheds, 0,
"a repeated short burst must not trigger drift correction"
);
}
/// The hard cap is the only absolute latency guarantee — it trims immediately, without
/// waiting for the drift timer.
#[test]
fn hard_cap_trims_at_once() {
let pm = per_ms(2);
let mut p = JitterPolicy::new(JitterTuning::AAUDIO, 2);
let s = p.step(500 * pm, 5 * pm);
assert!(
s.drop_front > 0,
"a 500 ms backlog must be trimmed on the spot"
);
assert_eq!(s.crossfade, 0, "a blown cap is already a discontinuity");
let left = 500 * pm - s.drop_front;
assert!(
left <= JitterTuning::AAUDIO.hard_cap_ms as usize * pm,
"trim must land at or under the hard cap"
);
}
/// One transient drain must not manufacture a fresh target's worth of silence — the bug
/// Android fixed and Linux/Windows still carried.
#[test]
fn deprime_requires_hysteresis() {
let pm = per_ms(2);
let want = 5 * pm;
let mut p = JitterPolicy::new(JitterTuning::PIPEWIRE, 2);
// An EMPTY ring must emit silence and stay un-primed, however many callbacks it sees.
for _ in 0..10 {
assert!(p.step(0, want).silence, "an empty ring cannot play");
}
assert!(!p.is_primed());
// A ring already holding well over target primes on the first callback that sees it.
assert!(
!p.step(50 * pm, want).silence,
"a ring holding well over target must start immediately"
);
assert!(p.is_primed());
p.note_read(true); // one short read
assert!(p.is_primed(), "a single short read must not de-prime");
for _ in 1..JitterTuning::PIPEWIRE.deprime_after {
p.note_read(true);
}
assert!(!p.is_primed(), "a sustained drain must re-prime");
}
/// A device that pulls a big quantum cannot sustain a target below it: the effective target
/// must lift, or the ring oscillates prime → dropout → re-prime forever.
#[test]
fn target_lifts_above_a_large_device_quantum() {
let pm = per_ms(2);
let mut p = JitterPolicy::new(JitterTuning::PIPEWIRE, 2); // base target 15 ms
let want = 40 * pm; // a 40 ms graph quantum — far above the base target
// At exactly the base target the ring must NOT claim to be primed.
assert!(
p.step(15 * pm, want).silence,
"15 ms cannot serve a 40 ms quantum"
);
// Once it holds the quantum plus a frame, it may play.
let s = p.step((40 + FRAME_MS as usize) * pm, want);
assert!(!s.silence, "quantum + one frame must be enough to start");
}
/// Clustered underruns raise the floor (that device needs the slack); a long quiet spell
/// gives it back, so one bad minute doesn't cost latency for the whole session.
#[test]
fn target_grows_on_underruns_and_relaxes_when_quiet() {
let pm = per_ms(2);
let want = 5 * pm;
let mut p = JitterPolicy::new(JitterTuning::AAUDIO, 2);
let base = p.target_ms();
assert_eq!(base, JitterTuning::AAUDIO.base_target_ms);
for _ in 0..40 {
// Keep it primed and starve it: depth is always enough to prime, never to serve.
while !p.is_primed() {
p.step(200 * pm, want);
}
p.step(200 * pm, want);
p.note_read(true);
}
let grown = p.target_ms();
assert!(
grown > base,
"clustered underruns must raise the floor ({base} → {grown})"
);
assert!(
grown <= JitterTuning::AAUDIO.max_target_ms,
"growth must respect max_target_ms"
);
// Now a long clean run relaxes it back.
for _ in 0..(SHRINK_QUIET_MS as usize * 3 / 5) {
p.step(grown as usize * pm + want, want);
p.note_read(false);
}
assert!(
p.target_ms() < grown,
"a quiet spell must give the growth back"
);
assert!(p.target_ms() >= base, "…but never below the base target");
}
/// The crossfade must leave a continuous waveform: splicing a ramp must not introduce a step
/// bigger than the ramp's own per-sample slope.
#[test]
fn crossfade_drop_splices_without_a_step() {
use std::collections::VecDeque;
// A slow ramp: any hard splice shows up as a visible jump.
let mut ring: VecDeque<f32> = (0..1000).map(|i| i as f32).collect();
let (drop, fade) = (240, 96);
crossfade_drop(&mut ring, drop, fade);
assert_eq!(ring.len(), 1000 - drop);
// Across the whole faded region the step between neighbours stays bounded — a hard drop
// would show a `drop`-sized jump at index 0.
for i in 0..fade {
let step = (ring[i + 1] - ring[i]).abs();
assert!(
step < drop as f32,
"sample {i}: step {step} looks like a hard splice"
);
}
// Tail is untouched.
assert_eq!(ring[ring.len() - 1], 999.0);
}
#[test]
fn crossfade_drop_handles_degenerate_inputs() {
use std::collections::VecDeque;
let mut ring: VecDeque<f32> = (0..10).map(|i| i as f32).collect();
crossfade_drop(&mut ring, 0, 4); // nothing to drop
assert_eq!(ring.len(), 10);
crossfade_drop(&mut ring, 99, 4); // more than we hold — refuse
assert_eq!(ring.len(), 10);
crossfade_drop(&mut ring, 10, 4); // exactly all of it: no room to fade, hard drop
assert!(ring.is_empty());
}
#[test]
fn wasapi_masks_are_correct() {
assert_eq!(wasapi_channel_mask(2), 0x3);
assert_eq!(wasapi_channel_mask(6), 0x3F);
assert_eq!(wasapi_channel_mask(8), 0x63F); // NOT 0xFF
// Bit count must equal the channel count.
assert_eq!(wasapi_channel_mask(2).count_ones(), 2);
assert_eq!(wasapi_channel_mask(6).count_ones(), 6);
assert_eq!(wasapi_channel_mask(8).count_ones(), 8);
}
#[test]
fn spa_positions_match_wire_order() {
assert_eq!(spa_positions(2), &[3, 4]);
assert_eq!(spa_positions(6), &[3, 4, 5, 6, 12, 13]);
assert_eq!(spa_positions(8), &[3, 4, 5, 6, 12, 13, 7, 8]);
assert_eq!(spa_positions(2).len(), 2);
assert_eq!(spa_positions(6).len(), 6);
assert_eq!(spa_positions(8).len(), 8);
}
/// Real-libopus proof that the shared layout round-trips with channel identity: a tone fed
/// into wire channel N (host `opus::MSEncoder`) comes back out on channel N (client
/// `opus::MSDecoder`), for stereo / 5.1 / 7.1. This is the single guarantee the whole
/// feature rests on — encoder layout == decoder layout == identity mapping — so if a layout
/// constant is ever wrong, this fails. Gated on `quic` (where `opus` is a dependency).
#[cfg(feature = "quic")]
#[test]
fn multistream_layout_roundtrips_with_channel_identity() {
const SR: u32 = 48_000;
const SAMPLES: usize = 240; // 5 ms @ 48 kHz
for &channels in &[2u8, 6, 8] {
let l = layout_for(channels, false);
let ch = l.channels as usize;
let mut enc = opus::MSEncoder::new(
SR,
l.streams,
l.coupled,
l.mapping,
opus::Application::LowDelay,
)
.expect("MSEncoder");
enc.set_bitrate(opus::Bitrate::Bits(l.bitrate)).unwrap();
enc.set_vbr(false).unwrap();
let mut dec =
opus::MSDecoder::new(SR, l.streams, l.coupled, l.mapping).expect("MSDecoder");
for tone_ch in 0..ch {
let mut out = vec![0u8; 4000];
let mut energy = vec![0f64; ch];
// A few frames to clear the codec startup transient before measuring.
for f in 0..8 {
let mut frame = vec![0f32; SAMPLES * ch];
for t in 0..SAMPLES {
let phase = (f * SAMPLES + t) as f32 * 440.0 * 2.0 * std::f32::consts::PI
/ SR as f32;
frame[t * ch + tone_ch] = 0.5 * phase.sin();
}
let n = enc.encode_float(&frame, &mut out).unwrap();
let mut decoded = vec![0f32; SAMPLES * ch];
let got = dec.decode_float(&out[..n], &mut decoded, false).unwrap();
assert_eq!(got, SAMPLES, "{channels}ch frame size");
if f >= 4 {
for t in 0..SAMPLES {
for (c, e) in energy.iter_mut().enumerate() {
*e += (decoded[t * ch + c] as f64).powi(2);
}
}
}
}
let loudest = (0..ch)
.max_by(|&a, &b| energy[a].total_cmp(&energy[b]))
.unwrap();
assert_eq!(
loudest, tone_ch,
"{channels}ch: tone in channel {tone_ch} must come out on {tone_ch} (energies {energy:?})"
);
}
}
}
}