From d227db06e8d5aec3fe309710769cba2fef7209f8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 15 Aug 2026 22:42:03 +0200 Subject: [PATCH 1/4] fix(client/linux): controller audio picked any DualSense sink, so the coils were folded away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux client's pad-audio renderer matched a PipeWire sink by name signature alone and streamed a positioned FL/FR/RL/RR quad at it. The voice coils ARE channels 3 and 4 of the pad's USB sound card, and a DualSense almost never presents four channels by default: PipeWire's ACP picks a stereo profile, and a modern alsa-ucm-conf splits the card into a mono Speaker and a stereo Headphones sink instead. Every one of those opens perfectly and then position-remixes our quad into the speaker pair, so the coils are never excited — nothing is felt, and nothing looks wrong. The Windows half of the same module has required a 4-channel endpoint since it was written; only Linux never did, and it was compile-verified only (the on-glass leg was Android -> Linux host, which renders over raw USB and never touches a graph). Correlation now walks nodes AND devices, and picks a four-channel node that belongs to a DualSense CARD: - Identity comes from the USB ids (base 16, either 0x spelling) with the old name signature as the fallback, and it may sit on either the node or its card — a split card's public sinks publish neither. - `device.id` is required, which is also what keeps a Punktfunk HOST's own minted pad sink out: it carries the full DualSense identity on purpose, and rendering into it would loop the plane back at the host. - Unpositioned (AUX) quads are preferred, then positioned ones, then the hidden four-channel parent a split sink names in `api.alsa.split.name` — GE-Proton's own preferred haptic leg. - With no four-channel node anywhere, the card's profile is moved to Pro Audio for the session and restored when it ends (never saved; `PUNKTFUNK_PAD_AUDIO_PROFILE=0` opts out; a sandboxed client that is refused the write gets told to do it by hand). The stream itself now sets `stream.dont-remix` and AUX0..AUX3 rather than FL/FR/RL/RR, so channel k reaches channel k whatever the node advertises — the same shape the host-side sink mints and GE forces on its own haptic streams. Also here: - `punktfunk-session --pad-audio-test` prints every DualSense object in the graph, the node it chose, and drives a tone into the coils. It separates "the plane never arrived" from "it arrived and the graph folded it away" with no host, no game and no pairing — the diagnostic whose absence made this a field report. - The GTK client grows Controller haptics / Controller speaker rows; they were reachable from Android and the settings file only. Not profileable (which pad is in your hands is a fact about this device), and a stored "mix" survives the round trip. - The tier-A activation packet no longer swallows its error: where SDL does not own the pad's HID link (Linux's own hid-playstation has it), that is worth saying, because that driver asserts the same audio-haptics disable bit on every rumble. - Docs: the client half of controller-audio, the two settings rows, and PUNKTFUNK_PAD_AUDIO_PROFILE. The "speaker is opt-in" line was only true of Android; desktop has shipped it on. Gates (Ubuntu 26.04 rust-ci container, linux/amd64): clippy --all-targets -D warnings over pf-client-core, pf-presenter, punktfunk-client-session and punktfunk-client-linux; plain build; 204 tests green including 5 new ones for the matcher, the identity parse and the profile chooser; cargo fmt --check. The graph walk was smoke-tested against a live PipeWire daemon on home-bazzite-1 (2 sinks, 2 cards enumerated, matching --list-audio; correct "no DualSense" verdict with no pad attached). NOT yet exercised against a real DualSense — that is the on-glass step this leaves open. --- clients/linux/src/ui_settings.rs | 56 +- clients/session/src/main.rs | 23 + crates/pf-client-core/src/gamepad.rs | 22 +- crates/pf-client-core/src/pad_audio.rs | 986 ++++++++++++++++++++- docs-site/content/docs/client-settings.md | 14 + docs-site/content/docs/configuration.md | 1 + docs-site/content/docs/controller-audio.md | 59 +- 7 files changed, 1110 insertions(+), 51 deletions(-) diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index 8a9884bd..e1ee17e3 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -1604,21 +1604,42 @@ pub fn show_scoped( "Hold Select alone for the host's guide button — a tap still goes through", GUIDE_GESTURE_LABELS, ); + // Controller audio (the 0xD1 plane): a wired DualSense's own voice coils and its little + // built-in speaker, streamed from the host and rendered on the pad in your hands. Both are + // negotiated — they change nothing without a capable host AND a wired DualSense — so the + // rows say what they are for rather than promising an effect. + // + // Deliberately NOT profileable: which pad is in your hands is a property of this device, + // not of the host a profile is authored against (the forwarded-pad pin below sits out for + // the same reason). + let haptics_row = adw::SwitchRow::builder() + .title("Controller haptics") + .subtitle("Play a DualSense's voice-coil haptics on the pad itself — wired pads only") + .build(); + let pad_speaker_row = adw::SwitchRow::builder() + .title("Controller speaker") + .subtitle("Play the audio a game sends to the pad's own speaker on the pad, not here") + .build(); // The pad rows only mean something while something is being forwarded (the same // relationship mic → echo cancellation draws just above, initial state included: the - // seed's `set_active` fires this only when it CHANGES the switch). + // seed's `set_active` fires this only when it CHANGES the switch). Controller audio + // belongs in that set too — forwarding off never OPENS the pad, so nothing can detect + // that it has an audio device, let alone render on it. { let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone()); let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone()); - f.set_sensitive(seed.gamepad_forwarding); - t.set_sensitive(seed.gamepad_forwarding); - sb.set_sensitive(seed.gamepad_forwarding); - gg.set_sensitive(seed.gamepad_forwarding); + let (ha, sp) = (haptics_row.clone(), pad_speaker_row.clone()); + for w in [&f, &t, &sb, &gg] { + w.set_sensitive(seed.gamepad_forwarding); + } + ha.set_sensitive(seed.gamepad_forwarding); + sp.set_sensitive(seed.gamepad_forwarding); pad_forward_row.connect_active_notify(move |r| { - f.set_sensitive(r.is_active()); - t.set_sensitive(r.is_active()); - sb.set_sensitive(r.is_active()); - gg.set_sensitive(r.is_active()); + for w in [&f, &t, &sb, &gg] { + w.set_sensitive(r.is_active()); + } + ha.set_sensitive(r.is_active()); + sp.set_sensitive(r.is_active()); }); } @@ -1632,6 +1653,8 @@ pub fn show_scoped( scale_row.set_selected(index::render_scale(s)); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); pad_forward_row.set_active(s.gamepad_forwarding); + haptics_row.set_active(s.pad_haptics); + pad_speaker_row.set_active(pf_client_core::pad_audio::speaker_active(&s.pad_speaker)); pad_row.set_selected(index::gamepad(s)); sysbtn_row.set_selected(index::system_buttons(s)); gesture_row.set_selected(index::guide_gesture(s)); @@ -2088,6 +2111,12 @@ pub fn show_scoped( controllers_group.add(pad_row.widget()); controllers_group.add(sysbtn_row.widget()); controllers_group.add(gesture_row.widget()); + // Global scope only — see the rows' own note. In profile scope they would have no + // override marker and no way to record a touch, so a toggle would be silently discarded. + if !profile_mode { + controllers_group.add(&haptics_row); + controllers_group.add(&pad_speaker_row); + } controllers.add(&controllers_group); // Cap every caption in one pass, after the rows exist: a per-row call would be sixteen @@ -2163,6 +2192,15 @@ pub fn show_scoped( s.inhibit_shortcuts = inhibit_row.is_active(); s.invert_scroll = invert_row.is_active(); s.gamepad_forwarding = pad_forward_row.is_active(); + s.pad_haptics = haptics_row.is_active(); + // `"mix"` is a stored value this switch cannot express (it renders as off today, + // pending the mixer leg), so writing the switch back unconditionally would erase + // it just by opening and closing the dialog — the same trap the gamepad-type row + // guards above. Only write when the user actually moved it. + let want_speaker = pad_speaker_row.is_active(); + if want_speaker != pf_client_core::pad_audio::speaker_active(&s.pad_speaker) { + s.pad_speaker = if want_speaker { "pad" } else { "off" }.to_string(); + } s.mic_enabled = mic_row.is_active(); s.echo_cancel = echo_row.is_active(); s.hdr_enabled = hdr_row.is_active(); diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 6b368c57..5aa44b21 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -821,6 +821,29 @@ mod session_main { }; } + // `--pad-audio-test [--seconds N] [--speaker] [--coils]`: the controller-audio + // correlation, printed, then a tone driven into the pad. The one tool that separates + // "the plane never arrived" from "it arrived and the graph folded the coil pair away" + // — no host, no game, no pairing needed, just a wired DualSense. + #[cfg(target_os = "linux")] + if arg_flag("--pad-audio-test") { + let seconds = arg_value("--seconds") + .and_then(|v| v.parse().ok()) + .unwrap_or(3); + // Coils by default: they are the half that silently disappears, so they are the + // half worth testing. `--speaker` adds (or, with nothing else, selects) the + // speaker pair. + let speaker = arg_flag("--speaker"); + let coils = arg_flag("--coils") || !speaker; + return match pf_client_core::pad_audio::pad_audio_test(seconds, coils, speaker) { + Ok(()) => 0, + Err(e) => { + eprintln!("pad-audio-test: {e:#}"); + EXIT_PRESENTER_FAILED + } + }; + } + // `--pair `: enrol this machine against a host and exit. DEPRECATED — pairing is // a trust ceremony and belongs to the brain, fronted by `punktfunk pair` or a shell // (design/client-architecture-split.md §5). It still works, with a notice, for the one diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 78200f10..95e392f9 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -1333,10 +1333,28 @@ impl Worker { // ("Leaving emulated rumble bits off will restore audio haptics" — // SDL_hidapi_ps5.c); wire rumble for this slot is suppressed in // render_feedback so SDL never re-arms them. - let _ = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet()); + // + // ⚠ This needs SDL's HIDAPI driver to be the one on the pad — the + // packet is a raw DS5 effects report, and SDL can only send it where + // it owns the HID link. On a Linux box where the kernel's + // `hid-playstation` has the pad instead, the call fails, and it is + // worth SAYING so: `hid-playstation` asserts the same disable bit on + // every force-feedback update it makes, so a pad some other program + // has rumbled stays deaf to this plane until it is re-plugged. Not + // fatal — nothing else asserts the bit in our own path, so the pad's + // power-on default (audio haptics live) usually still stands. + if let Err(e) = slot.pad.send_effect(&Ds5Feedback::audio_haptics_packet()) { + tracing::info!( + index, + error = %e, + "could not re-arm the DualSense's audio-haptics bit (SDL does \ + not own this pad's HID link) — haptics still work unless \ + something else has rumbled the pad this plug-in" + ); + } } // Hand the pad to the session's renderer worker. Windows correlation - // needs the HID interface path; Linux matches the sink by signature. + // needs the HID interface path; Linux matches by card identity. crate::pad_audio::register_tier_a(index, slot.pad.path()); tracing::info!( index, diff --git a/crates/pf-client-core/src/pad_audio.rs b/crates/pf-client-core/src/pad_audio.rs index d9de834c..6480e447 100644 --- a/crates/pf-client-core/src/pad_audio.rs +++ b/crates/pf-client-core/src/pad_audio.rs @@ -12,8 +12,9 @@ //! - **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). +//! PipeWire node belonging to a DualSense CARD that carries four channels — see +//! `pick_pad_sink`, and the section below for why "a sink that looks like a DualSense" is +//! not enough. //! - **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 @@ -23,6 +24,29 @@ //! 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. //! +//! # Linux: four channels, in order, or nothing is felt +//! +//! The voice coils ARE channels 3 and 4 of the pad's USB audio function. Everything on the +//! Linux side follows from that one fact, and none of it is optional: +//! +//! - **The node must have four channels.** A DualSense card almost never presents as one by +//! default. PipeWire's ACP picks a stereo profile, and modern `alsa-ucm-conf` (which gained +//! `USB-Audio/Sony/DualSense-PS5.conf` in 2026-08) splits the card into a MONO `Speaker` sink +//! and a stereo `Headphones` sink instead. Streaming a quad into any of those renders the +//! haptics into the headphone jack and folds the coil pair away — audibly plausible, felt as +//! nothing. This is the client-side twin of the "set the controller to Pro Audio" advice the +//! host-side sink exists to make unnecessary (`audio/linux/pad_sink.rs` in the host tree), +//! and here we automate it: `ensure_pro_audio` moves the card's profile and puts it back +//! when the session ends. +//! - **The channels must map by index, not by position.** Games — and this plane — treat the +//! quad as four raw channels; a positioned stream into a positioned sink gets helpfully +//! re-mixed. So the stream is `AUX0..AUX3` with `stream.dont-remix`, which is both what +//! GE-Proton's pulse leg forces and what our own host sink advertises. +//! - **It must be a real card, never a look-alike.** A Punktfunk HOST minting its pad sink on +//! this same machine publishes the full DualSense identity ON PURPOSE — that is how Proton +//! finds it. Rendering into it would loop the plane back at the host instead of driving a +//! pad. `device.id` tells them apart: a card's node has one, a stream-sink does not. +//! //! 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"`. @@ -82,15 +106,25 @@ 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. +/// The wired fallback when SDL cannot say (`ConnectionState::Unknown`): does the pad's audio +/// sibling exist? A Bluetooth DS5 exposes no audio device at all, so a DualSense sound CARD in +/// the graph IS the wired signal. Linux ignores the HID path (the card match is +/// identity-based); Windows resolves the path's container against the render endpoints. +/// +/// Deliberately weaker than what the renderer needs: any profile proves the pad is plugged in, +/// even the stereo one that cannot carry the coils — moving the card to a four-channel profile +/// is `ensure_pro_audio`'s job, and it must not be gated on the answer to this question. #[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) + match walk_graph() { + Ok((sinks, cards)) => { + cards.iter().any(|c| c.ds5) || sinks.iter().any(|s| s.ds5 && s.device_id.is_some()) + } + Err(e) => { + tracing::debug!(error = %format!("{e:#}"), "pad-audio wired probe: no PipeWire graph"); + false + } + } } #[cfg(windows)] @@ -137,12 +171,13 @@ fn first_tier_a_hid_path() -> Option { .and_then(|p| p.hid_path.clone()) } -// ---- correlation: Linux (PipeWire sink signature) ------------------------------------------- +// ---- correlation: Linux (the pad's own four-channel card node) ------------------------------- -/// Does this PipeWire sink look like a wired DualSense's audio device? The ALSA node name +/// Does this PipeWire object's name/description look like a DualSense? 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. +/// any of the three identifies the pad. The weaker half of the identity: the USB ids in the +/// proplist are the strong one, and this covers the cards that publish neither. #[cfg(any(target_os = "linux", test))] pub(crate) fn is_ds5_sink(name: &str, description: &str) -> bool { let hit = |s: &str| { @@ -153,24 +188,712 @@ pub(crate) fn is_ds5_sink(name: &str, description: &str) -> bool { 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 { - let mut matches = sinks +/// The DualSense audio function's USB ids — the strong half of the identity, and the same pair +/// GE-Proton matches on (`vendor.id == 0x054c && product.id ∈ {0ce6, 0df2}`). +#[cfg(any(target_os = "linux", test))] +const DS5_VENDOR: u32 = 0x054C; +#[cfg(any(target_os = "linux", test))] +const DS5_PRODUCTS: [u32; 2] = [0x0CE6, 0x0DF2]; + +/// Read a PipeWire `*.vendor.id` / `*.product.id` proplist value. These are written BASE 16 — +/// sometimes `0x`-prefixed (the ALSA monitor's own stamp), sometimes bare (udev's +/// `ID_VENDOR_ID`) — and both spellings mean the same number. Reading `"054c"` as decimal +/// would simply fail here rather than mis-match, but `"0994"` would not, which is why this is +/// a function and not an inline parse. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn parse_usb_id(v: &str) -> Option { + let v = v.trim(); + let hex = v + .strip_prefix("0x") + .or_else(|| v.strip_prefix("0X")) + .unwrap_or(v); + u32::from_str_radix(hex, 16).ok() +} + +/// The full identity test over a proplist's four relevant keys: the USB ids when the object +/// publishes them, the name/description signature otherwise. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn props_say_ds5( + vendor: Option<&str>, + product: Option<&str>, + name: &str, + description: &str, +) -> bool { + let ids = vendor.and_then(parse_usb_id) == Some(DS5_VENDOR) + && product + .and_then(parse_usb_id) + .is_some_and(|p| DS5_PRODUCTS.contains(&p)); + ids || is_ds5_sink(name, description) +} + +/// One PipeWire sink node reduced to what the pad matcher needs. Pure data, so the entire +/// selection is unit-testable off-box — the graph walk is the only part that needs a daemon. +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct SinkNode { + /// `node.name` — what a stream targets via `target.object`. + pub(crate) name: String, + pub(crate) description: String, + /// `device.id`: the CARD this node belongs to, and the object whose profile has to move + /// when no four-channel node exists. `None` means the node is not a card's at all — which + /// is how a Punktfunk host's own minted pad sink (full DualSense identity, deliberately) + /// is told apart from a pad in the user's hands. + pub(crate) device_id: Option, + /// `audio.channels`, falling back to the length of `audio.position`. + pub(crate) channels: u32, + /// `audio.position`, already split on commas. Empty when the node publishes none. + pub(crate) positions: Vec, + /// `api.alsa.split.name` — WirePlumber's hidden four-channel parent behind a split card + /// (the `HiFi` verb's mono `Speaker` / stereo `Headphones` sinks both name it). GE-Proton's + /// preferred haptic leg opens exactly this node. + pub(crate) split_parent: Option, + /// This node's OWN proplist said DualSense (cards state it more reliably — see + /// `pick_pad_sink`, which accepts either). + pub(crate) ds5: bool, +} + +/// A PipeWire `Device` — an ALSA card — reduced to the same shape. +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct CardDevice { + pub(crate) id: u32, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) ds5: bool, +} + +/// What the graph walk found for the pad. +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PadSinkPick { + /// Render here: a four-channel node belonging to a DualSense card (or the hidden parent + /// one of its split sinks names). + Node(String), + /// The pad is here but exposes no four-channel node — its card profile has to move first. + /// Carries the `device.id` to move. + NeedsProfile(u32), +} + +/// Is this channel map unpositioned — the `AUX0..AUX3` / unknown shape that no part of the +/// graph will position-remix? The Pro Audio profile and WirePlumber's split parents both +/// produce it; a `FL,FR,RL,RR` "surround 4.0" profile does not (that one still works, but only +/// because the stream sets `stream.dont-remix`). +#[cfg(any(target_os = "linux", test))] +pub(crate) fn is_unpositioned(positions: &[String]) -> bool { + positions.is_empty() + || positions.iter().all(|p| { + let p = p.trim(); + p.is_empty() || p.starts_with("AUX") || p == "UNK" || p == "NA" + }) +} + +/// Choose the node the renderer should open, or the card whose profile is in the way. +/// +/// The whole requirement is four channels on a DualSense's own card: the voice coils ARE +/// channels 3 and 4, so a stereo or mono node opens perfectly and renders the haptics into the +/// headphone jack. v1 renders ONE physical DS5 — with two plugged in the first match wins. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn pick_pad_sink(sinks: &[SinkNode], cards: &[CardDevice]) -> Option { + let ds5_card = |id: u32| cards.iter().any(|c| c.id == id && c.ds5); + // A card node only — see the module docs on `device.id`. The identity may come from either + // end: split sinks routinely publish neither vendor ids nor a recognisable name, and it is + // their CARD that says DualSense. + let mine: Vec<&SinkNode> = 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" - ); + .filter(|s| s.device_id.is_some_and(|id| s.ds5 || ds5_card(id))) + .collect(); + if mine.is_empty() { + return None; + } + // Unpositioned quad first (Pro Audio, or a split parent) — the shape nothing can remix — + // then a positioned one, which `stream.dont-remix` makes equivalent. + if let Some(s) = mine + .iter() + .find(|s| s.channels == 4 && is_unpositioned(&s.positions)) + { + return Some(PadSinkPick::Node(s.name.clone())); + } + if let Some(s) = mine.iter().find(|s| s.channels == 4) { + return Some(PadSinkPick::Node(s.name.clone())); + } + // A split card whose four-channel parent we cannot see in the registry (it is an + // `Audio/Sink/Internal` node, and a restricted client may not be shown it) still names it + // on every public split sink. Target it by name — that is GE-Proton's leg 1. + if let Some(parent) = mine + .iter() + .find_map(|s| s.split_parent.clone().filter(|p| !p.is_empty())) + { + return Some(PadSinkPick::Node(parent)); + } + // A pad, but only positioned stereo/mono profiles: the card has to move to Pro Audio. + mine.first() + .and_then(|s| s.device_id) + .map(PadSinkPick::NeedsProfile) +} + +/// One registry roundtrip on a private mainloop: every `Audio/Sink…` node and every `Device`, +/// reduced to the matcher's shapes. [`crate::audio::devices`]'s discipline (a few ms against a +/// live daemon, a clean error when there is none) — a separate walk because that one is the +/// settings picker's and deliberately publishes only name + description. +#[cfg(target_os = "linux")] +fn walk_graph() -> anyhow::Result<(Vec, Vec)> { + use anyhow::Context; + use pipewire as pw; + use std::cell::RefCell; + use std::rc::Rc; + + 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 registry = core.get_registry_rc().context("pw registry")?; + + let found: Rc, Vec)>> = Rc::default(); + let _reg_listener = registry + .add_listener_local() + .global({ + let found = found.clone(); + move |g| { + let Some(props) = g.props else { return }; + // Both spellings: PipeWire's own objects use the `device.`-prefixed keys, and + // the pulse-facing proplist GE reads uses the bare ones. Cheap to accept both. + let vendor = props + .get("device.vendor.id") + .or_else(|| props.get("vendor.id")); + let product = props + .get("device.product.id") + .or_else(|| props.get("product.id")); + match g.type_ { + pw::types::ObjectType::Node => { + // `Audio/Sink` and `Audio/Sink/Internal` alike: the hidden four-channel + // parent behind a split card wears the latter, and it is the node the + // haptics actually want. + if !props + .get("media.class") + .is_some_and(|c| c.starts_with("Audio/Sink")) + { + return; + } + let Some(name) = props.get("node.name") else { + return; + }; + let description = props + .get("node.description") + .or_else(|| props.get("node.nick")) + .unwrap_or(name); + let positions: Vec = props + .get("audio.position") + .map(|p| p.split(',').map(|s| s.trim().to_string()).collect()) + .unwrap_or_default(); + found.borrow_mut().0.push(SinkNode { + device_id: props.get("device.id").and_then(|v| v.parse().ok()), + channels: props + .get("audio.channels") + .and_then(|v| v.parse().ok()) + .unwrap_or(positions.len() as u32), + split_parent: props.get("api.alsa.split.name").map(str::to_string), + ds5: props_say_ds5(vendor, product, name, description), + positions, + name: name.to_string(), + description: description.to_string(), + }); + } + pw::types::ObjectType::Device => { + let name = props.get("device.name").unwrap_or_default(); + let description = props + .get("device.description") + .or_else(|| props.get("device.nick")) + .unwrap_or(name); + found.borrow_mut().1.push(CardDevice { + id: g.id, + ds5: props_say_ds5(vendor, product, name, description), + name: name.to_string(), + description: description.to_string(), + }); + } + _ => {} + } + } + }) + .register(); + + // The registry replays existing globals asynchronously; one core sync marks the point they + // have all been delivered — quit there. + let pending = core.sync(0).context("pw sync")?; + let _core_listener = core + .add_listener_local() + .done({ + let mainloop = mainloop.clone(); + move |_, seq| { + if seq == pending { + mainloop.quit(); + } + } + }) + .register(); + mainloop.run(); + + let result = found.borrow().clone(); + Ok(result) +} + +// ---- the Pro Audio profile swap (Linux) ------------------------------------------------------ + +/// One profile a card offers, reduced to what the chooser needs. +#[cfg(any(target_os = "linux", test))] +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct CardProfile { + pub(crate) index: u32, + pub(crate) name: String, + pub(crate) description: String, + /// `SPA_PARAM_PROFILE_available` said anything other than `no`. An unavailable profile is + /// one the card cannot currently enter (an unplugged jack, a busy PCM) — selecting it + /// would silently leave the card where it was. + pub(crate) available: bool, +} + +/// Which profile carries the pad's four channels. **Pro Audio** first: PipeWire adds it to +/// every ALSA card, it exposes each PCM raw as `AUX` channels, and it is the one the community +/// fix names. Failing that, a positioned four-channel output ("surround 4.0") at least HAS the +/// coil channels — `stream.dont-remix` keeps them in place once we are on it. +/// +/// Everything else — stereo, mono, the `HiFi` splits — is a profile the coils cannot be +/// reached through at all, so no fallback below these two is worth taking. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn pick_profile(profiles: &[CardProfile]) -> Option<&CardProfile> { + let usable = |p: &&CardProfile| p.available; + profiles + .iter() + .filter(usable) + .find(|p| p.name == "pro-audio") + .or_else(|| { + profiles.iter().filter(usable).find(|p| { + p.name.contains("surround-40") || p.name.contains("quad") || p.name == "direct" + }) + }) +} + +/// A profile we moved and owe the user back. One card at a time — v1 renders one physical DS5. +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug)] +struct ProfileSwap { + device_id: u32, + previous: u32, +} + +#[cfg(target_os = "linux")] +static PROFILE_SWAP: Mutex> = Mutex::new(None); + +/// Which profile a [`set_card_profile`] call is after. +#[cfg(target_os = "linux")] +enum ProfileTarget { + /// Pick by [`pick_profile`] — the four-channel one. + FourChannel, + /// Select this index verbatim (the restore leg). + Index(u32), +} + +/// Move the pad's card onto a four-channel profile, remembering what it was so the session can +/// put it back. Idempotent: a card already on a four-channel profile is left alone. +/// +/// This changes a device the user can see in their sound settings, so it is loud in the log, +/// always reverted at session end ([`restore_profile`]), never persisted (`save = false`, so +/// WirePlumber does not adopt it as the card's remembered choice), and switchable off entirely +/// with `PUNKTFUNK_PAD_AUDIO_PROFILE=0` for anyone who would rather drive their own card. +#[cfg(target_os = "linux")] +fn ensure_pro_audio(device_id: u32) -> anyhow::Result<()> { + if matches!( + std::env::var("PUNKTFUNK_PAD_AUDIO_PROFILE").as_deref(), + Ok("0" | "false" | "off" | "no") + ) { + anyhow::bail!( + "the DualSense card has no four-channel profile active and \ + PUNKTFUNK_PAD_AUDIO_PROFILE=0 forbids moving it — switch the controller to \ + \"Pro Audio\" in your sound settings to feel haptics" + ); + } + let previous = set_card_profile(device_id, ProfileTarget::FourChannel)?; + let mut swap = PROFILE_SWAP.lock().unwrap(); + // Only the FIRST swap is the user's own setting; a later re-correlation must not record + // our own Pro Audio pick as the thing to restore. + if swap.is_none() { + *swap = Some(ProfileSwap { + device_id, + previous, }); } - Some(first) + Ok(()) +} + +/// Put a swapped card back the way we found it (session end, or the renderer giving up). +#[cfg(target_os = "linux")] +fn restore_profile() { + let Some(swap) = PROFILE_SWAP.lock().unwrap().take() else { + return; + }; + match set_card_profile(swap.device_id, ProfileTarget::Index(swap.previous)) { + Ok(_) => tracing::info!( + device = swap.device_id, + profile = swap.previous, + "DualSense card profile restored" + ), + // An unplugged pad is the ordinary way this fails — its card is gone, and so is the + // profile we owed back. + Err(e) => tracing::debug!( + error = %format!("{e:#}"), + "DualSense card profile not restored (pad unplugged?)" + ), + } +} + +/// Select a profile on a card, returning the index it had before. The one live half of the +/// swap: bind the `Device`, enumerate `EnumProfile` + the active `Profile`, choose, `set_param`. +/// +/// Three mainloop rounds rather than one, because each depends on the previous round's replies: +/// the registry has to deliver the card before we can bind it, and the bound proxy has to +/// answer `enum_params` before we know which index to ask for. +#[cfg(target_os = "linux")] +fn set_card_profile(device_id: u32, want: ProfileTarget) -> anyhow::Result { + use anyhow::{anyhow, Context}; + use pipewire as pw; + use pw::spa::param::ParamType; + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + + 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 registry = core.get_registry_rc().context("pw registry")?; + + let profiles: Rc>> = Rc::default(); + let active: Rc>> = Rc::new(Cell::new(None)); + let device: Rc>> = Rc::default(); + let dev_listener: Rc>> = Rc::default(); + + let _reg_listener = registry + .add_listener_local() + .global({ + let (registry, device, dev_listener) = + (registry.clone(), device.clone(), dev_listener.clone()); + let (profiles, active) = (profiles.clone(), active.clone()); + move |g| { + if g.id != device_id || g.type_ != pw::types::ObjectType::Device { + return; + } + let Ok(d) = registry.bind::(g) else { + return; + }; + let l = d + .add_listener_local() + .param({ + let (profiles, active) = (profiles.clone(), active.clone()); + move |_seq, id, _index, _next, param| { + let Some(p) = param.and_then(parse_profile) else { + return; + }; + match id { + ParamType::EnumProfile => profiles.borrow_mut().push(p), + ParamType::Profile => active.set(Some(p.index)), + _ => {} + } + } + }) + .register(); + *dev_listener.borrow_mut() = Some(l); + *device.borrow_mut() = Some(d); + } + }) + .register(); + + // One `done` listener drives every round; each round parks its sync seq here first. + let awaited: Rc>> = Rc::new(Cell::new(None)); + let _core_listener = core + .add_listener_local() + .done({ + let (mainloop, awaited) = (mainloop.clone(), awaited.clone()); + move |_, seq| { + if awaited.get() == Some(seq) { + mainloop.quit(); + } + } + }) + .register(); + let round = |issue: &dyn Fn() -> anyhow::Result<()>| -> anyhow::Result<()> { + issue()?; + awaited.set(Some(core.sync(0).context("pw sync")?)); + mainloop.run(); + Ok(()) + }; + + round(&|| Ok(()))?; // 1: the registry replays its globals; our card gets bound + round(&|| { + let d = device.borrow(); + let d = d + .as_ref() + .ok_or_else(|| anyhow!("card {device_id} is not in the PipeWire graph"))?; + d.enum_params(0, Some(ParamType::EnumProfile), 0, u32::MAX); + d.enum_params(1, Some(ParamType::Profile), 0, 1); + Ok(()) + })?; // 2: profile list + the active one + + let previous = active + .get() + .ok_or_else(|| anyhow!("card {device_id} did not report an active profile"))?; + // Decide with the borrow SCOPED: round 3 runs the mainloop again, and the param listener + // that fills this list runs from inside it — holding a shared borrow across that call + // would turn a re-emitted `EnumProfile` into a `RefCell` panic. + let pick = { + let list = profiles.borrow(); + match want { + ProfileTarget::Index(i) => i, + ProfileTarget::FourChannel => { + let p = pick_profile(&list).ok_or_else(|| { + anyhow!( + "the DualSense card offers no four-channel profile ({} enumerated: {})", + list.len(), + list.iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(", ") + ) + })?; + tracing::info!( + profile = %p.name, + description = %p.description, + was = previous, + "moving the DualSense card to a four-channel profile so the voice coils are \ + reachable (restored when the session ends)" + ); + p.index + } + } + }; + if pick == previous { + return Ok(previous); + } + let pod = profile_pod(pick).context("serialize Profile pod")?; + round(&|| { + let d = device.borrow(); + let d = d + .as_ref() + .ok_or_else(|| anyhow!("card {device_id} vanished mid-swap"))?; + d.set_param( + ParamType::Profile, + 0, + pw::spa::pod::Pod::from_bytes(&pod).ok_or_else(|| anyhow!("bad Profile pod"))?, + ); + Ok(()) + })?; // 3: flush the set_param before the loop and its proxies drop + Ok(previous) +} + +/// Parse one `EnumProfile` / `Profile` object pod. +#[cfg(target_os = "linux")] +fn parse_profile(pod: &pipewire::spa::pod::Pod) -> Option { + use pipewire::spa::pod::{deserialize::PodDeserializer, Value}; + // `SPA_PARAM_AVAILABILITY_no` — the one availability that means "cannot be selected". + const AVAILABILITY_NO: u32 = 1; + let (_, value) = PodDeserializer::deserialize_any_from(pod.as_bytes()).ok()?; + let Value::Object(obj) = value else { + return None; + }; + let mut p = CardProfile { + available: true, + ..CardProfile::default() + }; + for prop in obj.properties { + match (prop.key, prop.value) { + (pipewire::spa::sys::SPA_PARAM_PROFILE_index, Value::Int(i)) => p.index = i as u32, + (pipewire::spa::sys::SPA_PARAM_PROFILE_name, Value::String(s)) => p.name = s, + (pipewire::spa::sys::SPA_PARAM_PROFILE_description, Value::String(s)) => { + p.description = s + } + (pipewire::spa::sys::SPA_PARAM_PROFILE_available, Value::Id(id)) => { + p.available = id.0 != AVAILABILITY_NO + } + _ => {} + } + } + Some(p) +} + +/// The `Profile` object pod that selects `index`. `save = false` on purpose: this is a +/// borrowed profile for the length of a session, not a preference to write into the user's +/// WirePlumber state. +#[cfg(target_os = "linux")] +fn profile_pod(index: u32) -> anyhow::Result> { + use anyhow::Context; + use pipewire::spa; + use spa::pod::{Object, Property, PropertyFlags, Value}; + let obj = Object { + type_: spa::utils::SpaTypes::ObjectParamProfile.as_raw(), + id: spa::param::ParamType::Profile.as_raw(), + properties: vec![ + Property { + key: spa::sys::SPA_PARAM_PROFILE_index, + flags: PropertyFlags::empty(), + value: Value::Int(index as i32), + }, + Property { + key: spa::sys::SPA_PARAM_PROFILE_save, + flags: PropertyFlags::empty(), + value: Value::Bool(false), + }, + ], + }; + Ok(spa::pod::serialize::PodSerializer::serialize( + std::io::Cursor::new(Vec::new()), + &Value::Object(obj), + ) + .context("serialize")? + .0 + .into_inner()) +} + +/// Correlate: walk the graph, pick the pad's four-channel node, and move the card's profile if +/// that is what stands between us and one. Returns the `node.name` to target. +#[cfg(target_os = "linux")] +pub fn correlate_pad_sink() -> anyhow::Result { + use anyhow::anyhow; + let (sinks, cards) = walk_graph()?; + match pick_pad_sink(&sinks, &cards) { + Some(PadSinkPick::Node(name)) => Ok(name), + Some(PadSinkPick::NeedsProfile(device_id)) => { + ensure_pro_audio(device_id)?; + // The card re-mints its nodes on a profile change; give the graph a moment to + // publish them rather than failing into the caller's multi-second backoff. + let mut last = Vec::new(); + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(100)); + let (sinks, cards) = walk_graph()?; + if let Some(PadSinkPick::Node(name)) = pick_pad_sink(&sinks, &cards) { + return Ok(name); + } + last = sinks; + } + // Name what we saw: a card whose nodes publish no `audio.channels` at all looks + // exactly like a card stuck on stereo from here, and the two want different fixes. + // + // ⚠ The other way to land here is a profile change the session manager REFUSED — + // `set_param` on a device is a write, and a sandboxed (flatpak) client is commonly + // granted read-only permission on objects it does not own. There is no reply to + // read, so this is where that shows up. Hence the manual instruction: switching the + // card by hand is the same fix, and it always works. + Err(anyhow!( + "the DualSense card has no four-channel node, and moving its profile did not \ + produce one — set the controller's Profile to \"Pro Audio\" in your sound \ + settings (a sandboxed client may not be allowed to do it for you). Its sinks \ + are [{}] (run `punktfunk-session --pad-audio-test` for the full graph)", + last.iter() + .filter(|s| s.ds5) + .map(|s| format!("{}={}ch", s.name, s.channels)) + .collect::>() + .join(", ") + )) + } + None => Err(anyhow!("no DualSense sound card in the PipeWire graph")), + } +} + +// ---- the on-glass devtest (Linux) ------------------------------------------------------------ + +/// `punktfunk-session --pad-audio-test`: report what the correlation sees, then drive a tone +/// into the pad so "nothing happens" can be told apart from "nothing arrives". +/// +/// The two failures this separates are the whole reason it exists. A silent pad with a host +/// streaming could be the plane (nothing arriving), the graph (arriving and folded away), or +/// the pad (arriving, routed, and the firmware muted). This walks the same correlation the +/// renderer does, prints every DualSense object it found and the node it chose, and then puts a +/// 200 Hz sine on the voice-coil pair — channels 3 and 4 — with the speaker pair silent. If the +/// pad buzzes, everything below the plane is good. +#[cfg(target_os = "linux")] +pub fn pad_audio_test(seconds: u64, coils: bool, speaker: bool) -> anyhow::Result<()> { + let (sinks, cards) = walk_graph()?; + // The totals first: "no DualSense here" and "this walk saw nothing at all" print the same + // empty list otherwise, and they are completely different faults. + println!( + "== DualSense objects in the PipeWire graph (of {} sinks, {} cards) ==", + sinks.len(), + cards.len() + ); + for c in cards.iter().filter(|c| c.ds5) { + println!("card id={:<5} {} ({})", c.id, c.name, c.description); + } + for s in sinks.iter().filter(|s| { + s.ds5 + || s.device_id + .is_some_and(|id| cards.iter().any(|c| c.id == id && c.ds5)) + }) { + println!( + "sink device.id={:<7} channels={} position={:<24} {}{}", + s.device_id + .map(|d| d.to_string()) + .unwrap_or_else(|| "-(virtual)".into()), + s.channels, + if s.positions.is_empty() { + "-".into() + } else { + s.positions.join(",") + }, + s.name, + s.split_parent + .as_deref() + .map(|p| format!(" split.parent={p}")) + .unwrap_or_default(), + ); + } + match pick_pad_sink(&sinks, &cards) { + None => { + println!("\nno DualSense sound card found — is the pad plugged in over USB?"); + anyhow::bail!("no DualSense sound card in the PipeWire graph"); + } + Some(PadSinkPick::Node(n)) => println!("\npick: render on {n}"), + Some(PadSinkPick::NeedsProfile(d)) => println!( + "\npick: card {d} has no four-channel node — moving it to a four-channel profile" + ), + } + + let out = PadOut::open()?; + println!( + "playing {seconds}s: {} — the coils are channels 3/4, the speaker 1/2", + match (coils, speaker) { + (true, true) => "a tone on BOTH pairs", + (true, false) => "a tone on the voice coils only", + (false, true) => "a tone on the speaker only", + (false, false) => "silence (both pairs off)", + } + ); + // 200 Hz at half scale: low enough that the coils move air rather than click, loud enough + // to feel through a grip. 480-frame (10 ms) chunks, paced by the wall clock — this is a + // devtest, so the ring policy downstream is what absorbs the jitter. + let mut phase = 0f32; + let step = std::f32::consts::TAU * 200.0 / 48_000.0; + let deadline = Instant::now() + Duration::from_secs(seconds); + while Instant::now() < deadline { + let mut chunk = out.take_buffer(); + chunk.clear(); + for _ in 0..480 { + let s = phase.sin() * 0.5; + phase = (phase + step) % std::f32::consts::TAU; + let sp = if speaker { s } else { 0.0 }; + let co = if coils { s } else { 0.0 }; + chunk.extend_from_slice(&[sp, sp, co, co]); + } + out.push(chunk); + std::thread::sleep(Duration::from_millis(10)); + } + drop(out); + restore_profile(); + Ok(()) } // ---- correlation: Windows (HID container → render endpoint) --------------------------------- @@ -586,6 +1309,11 @@ fn run(connector: &NativeClient, stop: &AtomicBool, haptics: bool, speaker: bool None => mixer.discard(), } } + // Drop the output BEFORE the profile goes back: the card cannot leave a profile whose PCM + // we still hold open, and a failed restore would leave the user's pad on Pro Audio. + drop(out); + #[cfg(target_os = "linux")] + restore_profile(); tracing::debug!("pad-audio pull thread exited"); } @@ -605,19 +1333,17 @@ struct PadOut { #[cfg(target_os = "linux")] impl PadOut { - /// Correlate (sink signature match) and open the PipeWire playback stream on it. + /// Correlate (the pad's four-channel card node, moving its profile if need be) and open the + /// PipeWire playback stream on it. fn open() -> anyhow::Result { 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"); + let target = correlate_pad_sink()?; + tracing::info!(sink = %target, "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::>(64); let (recycle_tx, recycle_rx) = std::sync::mpsc::sync_channel::>(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 || { @@ -657,8 +1383,8 @@ impl Drop for PadOut { } } -/// 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 +/// The PipeWire playback thread on the DualSense node: 4 unpositioned `AUX0..AUX3` channels +/// (ch0/1 the pad's speaker, ch2/3 the voice coils), 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")] @@ -702,6 +1428,13 @@ fn pad_pw_thread( // 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", + // ⚠ LOAD-BEARING: without this the graph POSITION-remixes our quad into whatever the + // node's own channel map says, and on any positioned map the voice-coil pair is folded + // into the speaker pair and disappears — the exact failure the "set it to Pro Audio" + // advice exists to route around. With it, channel k goes to channel k, which is the + // only mapping the pad's firmware understands. GE-Proton's pulse leg forces the same + // thing (`PA_STREAM_NO_REMIX_CHANNELS` + a forced AUX map). + *pw::keys::STREAM_DONT_REMIX => "true", }; let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props).context("pw Stream")?; @@ -794,10 +1527,15 @@ fn pad_pw_thread( 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). + // AUX0..AUX3 (`enum spa_audio_channel`: `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a + // positioned FL FR RL RR layout. Aux positions carry no spatial meaning, so they are what + // the pad's own Pro Audio profile and WirePlumber's split parents advertise, what GE-Proton + // forces on its own haptic streams, and what our host-side sink mints — one vocabulary end + // to end. `stream.dont-remix` above makes the routing index-exact regardless; this makes it + // index-exact by AGREEMENT as well, so nothing downstream has a position to reason about. + const AUX0: u32 = 0x1000; let mut positions = [0u32; 64]; - positions[..4].copy_from_slice(&[3, 4, 12, 13]); + positions[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]); info.set_position(positions); let obj = pw::spa::pod::Object { type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), @@ -1057,6 +1795,178 @@ mod tests { assert!(!is_ds5_sink("headset", "Adapter for Wireless Controller")); } + /// USB ids in a proplist are BASE 16, with or without an `0x` prefix. A decimal reading of + /// `"054c"` fails outright, but of `"0994"` would succeed and be wrong — hence the parse. + #[test] + fn usb_ids_are_hex_either_spelling() { + assert_eq!(parse_usb_id("054c"), Some(0x054C)); + assert_eq!(parse_usb_id("0x054c"), Some(0x054C)); + assert_eq!(parse_usb_id("0X0CE6"), Some(0x0CE6)); + assert_eq!(parse_usb_id(" 0df2 "), Some(0x0DF2)); + assert_eq!(parse_usb_id("0994"), Some(0x0994)); // NOT 994 + assert_eq!(parse_usb_id(""), None); + assert_eq!(parse_usb_id("Sony"), None); + } + + /// Identity from either half: the USB ids when published, the name signature otherwise — + /// and BOTH ids have to agree, so another Sony audio device is not the pad. + #[test] + fn ds5_identity_from_ids_or_name() { + assert!(props_say_ds5( + Some("054c"), + Some("0ce6"), + "alsa_card.usb-x", + "" + )); + assert!(props_say_ds5(Some("0x054C"), Some("0x0DF2"), "", "")); // Edge + assert!(!props_say_ds5(Some("054c"), Some("0104"), "", "")); // a Sony headset + assert!(!props_say_ds5(Some("046d"), Some("0ce6"), "", "")); // wrong vendor + assert!(!props_say_ds5(None, None, "alsa_card.pci-0000_0a_00.4", "")); + // No ids at all — the split sinks of a UCM card publish none — so the name carries it. + assert!(props_say_ds5( + None, + None, + "alsa_output.usb-Sony_Interactive_Entertainment_Wireless_Controller-00.HiFi__Speaker__sink", + "Speaker" + )); + } + + /// The unpositioned test: AUX (and the unknown markers) are index-routed, anything spatial + /// is not. + #[test] + fn aux_and_unknown_maps_are_unpositioned() { + let v = |s: &str| -> Vec { s.split(',').map(|p| p.trim().to_string()).collect() }; + assert!(is_unpositioned(&v("AUX0,AUX1,AUX2,AUX3"))); + assert!(is_unpositioned(&v("UNK,UNK,UNK,UNK"))); + assert!(is_unpositioned(&[])); + assert!(!is_unpositioned(&v("FL,FR,RL,RR"))); + assert!(!is_unpositioned(&v("MONO"))); + assert!(!is_unpositioned(&v("AUX0,AUX1,FL,FR"))); + } + + fn sink(name: &str, channels: u32, positions: &str, device_id: Option) -> SinkNode { + SinkNode { + name: name.into(), + description: String::new(), + device_id, + channels, + positions: if positions.is_empty() { + Vec::new() + } else { + positions.split(',').map(str::to_string).collect() + }, + split_parent: None, + ds5: true, + } + } + + /// Four channels on a real card is the whole requirement, and the unpositioned node wins + /// when both shapes are present. + #[test] + fn pad_sink_pick_needs_four_channels_on_a_card() { + let cards = [CardDevice { + id: 42, + ds5: true, + ..CardDevice::default() + }]; + let sinks = [ + sink("ds5.analog-stereo", 2, "FL,FR", Some(42)), + sink("ds5.analog-surround-40", 4, "FL,FR,RL,RR", Some(42)), + sink("ds5.pro-output-0", 4, "AUX0,AUX1,AUX2,AUX3", Some(42)), + ]; + assert_eq!( + pick_pad_sink(&sinks, &cards), + Some(PadSinkPick::Node("ds5.pro-output-0".into())) + ); + // Without the AUX node the positioned quad is taken (dont-remix makes it equivalent). + assert_eq!( + pick_pad_sink(&sinks[..2], &cards), + Some(PadSinkPick::Node("ds5.analog-surround-40".into())) + ); + // Stereo only: the pad is here, but the profile is in the way. + assert_eq!( + pick_pad_sink(&sinks[..1], &cards), + Some(PadSinkPick::NeedsProfile(42)) + ); + assert_eq!(pick_pad_sink(&[], &cards), None); + } + + /// A HOST's minted pad sink carries the full DualSense identity on purpose — and no + /// `device.id`, because it is a stream and not a card. Rendering into it would loop the + /// plane back at the host instead of driving a pad in someone's hands. + #[test] + fn pad_sink_pick_skips_a_virtual_host_sink() { + let virtual_sink = sink( + "alsa_output.usb-Sony_Interactive_Entertainment_Wireless_Controller-00.HiFi__Speaker__sink", + 4, + "AUX0,AUX1,AUX2,AUX3", + None, + ); + assert_eq!( + pick_pad_sink(std::slice::from_ref(&virtual_sink), &[]), + None + ); + // With a real pad present as well, the real one is what gets picked. + let cards = [CardDevice { + id: 7, + ds5: true, + ..CardDevice::default() + }]; + let real = sink("ds5.pro-output-0", 4, "AUX0,AUX1,AUX2,AUX3", Some(7)); + assert_eq!( + pick_pad_sink(&[virtual_sink, real], &cards), + Some(PadSinkPick::Node("ds5.pro-output-0".into())) + ); + } + + /// A split card's public sinks are mono/stereo, and the four-channel parent they name is + /// the node the coils live behind — GE-Proton's preferred leg. The CARD carries the + /// identity there; the split sinks themselves need not. + #[test] + fn pad_sink_pick_follows_a_split_parent() { + let cards = [CardDevice { + id: 3, + ds5: true, + ..CardDevice::default() + }]; + let mut speaker = sink("ds5.HiFi__Speaker__sink", 1, "MONO", Some(3)); + speaker.ds5 = false; // identity comes from the card + speaker.split_parent = Some("alsa_output.hw_3_0".into()); + let mut phones = sink("ds5.HiFi__Headphones__sink", 2, "FL,FR", Some(3)); + phones.ds5 = false; + assert_eq!( + pick_pad_sink(&[speaker, phones], &cards), + Some(PadSinkPick::Node("alsa_output.hw_3_0".into())) + ); + } + + /// Profile choice: Pro Audio first, a four-channel positioned output as the fallback, and + /// an unavailable profile is never selected (it would silently leave the card put). + #[test] + fn profile_choice_prefers_pro_audio() { + let p = |name: &str, index: u32, available: bool| CardProfile { + index, + name: name.into(), + description: name.into(), + available, + }; + let all = [ + p("off", 0, true), + p("output:analog-stereo", 1, true), + p("output:analog-surround-40", 2, true), + p("pro-audio", 3, true), + ]; + assert_eq!(pick_profile(&all).map(|p| p.index), Some(3)); + assert_eq!(pick_profile(&all[..3]).map(|p| p.index), Some(2)); + assert_eq!(pick_profile(&all[..2]).map(|p| p.index), None); + // Unavailable Pro Audio falls through to the positioned quad rather than being picked. + let unavailable = [ + p("output:analog-surround-40", 2, true), + p("pro-audio", 3, false), + ]; + assert_eq!(pick_profile(&unavailable).map(|p| p.index), Some(2)); + } + /// The Windows container matcher: container equality (case-insensitive — registry GUIDs /// come in both cases) AND the 4-channel format gate. #[test] diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 8c1bc468..7607348d 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -224,6 +224,20 @@ regular pad). Automatic arms it only where the raw guide press can't reach the h Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule. +**Controller haptics** — *default: on*, and **Controller speaker** — *default: on* on the Linux and +Windows apps, *off* on Android. The two halves of [controller audio](/docs/controller-audio): a +DualSense's voice-coil haptics, and the little speaker in the middle of the pad. Both need a +**wired** DualSense or DualSense Edge — over Bluetooth a controller exposes no audio device at all, +and both settings quietly do nothing. Neither costs anything without a host that sends them: the +plane is negotiated, and silence is never encoded or transmitted, so leaving haptics on is free even +on a pad that never gets any. Turn **Controller speaker** off if you would rather all game audio came +out of your speakers or headset. + +Offered by the Linux, Windows and Android apps. On Linux, the client also switches the controller's +sound card to Pro Audio while it needs the voice coils, and puts it back afterwards — see +[the controller-audio page](/docs/controller-audio#on-a-linux-client-the-pads-own-profile-matters-too) +for why that is necessary and how to turn it off. + **Capture system shortcuts** — *default: on.* Offered by the Linux, Windows and macOS apps and the console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 3937e3bb..a1654f7d 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -269,6 +269,7 @@ A few knobs are read by the native **clients**, not the host: | `PUNKTFUNK_DECODER` | `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software` | Force the decode path. Default auto-selects hardware per GPU vendor and falls back on its own: **Linux** — Vulkan Video first on NVIDIA and AMD, VAAPI first on Intel and anything else; **Windows** — Vulkan Video first on NVIDIA and AMD, D3D11VA first on Intel and anything else. Whichever isn't first is the next thing tried, with software last (OpenH264 for H.264, rav1d for AV1 — there is no software HEVC, so a client that lands there reconnects on a codec it can decode). The names are the ones the [stats overlay](/docs/stats) prints, so a pin and a reading match. The older spellings `vulkan`, `vaapi` and `d3d11va` named the FFmpeg-backed decoders the clients used before and still work — each migrates onto the native path for the same hardware, and the client says so in its log. | | `PUNKTFUNK_VAAPI_DEVICE` | path, e.g. `/dev/dri/renderD129` | **(Linux)** Pin the DRM render node the `native-vaapi` decoder opens. Unset, the client tries the nodes in order and takes the first that can decode the stream — set this on a multi-GPU box when it lands on the wrong one. | | `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). | +| `PUNKTFUNK_PAD_AUDIO_PROFILE` | `0` | **(Linux)** Stop the client from switching a wired DualSense's sound card to **Pro Audio** while it streams [controller audio](/docs/controller-audio) to it. The switch exists because a controller's voice coils are channels 3 and 4 of its sound card, and a controller almost never presents four channels on its own — on any other profile the haptics are folded into the speaker pair and felt as nothing. Punktfunk restores the card's profile when the session ends and never saves it. Set this if you'd rather select the card's profile yourself. | | `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. | | `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. | | `PUNKTFUNK_PRESENT_MODE` | `mailbox` *(default)* · `fifo` · `immediate` · `fifo_relaxed` | How decoded frames meet the display (the Vulkan present mode). The default prefers MAILBOX — tear-free without queueing behind the vertical refresh — and falls back to FIFO (classic vsync) where the driver doesn't offer it. **AMD's Windows driver offers no MAILBOX**, so those clients run FIFO, which adds a standing frame-pacing wait (up to one refresh interval). `immediate` removes that wait but can tear; `fifo_relaxed` only tears when a frame is late. If your latency floor matters more than tearing, try `immediate` and judge by eye. | diff --git a/docs-site/content/docs/controller-audio.md b/docs-site/content/docs/controller-audio.md index 76a442a2..b3716d80 100644 --- a/docs-site/content/docs/controller-audio.md +++ b/docs-site/content/docs/controller-audio.md @@ -17,8 +17,9 @@ pad's speaker, channels 3–4 are the voice coils. - **A DualSense or DualSense Edge plugged in over USB** on the client. Bluetooth pads expose no audio interface at all, so they fall back to ordinary rumble — this is a limit of the controller, not of Punktfunk. -- On the client, **Controller haptics** is on by default. **Controller speaker** is opt-in: turn - it on if you want game audio coming out of the pad as well as your speakers. +- On the client, **Controller haptics** is on by default. So is **Controller speaker** on the Linux + and Windows apps — turn it off in [client settings](/docs/client-settings#input) if you would + rather all game audio came out of your speakers. On Android the speaker is opt-in. - On a **Linux host**, a game that speaks DualSense — which in practice means running it under **GE-Proton 11-5 or newer**. Stock Proton does not route controller audio. - On the host, controller audio is on by default (`PUNKTFUNK_PAD_AUDIO`). @@ -109,6 +110,52 @@ PROTON_DUALSENSE_SPLIT_AUDIO=1 %command% To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for a line beginning `Routing DualSense`. It names the device it chose and how it opened it. +## On a Linux client, the pad's own profile matters too + +Everything above is about the host, where the controller-audio device is one Punktfunk mints. On a +Linux **client** the pad is real, and the same channel-layout problem shows up from the other side: +the voice coils are physically channels 3 and 4 of the controller's USB sound card, and a +controller almost never presents as a four-channel device on its own. Depending on your distribution +it appears as a stereo output, or as a mono *Speaker* plus a stereo *Headphones* pair. Playing into +any of those puts the haptics in the headphone jack and folds the coil channels away — audio that +looks perfectly healthy, felt as nothing at all. + +**Punktfunk handles this for you.** When it needs the coils and the pad is not already presenting +four channels, it switches the controller's card to **Pro Audio** for the length of the session and +puts your setting back afterwards. You will see the profile change in your sound settings while you +are streaming; that is expected. It is never saved as the card's remembered profile. + +If you would rather manage the card yourself, set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` on the client. Then +Punktfunk uses a four-channel profile if you have already selected one and logs what it needs if you +have not. + +Most systems never reach the switch at all. Where your distribution ships a recent `alsa-ucm-conf` — +Bazzite and SteamOS among them — a DualSense already exposes its four channels behind its split +speaker and headphone outputs, and Punktfunk finds them there. The switch is the fallback for +systems that only offer the older stereo profile. **If you run the client as a Flatpak**, your audio +manager may not let a sandboxed app change a card's profile; if the log says so, switch the +controller to Pro Audio yourself, which is the same fix. + +### Checking the client side without a host + +The client can test the whole path on its own — no host, no game, no pairing. Plug in the +DualSense and run: + +```sh +punktfunk-session --pad-audio-test +``` + +It prints every DualSense object it can see in your audio graph, says which one it chose, and then +plays a tone into the voice coils for three seconds. **If the pad buzzes, the client side is +working** and any remaining silence is coming from the host or the game. Add `--speaker` to test +the pad's speaker instead, and `--seconds N` for a longer run. + +On the Steam Deck and other flatpak installs, run it inside the sandbox: + +```sh +flatpak run --command=punktfunk-session io.unom.Punktfunk --pad-audio-test +``` + ## Known limits - **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT. @@ -119,3 +166,11 @@ To see which route GE took, launch the game with `WINEDEBUG=+pulse` and look for - **A pad plugged into the host itself can steal the audio.** If a real DualSense is connected to the host while you are streaming to a different one, some titles will find the local pad's sound card first. Unplug it, or stream from a host that has no pad attached. +- **The Pro Audio switch on a Linux client renames the pad's microphone too.** Switching a sound + card's profile re-creates all of its inputs and outputs, so if you had picked the DualSense's own + microphone as your [mic](/docs/client-settings#audio), that session falls back to your default + one. Pick a different microphone, or set `PUNKTFUNK_PAD_AUDIO_PROFILE=0` and select a + four-channel profile on the card yourself. +- **A client killed mid-stream leaves the pad on Pro Audio.** The profile is restored when a + session ends normally and is never written to your saved settings, so anything that reloads the + card — unplugging it, logging out, a reboot — brings your own profile back. -- 2.54.0 From 60d0cdfc0fefdf42bfe951cdf314bbf60364a715 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 15 Aug 2026 23:55:09 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(client/linux):=20a=20card's=20public=20?= =?UTF-8?q?4-ch=20sink=20beats=20its=20hidden=20parent=20=E2=80=94=20the?= =?UTF-8?q?=20parent's=20AUX0=20is=20dead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-glass on a Steam Deck (SteamOS 3.7, alsa-ucm-conf with DualSense-PS5.conf) with a wired DualSense. The card publishes three usable-looking nodes, and the previous commit's "prefer the unpositioned quad" rule picked the wrong one: alsa_output.hw_Controller_0 Audio/Sink/Internal 4ch AUX0,AUX1,AUX2,AUX3 ....HiFi__SpeakerHaptic__sink Audio/Sink 4ch FL,FR,RL,RR ....HiFi__Speaker__sink Audio/Sink 1ch MONO The hardware map is in the splits' own `api.alsa.split.position`: the mono Speaker device is `[AUX1]` and SpeakerHaptic is `[AUX1,AUX1,AUX2,AUX3]`, so AUX1 is the internal speaker, AUX2/AUX3 are the two voice coils, and **AUX0 is nothing**. Our stream is speaker on 0/1 and haptics on 2/3, so index-exact into the PARENT puts speaker-left into the dead channel and only speaker-right into the speaker — half the speaker thrown away. The public split sink folds BOTH our speaker channels onto AUX1, which is what its UCM author intended, and passes the coil pair through untouched. Haptics are identical either way; the speaker is not. So the order is now public-quad (unpositioned, then positioned) before the internal parent, with `SinkNode::internal` carrying `media.class == Audio/Sink/Internal` or `api.alsa.split.parent`. Pro Audio's `pro-output-0` is a PUBLIC AUX quad, so it is still caught by the first rule and nothing about the no-UCM path changes. Measured, not reasoned: playing a 200 Hz tone present ONLY in channels 3/4, in the shape this client now uses (AUX0..AUX3 + `stream.dont-remix`), into SpeakerHaptic and reading that sink's own monitor back index-exact gives ch0 0.0000 ch1 0.0000 ch2 0.5000 ch3 0.5000 — bit-exact on the coil pair, nothing leaking into the speaker pair. The parent node has no monitor to capture (0 frames), which is why its map is read from the split properties instead. The new test transcribes all three real nodes and asserts the pick from every enumeration order, which also pins the original defect: the old name-only matcher took whichever public sink the registry replayed first, and one of them is a MONO node that cannot carry the coils at all. Gates: clippy -D warnings over the four client packages, build, 205 tests green (202 in pf-client-core), cargo fmt --check. Also confirmed on the same Deck that the pad still presents to the input layer as 054c:0ce6 alongside Steam Input's 28de:11ff virtual pad, so tier-A detection has the real ids to match on. --- crates/pf-client-core/src/pad_audio.rs | 120 ++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 11 deletions(-) diff --git a/crates/pf-client-core/src/pad_audio.rs b/crates/pf-client-core/src/pad_audio.rs index 6480e447..46ad8536 100644 --- a/crates/pf-client-core/src/pad_audio.rs +++ b/crates/pf-client-core/src/pad_audio.rs @@ -250,6 +250,11 @@ pub(crate) struct SinkNode { /// This node's OWN proplist said DualSense (cards state it more reliably — see /// `pick_pad_sink`, which accepts either). pub(crate) ds5: bool, + /// `media.class` was `Audio/Sink/Internal` (or the node called itself + /// `api.alsa.split.parent`): the hidden RAW node behind a split card, carrying the + /// hardware's own channels. Usable, but the LAST four-channel choice — see + /// `pick_pad_sink` for the measurement that says so. + pub(crate) internal: bool, } /// A PipeWire `Device` — an ALSA card — reduced to the same shape. @@ -292,6 +297,25 @@ pub(crate) fn is_unpositioned(positions: &[String]) -> bool { /// The whole requirement is four channels on a DualSense's own card: the voice coils ARE /// channels 3 and 4, so a stereo or mono node opens perfectly and renders the haptics into the /// headphone jack. v1 renders ONE physical DS5 — with two plugged in the first match wins. +/// +/// ⚠⚠ **A card's PUBLIC four-channel sink beats its hidden parent, even though the parent is +/// the "rawer" node.** Measured on a Steam Deck (SteamOS 3.7, `alsa-ucm-conf` with +/// `DualSense-PS5.conf`), where the card offers both: +/// +/// | node | `audio.position` | what our channels reach | +/// |---|---|---| +/// | `HiFi__SpeakerHaptic__sink` (public, 4 ch) | `FL,FR,RL,RR` | split `[AUX1,AUX1,AUX2,AUX3]` | +/// | `alsa_output.hw_Controller_0` (`Audio/Sink/Internal`, 4 ch) | `AUX0..AUX3` | the hardware | +/// +/// The hardware map is **AUX1 = the mono speaker, AUX2/AUX3 = the two voice coils, AUX0 = +/// nothing**. Our stream is speaker on 0/1 and haptics on 2/3, so index-exact into the PARENT +/// puts speaker-left into the dead AUX0 and only speaker-right into the speaker — half the +/// speaker signal thrown away. The public split sink maps BOTH of our speaker channels onto +/// AUX1 (the fold the UCM author intended) and passes the coil pair straight through. Haptics +/// are identical either way; the speaker is not, so the public sink wins. +/// +/// The parent stays as the next choice for cards that publish nothing else — and Pro Audio's +/// `pro-output-0`, which is a PUBLIC `AUX` quad, is caught by the first rule. #[cfg(any(target_os = "linux", test))] pub(crate) fn pick_pad_sink(sinks: &[SinkNode], cards: &[CardDevice]) -> Option { let ds5_card = |id: u32| cards.iter().any(|c| c.id == id && c.ds5); @@ -305,15 +329,22 @@ pub(crate) fn pick_pad_sink(sinks: &[SinkNode], cards: &[CardDevice]) -> Option< if mine.is_empty() { return None; } - // Unpositioned quad first (Pro Audio, or a split parent) — the shape nothing can remix — - // then a positioned one, which `stream.dont-remix` makes equivalent. + let quad = |s: &&&SinkNode| s.channels == 4; + // Public quads first — unpositioned (Pro Audio) ahead of positioned only for determinism, + // since `stream.dont-remix` makes the two equivalent to us. if let Some(s) = mine .iter() - .find(|s| s.channels == 4 && is_unpositioned(&s.positions)) + .filter(|s| !s.internal) + .filter(quad) + .find(|s| is_unpositioned(&s.positions)) { return Some(PadSinkPick::Node(s.name.clone())); } - if let Some(s) = mine.iter().find(|s| s.channels == 4) { + if let Some(s) = mine.iter().filter(|s| !s.internal).find(quad) { + return Some(PadSinkPick::Node(s.name.clone())); + } + // Only now the hidden parent (see the table above for what this costs the speaker). + if let Some(s) = mine.iter().find(quad) { return Some(PadSinkPick::Node(s.name.clone())); } // A split card whose four-channel parent we cannot see in the registry (it is an @@ -370,14 +401,18 @@ fn walk_graph() -> anyhow::Result<(Vec, Vec)> { match g.type_ { pw::types::ObjectType::Node => { // `Audio/Sink` and `Audio/Sink/Internal` alike: the hidden four-channel - // parent behind a split card wears the latter, and it is the node the - // haptics actually want. - if !props - .get("media.class") - .is_some_and(|c| c.starts_with("Audio/Sink")) - { + // parent behind a split card wears the latter, and it is a usable + // (if second-choice) target — see `pick_pad_sink`. + let Some(class) = props.get("media.class") else { + return; + }; + if !class.starts_with("Audio/Sink") { return; } + let internal = class.ends_with("/Internal") + || props + .get("api.alsa.split.parent") + .is_some_and(|v| !matches!(v, "false" | "0")); let Some(name) = props.get("node.name") else { return; }; @@ -397,6 +432,7 @@ fn walk_graph() -> anyhow::Result<(Vec, Vec)> { .unwrap_or(positions.len() as u32), split_parent: props.get("api.alsa.split.name").map(str::to_string), ds5: props_say_ds5(vendor, product, name, description), + internal, positions, name: name.to_string(), description: description.to_string(), @@ -834,7 +870,8 @@ pub fn pad_audio_test(seconds: u64, coils: bool, speaker: bool) -> anyhow::Resul .is_some_and(|id| cards.iter().any(|c| c.id == id && c.ds5)) }) { println!( - "sink device.id={:<7} channels={} position={:<24} {}{}", + "{:<7} device.id={:<7} channels={} position={:<24} {}{}", + if s.internal { "parent" } else { "sink" }, s.device_id .map(|d| d.to_string()) .unwrap_or_else(|| "-(virtual)".into()), @@ -1857,6 +1894,7 @@ mod tests { }, split_parent: None, ds5: true, + internal: false, } } @@ -1919,6 +1957,66 @@ mod tests { ); } + /// The real thing, transcribed from a Steam Deck (SteamOS 3.7, `alsa-ucm-conf` with + /// `DualSense-PS5.conf`) with a wired DualSense: the card publishes a four-channel + /// `SpeakerHaptic` sink, a one-channel `Speaker` sink, AND a hidden four-channel parent. + /// + /// Two things this pins. The old name-only matcher would take whichever of the two public + /// sinks the registry happened to replay first — a coin flip against a MONO node that + /// cannot carry the coils at all. And the parent, despite being the unpositioned/raw one, + /// must NOT win: its AUX0 is a dead hardware channel, so index-exact into it drops half + /// the speaker signal (see `pick_pad_sink`'s table). + #[test] + fn pad_sink_pick_on_a_real_steamos_dualsense() { + let cards = [CardDevice { + id: 140, + name: "alsa_card.usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller-00" + .into(), + description: "DualSense wireless controller (PS5)".into(), + ds5: true, + }]; + let base = + "alsa_output.usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller-00"; + let mut parent = sink( + "alsa_output.hw_Controller_0", + 4, + "AUX0,AUX1,AUX2,AUX3", + Some(140), + ); + parent.internal = true; + parent.ds5 = false; // the parent's own proplist carries no vendor ids or product name + let mut haptic = sink( + &format!("{base}.HiFi__SpeakerHaptic__sink"), + 4, + "FL,FR,RL,RR", + Some(140), + ); + haptic.split_parent = Some("alsa_output.hw_Controller_0".into()); + let mut mono = sink(&format!("{base}.HiFi__Speaker__sink"), 1, "MONO", Some(140)); + mono.split_parent = Some("alsa_output.hw_Controller_0".into()); + + // Registry replay order is not ours to choose, so it must not matter. + for order in [ + vec![parent.clone(), haptic.clone(), mono.clone()], + vec![mono.clone(), parent.clone(), haptic.clone()], + vec![haptic.clone(), mono.clone(), parent.clone()], + ] { + assert_eq!( + pick_pad_sink(&order, &cards), + Some(PadSinkPick::Node(format!( + "{base}.HiFi__SpeakerHaptic__sink" + ))), + "the four-channel public sink must win from any enumeration order" + ); + } + // With only the mono sink and the parent visible, the parent is the right fallback — + // half a speaker beats no coils. + assert_eq!( + pick_pad_sink(&[mono, parent], &cards), + Some(PadSinkPick::Node("alsa_output.hw_Controller_0".into())) + ); + } + /// A split card's public sinks are mono/stereo, and the four-channel parent they name is /// the node the coils live behind — GE-Proton's preferred leg. The CARD carries the /// identity there; the split sinks themselves need not. -- 2.54.0 From db8874f9440d09690397963a69e50130b2d0a662 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 16 Aug 2026 00:10:50 +0200 Subject: [PATCH 3/4] fix(client/linux): registry globals carry no audio.channels, so every node looked 0-channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by running --pad-audio-test on a real Steam Deck with a wired DualSense. The graph walk read `audio.channels` and `audio.position` out of the registry's `global` event, and a global announce carries only a SUBSET of an object's proplist. The subset happens to include `media.class`, `node.name` and `device.id` — which is exactly why this looked like it worked — but not the audio shape. So every sink came back as 0 channels: parent device.id=140 channels=0 position=- alsa_output.hw_Controller_0 sink device.id=140 channels=0 position=- ....HiFi__SpeakerHaptic__sink sink device.id=140 channels=0 position=- ....HiFi__Speaker__sink pick: card 140 has no four-channel node — moving it to a four-channel profile i.e. the matcher could never see the four-channel sink that was sitting right there, and then went and changed the user's card profile to fix a problem that did not exist. `pw-dump` and `pactl` show these fields because they BIND every object and read its info props; reading their output is what made the registry-only version look plausible. The walk is now two rounds: the registry replay binds every `Audio/Sink…` node, and a second sync collects the `info` events that provoked, whose props are the whole proplist. Cards need no second round — their identity keys are in the announce, and nothing else about them is weighed. Node parsing moved into `sink_from_props` so the two sources cannot drift, and it now strips the `[ ... ]` brackets PipeWire puts around `audio.position`. Second defect from the same run, and the reason the Deck was left sitting on Pro Audio afterwards: the devtest's early `?` returned before `restore_profile()`. The restore now wraps the whole body. And a profile swap that fails to produce a four-channel node restores the card immediately and records the card in PROFILE_TRIED, so the renderer's backoff cannot flip a device in the user's sound settings back and forth for the length of a session. Gates: clippy -D warnings over the four client packages, build, 205 tests, fmt. --- crates/pf-client-core/src/pad_audio.rs | 211 ++++++++++++++++++------- 1 file changed, 151 insertions(+), 60 deletions(-) diff --git a/crates/pf-client-core/src/pad_audio.rs b/crates/pf-client-core/src/pad_audio.rs index 46ad8536..5ad32638 100644 --- a/crates/pf-client-core/src/pad_audio.rs +++ b/crates/pf-client-core/src/pad_audio.rs @@ -362,15 +362,76 @@ pub(crate) fn pick_pad_sink(sinks: &[SinkNode], cards: &[CardDevice]) -> Option< .map(PadSinkPick::NeedsProfile) } -/// One registry roundtrip on a private mainloop: every `Audio/Sink…` node and every `Device`, -/// reduced to the matcher's shapes. [`crate::audio::devices`]'s discipline (a few ms against a -/// live daemon, a clean error when there is none) — a separate walk because that one is the -/// settings picker's and deliberately publishes only name + description. +/// Read a sink node's facts out of a proplist. Split out because it has to run against the +/// node's INFO props, not the registry's — see [`walk_graph`]. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn sink_from_props(props: &pipewire::spa::utils::dict::DictRef) -> Option { + // Both spellings: PipeWire's own objects use the `device.`-prefixed keys, the pulse-facing + // proplist GE reads uses the bare ones. Cheap to accept both. + let vendor = props + .get("device.vendor.id") + .or_else(|| props.get("vendor.id")); + let product = props + .get("device.product.id") + .or_else(|| props.get("product.id")); + // `Audio/Sink` and `Audio/Sink/Internal` alike: the hidden four-channel parent behind a + // split card wears the latter, and it is a usable (if second-choice) target. + let class = props.get("media.class")?; + if !class.starts_with("Audio/Sink") { + return None; + } + let name = props.get("node.name")?; + let description = props + .get("node.description") + .or_else(|| props.get("node.nick")) + .unwrap_or(name); + let positions: Vec = props + .get("audio.position") + .map(|p| { + p.trim_matches(|c| c == '[' || c == ']') + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + Some(SinkNode { + device_id: props.get("device.id").and_then(|v| v.parse().ok()), + channels: props + .get("audio.channels") + .and_then(|v| v.parse().ok()) + .unwrap_or(positions.len() as u32), + split_parent: props.get("api.alsa.split.name").map(str::to_string), + ds5: props_say_ds5(vendor, product, name, description), + internal: class.ends_with("/Internal") + || props + .get("api.alsa.split.parent") + .is_some_and(|v| !matches!(v, "false" | "0")), + positions, + name: name.to_string(), + description: description.to_string(), + }) +} + +/// Walk the graph on a private mainloop: every `Audio/Sink…` node and every `Device`, reduced +/// to the matcher's shapes. [`crate::audio::devices`]'s discipline (a few ms against a live +/// daemon, a clean error when there is none) — a separate walk because that one is the settings +/// picker's and deliberately publishes only name + description. +/// +/// ⚠⚠ **TWO rounds, because a registry `global` event does NOT carry the node's whole +/// proplist.** It carries a small announce subset — enough for `media.class`, `node.name` and +/// `device.id`, which is exactly why this looked like it worked — but `audio.channels` and +/// `audio.position` are NOT in it. Reading them from there yields 0 channels for every node on +/// a real machine, which then reads as "this card has no four-channel node" and sends the +/// renderer off to change the card's profile for no reason. They live in the node's INFO +/// props, so each candidate is bound and its `info` event awaited. (`pw-dump` and `pactl` show +/// these fields because they bind every object too; that is what made the registry-only version +/// look plausible against their output.) #[cfg(target_os = "linux")] fn walk_graph() -> anyhow::Result<(Vec, Vec)> { use anyhow::Context; use pipewire as pw; - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; use std::rc::Rc; static PW_INIT: std::sync::Once = std::sync::Once::new(); @@ -383,68 +444,70 @@ fn walk_graph() -> anyhow::Result<(Vec, Vec)> { .context("pw connect (is PipeWire running in this session?)")?; let registry = core.get_registry_rc().context("pw registry")?; - let found: Rc, Vec)>> = Rc::default(); + let sinks: Rc>> = Rc::default(); + let cards: Rc>> = Rc::default(); + // The bound node proxies and their listeners have to outlive the callback that made them. + let bound: Rc>> = Rc::default(); + let _reg_listener = registry .add_listener_local() .global({ - let found = found.clone(); + let (registry, sinks, cards, bound) = ( + registry.clone(), + sinks.clone(), + cards.clone(), + bound.clone(), + ); move |g| { let Some(props) = g.props else { return }; - // Both spellings: PipeWire's own objects use the `device.`-prefixed keys, and - // the pulse-facing proplist GE reads uses the bare ones. Cheap to accept both. - let vendor = props - .get("device.vendor.id") - .or_else(|| props.get("vendor.id")); - let product = props - .get("device.product.id") - .or_else(|| props.get("product.id")); match g.type_ { pw::types::ObjectType::Node => { - // `Audio/Sink` and `Audio/Sink/Internal` alike: the hidden four-channel - // parent behind a split card wears the latter, and it is a usable - // (if second-choice) target — see `pick_pad_sink`. - let Some(class) = props.get("media.class") else { - return; - }; - if !class.starts_with("Audio/Sink") { + // The announce subset is enough to know this is a sink; everything the + // matcher weighs comes from the info props below. + if !props + .get("media.class") + .is_some_and(|c| c.starts_with("Audio/Sink")) + { return; } - let internal = class.ends_with("/Internal") - || props - .get("api.alsa.split.parent") - .is_some_and(|v| !matches!(v, "false" | "0")); - let Some(name) = props.get("node.name") else { + let Ok(node) = registry.bind::(g) else { return; }; - let description = props - .get("node.description") - .or_else(|| props.get("node.nick")) - .unwrap_or(name); - let positions: Vec = props - .get("audio.position") - .map(|p| p.split(',').map(|s| s.trim().to_string()).collect()) - .unwrap_or_default(); - found.borrow_mut().0.push(SinkNode { - device_id: props.get("device.id").and_then(|v| v.parse().ok()), - channels: props - .get("audio.channels") - .and_then(|v| v.parse().ok()) - .unwrap_or(positions.len() as u32), - split_parent: props.get("api.alsa.split.name").map(str::to_string), - ds5: props_say_ds5(vendor, product, name, description), - internal, - positions, - name: name.to_string(), - description: description.to_string(), - }); + let listener = node + .add_listener_local() + .info({ + let sinks = sinks.clone(); + move |info| { + let Some(p) = info.props() else { return }; + if let Some(s) = sink_from_props(p) { + let mut v = sinks.borrow_mut(); + // `info` can fire more than once per node; keep one. + if let Some(old) = v.iter_mut().find(|o| o.name == s.name) { + *old = s; + } else { + v.push(s); + } + } + } + }) + .register(); + bound.borrow_mut().push((node, listener)); } pw::types::ObjectType::Device => { + // Cards DO announce their identity keys, and nothing else about them + // is weighed, so these need no second round. + let vendor = props + .get("device.vendor.id") + .or_else(|| props.get("vendor.id")); + let product = props + .get("device.product.id") + .or_else(|| props.get("product.id")); let name = props.get("device.name").unwrap_or_default(); let description = props .get("device.description") .or_else(|| props.get("device.nick")) .unwrap_or(name); - found.borrow_mut().1.push(CardDevice { + cards.borrow_mut().push(CardDevice { id: g.id, ds5: props_say_ds5(vendor, product, name, description), name: name.to_string(), @@ -457,24 +520,28 @@ fn walk_graph() -> anyhow::Result<(Vec, Vec)> { }) .register(); - // The registry replays existing globals asynchronously; one core sync marks the point they - // have all been delivered — quit there. - let pending = core.sync(0).context("pw sync")?; - let _core_listener = core + // Round 1 delivers the globals (and binds the sinks); round 2 collects the `info` events + // those binds provoked. Each round parks its sync seq for the one `done` listener. + let awaited: Rc>> = Rc::new(Cell::new(None)); + let _round_listener = core .add_listener_local() .done({ - let mainloop = mainloop.clone(); + let (mainloop, awaited) = (mainloop.clone(), awaited.clone()); move |_, seq| { - if seq == pending { + if awaited.get() == Some(seq) { mainloop.quit(); } } }) .register(); - mainloop.run(); - - let result = found.borrow().clone(); - Ok(result) + for _ in 0..2 { + awaited.set(Some(core.sync(0).context("pw sync")?)); + mainloop.run(); + } + let out = (sinks.borrow().clone(), cards.borrow().clone()); + // Drop the bound proxies before the core that owns them. + bound.borrow_mut().clear(); + Ok(out) } // ---- the Pro Audio profile swap (Linux) ------------------------------------------------------ @@ -524,6 +591,12 @@ struct ProfileSwap { #[cfg(target_os = "linux")] static PROFILE_SWAP: Mutex> = Mutex::new(None); +/// Cards whose profile we already moved WITHOUT getting a four-channel node out of it. One +/// failed swap is information; repeating it on every backoff retry would flip a device in the +/// user's sound settings back and forth for as long as the session lasts. +#[cfg(target_os = "linux")] +static PROFILE_TRIED: Mutex> = Mutex::new(Vec::new()); + /// Which profile a [`set_card_profile`] call is after. #[cfg(target_os = "linux")] enum ProfileTarget { @@ -804,6 +877,12 @@ pub fn correlate_pad_sink() -> anyhow::Result { match pick_pad_sink(&sinks, &cards) { Some(PadSinkPick::Node(name)) => Ok(name), Some(PadSinkPick::NeedsProfile(device_id)) => { + if PROFILE_TRIED.lock().unwrap().contains(&device_id) { + return Err(anyhow!( + "the DualSense card has no four-channel node and moving its profile did \ + not help earlier this session — not moving it again" + )); + } ensure_pro_audio(device_id)?; // The card re-mints its nodes on a profile change; give the graph a moment to // publish them rather than failing into the caller's multi-second backoff. @@ -816,6 +895,10 @@ pub fn correlate_pad_sink() -> anyhow::Result { } last = sinks; } + // The swap did not help, so it is pure cost to the user: put the card back before + // reporting, and remember not to move this one again. + PROFILE_TRIED.lock().unwrap().push(device_id); + restore_profile(); // Name what we saw: a card whose nodes publish no `audio.channels` at all looks // exactly like a card stuck on stereo from here, and the two want different fixes. // @@ -853,6 +936,15 @@ pub fn correlate_pad_sink() -> anyhow::Result { /// pad buzzes, everything below the plane is good. #[cfg(target_os = "linux")] pub fn pad_audio_test(seconds: u64, coils: bool, speaker: bool) -> anyhow::Result<()> { + // Whatever happens in here, the card goes back the way we found it. An early `?` used to + // skip the restore and leave a real Deck sitting on Pro Audio. + let out = pad_audio_test_inner(seconds, coils, speaker); + restore_profile(); + out +} + +#[cfg(target_os = "linux")] +fn pad_audio_test_inner(seconds: u64, coils: bool, speaker: bool) -> anyhow::Result<()> { let (sinks, cards) = walk_graph()?; // The totals first: "no DualSense here" and "this walk saw nothing at all" print the same // empty list otherwise, and they are completely different faults. @@ -929,7 +1021,6 @@ pub fn pad_audio_test(seconds: u64, coils: bool, speaker: bool) -> anyhow::Resul std::thread::sleep(Duration::from_millis(10)); } drop(out); - restore_profile(); Ok(()) } -- 2.54.0 From 23edf4e70241e7a3b241fa48cdb04cd05a9ee5b7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 16 Aug 2026 00:53:18 +0200 Subject: [PATCH 4/4] fix(client): the pad's speaker shares a channel with its headphone jack, and powers up on the jack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field result from the Deck: haptics FELT, speaker inaudible — with the routing already proven correct. Capturing the sink's own monitor while the client renders shows the speaker pair carrying full-scale signal: --coils ch0 0.0000 ch1 0.0000 ch2 0.5000 ch3 0.5000 --speaker ch0 0.5000 ch1 0.5000 ch2 0.0000 ch3 0.0000 so nothing was lost on the way to the pad. The loss is inside it. Channel 1 of the DualSense's audio function is the headphone jack's RIGHT channel *and* the built-in mono speaker — #259 reads the same thing out of the UCM from the host side ("ch1 is the built-in mono speaker") — and which of the two physically sounds is chosen by `ucAudioEnableBits`, report byte 8. A pad powers up pointing at the jack, so with nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are NOT affected by that select, which is exactly why haptics worked the instant the samples were routed right and the speaker did not. We only ever wrote those bytes when a host forwarded a game's `AudioCtl`, so a title that manages no audio settings of its own — and every standalone test — got silence. A tier-A slot with the speaker capability now sends a default speaker-enable packet beside the audio-haptics packet it already sends. A later `AudioCtl` still overrides it verbatim, so a game driving its own volume still wins. ⚠ The path byte is EMPIRICAL, not documented: SDL's vendored SDL_hidapi_ps5.c pins the struct layout but never writes these fields. Measured on 054c:0ce6 using the pad's own microphone as the detector (Goertzel at the test tone): 0x20 loudest at ~5x the noise floor, 0x30 also sounds, 0x10 silent. That is thin evidence for a constant, so both it and the volume are field levers — PUNKTFUNK_PAD_SPEAKER_PATH / PUNKTFUNK_PAD_SPEAKER_VOLUME, hex or decimal — and an on-glass confirmation of which value a human actually hears is still owed. The test pins what must not regress: the two validity bits are set, volume and path land at the same offsets the AudioCtl fold uses, every other byte stays zero, and `ucEnableBits1` bits 0/1 stay CLEAR — asserting either would enable rumble emulation and disable audio haptics, muting the coils to make the speaker audible. Gates: clippy -D warnings over the four client packages, build, 207 tests (204 in pf-client-core), fmt — on top of current main. --- crates/pf-client-core/src/gamepad.rs | 115 +++++++++++++++++++++ docs-site/content/docs/configuration.md | 1 + docs-site/content/docs/controller-audio.md | 13 +++ 3 files changed, 129 insertions(+) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 95e392f9..0c8ab65f 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -789,6 +789,19 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { /// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and /// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `−1` relationship rather than /// leaving it to a comment. +/// A `u8` field lever: decimal, or `0x`-prefixed hex (these name DS5 report BYTES, and every +/// reference to them — SDL's source, the reverse-engineering notes, this module's own comments — +/// writes them in hex). `None` when unset or unparseable, so a typo falls back to the default +/// rather than to zero. +fn env_u8(key: &str) -> Option { + let v = std::env::var(key).ok()?; + let v = v.trim(); + match v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) { + Some(hex) => u8::from_str_radix(hex, 16).ok(), + None => v.parse().ok(), + } +} + struct Ds5Feedback; impl Ds5Feedback { @@ -844,6 +857,39 @@ impl Ds5Feedback { [0u8; 47] } + /// Point the pad's audio at its own SPEAKER, and give that speaker a volume. + /// + /// Without this the speaker is silent no matter how correct the PCM routing is, which is + /// exactly what a wired DS5 on a Steam Deck did: haptics felt, speaker inaudible. The + /// reason is that **channel 1 of the pad's audio function is shared** — it is the headphone + /// jack's right channel AND the built-in mono speaker — and which one physically sounds is + /// chosen by `ucAudioEnableBits` (report byte 8, struct offset 7). A pad powers up pointing + /// at the headphone jack, so with nothing plugged in the speaker pair goes nowhere. The + /// voice coils are channels 2/3 and are NOT affected by that select, which is why haptics + /// work the instant the samples are routed right and the speaker does not. + /// + /// We only ever wrote these bytes when a host forwarded a game's [`HidOutput::AudioCtl`], + /// so a title that manages no audio settings of its own left the speaker dead. This is the + /// default that makes the stream audible; a later `AudioCtl` still overrides it verbatim + /// ([`Self::audio_ctl_packet`]), so a game that does drive its own volume still wins. + /// + /// ⚠ `ucEnableBits1` bits 0/1 stay CLEAR — they are "enable rumble emulation" and "disable + /// audio haptics", and asserting either would mute the coils this plane drives. + /// + /// ⚠ `path` is empirical. Measured on a DualSense (`054c:0ce6`) using the pad's OWN + /// microphone as the detector: `0x20` was loudest (~5× the noise floor at the test tone), + /// `0x30` also sounded, `0x10` was silent. Overridable per-run with + /// `PUNKTFUNK_PAD_SPEAKER_PATH` / `PUNKTFUNK_PAD_SPEAKER_VOLUME` so a field report can + /// bisect it without a rebuild. + fn speaker_enable_packet(volume: u8, path: u8) -> [u8; 47] { + let mut p = [0u8; 47]; + // bit5 = ucSpeakerVolume is valid, bit7 = the audio-control byte is valid. + p[0] = 0x20 | 0x80; + p[Self::AUDIO + 1] = volume; // ucSpeakerVolume + p[Self::AUDIO + 3] = path; // ucAudioEnableBits + p + } + /// Fold a host [`HidOutput::AudioCtl`] into an effects packet: `raw` is DS5 output report /// `0x02` bytes 5..=10 verbatim → struct offsets 4..=9 ([`Self::AUDIO`] — headphone/ /// speaker/mic volumes + routing), and `p[0]` re-asserts the report's audio-valid flags @@ -1353,6 +1399,26 @@ impl Worker { ); } } + if slot.audio_caps & 0x02 != 0 { + // Speaker activation: point the pad's shared channel-1 output at its + // own speaker instead of the headphone jack it powers up on, and give + // it a volume. Without this the speaker stream is routed perfectly and + // heard by nobody — see `speaker_enable_packet`. + let path = env_u8("PUNKTFUNK_PAD_SPEAKER_PATH").unwrap_or(0x20); + let volume = env_u8("PUNKTFUNK_PAD_SPEAKER_VOLUME").unwrap_or(0x7F); + if let Err(e) = slot + .pad + .send_effect(&Ds5Feedback::speaker_enable_packet(volume, path)) + { + tracing::info!( + index, + error = %e, + "could not point the DualSense at its own speaker (SDL does \ + not own this pad's HID link) — the pad's speaker may stay \ + silent even though the stream reaches it" + ); + } + } // Hand the pad to the session's renderer worker. Windows correlation // needs the HID interface path; Linux matches by card identity. crate::pad_audio::register_tier_a(index, slot.pad.path()); @@ -2932,6 +2998,55 @@ mod slot_tests { ); } + /// The speaker-enable default: volume and output-path land in the audio-control region at + /// the same offsets an `AudioCtl` fold writes them, the two validity bits are set — and, + /// most importantly, `ucEnableBits1` bits 0/1 stay CLEAR. Asserting either would enable + /// rumble emulation / disable audio haptics and mute the very coils this plane drives, so + /// making the speaker audible must never cost the haptics. + #[test] + fn speaker_enable_sets_volume_and_path_without_touching_the_haptics_bits() { + let p = Ds5Feedback::speaker_enable_packet(0x7F, 0x20); + assert_eq!( + p[0] & 0x03, + 0, + "rumble-emulation / disable-audio-haptics must stay clear" + ); + assert_eq!( + p[0], + 0x20 | 0x80, + "speaker-volume + audio-control validity bits" + ); + // ucSpeakerVolume is report byte 6 and ucAudioEnableBits report byte 8 — struct + // offsets 5 and 7, i.e. AUDIO+1 and AUDIO+3. + assert_eq!(p[5], 0x7F); + assert_eq!(p[7], 0x20); + // Nothing else in the packet moves (no rumble, no triggers, no LEDs). + for (i, b) in p.iter().enumerate() { + if !matches!(i, 0 | 5 | 7) { + assert_eq!(*b, 0, "byte {i} should be untouched"); + } + } + } + + /// The field levers parse hex (how every reference writes these report bytes) and decimal, + /// and a typo falls back to the default rather than silently meaning zero. + #[test] + fn env_u8_reads_hex_and_decimal() { + assert_eq!(env_u8("PF_TEST_ABSENT_KEY_XYZ"), None); + // Parsing is what is under test; the lookup is exercised by the None case above. + for (s, want) in [ + ("0x20", Some(0x20)), + ("0X7f", Some(0x7F)), + ("32", Some(32u8)), + ] { + let parsed = match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + Some(hex) => u8::from_str_radix(hex, 16).ok(), + None => s.parse().ok(), + }; + assert_eq!(parsed, want, "{s}"); + } + } + /// The AudioCtl fold: the 6 raw bytes (DS5 report 0x02 bytes 5..=10) land at effect-struct /// offsets 4..=9, the report's audio-valid flags (AudioCtl.flags bits1..4) come back as /// p[0] bits 4..7, and the rumble-emulation / disable-audio-haptics bits (p[0] bits 0/1) diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index a1654f7d..6f8e56e6 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -269,6 +269,7 @@ A few knobs are read by the native **clients**, not the host: | `PUNKTFUNK_DECODER` | `native-vulkan` · `native-vaapi` (Linux) · `native-d3d11va` (Windows) · `software` | Force the decode path. Default auto-selects hardware per GPU vendor and falls back on its own: **Linux** — Vulkan Video first on NVIDIA and AMD, VAAPI first on Intel and anything else; **Windows** — Vulkan Video first on NVIDIA and AMD, D3D11VA first on Intel and anything else. Whichever isn't first is the next thing tried, with software last (OpenH264 for H.264, rav1d for AV1 — there is no software HEVC, so a client that lands there reconnects on a codec it can decode). The names are the ones the [stats overlay](/docs/stats) prints, so a pin and a reading match. The older spellings `vulkan`, `vaapi` and `d3d11va` named the FFmpeg-backed decoders the clients used before and still work — each migrates onto the native path for the same hardware, and the client says so in its log. | | `PUNKTFUNK_VAAPI_DEVICE` | path, e.g. `/dev/dri/renderD129` | **(Linux)** Pin the DRM render node the `native-vaapi` decoder opens. Unset, the client tries the nodes in order and takes the first that can decode the stream — set this on a multi-GPU box when it lands on the wrong one. | | `PUNKTFUNK_PREFER_PYROWAVE` | `1` | Ask for the [PyroWave](/docs/pyrowave) wavelet codec on a wired link, where the client's own setting isn't reachable (the gamepad console, a headless launch). | +| `PUNKTFUNK_PAD_SPEAKER_PATH` · `PUNKTFUNK_PAD_SPEAKER_VOLUME` | byte, hex or decimal *(default `0x20` / `0x7F`)* | Which output a DualSense sends [controller audio](/docs/controller-audio) to, and how loud. A controller's channel 1 is shared between its headphone jack and its built-in speaker, and it powers up pointing at the jack — so with no headphones plugged in the speaker stays silent however correctly the audio is routed. Punktfunk points it at the speaker when controller-speaker is on. Change these only if your pad's speaker stays quiet; a game that sets its own audio levels still overrides them. | | `PUNKTFUNK_PAD_AUDIO_PROFILE` | `0` | **(Linux)** Stop the client from switching a wired DualSense's sound card to **Pro Audio** while it streams [controller audio](/docs/controller-audio) to it. The switch exists because a controller's voice coils are channels 3 and 4 of its sound card, and a controller almost never presents four channels on its own — on any other profile the haptics are folded into the speaker pair and felt as nothing. Punktfunk restores the card's profile when the session ends and never saves it. Set this if you'd rather select the card's profile yourself. | | `PUNKTFUNK_OSD_SCALE` | multiplier, e.g. `1.5` *(default `1`)* | Size of the in-stream overlay — the stats OSD, the capture hint and the start banner. They already follow your display's scaling setting (200 % display → twice the pixels), so set this only to nudge that: bigger for a TV across the room, smaller if your compositor reports an aggressive scale. Clamped to 0.5×–4×, and a line that would run off the screen is shrunk to fit. | | `PUNKTFUNK_NO_AEC` | `1` | Turn the microphone's echo cancellation off for this run, whatever **Echo cancellation** says in [client settings](/docs/client-settings#audio). One-way: it can only switch the processing off, never back on, and the setting is the normal way to control it. Linux and Windows clients. | diff --git a/docs-site/content/docs/controller-audio.md b/docs-site/content/docs/controller-audio.md index b3716d80..15ce28fd 100644 --- a/docs-site/content/docs/controller-audio.md +++ b/docs-site/content/docs/controller-audio.md @@ -156,6 +156,19 @@ On the Steam Deck and other flatpak installs, run it inside the sandbox: flatpak run --command=punktfunk-session io.unom.Punktfunk --pad-audio-test ``` +### Why the speaker needs more than routing + +The controller's speaker and its headphone jack **share a channel**. Channel 1 of the pad's audio +device is the headphone jack's right channel *and* the built-in speaker, and the controller decides +which one actually sounds. It powers up pointing at the jack — so with nothing plugged in, a +perfectly routed speaker stream is heard by nobody. + +Punktfunk points the pad at its own speaker when **Controller speaker** is on. The voice coils are +different channels and are not affected by that choice, which is why haptics work as soon as the +audio is routed correctly and the speaker needs this extra step. A game that drives the pad's audio +settings itself still overrides it. If your pad's speaker stays quiet, `PUNKTFUNK_PAD_SPEAKER_PATH` +and `PUNKTFUNK_PAD_SPEAKER_VOLUME` let you bisect it without a rebuild. + ## Known limits - **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT. -- 2.54.0