From 118758ff0b803271a60f48285aabe6dca9c6c40d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 12 Aug 2026 17:58:10 +0200 Subject: [PATCH] fix(pad-audio): the Linux pad sink speaks GE-Proton's AUX0-3 channel shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field report (GE-Proton 11-5, real DualSense on-host) surfaced the missing constraint: haptics only work when the pad's card runs the Pro Audio profile — because GE's route opens the node through its bundled pipewire-alsa plugin with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with stream.dont-remix (proton-ds5-haptic patches 0013/0115/0116: "the hidden PipeWire parent for a DualSense output exposes AUX0 through AUX3"). A positioned FL FR RL RR sink puts those writers through position channelmix instead of index passthrough. The sink now advertises AUX0..AUX3. Proven on the box: an AUX-mapped rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair 0.3662); a positioned stray stream folds into the speaker pair and never excites the coils. The devtest reports per-pair peaks so exactly this class of remix bug is visible. Also confirmed from the GE patch set while here: device matching is device.bus/vendor.id/product.id + the Sony/Wireless_Controller name substrings (both of which the sink carries), and the MMDevice container is now synthesized from the wine-side HID USB parent (patch 0112) — the old pure-PW-node GUID_NULL concern no longer applies on GE >= 11-4. --- .../punktfunk-host/src/audio/linux/pad_sink.rs | 17 +++++++++++++---- crates/punktfunk-host/src/devtest.rs | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) 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");