Files
punktfunk/crates/pf-client-core/src/pad_audio.rs
T
enricobuehler 56adb47026
ci / web (pull_request) Successful in 56s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m40s
ci / docs-site (pull_request) Successful in 2m33s
ci / rust-arm64 (pull_request) Successful in 2m43s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m17s
android / android (pull_request) Successful in 4m12s
ci / rust (pull_request) Successful in 6m21s
fix(client-core): pad-audio references the WASAPI module by its mounted name
The Windows build of pf-client-core has been red on main since the
pad-audio merge (#23): pad_audio.rs calls
`crate::audio_wasapi::device_by_id`, but lib.rs mounts audio_wasapi.rs AS
`crate::audio` via the #[path] per-OS swap — the `audio_wasapi` module
name never exists. Windows-gated call site, so every Linux leg stayed
green while both `windows / build` targets failed E0433.

One-line rename to the mounted path (+ the comment that pointed readers
at the phantom name). Verification is the PR's own windows leg — the
crate builds on no other platform this path compiles on.
2026-08-05 08:15:02 +02:00

1223 lines
52 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.
//! Pad audio (the 0xD1 plane): render the host's per-gamepad DualSense streams — voice-coil
//! haptics (kind 0, the BACK channel pair) and the built-in speaker (kind 1, the FRONT pair) —
//! into a USB-connected physical DualSense's own 4-channel audio device.
//!
//! Tier A only (v1): a WIRED DualSense / DualSense Edge — Bluetooth exposes no audio device, so
//! wired is what makes the 4-ch sibling exist at all. The gamepad worker detects tier A at slot
//! open ([`crate::gamepad`]) and declares the pad's render capabilities to the core
//! ([`punktfunk_core::client::NativeClient::set_pad_audio_caps`]), which rides them on the
//! arrival (flags bits 8/9) toward a `HOST_CAP_PAD_AUDIO` host; the host then emits 0xD1 for
//! exactly those pads. This module owns everything after that:
//!
//! - **Correlation** pad ↔ audio device. Windows: the SDL HID interface path → the devnode's
//! `ContainerID` (registry) → the active eRender endpoint whose stamped
//! `PKEY_Device_ContainerId` matches AND whose device format has 4 channels. Linux: the
//! PipeWire sink whose name/description carries the DualSense signature (one physical DS5 in
//! v1 — first match wins).
//! - **The renderer worker** ([`spawn`]): drains [`NativeClient::next_pad_audio`] (the plane's
//! single consumer), Opus-decodes per (pad, kind) with seq-gap PLC (the session audio path's
//! [`AudioGapTracker`] discipline), interleaves both pairs into one 4-ch stream
//! (speaker → channels 0/1, haptics → 2/3 — the DS5 device's own layout), and plays it on the
//! correlated device: WASAPI shared/event-driven on Windows, a PipeWire playback stream with
//! `target.object` on Linux — both with the session players' 3-quantum prime/cap/re-prime
//! ring policy at a SMALLER floor (haptics are felt latency). The output device is opened
//! lazily on the first arriving frame and re-correlated with backoff when it goes away.
//!
//! The `Settings` side: `pad_haptics` (bool) and `pad_speaker` (`"pad"`/`"mix"`/`"off"`) gate
//! the `CLIENT_CAP_PAD_AUDIO` advertisement and the per-pad capability bits; `"mix"` (fold the
//! speaker into the main stream audio) is a declared TODO and renders as `"off"`.
use punktfunk_core::audio::AudioGapTracker;
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// The render layout: 4 interleaved f32 channels — speaker FL/FR on 0/1, haptics (rear pair
/// on the wire layout, the voice coils on the device) on 2/3.
const PAD_CHANNELS: usize = 4;
/// Mixer depth bound (frames @48 kHz — 100 ms). Only guards a wedged/absent output; the live
/// latency bound is the platform ring policy's cap.
const MAX_BUFFER_FRAMES: usize = 4800;
/// Device (re)correlation backoff bounds: a missing DualSense audio device is polled at
/// [`RETRY_MIN`] doubling to [`RETRY_MAX`] — correlation enumerates the audio graph, so it must
/// not run per frame.
const RETRY_MIN: Duration = Duration::from_secs(1);
const RETRY_MAX: Duration = Duration::from_secs(8);
// ---- settings vocabulary --------------------------------------------------------------------
/// Whether the `pad_speaker` setting asks for a renderer: `"pad"` = the physical pad's speaker
/// (the only implemented target). `"mix"` — fold the speaker stream into the main session
/// audio — is a declared TODO: it logs once and renders as `"off"` so the setting name can ship
/// before the mixer leg does. Anything else (including `"off"`) = no renderer.
pub fn speaker_active(mode: &str) -> bool {
match mode {
"pad" => true,
"mix" => {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
tracing::info!(
"pad_speaker=\"mix\" is not implemented yet (TODO: fold the DS5 speaker \
stream into the main session audio) — treating it as \"off\""
);
});
false
}
_ => false,
}
}
// ---- tier-A detection -----------------------------------------------------------------------
/// Tier A = a physical DualSense (`054C:0CE6`) or DualSense Edge (`054C:0DF2`) on a WIRED
/// connection — only USB exposes the pad's 4-ch audio device (Bluetooth pads are tier B/C,
/// out of scope here). Pure so the policy is testable; the wired signal comes from
/// `SDL_GetGamepadConnectionState`, falling back to the audio-sibling probe
/// ([`wired_audio_sibling`]) when SDL answers Unknown.
pub(crate) fn is_tier_a_ds5(vid: u16, pid: u16, wired: bool) -> bool {
vid == 0x054C && matches!(pid, 0x0CE6 | 0x0DF2) && wired
}
/// The wired fallback when SDL cannot say (`ConnectionState::Unknown`): does the pad's 4-ch
/// audio sibling exist? A Bluetooth DS5 exposes no audio device, so a resolvable device IS the
/// wired signal. Linux ignores the HID path (the sink match is signature-based); Windows
/// resolves the path's container against the render endpoints.
#[cfg(target_os = "linux")]
pub(crate) fn wired_audio_sibling(_hid_path: Option<&str>) -> bool {
crate::audio::devices()
.map(|(sinks, _)| sinks.iter().any(|d| is_ds5_sink(&d.name, &d.description)))
.unwrap_or(false)
}
#[cfg(windows)]
pub(crate) fn wired_audio_sibling(hid_path: Option<&str>) -> bool {
hid_path.is_some_and(|p| correlate_pad_endpoint(p).is_ok())
}
// ---- tier-A pad registry (gamepad worker → renderer worker) ---------------------------------
/// One tier-A pad the gamepad worker holds open: its wire index and (Windows correlation) the
/// SDL HID device path. Registered at slot open, dropped at slot close.
struct TierAPad {
index: u8,
/// Read by the Windows correlation only — Linux matches the sink by signature.
#[cfg_attr(not(windows), allow(dead_code))]
hid_path: Option<String>,
}
/// The tier-A pads currently open, shared between the gamepad worker (writer, at slot
/// open/close) and the session renderer worker (reader, at device correlation). A process-wide
/// static because the two workers meet nowhere else: the gamepad service is app-lifetime, the
/// renderer is per-session.
static TIER_A_PADS: Mutex<Vec<TierAPad>> = Mutex::new(Vec::new());
/// Gamepad worker: a tier-A slot opened on wire index `index` (idempotent per index).
pub(crate) fn register_tier_a(index: u8, hid_path: Option<String>) {
let mut pads = TIER_A_PADS.lock().unwrap();
pads.retain(|p| p.index != index);
pads.push(TierAPad { index, hid_path });
}
/// Gamepad worker: the tier-A slot on `index` closed (no-op for non-tier-A indices).
pub(crate) fn unregister_tier_a(index: u8) {
TIER_A_PADS.lock().unwrap().retain(|p| p.index != index);
}
/// The first registered tier-A pad's HID path (v1 renders one physical DS5).
#[cfg(windows)]
fn first_tier_a_hid_path() -> Option<String> {
TIER_A_PADS
.lock()
.unwrap()
.first()
.and_then(|p| p.hid_path.clone())
}
// ---- correlation: Linux (PipeWire sink signature) -------------------------------------------
/// Does this PipeWire sink look like a wired DualSense's audio device? The ALSA node name
/// carries the USB vendor string (`Sony_Interactive_Entertainment`), descriptions carry the
/// product (`DualSense`), and the kernel's fallback device name is `Wireless Controller` —
/// any of the three identifies the pad's sink.
#[cfg(any(target_os = "linux", test))]
pub(crate) fn is_ds5_sink(name: &str, description: &str) -> bool {
let hit = |s: &str| {
s.contains("Sony_Interactive_Entertainment")
|| s.contains("DualSense")
|| s.starts_with("Wireless Controller")
};
hit(name) || hit(description)
}
/// First-match pick over an enumerated sink list (v1 supports ONE physical DS5; more than one
/// match logs once and keeps the first).
#[cfg(target_os = "linux")]
fn find_ds5_sink(sinks: &[crate::audio::AudioDevice]) -> Option<crate::audio::AudioDevice> {
let mut matches = sinks
.iter()
.filter(|d| is_ds5_sink(&d.name, &d.description));
let first = matches.next()?.clone();
if matches.next().is_some() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
tracing::info!(
sink = %first.name,
"multiple DualSense audio sinks — v1 renders one physical DS5, using the first"
);
});
}
Some(first)
}
// ---- correlation: Windows (HID container → render endpoint) ---------------------------------
/// One enumerated render endpoint reduced to what the matcher needs. Pure data so the container
/// match is unit-testable off-box.
#[cfg(any(windows, test))]
pub(crate) struct EndpointCandidate {
/// The `IMMDevice` endpoint id (`{0.0.0.00000000}.{…}`) — what WASAPI's device targeting
/// takes.
pub(crate) id: String,
/// The endpoint's `PKEY_Device_ContainerId` as a braced lowercase GUID string; `None` when
/// unreadable.
pub(crate) container: Option<String>,
/// Channel count of the endpoint's device format.
pub(crate) channels: u16,
}
/// The endpoint belonging to the pad: container match AND a 4-channel device format (the DS5
/// audio function is the only 4-ch endpoint in its container — the mix-format gate keeps a
/// hypothetical sibling stereo endpoint from winning).
#[cfg(any(windows, test))]
pub(crate) fn pick_pad_endpoint<'a>(
endpoints: &'a [EndpointCandidate],
container: &str,
) -> Option<&'a EndpointCandidate> {
endpoints.iter().find(|e| {
e.channels == 4
&& e.container
.as_deref()
.is_some_and(|c| c.eq_ignore_ascii_case(container))
})
}
/// A device INSTANCE id from a device INTERFACE path: strip the `\\?\` (or `\\.\`) prefix,
/// the `#` separators become `\`, and the trailing `{interface-class-guid}` segment drops —
/// `\\?\HID#VID_054C&PID_0CE6#8&2de&0&0000#{4d1e55b2-…}` → `HID\VID_054C&PID_0CE6\8&2de&0&0000`.
/// That instance id is the devnode's key under `HKLM\SYSTEM\CurrentControlSet\Enum`, where its
/// `ContainerID` lives.
#[cfg(any(windows, test))]
pub(crate) fn hid_instance_from_interface_path(path: &str) -> Option<String> {
let p = path
.strip_prefix(r"\\?\")
.or_else(|| path.strip_prefix(r"\\.\"))
.unwrap_or(path);
let mut segs: Vec<&str> = p.split('#').collect();
if let Some(last) = segs.last() {
if last.starts_with('{') && last.ends_with('}') {
segs.pop();
}
}
if segs.len() != 3 || segs.iter().any(|s| s.is_empty()) {
return None;
}
Some(segs.join("\\"))
}
/// Parse a serialized `VT_CLSID` PROPVARIANT registry blob (the on-disk shape of the MMDevices
/// property store: 8-byte header `[vt, 0, 0, 0, 1, 0, 0, 0]`, then the GUID in registry byte
/// order) into a braced lowercase GUID string. `None` for anything else.
#[cfg(any(windows, test))]
pub(crate) fn container_guid_from_blob(bytes: &[u8]) -> Option<String> {
const VT_CLSID: u8 = 0x48;
if bytes.len() < 24 || bytes[0] != VT_CLSID {
return None;
}
let g = &bytes[8..24];
let d1 = u32::from_le_bytes([g[0], g[1], g[2], g[3]]);
let d2 = u16::from_le_bytes([g[4], g[5]]);
let d3 = u16::from_le_bytes([g[6], g[7]]);
Some(format!(
"{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}",
d1, d2, d3, g[8], g[9], g[10], g[11], g[12], g[13], g[14], g[15]
))
}
/// Resolve the SDL HID interface path to the matching 4-ch render endpoint id — the Windows
/// correlation chain: interface path → instance id → devnode `ContainerID` (registry) → the
/// active eRender endpoint whose stamped `PKEY_Device_ContainerId` matches with a 4-channel
/// device format. Registry-only for the property reads (the MMDevices ACL denies writes, never
/// reads); the endpoint enumeration runs on its own MTA thread like [`crate::audio::devices`].
#[cfg(windows)]
pub(crate) fn correlate_pad_endpoint(hid_path: &str) -> anyhow::Result<String> {
use anyhow::Context;
let instance = hid_instance_from_interface_path(hid_path)
.with_context(|| format!("unrecognised HID interface path shape: {hid_path}"))?;
let container = hid_container_id(&instance)
.with_context(|| format!("no ContainerID on devnode {instance}"))?;
let endpoints = render_endpoints()?;
pick_pad_endpoint(&endpoints, &container)
.map(|e| e.id.clone())
.with_context(|| {
format!(
"no active 4-ch render endpoint in container {container} \
({} endpoints inspected)",
endpoints.len()
)
})
}
/// The devnode's `ContainerID` value (a braced GUID string) under
/// `HKLM\SYSTEM\CurrentControlSet\Enum\<instance>`.
#[cfg(windows)]
fn hid_container_id(instance: &str) -> anyhow::Result<String> {
use anyhow::Context;
let key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
.open_subkey(format!(r"SYSTEM\CurrentControlSet\Enum\{instance}"))
.with_context(|| format!(r"open Enum\{instance}"))?;
key.get_value::<String, _>("ContainerID")
.context("read ContainerID")
}
/// Enumerate the active eRender endpoints with the container + channel facts the matcher needs.
/// Its own short-lived MTA thread (the caller may sit in an STA — the [`crate::audio::devices`]
/// discipline); one broken endpoint must not hide the rest.
#[cfg(windows)]
fn render_endpoints() -> anyhow::Result<Vec<EndpointCandidate>> {
use anyhow::{anyhow, Context};
std::thread::Builder::new()
.name("pf-pad-audio-enum".into())
.spawn(|| -> anyhow::Result<Vec<EndpointCandidate>> {
wasapi::initialize_mta()
.ok()
.context("CoInitializeEx (MTA)")?;
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
let coll = enumerator
.get_device_collection(&wasapi::Direction::Render)
.context("render endpoint collection")?;
let mut out = Vec::new();
for i in 0..coll.get_nbr_devices().context("endpoint count")? {
let Ok(dev) = coll.get_device_at_index(i) else {
continue;
};
let Ok(id) = dev.get_id() else {
continue;
};
let channels = dev
.get_device_format()
.map(|f| f.get_nchannels())
.unwrap_or(0);
out.push(EndpointCandidate {
container: endpoint_container_id(&id),
id,
channels,
});
}
Ok(out)
})
.context("spawn pad-audio enumeration thread")?
.join()
.map_err(|_| anyhow!("pad-audio enumeration thread panicked"))?
}
/// The endpoint's stamped `PKEY_Device_ContainerId`, read from its MMDevices property store in
/// the registry (`…\MMDevices\Audio\Render\{ep-guid}\Properties`, value
/// `"{8c7ed206-3f8a-4827-b3ab-ae9e1faefc6c},2"`, a serialized VT_CLSID blob).
#[cfg(windows)]
fn endpoint_container_id(endpoint_id: &str) -> Option<String> {
let guid = endpoint_id.rfind('{').map(|i| &endpoint_id[i..])?;
let key = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
.open_subkey(format!(
r"SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render\{guid}\Properties"
))
.ok()?;
let v = key
.get_raw_value("{8c7ed206-3f8a-4827-b3ab-ae9e1faefc6c},2")
.ok()?;
container_guid_from_blob(&v.bytes)
}
// ---- the 4-channel mixer --------------------------------------------------------------------
/// Interleave the two independent stereo streams into one 4-ch frame stream: speaker
/// ([`PAD_AUDIO_KIND_SPEAKER`]) on channels 0/1, haptics ([`PAD_AUDIO_KIND_HAPTICS`]) on 2/3.
/// Each kind has its own write cursor (they arrive on different cadences — 10 ms vs 5 ms);
/// [`pop`](Self::pop) emits every frame the FURTHER-ahead kind has filled, with the lagging
/// (or absent) kind's pair reading zeros — so a haptics-only session plays voice-coil audio
/// with a silent speaker pair and vice versa. Pure logic (unit-tested); pacing and latency
/// bounds live in the platform ring downstream.
pub(crate) struct QuadMixer {
/// Interleaved 4-ch samples; the front is the next frame to output. Length is always
/// `ready_frames() * 4` (pushes zero-extend for their own cursor only).
ring: std::collections::VecDeque<f32>,
/// Per-kind write cursor in FRAMES relative to the ring front (`[haptics, speaker]` —
/// indexed by the wire `kind`).
written: [usize; 2],
}
impl QuadMixer {
pub(crate) fn new() -> QuadMixer {
QuadMixer {
ring: std::collections::VecDeque::new(),
written: [0; 2],
}
}
/// Write one decoded stereo chunk (interleaved L/R) for `kind` at that kind's cursor,
/// zero-extending the ring as needed. Depth is bounded by [`MAX_BUFFER_FRAMES`] — overflow
/// drops the oldest frames (both kinds shift together, so the interleave never skews).
pub(crate) fn push(&mut self, kind: u8, stereo: &[f32]) {
let k = (kind as usize).min(1);
let off = if kind == PAD_AUDIO_KIND_SPEAKER { 0 } else { 2 };
let frames = stereo.len() / 2;
let base = self.written[k];
let need = (base + frames) * PAD_CHANNELS;
if self.ring.len() < need {
self.ring.resize(need, 0.0);
}
for (i, fr) in stereo.chunks_exact(2).enumerate() {
let at = (base + i) * PAD_CHANNELS + off;
self.ring[at] = fr[0];
self.ring[at + 1] = fr[1];
}
self.written[k] = base + frames;
let over = self.ready_frames().saturating_sub(MAX_BUFFER_FRAMES);
if over > 0 {
self.drop_front(over);
}
}
/// Frames ready to output: the further-ahead kind's cursor (the other pair reads zeros).
pub(crate) fn ready_frames(&self) -> usize {
self.written[0].max(self.written[1])
}
/// Append every ready frame (interleaved 4-ch) to `out`; returns the frame count. Both
/// cursors move back together, so a kind that lagged simply resumes at the new front.
pub(crate) fn pop(&mut self, out: &mut Vec<f32>) -> usize {
let frames = self.ready_frames();
let n = frames * PAD_CHANNELS;
debug_assert_eq!(self.ring.len(), n);
out.extend(self.ring.drain(..n.min(self.ring.len())));
for w in &mut self.written {
*w = w.saturating_sub(frames);
}
frames
}
/// Throw the ready frames away (no output device right now).
pub(crate) fn discard(&mut self) {
let f = self.ready_frames();
self.drop_front(f);
}
fn drop_front(&mut self, frames: usize) {
let n = (frames * PAD_CHANNELS).min(self.ring.len());
self.ring.drain(..n);
let f = n / PAD_CHANNELS;
for w in &mut self.written {
*w = w.saturating_sub(f);
}
}
}
// ---- decode + PLC ---------------------------------------------------------------------------
/// Per-(pad, kind) decode state: a stereo 48 kHz Opus decoder, the seq-gap tracker, and the
/// last decoded frame size (the PLC synthesis unit — session.rs's audio-thread discipline).
struct KindStream {
dec: opus::Decoder,
gaps: AudioGapTracker,
frame_samples: usize,
}
/// How many concealment frames to synthesize before decoding `seq`: the tracker's capped gap
/// count — but 0 until a first frame decoded (`frame_samples == 0`; there is nothing to size
/// the PLC from). The tracker is ALWAYS fed, so a pre-first-frame gap can't replay later as a
/// phantom gap. Pure (unit-tested).
fn plc_frames(gaps: &mut AudioGapTracker, seq: u32, frame_samples: usize) -> u32 {
let missing = gaps.missing_before(seq);
if frame_samples == 0 {
0
} else {
missing
}
}
// ---- the renderer worker --------------------------------------------------------------------
/// Spawn the pad-audio renderer thread — the 0xD1 plane's single consumer, started by the
/// session pump whenever the settings could render (`pad_haptics` / `pad_speaker == "pad"`).
/// The output device is opened LAZILY on the first arriving frame: frames only flow once a
/// tier-A pad declared render caps on its arrival, so a session without a wired DualSense
/// costs one idle 10 ms poll loop and never touches the audio graph. Exits on the session
/// stop flag (join it like the audio thread) or the plane closing.
pub(crate) fn spawn(
connector: Arc<NativeClient>,
stop: Arc<AtomicBool>,
haptics: bool,
speaker: bool,
) -> Option<std::thread::JoinHandle<()>> {
std::thread::Builder::new()
.name("pf-pad-audio".into())
.spawn(move || run(&connector, &stop, haptics, speaker))
.map_err(|e| tracing::warn!(error = %e, "pad-audio thread failed to start"))
.ok()
}
fn run(connector: &NativeClient, stop: &AtomicBool, haptics: bool, speaker: bool) {
// 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];
let mut mixer = QuadMixer::new();
let mut pcm = vec![0f32; 5760 * 2]; // scratch: max Opus frame (120 ms) × stereo
let mut out: Option<PadOut> = None;
let mut active_pad: Option<u8> = None;
let mut other_pad_logged = false;
let mut open_fail_logged = false;
let mut retry_at = Instant::now();
let mut backoff = RETRY_MIN;
while !stop.load(Ordering::SeqCst) {
let Some(f) = connector.next_pad_audio(Duration::from_millis(10)) else {
if connector.is_session_ended() {
break;
}
continue;
};
// The host only emits kinds the arrival declared, but the settings gate is re-checked
// here so a stale host can never force an undeclared renderer.
if f.kind > 1
|| (f.kind == PAD_AUDIO_KIND_HAPTICS && !haptics)
|| (f.kind == PAD_AUDIO_KIND_SPEAKER && !speaker)
{
continue;
}
// v1 renders ONE physical DualSense: latch the first streaming pad, drop the rest.
match active_pad {
None => active_pad = Some(f.pad),
Some(p) if p != f.pad => {
if !other_pad_logged {
other_pad_logged = true;
tracing::info!(
rendered = p,
ignored = f.pad,
"pad audio from a second pad — v1 renders one physical DualSense"
);
}
continue;
}
_ => {}
}
let k = f.kind as usize;
if streams[k].is_none() {
match opus::Decoder::new(48_000, opus::Channels::Stereo) {
Ok(dec) => {
streams[k] = Some(KindStream {
dec,
gaps: AudioGapTracker::new(),
frame_samples: 0,
})
}
Err(e) => {
tracing::warn!(error = %e, kind = f.kind, "pad-audio opus decoder failed");
continue;
}
}
}
let st = streams[k].as_mut().expect("inserted above");
// Conceal lost packets (a seq gap) with libopus PLC before decoding the arrival —
// the session audio thread's exact discipline. A frozen seq (the host paused the
// stream) produces no packets at all, which is silence by construction.
for _ in 0..plc_frames(&mut st.gaps, f.seq, st.frame_samples) {
let n = st.frame_samples * 2;
if let Ok(samples) = st.dec.decode_float(&[], &mut pcm[..n], false) {
mixer.push(f.kind, &pcm[..samples * 2]);
}
}
if !f.opus.is_empty() {
match st.dec.decode_float(&f.opus, &mut pcm, false) {
Ok(samples) => {
st.frame_samples = samples;
mixer.push(f.kind, &pcm[..samples * 2]);
}
Err(e) => tracing::debug!(error = %e, kind = f.kind, "pad-audio opus decode"),
}
}
// Output: open lazily (frames flowing prove a tier-A pad exists), drop + re-correlate
// with backoff when the device goes away (USB unplug kills the sink/endpoint).
if out.as_ref().is_some_and(PadOut::finished) {
tracing::info!("pad-audio output ended (device gone?) — re-correlating");
out = None;
retry_at = Instant::now() + backoff;
backoff = (backoff * 2).min(RETRY_MAX);
}
if out.is_none() && Instant::now() >= retry_at {
match PadOut::open() {
Ok(o) => {
tracing::info!("pad-audio output opened on the DualSense audio device");
out = Some(o);
backoff = RETRY_MIN;
open_fail_logged = false;
}
Err(e) => {
if !open_fail_logged {
open_fail_logged = true;
tracing::warn!(
error = %format!("{e:#}"),
"no DualSense audio device — pad audio parked (retrying with backoff)"
);
}
retry_at = Instant::now() + backoff;
backoff = (backoff * 2).min(RETRY_MAX);
}
}
}
match &out {
Some(o) => {
let mut chunk = o.take_buffer();
if mixer.pop(&mut chunk) > 0 {
o.push(chunk);
}
}
None => mixer.discard(),
}
}
tracing::debug!("pad-audio pull thread exited");
}
// ---- platform output: Linux (PipeWire) ------------------------------------------------------
/// The platform output half: a dedicated device thread fed interleaved 4-ch f32 chunks over a
/// bounded channel with a recycle pool (the `AudioPlayer` shape), targeting the correlated
/// DualSense device. `finished()` is the device-gone signal — the worker drops the handle and
/// re-correlates with backoff.
#[cfg(target_os = "linux")]
struct PadOut {
pcm_tx: std::sync::mpsc::SyncSender<Vec<f32>>,
recycle_rx: std::sync::mpsc::Receiver<Vec<f32>>,
quit_tx: pipewire::channel::Sender<()>,
thread: Option<std::thread::JoinHandle<()>>,
}
#[cfg(target_os = "linux")]
impl PadOut {
/// Correlate (sink signature match) and open the PipeWire playback stream on it.
fn open() -> anyhow::Result<PadOut> {
use anyhow::Context;
let (sinks, _) = crate::audio::devices().context("enumerate sinks")?;
let sink =
find_ds5_sink(&sinks).ok_or_else(|| anyhow::anyhow!("no DualSense sink found"))?;
tracing::info!(sink = %sink.name, description = %sink.description, "pad-audio sink matched");
// 64 × 5 ms of slack between the renderer worker and the PipeWire loop, with the
// recycle pool keeping the steady state allocation-free (the AudioPlayer shape).
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let (recycle_tx, recycle_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<()>();
let target = sink.name;
let thread = std::thread::Builder::new()
.name("pf-pad-audio-out".into())
.spawn(move || {
if let Err(e) = pad_pw_thread(pcm_rx, recycle_tx, quit_rx, target) {
tracing::warn!(error = %format!("{e:#}"), "pad-audio playback thread ended");
}
})
.context("spawn pad-audio playback thread")?;
Ok(PadOut {
pcm_tx,
recycle_rx,
quit_tx,
thread: Some(thread),
})
}
fn take_buffer(&self) -> Vec<f32> {
self.recycle_rx.try_recv().unwrap_or_default()
}
fn push(&self, pcm: Vec<f32>) {
let _ = self.pcm_tx.try_send(pcm); // never block the renderer; drops are concealed
}
fn finished(&self) -> bool {
self.thread.as_ref().is_none_or(|t| t.is_finished())
}
}
#[cfg(target_os = "linux")]
impl Drop for PadOut {
fn drop(&mut self) {
let _ = self.quit_tx.send(());
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// The PipeWire playback thread on the DualSense sink: 4 channels positioned FL FR RL RR (the
/// pad's speaker pair + the voice-coil pair), a 5 ms quantum, and the session player's
/// adaptive ring policy at a SMALLER floor — haptics are felt latency, so the prime target is
/// 3 quanta bounded to [240, 2400] frames instead of the main player's [720, 9600].
#[cfg(target_os = "linux")]
fn pad_pw_thread(
pcm_rx: std::sync::mpsc::Receiver<Vec<f32>>,
recycle_tx: std::sync::mpsc::SyncSender<Vec<f32>>,
quit_rx: pipewire::channel::Receiver<()>,
target: String,
) -> anyhow::Result<()> {
use anyhow::Context;
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
static PW_INIT: std::sync::Once = std::sync::Once::new();
PW_INIT.call_once(pw::init);
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
let core = context
.connect_rc(None)
.context("pw connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
let props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CATEGORY => "Playback",
*pw::keys::MEDIA_ROLE => "Game",
*pw::keys::NODE_NAME => "punktfunk-pad-audio",
*pw::keys::NODE_DESCRIPTION => "Punktfunk Pad Audio",
// ~5 ms quantum (one haptics Opus frame) keeps the ring — and the felt latency — small.
*pw::keys::NODE_LATENCY => "240/48000",
// The correlated DualSense sink (raw key — the `keys::TARGET_OBJECT` constant is
// feature-gated on a newer libpipewire than we require; the wire name is stable).
"target.object" => target.as_str(),
// The pad unplugging must END this stream (the worker re-correlates), not let the
// session manager re-route 4-ch haptics onto the desktop speakers.
"node.dont-reconnect" => "true",
};
let stream =
pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props).context("pw Stream")?;
struct PadPlayData {
rx: std::sync::mpsc::Receiver<Vec<f32>>,
recycle: std::sync::mpsc::SyncSender<Vec<f32>>,
ring: std::collections::VecDeque<f32>,
primed: bool,
}
let ud = PadPlayData {
rx: pcm_rx,
recycle: recycle_tx,
ring: std::collections::VecDeque::new(),
primed: false,
};
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire pad-audio stream state");
// Device gone (USB unplug) with dont-reconnect: the stream errors out — end
// the thread so the worker's `finished()` check re-correlates with backoff.
if matches!(new, pw::stream::StreamState::Error(_)) {
mainloop.quit();
}
}
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
while let Ok(mut chunk) = ud.rx.try_recv() {
ud.ring.extend(chunk.iter().copied());
chunk.clear();
let _ = ud.recycle.try_send(chunk);
}
let stride = 4 * PAD_CHANNELS; // F32LE interleaved
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let data = &mut datas[0];
let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0);
let want = want_frames * PAD_CHANNELS;
// The adaptive jitter buffer at the pad floor: prime to ~3 quanta within
// [240, 2400] frames, cap ~1 quantum of slack beyond, re-prime after a drain.
let target = (3 * want).clamp(240 * PAD_CHANNELS, 2400 * PAD_CHANNELS);
while ud.ring.len() > target.max(want) + want {
ud.ring.pop_front();
}
if !ud.primed && ud.ring.len() >= target {
ud.primed = true;
}
let n_frames = if let Some(slice) = data.data() {
for k in 0..want {
let s = if ud.primed {
ud.ring.pop_front().unwrap_or(0.0)
} else {
0.0
};
let off = k * 4;
slice[off..off + 4].copy_from_slice(&s.to_le_bytes());
}
want_frames
} else {
0
};
if ud.ring.is_empty() {
ud.primed = false;
}
let chunk = data.chunk_mut();
*chunk.offset_mut() = 0;
*chunk.stride_mut() = stride as _;
*chunk.size_mut() = (stride * n_frames) as _;
}));
if outcome.is_err() {
tracing::error!("panic in pipewire pad-audio callback");
}
})
.register()
.context("register pad-audio listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(48_000);
info.set_channels(PAD_CHANNELS as u32);
// FL FR RL RR (SPA ids 3 4 12 13): the front pair is the pad's speaker, the rear pair the
// voice coils — the DS5 device's own channel order, identity-routed (no remix wanted).
let mut positions = [0u32; 64];
positions[..4].copy_from_slice(&[3, 4, 12, 13]);
info.set_position(positions);
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize pad format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("pad pod from bytes")?];
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw pad stream connect")?;
mainloop.run();
tracing::debug!("pipewire pad-audio loop exited");
Ok(())
}
// ---- platform output: Windows (WASAPI) ------------------------------------------------------
#[cfg(windows)]
struct PadOut {
pcm_tx: std::sync::mpsc::SyncSender<Vec<f32>>,
recycle_rx: std::sync::mpsc::Receiver<Vec<f32>>,
stop: Arc<AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
#[cfg(windows)]
impl PadOut {
/// Correlate (HID container → endpoint id) and open a shared event-driven render stream ON
/// that endpoint (`audio_wasapi::render_thread`'s shape — autoconvert, default period).
fn open() -> anyhow::Result<PadOut> {
use anyhow::{anyhow, Context};
let hid_path =
first_tier_a_hid_path().ok_or_else(|| anyhow!("no tier-A pad registered"))?;
let endpoint = correlate_pad_endpoint(&hid_path)?;
tracing::info!(endpoint = %endpoint, "pad-audio endpoint correlated");
let (pcm_tx, pcm_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let (recycle_tx, recycle_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<anyhow::Result<()>>(1);
let stop = Arc::new(AtomicBool::new(false));
let stop_t = stop.clone();
let thread = std::thread::Builder::new()
.name("pf-pad-audio-out".into())
.spawn(move || {
if let Err(e) = pad_render_thread(pcm_rx, recycle_tx, stop_t, ready_tx, &endpoint) {
tracing::warn!(error = %format!("{e:#}"), "pad-audio render thread ended");
}
})
.context("spawn pad-audio render thread")?;
match ready_rx.recv_timeout(Duration::from_secs(3)) {
Ok(Ok(())) => Ok(PadOut {
pcm_tx,
recycle_rx,
stop,
thread: Some(thread),
}),
Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow!("pad-audio render init timed out")),
}
}
fn take_buffer(&self) -> Vec<f32> {
self.recycle_rx.try_recv().unwrap_or_default()
}
fn push(&self, pcm: Vec<f32>) {
let _ = self.pcm_tx.try_send(pcm); // never block the renderer; drops are concealed
}
fn finished(&self) -> bool {
self.thread.as_ref().is_none_or(|t| t.is_finished())
}
}
#[cfg(windows)]
impl Drop for PadOut {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// The WASAPI render thread ON the correlated endpoint: shared event-driven, autoconvert, 4 ch
/// f32 masked FL|FR|BL|BR (0x33 — the DS5 endpoint's own layout, so the map is identity), and
/// the session player's ring policy at the pad floor ([240, 2400] frames instead of
/// [720, 9600] — haptics are felt latency). Any device error (unplug) ends the thread; the
/// worker's `finished()` check re-correlates with backoff.
#[cfg(windows)]
fn pad_render_thread(
pcm_rx: std::sync::mpsc::Receiver<Vec<f32>>,
recycle_tx: std::sync::mpsc::SyncSender<Vec<f32>>,
stop: Arc<AtomicBool>,
ready: std::sync::mpsc::SyncSender<anyhow::Result<()>>,
endpoint_id: &str,
) -> anyhow::Result<()> {
use anyhow::{anyhow, Context};
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
if let Err(e) = wasapi::initialize_mta()
.ok()
.context("CoInitializeEx (MTA)")
{
let _ = ready.send(Err(e));
return Ok(());
}
let res = (|| -> anyhow::Result<()> {
const BLOCK_ALIGN: usize = PAD_CHANNELS * 4; // f32 interleaved
let enumerator = wasapi::DeviceEnumerator::new().context("DeviceEnumerator")?;
// Not `get_device`: that helper resolves through a freed string — see
// [`crate::audio::device_by_id`] (audio_wasapi.rs, mounted as `crate::audio` on
// Windows by lib.rs's `#[path]` swap — there is no `audio_wasapi` module name).
let device = crate::audio::device_by_id(&enumerator, &Direction::Render, endpoint_id)
.map_err(|e| anyhow!("correlated endpoint not found: {e:#}"))?;
let mut audio_client = device.get_iaudioclient().context("IAudioClient")?;
// FL|FR|BL|BR: front pair = the pad's speaker, back pair = the voice coils.
let desired = WaveFormat::new(32, 32, &SampleType::Float, 48_000, PAD_CHANNELS, Some(0x33));
let (default_period, _min_period) =
audio_client.get_device_period().context("device period")?;
let mode = StreamMode::EventsShared {
autoconvert: true,
buffer_duration_hns: default_period,
};
audio_client
.initialize_client(&desired, &Direction::Render, &mode)
.context("initialize pad render client")?;
let h_event = audio_client.set_get_eventhandle().context("event handle")?;
let render_client = audio_client
.get_audiorenderclient()
.context("IAudioRenderClient")?;
audio_client
.start_stream()
.context("start pad render stream")?;
let _ = ready.send(Ok(()));
// The adaptive jitter buffer in f32-byte units, at the pad floor (see the module doc).
let mut ring: std::collections::VecDeque<u8> = std::collections::VecDeque::new();
let mut primed = false;
let mut out = Vec::new();
while !stop.load(Ordering::Relaxed) {
if h_event.wait_for_event(100).is_err() {
continue;
}
while let Ok(mut chunk) = pcm_rx.try_recv() {
for s in chunk.iter() {
ring.extend(s.to_le_bytes());
}
chunk.clear();
let _ = recycle_tx.try_send(chunk);
}
let avail_frames = audio_client
.get_available_space_in_frames()
.context("available space")? as usize;
if avail_frames == 0 {
continue;
}
let want_bytes = avail_frames * BLOCK_ALIGN;
// Prime to ~3 quanta within [240, 2400] frames; cap ~1 quantum of slack beyond;
// instant re-prime on a genuine drain.
let target = (3 * want_bytes).clamp(240 * BLOCK_ALIGN, 2400 * BLOCK_ALIGN);
let cap = target.max(want_bytes) + want_bytes;
if ring.len() > cap {
ring.drain(..ring.len() - cap);
}
if !primed && ring.len() >= target {
primed = true;
}
out.clear();
out.resize(want_bytes, 0);
if primed {
let n = ring.len().min(want_bytes);
for (dst, b) in out.iter_mut().zip(ring.drain(..n)) {
*dst = b;
}
}
if ring.is_empty() {
primed = false;
}
render_client
.write_to_device(avail_frames, &out, None)
.context("write_to_device")?;
}
audio_client.stop_stream().ok();
Ok(())
})();
if let Err(ref e) = res {
let _ = ready.send(Err(anyhow::anyhow!("{e:#}")));
}
res
}
// ---- tests ----------------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// The speaker mode gate: only `"pad"` renders today; `"mix"` is the declared TODO and
/// reads as off; unknown values (a future store, a typo) fail safe to off.
#[test]
fn speaker_mode_gates() {
assert!(speaker_active("pad"));
assert!(!speaker_active("off"));
assert!(!speaker_active("mix")); // TODO leg — off until the mixer exists
assert!(!speaker_active(""));
assert!(!speaker_active("Pad")); // stored names are lowercase; anything else is off
}
/// Tier A needs all three facts: the DualSense/Edge VID:PID and a wired connection.
#[test]
fn tier_a_is_wired_ds5_or_edge_only() {
assert!(is_tier_a_ds5(0x054C, 0x0CE6, true)); // DualSense
assert!(is_tier_a_ds5(0x054C, 0x0DF2, true)); // DualSense Edge
assert!(!is_tier_a_ds5(0x054C, 0x0CE6, false)); // Bluetooth → no audio device
assert!(!is_tier_a_ds5(0x054C, 0x05C4, true)); // DualShock 4
assert!(!is_tier_a_ds5(0x045E, 0x0CE6, true)); // wrong vendor, right product id
assert!(!is_tier_a_ds5(0x28DE, 0x1205, true)); // Steam Deck
}
/// The Linux sink signature: USB vendor string in the node name, product in the
/// description, or the kernel's bare "Wireless Controller" fallback — and nothing else.
#[test]
fn ds5_sink_signature_matching() {
assert!(is_ds5_sink(
"alsa_output.usb-Sony_Interactive_Entertainment_Wireless_Controller-00.analog-stereo",
"Wireless Controller Analog Stereo"
));
assert!(is_ds5_sink(
"alsa_output.usb-054c_0ce6-00",
"DualSense Wireless Controller"
));
assert!(is_ds5_sink("Wireless Controller", ""));
assert!(is_ds5_sink("", "Wireless Controller Audio"));
assert!(!is_ds5_sink(
"alsa_output.pci-0000_0a_00.4.analog-stereo",
"Built-in Audio Analog Stereo"
));
// "Wireless Controller" must LEAD the string — a headset description mentioning
// "... for Wireless Controller" is not the pad.
assert!(!is_ds5_sink("headset", "Adapter for Wireless Controller"));
}
/// The Windows container matcher: container equality (case-insensitive — registry GUIDs
/// come in both cases) AND the 4-channel format gate.
#[test]
fn endpoint_pick_needs_container_and_four_channels() {
let cands = [
EndpointCandidate {
id: "{0.0.0.00000000}.{aaaa}".into(),
container: Some("{11111111-2222-3333-4444-555555555555}".into()),
channels: 2, // right container, stereo — not the pad function
},
EndpointCandidate {
id: "{0.0.0.00000000}.{bbbb}".into(),
container: Some("{99999999-2222-3333-4444-555555555555}".into()),
channels: 4, // 4-ch but another container
},
EndpointCandidate {
id: "{0.0.0.00000000}.{cccc}".into(),
container: Some("{11111111-2222-3333-4444-555555555555}".into()),
channels: 4, // the pad
},
EndpointCandidate {
id: "{0.0.0.00000000}.{dddd}".into(),
container: None,
channels: 4,
},
];
let hit = pick_pad_endpoint(&cands, "{11111111-2222-3333-4444-555555555555}").unwrap();
assert_eq!(hit.id, "{0.0.0.00000000}.{cccc}");
// Case-insensitive (Enum stores uppercase, MMDevices lowercase).
let hit = pick_pad_endpoint(
&cands,
"{11111111-2222-3333-4444-555555555555}"
.to_uppercase()
.as_str(),
)
.unwrap();
assert_eq!(hit.id, "{0.0.0.00000000}.{cccc}");
assert!(pick_pad_endpoint(&cands, "{00000000-0000-0000-0000-000000000000}").is_none());
}
/// Interface path → instance id: prefix stripped, `#` → `\`, interface-class GUID dropped;
/// garbage shapes are rejected rather than mis-keyed into the registry.
#[test]
fn hid_interface_path_to_instance_id() {
assert_eq!(
hid_instance_from_interface_path(
r"\\?\HID#VID_054C&PID_0CE6#8&2de99099&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}"
)
.as_deref(),
Some(r"HID\VID_054C&PID_0CE6\8&2de99099&0&0000")
);
// hidapi paths come lowercase and sometimes without the class GUID — both parse.
assert_eq!(
hid_instance_from_interface_path(r"\\?\hid#vid_054c&pid_0df2#7&1a2b3c4d&1&0000")
.as_deref(),
Some(r"hid\vid_054c&pid_0df2\7&1a2b3c4d&1&0000")
);
for bad in ["", "/dev/hidraw3", r"\\?\HID#VID_054C", "a#b#c#d#e"] {
assert_eq!(
hid_instance_from_interface_path(bad),
None,
"{bad:?} parsed"
);
}
}
/// The serialized VT_CLSID blob (8-byte PROPVARIANT header + registry-order GUID) parses to
/// the braced string; short/foreign blobs don't.
#[test]
fn container_blob_parses_vt_clsid() {
// {11223344-5566-7788-99aa-bbccddeeff00}: data1/2/3 little-endian on disk.
let mut blob = vec![0x48, 0, 0, 0, 1, 0, 0, 0];
blob.extend_from_slice(&[
0x44, 0x33, 0x22, 0x11, 0x66, 0x55, 0x88, 0x77, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
]);
assert_eq!(
container_guid_from_blob(&blob).as_deref(),
Some("{11223344-5566-7788-99aa-bbccddeeff00}")
);
assert_eq!(container_guid_from_blob(&blob[..20]), None); // truncated
let mut wrong_vt = blob.clone();
wrong_vt[0] = 0x41; // VT_BLOB — a format value, not a container
assert_eq!(container_guid_from_blob(&wrong_vt), None);
}
/// The 4-ch interleave: speaker frames land on channels 0/1 at the speaker cursor, haptics
/// on 2/3 at theirs, and the pop emits exactly the further-ahead kind's frame count with
/// the lagging kind's tail zeroed.
#[test]
fn mixer_interleaves_kinds_into_quad_frames() {
let mut m = QuadMixer::new();
// 2 speaker frames, 1 haptics frame.
m.push(PAD_AUDIO_KIND_SPEAKER, &[1.0, 2.0, 3.0, 4.0]);
m.push(PAD_AUDIO_KIND_HAPTICS, &[5.0, 6.0]);
assert_eq!(m.ready_frames(), 2);
let mut out = Vec::new();
assert_eq!(m.pop(&mut out), 2);
assert_eq!(out, vec![1.0, 2.0, 5.0, 6.0, 3.0, 4.0, 0.0, 0.0]);
assert_eq!(m.ready_frames(), 0);
// After the pop both cursors are back at the front: the next haptics frame starts a
// fresh quad frame with a silent speaker pair.
m.push(PAD_AUDIO_KIND_HAPTICS, &[7.0, 8.0]);
let mut out = Vec::new();
assert_eq!(m.pop(&mut out), 1);
assert_eq!(out, vec![0.0, 0.0, 7.0, 8.0]);
}
/// A single flowing kind never conjures data on the other pair (kind 1 → 0/1, kind 0 → 2/3).
#[test]
fn mixer_missing_kind_stays_zero() {
let mut m = QuadMixer::new();
m.push(PAD_AUDIO_KIND_HAPTICS, &[0.5, -0.5, 0.25, -0.25]);
let mut out = Vec::new();
assert_eq!(m.pop(&mut out), 2);
assert_eq!(out, vec![0.0, 0.0, 0.5, -0.5, 0.0, 0.0, 0.25, -0.25]);
let mut m = QuadMixer::new();
m.push(PAD_AUDIO_KIND_SPEAKER, &[0.5, -0.5]);
let mut out = Vec::new();
assert_eq!(m.pop(&mut out), 1);
assert_eq!(out, vec![0.5, -0.5, 0.0, 0.0]);
}
/// The depth bound: a wedged output can't grow the mixer past [`MAX_BUFFER_FRAMES`]; the
/// oldest frames drop and both cursors shift together (no interleave skew).
#[test]
fn mixer_caps_depth_dropping_oldest() {
let mut m = QuadMixer::new();
let chunk = vec![1.0f32; 480 * 2]; // 480 frames per push
for _ in 0..12 {
m.push(PAD_AUDIO_KIND_HAPTICS, &chunk); // 5760 frames pushed
}
assert_eq!(m.ready_frames(), MAX_BUFFER_FRAMES);
// A late speaker push still lands at ITS cursor (0 after the drops) — front of ring.
m.push(PAD_AUDIO_KIND_SPEAKER, &[9.0, 9.0]);
let mut out = Vec::new();
m.pop(&mut out);
assert_eq!(&out[..4], &[9.0, 9.0, 1.0, 1.0]);
// `discard` empties without output.
m.push(PAD_AUDIO_KIND_HAPTICS, &chunk);
m.discard();
assert_eq!(m.ready_frames(), 0);
}
/// Seq-gap PLC counting mirrors the session audio thread: nothing for the first packet or
/// in-order flow, the exact gap for a loss, the tracker's cap for a burst — and 0 before a
/// first decode (`frame_samples == 0`) while STILL consuming the gap (no phantom replay).
#[test]
fn plc_counts_gaps_like_the_session_audio_path() {
let mut gaps = AudioGapTracker::new();
assert_eq!(plc_frames(&mut gaps, 0, 480), 0); // first packet
assert_eq!(plc_frames(&mut gaps, 1, 480), 0); // in-order
assert_eq!(plc_frames(&mut gaps, 5, 480), 3); // 2,3,4 lost
assert_eq!(plc_frames(&mut gaps, 5, 480), 0); // duplicate
assert_eq!(plc_frames(&mut gaps, 4, 480), 0); // reorder — nothing to conceal
assert_eq!(plc_frames(&mut gaps, 1000, 480), 10); // burst, capped (MAX_CONCEAL_PACKETS)
// Before the first decode there is no frame size to synthesize from — but the tracker
// must still advance, or this gap would replay against the next packet.
let mut gaps = AudioGapTracker::new();
assert_eq!(plc_frames(&mut gaps, 7, 0), 0);
assert_eq!(plc_frames(&mut gaps, 12, 0), 0); // gap consumed silently
assert_eq!(plc_frames(&mut gaps, 13, 480), 0); // in-order once decoding starts
}
}