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..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 @@ -1333,10 +1379,48 @@ 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" + ); + } + } + 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 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, @@ -2914,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/crates/pf-client-core/src/pad_audio.rs b/crates/pf-client-core/src/pad_audio.rs index d9de834c..5ad32638 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,840 @@ 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, + /// `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. +#[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. +/// +/// ⚠⚠ **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); + // 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; + } + 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() + .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().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 + // `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) +} + +/// 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::{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 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 (registry, sinks, cards, bound) = ( + registry.clone(), + sinks.clone(), + cards.clone(), + bound.clone(), ); + move |g| { + let Some(props) = g.props else { return }; + match g.type_ { + pw::types::ObjectType::Node => { + // 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 Ok(node) = registry.bind::(g) else { + return; + }; + 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); + cards.borrow_mut().push(CardDevice { + id: g.id, + ds5: props_say_ds5(vendor, product, name, description), + name: name.to_string(), + description: description.to_string(), + }); + } + _ => {} + } + } + }) + .register(); + + // 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, awaited) = (mainloop.clone(), awaited.clone()); + move |_, seq| { + if awaited.get() == Some(seq) { + mainloop.quit(); + } + } + }) + .register(); + 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) ------------------------------------------------------ + +/// 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); + +/// 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 { + /// 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)) => { + 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. + 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; + } + // 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. + // + // ⚠ 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<()> { + // 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. + 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!( + "{:<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()), + 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); + Ok(()) } // ---- correlation: Windows (HID container → render endpoint) --------------------------------- @@ -586,6 +1437,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 +1461,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 +1511,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 +1556,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 +1655,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 +1923,239 @@ 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, + internal: false, + } + } + + /// 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())) + ); + } + + /// 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. + #[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..6f8e56e6 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -269,6 +269,8 @@ 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. | | `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..15ce28fd 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,65 @@ 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 +``` + +### 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. @@ -119,3 +179,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.