diff --git a/crates/punktfunk-host/src/audio/linux/pad_sink.rs b/crates/punktfunk-host/src/audio/linux/pad_sink.rs index cc6c23bd..cbfad6b1 100644 --- a/crates/punktfunk-host/src/audio/linux/pad_sink.rs +++ b/crates/punktfunk-host/src/audio/linux/pad_sink.rs @@ -198,12 +198,21 @@ impl crate::audio::AudioCapturer for PadSinkCapturer { } } -/// SPA channel positions for the pad quad: FL FR RL RR (`enum spa_audio_channel`: FL=3 FR=4 -/// RL=12 RR=13). NOT the session capturer's 4-ch order — the pad layout has no center/LFE; the -/// rear pair is the voice coils. +/// SPA channel positions for the pad quad: AUX0..AUX3 (`enum spa_audio_channel`: +/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned FL FR RL RR layout. This is the +/// shape a REAL DualSense exposes on the PipeWire path GE-Proton's haptics were built and +/// field-validated against: its `open_dualsense_haptic_pcm` targets the node through the +/// bundled pipewire-alsa plugin with `aux_channels=1` — "the hidden PipeWire parent for a +/// DualSense output exposes AUX0 through AUX3" (proton-ds5-haptic patch 0115) — and its pulse +/// fallback forces a `PA_CHANNEL_POSITION_AUX0..3` map. On a real pad that shape is the card's +/// Pro Audio profile (the community-reported requirement for GE ≥11-4). Aux positions carry no +/// spatial meaning, so nothing in the graph position-remixes into (or out of) the sink — +/// writers land by INDEX, exactly the raw quad the pad speaks: ch0/1 = speaker, ch2/3 = voice +/// coils (the same order the Windows endpoint is stamped with and `split_quad` assumes). fn pad_positions() -> [u32; 64] { + const AUX0: u32 = 0x1000; let mut pos = [0u32; 64]; - pos[..4].copy_from_slice(&[3, 4, 12, 13]); + pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]); pos } diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 6eb17ff8..4f4370b0 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -261,22 +261,30 @@ pub fn pad_sink_test(args: &[String]) -> Result<()> { cap.node_name, cap.node_name ); let deadline = Instant::now() + Duration::from_secs(secs); - let (mut chunks, mut samples, mut peak) = (0u64, 0u64, 0f32); + let (mut chunks, mut samples) = (0u64, 0u64); + // Per-pair peaks: ch0/1 = speaker, ch2/3 = voice coils — the split_quad contract. Proving + // the pairs separately is the point of this devtest: a positional remix upstream would + // smear or zero one pair while a global peak still looks healthy. + let (mut peak_spk, mut peak_coil) = (0f32, 0f32); let mut last_report = Instant::now(); while Instant::now() < deadline { let c = cap.next_chunk().context("pad sink capture")?; if !c.is_empty() { chunks += 1; samples += c.len() as u64; - peak = c.iter().fold(peak, |p, s| p.max(s.abs())); + for f in c.chunks_exact(4) { + peak_spk = peak_spk.max(f[0].abs()).max(f[1].abs()); + peak_coil = peak_coil.max(f[2].abs()).max(f[3].abs()); + } } if last_report.elapsed() >= Duration::from_secs(1) { last_report = Instant::now(); println!( - " chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) peak={peak:.4}", + " chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) \ + peak_speaker={peak_spk:.4} peak_coils={peak_coil:.4}", samples as f64 / (4.0 * 48.0) ); - (chunks, samples, peak) = (0, 0, 0.0); + (chunks, samples, peak_spk, peak_coil) = (0, 0, 0.0, 0.0); } } println!("pad-sink-test: done");