From fb05145e36109eeae881ff396d35aeeea6627591 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 16 Aug 2026 00:31:56 +0200 Subject: [PATCH] The virtual pad wore one AUX node where a real DualSense shows a three-node split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A game that renders DS5 haptics writes a POSITIONED FL/FR/RL/RR quad, because that is the only public 4-channel surface a physically connected pad publishes. We minted a single AUX0..AUX3 node wearing the mono sink's `Speaker__sink` name, so that write was position-remixed on arrival and the coil pair folded away — measured on .181, `peak_speaker=0.2441` with `peak_coils=0.0000`. The haptics were discarded silently: nothing errored, nothing logged, the sink looked healthy. Measured a real DS5 (054c:0ce6, USB) on a SteamOS 3.7 Deck running alsa-ucm-conf 1.2.14-2.4 and minted what it actually presents — a card's worth of nodes, not one: alsa_output.hw_punktfunkpad_0 Audio/Sink/Internal 4ch AUX0..AUX3 …-.HiFi__SpeakerHaptic__sink Audio/Sink 4ch FL FR RL RR …-.HiFi__Speaker__sink Audio/Sink 1ch MONO with the public pair naming the hidden parent in `api.alsa.split.name` (GE-Proton's `pipewire:NODE=` haptic leg) and the parent naming itself, exactly as the specimen does. Everything written to any of the three is summed onto one hardware quad, which a real pad gets free from ALSA SplitPCM and we have to do by hand — GE drives the haptic leg and the controller-effect leg AT ONCE by design, so emitting each node's buffers straight into the chunk channel would interleave them and gap both halves. The UCM also settles two things this file had wrong: - The four hardware channels: `Headphones` takes Channel0 0/Channel1 1, `Speaker` takes Channel0 1, both haptic devices take Channel2 2/Channel3 3. So ch1 is the built-in mono speaker, and a mono write landing on ch0 (what a bare AUX node does) would have played controller effects into the headphone LEFT channel with the speaker silent. - A plain DualSense's USB iProduct is "DualSense Wireless Controller", model word included. The `DualSense_` infix was dropped here to keep `Sony_Interactive_Entertainment_Wireless_Controller` contiguous — a property no real pad has either, so the name now carries the infix and drops the invented MAC (a real pad has no USB iSerialNumber; the trailing ALSA card index disambiguates). `device.vendor.id`/`device.product.id` also gain the `0x` prefix the specimen publishes. `strtol(s,_,16)` and `strtoul(s,_,0)` both yield 0x054c for "0x054c", while the bare "054c" we published is parse-dependent — base 0 reads it as octal 054, stops at the `c`, and yields 44, matching nothing. On glass on .181, index-exact in every leg (amplitudes encode the source channel): positioned coils-only → SpeakerHaptic__sink speaker 0.0000 coils 0.4883 (was 0.0000) AUX coils-only → parent speaker 0.0000 coils 0.4883 (unchanged) positioned front-only → SpeakerHaptic__sink speaker 0.3052 coils 0.0000 mono → Speaker__sink speaker 0.2747 coils 0.0000 both legs concurrently speaker 0.2747 coils 0.4883 Three deliberate deviations from the specimen stay, each documented at the head of the module: `node.description` keeps "Wireless Controller" (FF14/FF7R case-sensitive `wcsstr`), `priority.session` stays low (our nodes come and go with pad arrival and must never win a default-sink election), and `api.alsa.split.position` is not set (it is WirePlumber's own management trigger). Gate: clippy --all-targets -D warnings clean, 9/9 pad_sink tests, fmt clean, all in punktfunk-rust-ci linux/amd64. --- .../src/audio/linux/pad_sink.rs | 928 ++++++++++++------ crates/punktfunk-host/src/devtest.rs | 32 +- 2 files changed, 667 insertions(+), 293 deletions(-) diff --git a/crates/punktfunk-host/src/audio/linux/pad_sink.rs b/crates/punktfunk-host/src/audio/linux/pad_sink.rs index e3243e9f..b65e1170 100644 --- a/crates/punktfunk-host/src/audio/linux/pad_sink.rs +++ b/crates/punktfunk-host/src/audio/linux/pad_sink.rs @@ -1,46 +1,77 @@ -//! Per-pad DualSense audio sink (Linux): one PipeWire `Audio/Sink` stream node per -//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's -//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker -//! audio finds "the controller's audio device" and plays into us. We own the sink, so the -//! `process()` callback IS the capture: 4-ch F32 48 kHz (AUX0..AUX3 — front pair = speaker, -//! back pair = voice coils, the same quad *order* the Windows endpoint is stamped with) lands -//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`). +//! Per-pad DualSense audio topology (Linux): the PipeWire node graph a **physically connected** +//! DualSense presents, minted for a virtual pad — so a game that renders voice-coil haptics or +//! pad-speaker audio finds "the controller's audio device" in the shape it was written against +//! and plays into us. We own the nodes, so `process()` IS the capture: everything written to any +//! of them is mixed into one 4-ch F32 48 kHz quad (ch0/1 = speaker/headphone pair, ch2/3 = voice +//! coils) that feeds the 0xD1 lanes (`native/pad_audio.rs`). //! -//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread, -//! Terminate channel, ready handshake, bounded lossy chunk hand-off) with two deliberate -//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY -//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware. +//! ## The specimen //! -//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton matches -//! layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`, -//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings -//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community -//! WirePlumber rule keys on the node-name substring and sets `node.description = -//! "Wireless Controller"` (we mint it that way from the start). +//! Measured 2026-08-15 against a real DS5 (`054c:0ce6`) wired to a SteamOS 3.7 Deck running +//! `alsa-ucm-conf` 1.2.14-2.4, whose `USB-Audio/Sony/DualSense-PS5` UCM splits the pad's single +//! 4-channel PCM into **one card object plus three playback nodes**: //! -//! We wear a real pad's **name** and Pro Audio's **channel layout** — deliberately not the same -//! profile for both, because no single real-pad profile satisfies GE on its own. +//! | node | `media.class` | format | +//! |---|---|---| +//! | `alsa_output.hw_Controller_0` | `Audio/Sink/Internal` (hidden parent, `api.alsa.split.parent`) | S16LE **4ch AUX0..AUX3** | +//! | `…-00.HiFi__SpeakerHaptic__sink` | `Audio/Sink` | F32LE **4ch POSITIONED FL FR RL RR** | +//! | `…-00.HiFi__Speaker__sink` | `Audio/Sink` | F32LE **1ch MONO** | //! -//! Since alsa-ucm-conf gained `USB-Audio/Sony/DualSense-PS5` (2026-08-03) a real pad's profiles -//! are UCM SplitPCM views of one 4-channel PCM: a mono `Speaker__sink`, a stereo `Headphones` -//! sink, and a 4-channel `Direct__Direct__sink` (added "for wine compatibility"), plus ACP's -//! always-present Pro Audio. GE renders haptics as an `AUX0..AUX3` stream, so on every -//! *positioned* profile the graph re-mixes and the voice-coil pair is folded away — that is the -//! whole content of the field advice "you only need the controller audio set to Pro Audio", and -//! it is why this sink is one flat AUX quad rather than an emulation of the split topology. But -//! the pad-SPEAKER half of GE only binds to a sink whose name says `Speaker__sink`, and its -//! Windows 4-channel format forcing hangs off the same test. So the name says `Speaker__sink` -//! and the channels are Pro Audio's. GE explicitly supports that combination on real hardware -//! (see [`split_target`] and the node-name comment). +//! The UCM also states what the four hardware channels ARE, which nothing else documents: +//! `Headphones` takes `Channel0 0`/`Channel1 1`, `Speaker` takes `Channel0 1`, and both haptic +//! devices take `Channel2 2`/`Channel3 3`. So **ch0 = headphone L, ch1 = headphone R AND the +//! internal mono speaker, ch2/ch3 = the two voice coils** — the layout `split_quad` and the +//! Windows endpoint stamp already assume, now confirmed against hardware rather than inferred. //! -//! What a pure PipeWire node still cannot satisfy is wine's ContainerId derivation (udev walk to -//! a `usb_device` parent → `GUID_NULL`; our pad is uhid and has no USB parent at all) and GE's -//! raw-ALSA leg (`snd_pcm_open` on an `api.alsa.path` that must be a real card). Its -//! `pipewire:NODE=` leg we *can* satisfy — see [`split_target`]. Every identity string has an -//! env override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC` with -//! `{pad}` / `{mac}` placeholders, `PUNKTFUNK_PAD_SINK_SPLIT_NAME`). +//! ## Why all three, and not just the AUX quad +//! +//! We used to mint ONE node: a 4-channel AUX quad wearing the mono sink's `Speaker__sink` name. +//! That satisfies GE-Proton's haptic leg (it opens `api.alsa.split.name` as `pipewire:NODE=…` +//! with `aux_channels=1` — the AUX shape is exactly right there), but it is not what a writer +//! aimed at a real pad meets. A real pad's only PUBLIC 4-channel surface is **positioned** +//! FL/FR/RL/RR, and a positioned quad landing on an AUX node is position-remixed: measured on +//! .181, `peak_speaker=0.2441` with `peak_coils=0.0000` — the coil pair is folded away and the +//! haptics are silently discarded. Minting the real split means both routes land correctly: +//! positioned writers take `SpeakerHaptic__sink`, AUX writers take the parent, and the mono +//! controller-speaker takes `Speaker__sink` at the pad's real speaker channel (ch1, not ch0). +//! +//! ## Identity +//! +//! GE-Proton matches layered — pulse proplist (`device.bus == "usb"`, +//! `device.vendor.id == 0x054c`, `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings +//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`). pipewire-pulse fills a +//! sink's proplist from the node's own props verbatim (`fill_sink_info_proplist`), so every key +//! here reaches a wine/Proton client. The ids carry the **`0x` prefix the specimen publishes**: +//! `strtol(s, _, 16)` and `strtoul(s, _, 0)` both yield 0x054c for `"0x054c"`, while the bare +//! `"054c"` we used to publish is parse-dependent (base 0 reads it as octal `054`, stops at the +//! `c`, and yields 44 — no match at all). +//! +//! What a pure PipeWire graph still cannot satisfy is wine's ContainerId derivation (udev walk to +//! a `usb_device` parent → `GUID_NULL`; our pad is uhid and has no USB parent) and GE's raw-ALSA +//! leg (`snd_pcm_open` on an `api.alsa.path` that must be a real card, which is why we never set +//! that key). Its `pipewire:NODE=` leg we *do* satisfy — see [`split_target`]. +//! +//! Deliberate deviations from the specimen, each for a reason that outranks fidelity: +//! - `node.description` stays **"Wireless Controller"**, not the specimen's "DualSense wireless +//! controller (PS5)": wine hands `node.description` to `PKEY_Device_FriendlyName` and FF14/FF7R +//! do a case-sensitive `wcsstr(name, L"Wireless Controller")` that a lowercase "wireless" +//! fails. GE papers over this on real pads via `PROTON_SONY_WINDOWS_DEVICE_NAMES`; we do not +//! need the workaround if we never present the losing string. `PUNKTFUNK_PAD_SINK_DESC` flips +//! it for a field A/B. +//! - `priority.session` stays low (specimen: 1100 parent / 90 public). Our nodes appear and +//! vanish with pad arrival and must never win WirePlumber's default-sink election — games reach +//! them BY IDENTITY, nothing may auto-route here. +//! - `api.alsa.split.position` is NOT set even though the specimen carries it: that key is +//! WirePlumber's own `split_nodes_om` trigger, and inviting WirePlumber to manage nodes it did +//! not create is a different bug. `api.alsa.split.hw-position` (informational) we do carry. +//! +//! Every identity string has an env override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / +//! `PUNKTFUNK_PAD_SINK_DESC` with `{pad}` / `{mac}` placeholders, `PUNKTFUNK_PAD_SINK_SPLIT_NAME`, +//! `PUNKTFUNK_PAD_SINK_PARENT_CLASS`). use anyhow::{anyhow, Context, Result}; +use std::cell::RefCell; +use std::rc::Rc; use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError}; use std::thread; use std::time::Duration; @@ -48,8 +79,7 @@ use std::time::Duration; /// Message asking the PipeWire loop thread to quit (sent from `Drop`). struct Terminate; -/// The pad sink's fixed channel count — quad, mirroring the Windows endpoint stamp -/// (`native/pad_audio.rs::CAP_CHANNELS` splits on the same layout). +/// The pad's fixed channel count — the hardware quad every node is mixed down onto. const PAD_CHANNELS: u32 = 4; /// How many pad slots may carry a sink (`PUNKTFUNK_PAD_AUDIO_SLOTS`, default all 4 — a PipeWire @@ -78,9 +108,9 @@ pub(crate) fn pipewire_reachable() -> bool { /// The pad's virtual MAC as colon-separated display hex — [`ds_pairing_reply`]'s bytes 1..7 /// are LSB-first (the report layout `hid-playstation` adopts as the HID `uniq` via `%pMR`, -/// i.e. printed reversed), so the display form reverses them. Unique per pad (the low octet -/// carries the pad index), which keeps multi-pad sinks distinct for the same reason the MAC -/// itself must be: SDL/Steam and the matchers dedup by serial. +/// i.e. printed reversed), so the display form reverses them. The audio nodes no longer carry it +/// (a real pad has no USB `iSerialNumber`, so neither do we — see [`PadSinkIdentity::new`]), but +/// it stays available to the `{mac}` override placeholder for field debugging. /// /// [`ds_pairing_reply`]: pf_inject::dualsense_proto::ds_pairing_reply fn pad_mac(pad: u8) -> String { @@ -101,15 +131,23 @@ fn expand(template: &str, pad: u8, mac: &str) -> String { .replace("{mac}", mac) } -/// The full identity a pad sink wears, resolved once at open. +/// The full identity the pad's nodes wear, resolved once at open. struct PadSinkIdentity { - node_name: String, + /// The public **mono** speaker sink — what GE-Proton's `is_dualsense_speaker_sink` binds the + /// controller-effect stream to, and the node the whole identity hangs off. + speaker_name: String, + /// The public **positioned quad** sink (UCM `SpeakerHaptic`) — where a writer aimed at a real + /// modern pad puts haptics. + haptic_name: String, + /// The hidden **AUX quad** parent: our capture point and GE's `pipewire:NODE=` target. + parent_name: String, description: String, serial: String, product_id: &'static str, product_name: &'static str, card_name: &'static str, long_card_name: String, + components: &'static str, /// GE-Proton's `api.alsa.split.name` — the node name it opens as `pipewire:NODE=…` for the /// haptic stream. Empty disables the key. See [`split_target`]. split_name: String, @@ -118,33 +156,30 @@ struct PadSinkIdentity { /// GE-Proton reads `api.alsa.split.name` off the sink it is about to render haptics into and, /// on its preferred leg, opens *that* node through its bundled pipewire-alsa plugin as /// `pipewire:NODE=` with `aux_channels=1` (patches 0114/0115/0116 of `proton-ds5-haptic`). -/// On a real pad the key names the **hidden 4-channel parent** WirePlumber mints for the UCM -/// SplitPCM profile — the public mono `Speaker__sink` is only a 1-channel split of it, so -/// rendering four channels at the public sink would lose the voice-coil pair. +/// On the specimen the key names the hidden 4-channel parent — the public mono `Speaker__sink` is +/// only a 1-channel split of it, so rendering four channels at the public sink would lose the +/// voice-coil pair. We now mint that same parent, so the key names it too (it used to name the +/// sink itself, which was the honest answer while we had no split). /// -/// We have no split: the sink IS the four-channel AUX node, so the honest value of the key is -/// our own `node.name` — GE then targets us directly instead of falling back to a leg that was -/// written to work around a topology we do not have. Without the key that leg cannot engage at -/// all (`get_dualsense_haptic_target` returns NULL), which is why titles GE auto-switches into -/// "Windows Sony audio mode" (the 8-format-probe games: Assassin's Creed, Death Stranding DC, -/// MH Wilds) never reached our sink. +/// The parent's OWN copy of the key is self-referential, exactly as on the specimen +/// (`alsa_output.hw_Controller_0` carries `api.alsa.split.name = alsa_output.hw_Controller_0`). /// /// `PUNKTFUNK_PAD_SINK_SPLIT_NAME` is the field lever: `0`/`false`/`off` drops the key (GE then -/// takes its Pulse leg, which also works for us because our channel positions already match its -/// forced `AUX0..AUX3` map), any other value overrides the target verbatim. -fn split_target(node_name: &str) -> String { +/// takes its Pulse leg, which also works for us because the parent's channel positions already +/// match its forced `AUX0..AUX3` map), any other value overrides the target verbatim. +fn split_target(parent_name: &str) -> String { resolve_split_target( - node_name, + parent_name, std::env::var("PUNKTFUNK_PAD_SINK_SPLIT_NAME").ok(), ) } /// [`split_target`]'s decision, with the environment lifted out so it is testable. -fn resolve_split_target(node_name: &str, override_var: Option) -> String { +fn resolve_split_target(parent_name: &str, override_var: Option) -> String { match override_var.as_deref().map(str::trim) { Some("0" | "false" | "off" | "no") => String::new(), Some(v) if !v.is_empty() => v.to_string(), - _ => node_name.to_string(), + _ => parent_name.to_string(), } } @@ -152,97 +187,236 @@ impl PadSinkIdentity { fn new(pad: u8, edge: bool) -> PadSinkIdentity { let mac = pad_mac(pad); let mac_bare: String = mac.chars().filter(|c| *c != ':').collect(); - // The pad's USB `iProduct` string verbatim — a plain DualSense reports "Wireless - // Controller" with NO model word (only the Edge carries one). Getting this wrong is not - // cosmetic: udev builds the ALSA name out of manufacturer+product, so an invented - // `DualSense_` infix broke the contiguous `Sony_Interactive_Entertainment_Wireless_ - // Controller` substring that the community WirePlumber rule and GE-Proton's - // `alsa_output.usb-Sony_Interactive_Entertainment_…` matchers key on. - let (usb_product, product_id, product_name, card_name) = if edge { + // The pad's USB `iProduct` verbatim. The specimen settles a question this file previously + // got backwards: a plain DualSense reports **"DualSense Wireless Controller"**, model word + // included — its ALSA card is `usb-Sony_Interactive_Entertainment_DualSense_Wireless_ + // Controller-00`, so `Sony_Interactive_Entertainment_Wireless_Controller` is NOT contiguous + // on real hardware either, and dropping the infix to preserve that substring was chasing a + // property no pad has. GE's own fallback is a two-substring test (`alsa_output.usb-Sony_ + // Interactive_Entertainment_` AND `Wireless_Controller`), which the real form passes. + let (usb_product, product_id, product_name, card_name, components) = if edge { ( "DualSense_Edge_Wireless_Controller", - "0df2", + "0x0df2", "DualSense Edge Wireless Controller", "DualSense Edge Wireless Controller", + "USB054c:0df2", ) } else { ( - "Wireless_Controller", - "0ce6", + "DualSense_Wireless_Controller", + "0x0ce6", + "DualSense wireless controller (PS5)", "DualSense Wireless Controller", - "Wireless Controller", + "USB054c:0ce6", ) }; - // udev's `ID_SERIAL`: manufacturer_product_serial. A real pad has no USB serial, so ALSA - // falls back to the card index; we carry the pad's virtual MAC there instead, which keeps - // multi-pad sinks distinct without disturbing the matched prefix. - let serial = format!("Sony_Interactive_Entertainment_{usb_product}_{mac_bare}"); - // The `…-00.__Speaker__sink` suffix is LOAD-BEARING, not decoration. GE-Proton's - // `is_dualsense_speaker_sink()` is a pure substring test for `Speaker__sink` (plus the - // USB ids, or the `alsa_output.usb-Sony_Interactive_Entertainment_` + `Wireless_Controller` + // udev's `ID_SERIAL` is manufacturer_product[_serial]. A real DualSense has **no USB + // iSerialNumber**, so the specimen's is just manufacturer_product and ALSA disambiguates + // multiple cards with the trailing index instead. We do the same and put the pad index + // there — which also drops the invented MAC infix that no real pad's name carries. + let serial = format!("Sony_Interactive_Entertainment_{usb_product}"); + let card_index = format!("{pad:02}"); + // The `…-.HiFi__Speaker__sink` suffix is LOAD-BEARING, not decoration. GE-Proton's + // `is_dualsense_speaker_sink()` is a pure substring test for `Speaker__sink` (plus the USB + // ids, or the `alsa_output.usb-Sony_Interactive_Entertainment_` + `Wireless_Controller` // pair we also carry), and three things hang off it: `apply_windows_sony_audio_format()` // forces the wine endpoint to the Windows 4×48 kHz `KSAUDIO_SPEAKER_QUAD` layout DS5 // titles probe for, the pad-SPEAKER (mono controller-effect) streams will only bind and // retarget to a sink it accepts, and the whole controller-audio endpoint lands on the - // identity Spider-Man's working path used. A suffix naming any other profile — the - // `analog-surround-40` we used to mint, or a truthful `pro-output-0` — matches none of - // it, which left the speaker half of this feature with nothing to attach to. + // identity Spider-Man's working path used. // - // Carrying `Speaker__sink` AND [`split_target`] at once is a real pad's shape, not a - // contrivance: GE's own `is_dualsense_endpoint_speaker_sink` notes that "Edge speaker - // sinks may also carry raw haptic metadata", and handles the pair by keeping them as - // routing targets while withholding the *shared* mono endpoint id (a Spider-Man - // enumeration crash). The exclusion GE once had in `is_dualsense_speaker_sink` itself is - // gone. What we do NOT copy is a real pad's mono channel count: the sink stays four raw - // AUX channels — which is exactly what that endpoint is forced to advertise anyway. - let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") { + // ⚠ `SpeakerHaptic__sink` deliberately does NOT contain `Speaker__sink` (the `Haptic` + // infix breaks the substring), which is exactly how the specimen keeps the two apart — + // GE binds the mono speaker to the mono sink and nothing else. + let speaker_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") { Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare), - _ => format!("alsa_output.usb-{serial}-00.HiFi__Speaker__sink"), + _ => format!("alsa_output.usb-{serial}-{card_index}.HiFi__Speaker__sink"), }; + let haptic_name = + format!("alsa_output.usb-{serial}-{card_index}.HiFi__SpeakerHaptic__sink"); + // The specimen's parent is named after its ALSA PCM (`hw:Controller,0` → + // `alsa_output.hw_Controller_0`). Ours names the pseudo-card the pad would be. + let parent_name = format!("alsa_output.hw_punktfunkpad{pad}_0"); // What the community WirePlumber rule renames real pads TO — minted that way directly. - // Deliberately NOT the udev/hwdb description a real card gets ("DualSense wireless - // controller (PS5)"): wine hands `node.description` straight to the endpoint's - // `PKEY_Device_FriendlyName`, and the title matchers do a case-sensitive - // `wcsstr(name, L"Wireless Controller")` (FF14, FF7R) that a lowercase "wireless" fails. + // See the module docs for why this is NOT the specimen's hwdb description. let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") { Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac), _ => "Wireless Controller".to_string(), }; - let split_name = split_target(&node_name); + let split_name = split_target(&parent_name); PadSinkIdentity { long_card_name: format!( "Sony Interactive Entertainment {card_name} at usb-punktfunk-pad{pad}, full speed" ), - node_name, + speaker_name, + haptic_name, + parent_name, description, serial, product_id, product_name, card_name, + components, split_name, } } } -/// A live per-pad sink + its capture. Same next-chunk contract as every -/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet sink (keep me), `Err` = -/// dead loop thread (reopen me). Dropping tears the sink node down promptly via the Terminate +/// Which node a buffer arrived on. The discriminant is the mixer's contribution bit. +#[derive(Clone, Copy, PartialEq, Eq)] +enum PadNode { + /// Hidden AUX quad parent — GE's haptic leg and any Pro-Audio-shaped writer. + Parent = 0, + /// Public positioned quad (UCM `SpeakerHaptic`). + Haptic = 1, + /// Public mono speaker. + Speaker = 2, +} + +impl PadNode { + /// Source-channel → hardware-quad-channel map for this node. + /// + /// The two quads are identity: the parent already speaks the hardware order, and the + /// positioned sink's FL/FR/RL/RR line up with it one-for-one — which is precisely the UCM's + /// `HeadphonesHaptic` mapping (`Channel0 0, Channel1 1, Channel2 2, Channel3 3`). We take + /// that over `SpeakerHaptic`'s own `Channel0 1, Channel1 1` fold because summing FL+FR into + /// ch1 exists only to feed a physical mono speaker; we are shipping the quad onward to a + /// client that may have headphones in the pad's jack, and destroying the stereo pair here + /// could not be undone there. + /// + /// The mono speaker maps to **ch1**, per the UCM's `Speaker` device (`Channel0 1`) — the pad's + /// built-in speaker is hardware channel 1, not channel 0. Landing it on ch0 (which is what a + /// mono stream into a bare AUX node does) would put controller-effect audio in the headphone + /// LEFT channel and leave the speaker silent. + fn channel_map(self) -> &'static [usize] { + match self { + PadNode::Parent | PadNode::Haptic => &[0, 1, 2, 3], + PadNode::Speaker => &[1], + } + } + + /// How many channels the node's own format carries. + fn src_channels(self) -> usize { + match self { + PadNode::Parent | PadNode::Haptic => 4, + PadNode::Speaker => 1, + } + } +} + +/// Sums what the three nodes receive into one hardware quad. +/// +/// A real pad needs no mixer — its public sinks are ALSA SplitPCM views that the kernel sums into +/// one PCM. We have three independent PipeWire nodes, and GE drives two of them AT ONCE by design +/// (the haptic leg on the parent, the controller-effect leg on the mono sink), so summing is not +/// optional: emitting each node's buffers straight into the chunk channel would interleave them +/// and gap both halves ~50%. +/// +/// Alignment is by contribution round, not by timestamp: a node that contributes twice before its +/// peers have contributed once closes the window. With every node on the same graph quantum that +/// is sample-exact; when quanta differ it costs sub-quantum skew, which haptics cannot resolve. +/// The cost is one quantum of buffering (~5 ms at the usual 240-frame quantum). +struct Mixer { + /// Accumulation buffer, 4-ch interleaved. + buf: Vec, + /// Frames currently accumulated (the longest contribution this round). + frames: usize, + /// Bitmask of [`PadNode`]s that have contributed to the open window. + contributed: u8, + tx: std::sync::mpsc::SyncSender>, + /// Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible drops + /// cost a field investigation on the desktop plane once — count and warn, power-of-two + /// throttled (this runs at the graph quantum). + dropped: u64, +} + +impl Mixer { + fn new(tx: std::sync::mpsc::SyncSender>) -> Mixer { + Mixer { + buf: Vec::new(), + frames: 0, + contributed: 0, + tx, + dropped: 0, + } + } + + fn add(&mut self, node: PadNode, samples: &[f32]) { + let src = node.src_channels(); + let bit = 1u8 << (node as u8); + // This node is starting its next round — close the one its peers already summed into. + if self.contributed & bit != 0 { + self.flush(); + } + self.contributed |= bit; + let quad = PAD_CHANNELS as usize; + let n = samples.len() / src; + if self.buf.len() < n * quad { + self.buf.resize(n * quad, 0.0); + } + self.frames = self.frames.max(n); + for (f, frame) in samples.chunks_exact(src).enumerate() { + for (s, &out) in node.channel_map().iter().enumerate() { + self.buf[f * quad + out] += frame[s]; + } + } + } + + fn flush(&mut self) { + self.contributed = 0; + if self.frames == 0 { + return; + } + let used = self.frames * PAD_CHANNELS as usize; + let out = self.buf[..used].to_vec(); + self.buf[..used].fill(0.0); + self.frames = 0; + if self.tx.try_send(out).is_err() { + self.dropped += 1; + if self.dropped.is_power_of_two() { + tracing::warn!( + dropped = self.dropped, + "pad-audio encode thread not keeping up — captured pad audio dropped \ + (haptics will click)" + ); + } + } + } +} + +/// Per-stream callback state. +struct PadUd { + mix: Rc>, + node: PadNode, +} + +/// A live per-pad node graph + its capture. Same next-chunk contract as every +/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet pad (keep me), `Err` = +/// dead loop thread (reopen me). Dropping tears the nodes down promptly via the Terminate /// channel (a wedged PipeWire link head-blocks the daemon — see the session capturer's docs). pub struct PadSinkCapturer { chunks: Receiver>, quit: pipewire::channel::Sender, - /// The minted node name, for logs and the devtest. + /// The public mono speaker sink — the identity a title has to match. Kept as `node_name` for + /// the devtest and logs. pub node_name: String, - /// What GE-Proton will read as `api.alsa.split.name`; empty when the key is suppressed. + /// The public positioned-quad sink (UCM `SpeakerHaptic`). + pub haptic_name: String, + /// The hidden AUX parent, and what GE-Proton reads as `api.alsa.split.name`; empty when the + /// key is suppressed. pub split_name: String, } impl PadSinkCapturer { - /// Mint the sink for wire pad `pad` (`edge` = DualSense Edge identity) and start capturing. - /// Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the retry. + /// Mint the node graph for wire pad `pad` (`edge` = DualSense Edge identity) and start + /// capturing. Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the + /// retry. pub fn open(pad: u8, edge: bool) -> Result { let identity = PadSinkIdentity::new(pad, edge); - let node_name = identity.node_name.clone(); + let node_name = identity.speaker_name.clone(); + let haptic_name = identity.haptic_name.clone(); + let parent_name = identity.parent_name.clone(); let split_name = identity.split_name.clone(); let (tx, rx) = sync_channel::>(64); let (quit_tx, quit_rx) = pipewire::channel::channel::(); @@ -262,9 +436,8 @@ impl PadSinkCapturer { Ok(Err(e)) => return Err(e), Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")), } - // The identity a title has to match, in the log a field report will carry. Cheap once - // per pad, and it is the only place the negotiated strings are visible without a live - // `pactl` on the box. + // The identity a title has to match, in the log a field report will carry. Cheap once per + // pad, and it is the only place the negotiated strings are visible without a live `pactl`. let split_log = if split_name.is_empty() { "(suppressed)" } else { @@ -273,14 +446,18 @@ impl PadSinkCapturer { tracing::info!( pad, edge, - node_name = %node_name, + speaker_sink = %node_name, + haptic_sink = %haptic_name, + parent = %parent_name, split_name = %split_log, - "pad-audio sink minted (Pro Audio shape: 4ch AUX0..AUX3, ch0/1 speaker, ch2/3 coils)" + "pad-audio nodes minted (real-pad split: mono Speaker__sink + positioned \ + SpeakerHaptic__sink + hidden AUX parent; ch0/1 speaker, ch2/3 coils)" ); Ok(PadSinkCapturer { chunks: rx, quit: quit_tx, node_name, + haptic_name, split_name, }) } @@ -297,8 +474,8 @@ impl crate::audio::AudioCapturer for PadSinkCapturer { fn next_chunk(&mut self) -> Result> { match self.chunks.recv_timeout(Duration::from_secs(5)) { Ok(c) => Ok(c), - // A quiet pad sink (no game rendering pad audio — the common case) is NOT a - // failure; the per-pad streamer keeps us and its silence gate stays closed. + // A quiet pad (no game rendering pad audio — the common case) is NOT a failure; the + // per-pad streamer keeps us and its silence gate stays closed. Err(RecvTimeoutError::Timeout) => Ok(Vec::new()), Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire pad-sink thread ended")), } @@ -309,28 +486,107 @@ impl crate::audio::AudioCapturer for PadSinkCapturer { } } -/// 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] { +/// SPA channel positions for the parent's AUX quad (`enum spa_audio_channel`: +/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned layout. This is the shape the +/// specimen's hidden parent exposes and the one GE-Proton's haptics were built 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. Aux positions carry no spatial meaning, so nothing in the +/// graph position-remixes into or out of the parent: writers land by INDEX, exactly the raw quad +/// the pad speaks. +fn aux_positions() -> [u32; 64] { const AUX0: u32 = 0x1000; let mut pos = [0u32; 64]; pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]); pos } -/// The `!Send` MainLoop/Stream thread: mint the sink, hand capture chunks over, run until -/// Terminate / daemon death. Mirrors the session capturer's `pw_thread` stream-sink arm minus -/// the default-sink claim and the desktop-plane stats (the pad plane's observability lives in -/// the streamer's gate/encode logs). +/// SPA positions for the public quad: FL, FR, RL, RR — the specimen's `SpeakerHaptic__sink` +/// layout, and the only 4-channel surface a real pad publishes. A writer aimed at real hardware +/// sends exactly this, so accepting it unfolded is the whole point of minting the node. +fn positioned_quad() -> [u32; 64] { + let mut pos = [0u32; 64]; + // spa_audio_channel: FL = 3, FR = 4, RL = 12, RR = 13. + pos[..4].copy_from_slice(&[3, 4, 12, 13]); + pos +} + +/// SPA positions for the public mono speaker sink (`SPA_AUDIO_CHANNEL_MONO` = 2). +fn mono_position() -> [u32; 64] { + let mut pos = [0u32; 64]; + pos[0] = 2; + pos +} + +/// Serialize an `EnumFormat` pod for one node's F32LE 48 kHz layout. +fn format_pod(channels: u32, positions: [u32; 64]) -> Result> { + use pipewire as pw; + use pw::spa::param::audio::{AudioFormat, AudioInfoRaw}; + let mut info = AudioInfoRaw::new(); + info.set_format(AudioFormat::F32LE); + info.set_rate(crate::audio::SAMPLE_RATE); + info.set_channels(channels); + 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(), + }; + Ok(pw::spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &pw::spa::pod::Value::Object(obj), + ) + .context("serialize pad-sink format pod")? + .0 + .into_inner()) +} + +/// The shared `process()` body, as a closure — a macro rather than a function so the PipeWire +/// stream type never has to be named (it is only reachable through the builder's inference). +macro_rules! pad_process { + () => { + |stream, ud: &mut PadUd| { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let Some(mut buffer) = stream.dequeue_buffer() else { + return; + }; + let datas = buffer.datas_mut(); + if datas.is_empty() { + return; + } + let d = &mut datas[0]; + let (offset, size) = { + let c = d.chunk(); + (c.offset() as usize, c.size() as usize) + }; + let Some(buf) = d.data() else { return }; + if offset > buf.len() { + return; + } + let region = &buf[offset..(offset + size).min(buf.len())]; + // Negotiated as F32LE; reinterpret the byte region as interleaved f32. + let n = region.len() / 4; + let mut samples = Vec::with_capacity(n); + for i in 0..n { + samples.push(f32::from_le_bytes([ + region[i * 4], + region[i * 4 + 1], + region[i * 4 + 2], + region[i * 4 + 3], + ])); + } + ud.mix.borrow_mut().add(ud.node, &samples); + })); + if outcome.is_err() { + tracing::error!("panic in pipewire pad-sink callback — chunk dropped"); + } + } + }; +} + +/// The `!Send` MainLoop/Stream thread: mint the three nodes, hand mixed capture chunks over, run +/// until Terminate / daemon death. fn pad_sink_thread( tx: std::sync::mpsc::SyncSender>, quit_rx: pipewire::channel::Receiver, @@ -339,7 +595,7 @@ fn pad_sink_thread( ) -> Result<()> { use pipewire as pw; use pw::{properties::properties, spa}; - use spa::param::audio::{AudioFormat, AudioInfoRaw}; + use spa::param::audio::AudioInfoRaw; use spa::pod::Pod; let result = (|| -> Result<()> { @@ -369,63 +625,86 @@ fn pad_sink_thread( }) .register(); - let mut props = properties! { - *pw::keys::MEDIA_TYPE => "Audio", - *pw::keys::MEDIA_CLASS => "Audio/Sink", - // One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are - // felt latency; bursty delivery would ride through to the client's jitter buffer. - *pw::keys::NODE_LATENCY => "240/48000", - // Must NEVER win WirePlumber's default election against real hardware — games reach - // this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either). - "priority.session" => "50", - // The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus - // the human-readable pair pavucontrol and the game view show. Every one of these - // reaches a wine/Proton client verbatim — pipewire-pulse fills a sink's proplist - // from the node's own props (`fill_sink_info_proplist`), it does not curate them. - "device.bus" => "usb", - "device.vendor.id" => "054c", - "device.vendor.name" => "Sony Interactive Entertainment", - "device.form_factor" => "gamepad", - "device.icon_name" => "audio-card-analog-usb", - // The shape, stated as props and not only as a negotiated format: four raw AUX - // channels — ch0/1 speaker, ch2/3 voice coils — which is what "Pro Audio" means on a - // real pad's card and the only layout that survives GE-Proton's AUX0..AUX3 stream - // map unfolded. - "audio.channels" => "4", - "audio.position" => "AUX0,AUX1,AUX2,AUX3", - "api.alsa.pcm.stream" => "playback", - "alsa.driver_name" => "snd_usb_audio", - }; - props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str()); - props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str()); - props.insert(*pw::keys::NODE_NICK, identity.description.as_str()); - props.insert("device.serial", identity.serial.as_str()); - props.insert("device.product.id", identity.product_id); - props.insert("device.product.name", identity.product_name); - props.insert("alsa.card_name", identity.card_name); - props.insert("alsa.long_card_name", identity.long_card_name.as_str()); - // GE-Proton's preferred haptic leg; see `split_target`. Omitted (not empty) when the - // field lever turns it off, so `pa_proplist_gets` misses rather than returning "". - if !identity.split_name.is_empty() { - props.insert("api.alsa.split.name", identity.split_name.as_str()); - } - let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props) - .context("pw pad-sink Stream")?; + let mix = Rc::new(RefCell::new(Mixer::new(tx))); - // Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible - // drops cost a field investigation on the desktop plane once — count and warn here too, - // power-of-two throttled (this callback runs at the graph quantum). - struct PadUd { - tx: std::sync::mpsc::SyncSender>, - dropped: u64, - } - let ud = PadUd { tx, dropped: 0 }; - let _listener = stream - .add_local_listener_with_user_data(ud) + // Every node wears the same card identity — on the specimen they are three views of ONE + // USB card, and every matcher (GE's proplist leg included) reads these off whichever node + // it happens to be holding. + let base_props = |class: &str, name: &str, channels: &str, position: &str| { + let mut p = properties! { + *pw::keys::MEDIA_TYPE => "Audio", + // One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are + // felt latency; bursty delivery would ride through to the client's jitter buffer. + *pw::keys::NODE_LATENCY => "240/48000", + // Must NEVER win WirePlumber's default election against real hardware — games + // reach these nodes BY IDENTITY, nothing auto-routes here. + "priority.session" => "50", + "device.bus" => "usb", + "device.vendor.id" => "0x054c", + "device.vendor.name" => "Sony Corp.", + "device.form_factor" => "gamepad", + "device.icon_name" => "audio-card-analog", + "api.alsa.pcm.stream" => "playback", + "api.alsa.open.ucm" => "true", + "alsa.driver_name" => "snd_usb_audio", + "node.virtual" => "false", + // Informational on the specimen — the parent's raw layout, carried by every node. + // NOT `api.alsa.split.position`, which is WirePlumber's own management trigger. + "api.alsa.split.hw-position" => "[AUX0,AUX1,AUX2,AUX3]", + }; + p.insert(*pw::keys::MEDIA_CLASS, class); + p.insert(*pw::keys::NODE_NAME, name); + p.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str()); + p.insert("audio.channels", channels); + p.insert("audio.position", position); + p.insert("device.serial", identity.serial.as_str()); + p.insert("device.product.id", identity.product_id); + p.insert("device.product.name", identity.product_name); + p.insert("device.description", identity.product_name); + p.insert("alsa.card_name", identity.card_name); + p.insert("alsa.long_card_name", identity.long_card_name.as_str()); + p.insert("api.alsa.card.name", identity.card_name); + p.insert("api.alsa.card.longname", identity.long_card_name.as_str()); + p.insert("alsa.components", identity.components); + p.insert("alsa.id", "Controller"); + // GE-Proton's preferred haptic leg; see `split_target`. Omitted (not empty) when the + // field lever turns it off, so `pa_proplist_gets` misses rather than returning "". + if !identity.split_name.is_empty() { + p.insert("api.alsa.split.name", identity.split_name.as_str()); + } + p + }; + + // ---- the hidden AUX parent: our capture point and GE's `pipewire:NODE=` target -------- + // `Audio/Sink/Internal` is the specimen's class — hidden from pactl/pulse sink lists while + // still openable by name. `PUNKTFUNK_PAD_SINK_PARENT_CLASS` flips it to a plain + // `Audio/Sink` if a session manager ever refuses to let an Internal node take clients. + let parent_class = std::env::var("PUNKTFUNK_PAD_SINK_PARENT_CLASS") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "Audio/Sink/Internal".to_string()); + let mut parent_props = base_props( + &parent_class, + &identity.parent_name, + "4", + "AUX0,AUX1,AUX2,AUX3", + ); + parent_props.insert("api.alsa.split.parent", "true"); + parent_props.insert( + *pw::keys::NODE_NICK, + "Internal Mono Speaker + Haptic Feedback", + ); + let parent = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", parent_props) + .context("pw pad-sink parent Stream")?; + let _parent_listener = parent + .add_local_listener_with_user_data(PadUd { + mix: mix.clone(), + node: PadNode::Parent, + }) .state_changed({ let mainloop = mainloop.clone(); move |_s, _ud, old, new| { - tracing::debug!(?old, ?new, "pipewire pad-sink stream state"); + tracing::debug!(?old, ?new, "pipewire pad-sink parent state"); if matches!(new, pw::stream::StreamState::Error(_)) { mainloop.quit(); } @@ -438,97 +717,96 @@ fn pad_sink_thread( } let mut info = AudioInfoRaw::default(); if info.parse(param).is_ok() { - // We own the sink, so this IS the format games render into (nothing can - // have narrowed it upstream — the same guarantee as stream-sink mode). + // We own the node, so this IS the format games render into. tracing::info!( format = ?info.format(), rate = info.rate(), channels = info.channels(), - "pad-sink format negotiated" + "pad-sink parent format negotiated" ); } }) - .process(|stream, ud| { - let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let Some(mut buffer) = stream.dequeue_buffer() else { - return; - }; - let datas = buffer.datas_mut(); - if datas.is_empty() { - return; - } - let d = &mut datas[0]; - let (offset, size) = { - let c = d.chunk(); - (c.offset() as usize, c.size() as usize) - }; - let Some(buf) = d.data() else { return }; - if offset > buf.len() { - return; - } - let region = &buf[offset..(offset + size).min(buf.len())]; - // Negotiated as F32LE; reinterpret the byte region as interleaved f32. - let n = region.len() / 4; - let mut samples = Vec::with_capacity(n); - for i in 0..n { - let b = [ - region[i * 4], - region[i * 4 + 1], - region[i * 4 + 2], - region[i * 4 + 3], - ]; - samples.push(f32::from_le_bytes(b)); - } - if ud.tx.try_send(samples).is_err() { - ud.dropped += 1; - if ud.dropped.is_power_of_two() { - tracing::warn!( - dropped = ud.dropped, - "pad-audio encode thread not keeping up — captured pad audio \ - dropped (haptics will click)" - ); - } - } - })); - if outcome.is_err() { - tracing::error!("panic in pipewire pad-sink callback — chunk dropped"); - } - }) + .process(pad_process!()) .register() - .context("register pad-sink stream listener")?; + .context("register pad-sink parent listener")?; - let mut info = AudioInfoRaw::new(); - info.set_format(AudioFormat::F32LE); - info.set_rate(crate::audio::SAMPLE_RATE); - info.set_channels(PAD_CHANNELS); - info.set_position(pad_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 = pw::spa::pod::serialize::PodSerializer::serialize( - std::io::Cursor::new(Vec::new()), - &pw::spa::pod::Value::Object(obj), - ) - .context("serialize pad-sink format pod")? - .0 - .into_inner(); - let mut params = [Pod::from_bytes(&values).context("pad-sink pod from bytes")?]; + // ---- the public positioned quad (UCM `SpeakerHaptic`) -------------------------------- + let mut haptic_props = base_props("Audio/Sink", &identity.haptic_name, "4", "FL,FR,RL,RR"); + haptic_props.insert("device.profile.name", "HiFi: SpeakerHaptic: sink"); + haptic_props.insert( + "device.profile.description", + "Internal Mono Speaker + Haptic Feedback", + ); + haptic_props.insert( + *pw::keys::NODE_NICK, + "Internal Mono Speaker + Haptic Feedback", + ); + let haptic = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", haptic_props) + .context("pw pad-sink haptic Stream")?; + let _haptic_listener = haptic + .add_local_listener_with_user_data(PadUd { + mix: mix.clone(), + node: PadNode::Haptic, + }) + .process(pad_process!()) + .register() + .context("register pad-sink haptic listener")?; - // RT_PROCESS for the same reason as every host-owned stream node here: the sink must be - // a synchronous graph member that joins its producers' driver group, or `process()` - // never fires on a busy graph (see the mic's connect comment in mod.rs). - stream + // ---- the public mono speaker --------------------------------------------------------- + let mut speaker_props = base_props("Audio/Sink", &identity.speaker_name, "1", "MONO"); + speaker_props.insert("device.profile.name", "HiFi: Speaker: sink"); + speaker_props.insert("device.profile.description", "Internal Mono Speaker"); + speaker_props.insert(*pw::keys::NODE_NICK, "Internal Mono Speaker"); + let speaker = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", speaker_props) + .context("pw pad-sink speaker Stream")?; + let _speaker_listener = speaker + .add_local_listener_with_user_data(PadUd { + mix: mix.clone(), + node: PadNode::Speaker, + }) + .process(pad_process!()) + .register() + .context("register pad-sink speaker listener")?; + + // RT_PROCESS for the same reason as every host-owned stream node here: a sink must be a + // synchronous graph member that joins its producers' driver group, or `process()` never + // fires on a busy graph (see the mic's connect comment in mod.rs). + let flags = pw::stream::StreamFlags::AUTOCONNECT + | pw::stream::StreamFlags::MAP_BUFFERS + | pw::stream::StreamFlags::RT_PROCESS; + + let parent_fmt = format_pod(PAD_CHANNELS, aux_positions())?; + let mut parent_params = [Pod::from_bytes(&parent_fmt).context("parent pod from bytes")?]; + parent .connect( - spa::utils::Direction::Input, // we CONSUME what games render into the sink + spa::utils::Direction::Input, None, - pw::stream::StreamFlags::AUTOCONNECT - | pw::stream::StreamFlags::MAP_BUFFERS - | pw::stream::StreamFlags::RT_PROCESS, - &mut params, + flags, + &mut parent_params, ) - .context("pw pad-sink stream connect")?; + .context("pw pad-sink parent connect")?; + + let haptic_fmt = format_pod(PAD_CHANNELS, positioned_quad())?; + let mut haptic_params = [Pod::from_bytes(&haptic_fmt).context("haptic pod from bytes")?]; + haptic + .connect( + spa::utils::Direction::Input, + None, + flags, + &mut haptic_params, + ) + .context("pw pad-sink haptic connect")?; + + let speaker_fmt = format_pod(1, mono_position())?; + let mut speaker_params = [Pod::from_bytes(&speaker_fmt).context("speaker pod from bytes")?]; + speaker + .connect( + spa::utils::Direction::Input, + None, + flags, + &mut speaker_params, + ) + .context("pw pad-sink speaker connect")?; let _ = ready.send(Ok(())); mainloop.run(); @@ -558,43 +836,58 @@ mod tests { fn identity_carries_every_match_surface() { let id = PadSinkIdentity::new(0, false); // GE-Proton's `string_contains_dualsense_name` legs, each checked separately. - assert!(id.node_name.contains("Sony_Interactive_Entertainment")); - assert!(id.node_name.contains("Wireless_Controller")); - // …and the CONTIGUOUS form the community WirePlumber rule and GE's - // `alsa_output.usb-Sony_Interactive_Entertainment_` prefix test want. An invented - // `DualSense_` infix used to split this in two and miss both. - assert!(id - .node_name - .starts_with("alsa_output.usb-Sony_Interactive_Entertainment_Wireless_Controller_")); + assert!(id.speaker_name.contains("Sony_Interactive_Entertainment")); + assert!(id.speaker_name.contains("Wireless_Controller")); + assert!(id.speaker_name.contains("DualSense")); + // The specimen's exact prefix: a real DS5's ALSA card is + // `usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller-00`. + assert!(id.speaker_name.starts_with( + "alsa_output.usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller-00." + )); // The suffix GE's `is_dualsense_speaker_sink` substring-tests for — the pad-speaker - // binding and the Windows 4ch format forcing both hang off it (never `analog-*`, which - // matches nothing of GE's and names a positioned profile we do not wear). - assert!(id.node_name.ends_with("-00.HiFi__Speaker__sink")); - assert!(id.node_name.contains("Speaker__sink")); - // No colons in a udev-style serial/name. - assert!(!id.node_name.contains(':')); + // binding and the Windows 4ch format forcing both hang off it. + assert!(id.speaker_name.ends_with("-00.HiFi__Speaker__sink")); + assert!(id.speaker_name.contains("Speaker__sink")); + // …and the positioned sibling must NOT satisfy that test, or GE would bind the mono + // controller-effect stream to the quad. The `Haptic` infix is what keeps them apart. + assert!(!id.haptic_name.contains("Speaker__sink")); + assert!(id.haptic_name.ends_with("-00.HiFi__SpeakerHaptic__sink")); + // No colons in a udev-style serial/name, and no invented MAC infix (a real pad has no + // USB iSerialNumber, so the trailing card index disambiguates instead). + assert!(!id.speaker_name.contains(':')); + assert!(!id.speaker_name.contains("35533AD6E774")); // Case-sensitive `wcsstr(FriendlyName, L"Wireless Controller")` (FF14, FF7R). assert_eq!(id.description, "Wireless Controller"); - assert_eq!(id.product_id, "0ce6"); - assert_eq!(id.card_name, "Wireless Controller"); + // The ids carry the `0x` prefix the specimen publishes — parseable under base 16 AND + // base 0, unlike the bare form. + assert_eq!(id.product_id, "0x0ce6"); + assert_eq!(id.components, "USB054c:0ce6"); + assert_eq!(id.card_name, "DualSense Wireless Controller"); assert!(id.long_card_name.contains("Sony Interactive Entertainment")); + let edge = PadSinkIdentity::new(1, true); // GE tests the Edge with the full `DualSense_Edge_Wireless_Controller` substring. assert!(edge - .node_name + .speaker_name .contains("DualSense_Edge_Wireless_Controller")); - assert!(edge.node_name.contains("Speaker__sink")); - assert_eq!(edge.product_id, "0df2"); - // Distinct pads mint distinct names (the serial octet). - assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name); + assert!(edge.speaker_name.contains("Speaker__sink")); + assert_eq!(edge.product_id, "0x0df2"); + assert_eq!(edge.components, "USB054c:0df2"); + // Distinct pads mint distinct names (the ALSA-style card index, as on real hardware). + assert_ne!(id.speaker_name, PadSinkIdentity::new(1, false).speaker_name); + assert!(PadSinkIdentity::new(1, false) + .speaker_name + .ends_with("-01.HiFi__Speaker__sink")); + // Each pad's parent is distinct too — it is the capture point. + assert_ne!(id.parent_name, PadSinkIdentity::new(1, false).parent_name); } #[test] - fn split_target_points_at_the_node_itself_unless_overridden() { - // No split on our side: the sink IS the four-channel parent GE wants to open as - // `pipewire:NODE=…`, so the honest target is our own name. + fn split_target_points_at_the_parent_unless_overridden() { + // The specimen's public sinks name the hidden parent, and the parent names itself. let id = PadSinkIdentity::new(0, false); - assert_eq!(id.split_name, id.node_name); + assert_eq!(id.split_name, id.parent_name); + assert_ne!(id.split_name, id.speaker_name); // The field lever, both ways — through the pure form, so no test mutates the process // environment out from under a parallel test runner. assert_eq!(resolve_split_target("n", None), "n"); @@ -609,4 +902,75 @@ mod tests { assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB"); assert_eq!(expand("static", 0, "x"), "static"); } + + /// The UCM's channel arithmetic, which is the whole reason the mono sink exists separately: + /// the pad's built-in speaker is hardware channel **1**, not 0. + #[test] + fn channel_maps_follow_the_ucm() { + assert_eq!(PadNode::Parent.channel_map(), &[0, 1, 2, 3]); + assert_eq!(PadNode::Haptic.channel_map(), &[0, 1, 2, 3]); + assert_eq!(PadNode::Speaker.channel_map(), &[1]); + assert_eq!(PadNode::Speaker.src_channels(), 1); + assert_eq!(PadNode::Haptic.src_channels(), 4); + } + + /// One node alone: a round closes when that node contributes again, so the quad comes out + /// exactly as written (index-exact — the property the .41 on-glass run proved for the parent). + #[test] + fn mixer_passes_a_lone_quad_through_unchanged() { + let (tx, rx) = sync_channel::>(8); + let mut m = Mixer::new(tx); + m.add(PadNode::Parent, &[0.1, 0.2, 0.3, 0.4]); + // Nothing emitted yet — the window is open for peers to sum into. + assert!(rx.try_recv().is_err()); + m.add(PadNode::Parent, &[0.5, 0.6, 0.7, 0.8]); + assert_eq!(rx.try_recv().unwrap(), vec![0.1, 0.2, 0.3, 0.4]); + } + + /// The case a real pad gets for free and we must do by hand: GE drives the haptic leg and the + /// controller-effect leg AT ONCE, and both must survive in one quad. + #[test] + fn mixer_sums_concurrent_nodes_into_one_quad() { + let (tx, rx) = sync_channel::>(8); + let mut m = Mixer::new(tx); + // Haptics on the positioned sink: coils only. + m.add(PadNode::Haptic, &[0.0, 0.0, 0.5, 0.25]); + // Controller-effect audio on the mono sink, same round. + m.add(PadNode::Speaker, &[0.75]); + m.flush(); + // ch1 carries the mono speaker (UCM `Channel0 1`), ch2/3 the coils, ch0 untouched. + assert_eq!(rx.try_recv().unwrap(), vec![0.0, 0.75, 0.5, 0.25]); + } + + /// Ragged buffer lengths must not truncate the longer contributor or read out of bounds. + #[test] + fn mixer_handles_unequal_frame_counts() { + let (tx, rx) = sync_channel::>(8); + let mut m = Mixer::new(tx); + m.add(PadNode::Parent, &[1.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0]); + m.add(PadNode::Speaker, &[0.5]); + m.flush(); + let out = rx.try_recv().unwrap(); + assert_eq!(out.len(), 8); + assert_eq!(out[0], 1.0); + assert_eq!(out[1], 0.5); // mono summed into frame 0's ch1 + assert_eq!(out[4], 2.0); // frame 1 survived + assert_eq!(out[5], 0.0); + } + + /// A flush must leave no residue behind for the next round to inherit. + #[test] + fn mixer_clears_between_rounds() { + let (tx, rx) = sync_channel::>(8); + let mut m = Mixer::new(tx); + m.add(PadNode::Parent, &[1.0, 1.0, 1.0, 1.0]); + m.flush(); + assert_eq!(rx.try_recv().unwrap(), vec![1.0, 1.0, 1.0, 1.0]); + m.add(PadNode::Parent, &[0.25, 0.0, 0.0, 0.0]); + m.flush(); + assert_eq!(rx.try_recv().unwrap(), vec![0.25, 0.0, 0.0, 0.0]); + // An empty flush emits nothing at all (a quiet pad must not manufacture chunks). + m.flush(); + assert!(rx.try_recv().is_err()); + } } diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index cb2e2332..bea9d68a 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -231,11 +231,13 @@ pub fn dualsense_test(args: &[String]) -> Result<()> { Ok(()) } -/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture -/// from it — the WP3 on-glass gate with no client involved. Verify the identity with -/// `pactl list sinks` (name/description/proplist) and drive it with -/// `pw-play --target ` (or `paplay -d `); captured chunks print -/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30). +/// Mint one pad's audio node graph (the Linux 0xD1 source, `audio::pad_sink`) and capture the +/// mix — the WP3 on-glass gate with no client involved. Three nodes appear, mirroring the split a +/// physically connected DualSense presents: a mono `Speaker__sink`, a positioned-quad +/// `SpeakerHaptic__sink`, and the hidden AUX parent. Verify the identity with `pactl list sinks` +/// (name/description/proplist) and drive any of them with `pw-play --target `; +/// captured chunks print a per-second summary here. `--pad N` (default 0), `--edge`, +/// `--seconds N` (default 30). #[cfg(target_os = "linux")] pub fn pad_sink_test(args: &[String]) -> Result<()> { use crate::audio::AudioCapturer as _; @@ -256,18 +258,26 @@ pub fn pad_sink_test(args: &[String]) -> Result<()> { let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge) .context("mint pad-audio sink (is PipeWire running in this session?)")?; println!( - "pad sink minted: node.name = {}\n api.alsa.split.name = {} (what GE-Proton opens as \ - pipewire:NODE=…)\n inspect: pactl list sinks | grep -A25 Speaker__sink\n \ - drive it: pw-play --target '{}' --channel-map 'AUX0,AUX1,AUX2,AUX3' <48k-file>\n \ - (a POSITIONED wav folds into the speaker pair and never reaches the coils — the \ - channel-map is not optional)\nCapturing for {secs}s…", + "pad nodes minted (the split a real DualSense presents):\n \ + speaker sink = {} (mono — GE-Proton's is_dualsense_speaker_sink target)\n \ + haptic sink = {} (4ch POSITIONED FL,FR,RL,RR — the public quad a real pad shows)\n \ + parent = {} (4ch AUX0..AUX3, hidden — what GE opens as pipewire:NODE=…)\n \ + inspect: pactl list sinks | grep -A25 Speaker\n \ + drive the coils via the POSITIONED sink (what a real pad's writers use):\n \ + pw-play --target '{}' --channel-map 'front-left,front-right,rear-left,rear-right' <48k-file>\n \ + drive the coils via the AUX parent (GE's own leg):\n \ + pw-play --target '{}' --channel-map 'AUX0,AUX1,AUX2,AUX3' <48k-file>\n \ + (a POSITIONED wav aimed at the AUX PARENT still folds into the speaker pair — that is \ + why the positioned sink exists)\nCapturing for {secs}s…", cap.node_name, + cap.haptic_name, if cap.split_name.is_empty() { "(suppressed)" } else { cap.split_name.as_str() }, - cap.node_name + cap.haptic_name, + cap.split_name, ); let deadline = Instant::now() + Duration::from_secs(secs); let (mut chunks, mut samples) = (0u64, 0u64);