Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
998e5d3379 | ||
|
|
bec2d193c4 |
@@ -2,8 +2,8 @@
|
||||
//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's
|
||||
//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker
|
||||
//! audio finds "the controller's audio device" and plays into us. We own the sink, so the
|
||||
//! `process()` callback IS the capture: 4-ch F32 48 kHz (FL FR RL RR — front pair = speaker,
|
||||
//! back pair = voice coils, the same quad layout the Windows endpoint is stamped with) lands
|
||||
//! `process()` callback IS the capture: 4-ch F32 48 kHz (AUX0..AUX3 — front pair = speaker,
|
||||
//! back pair = voice coils, the same quad *order* the Windows endpoint is stamped with) lands
|
||||
//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`).
|
||||
//!
|
||||
//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread,
|
||||
@@ -11,17 +11,34 @@
|
||||
//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY
|
||||
//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware.
|
||||
//!
|
||||
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton 11-2+
|
||||
//! matches layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
|
||||
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton matches
|
||||
//! layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
|
||||
//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings
|
||||
//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community
|
||||
//! WirePlumber rule keys on the node-name substring and sets `node.description =
|
||||
//! "Wireless Controller"` (we mint it that way from the start). A pure PipeWire node cannot
|
||||
//! satisfy wine's ContainerId derivation (udev walk to a `usb_device` parent → `GUID_NULL`)
|
||||
//! nor GE's raw-ALSA fast path — both fall back to the Pulse-routed leg, which winepulse
|
||||
//! serves from exactly this node (it enumerates sinks). Every identity string has an env
|
||||
//! override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC`, with
|
||||
//! `{pad}` / `{mac}` placeholders).
|
||||
//! "Wireless Controller"` (we mint it that way from the start).
|
||||
//!
|
||||
//! We wear a real pad's **name** and Pro Audio's **channel layout** — deliberately not the same
|
||||
//! profile for both, because no single real-pad profile satisfies GE on its own.
|
||||
//!
|
||||
//! Since alsa-ucm-conf gained `USB-Audio/Sony/DualSense-PS5` (2026-08-03) a real pad's profiles
|
||||
//! are UCM SplitPCM views of one 4-channel PCM: a mono `Speaker__sink`, a stereo `Headphones`
|
||||
//! sink, and a 4-channel `Direct__Direct__sink` (added "for wine compatibility"), plus ACP's
|
||||
//! always-present Pro Audio. GE renders haptics as an `AUX0..AUX3` stream, so on every
|
||||
//! *positioned* profile the graph re-mixes and the voice-coil pair is folded away — that is the
|
||||
//! whole content of the field advice "you only need the controller audio set to Pro Audio", and
|
||||
//! it is why this sink is one flat AUX quad rather than an emulation of the split topology. But
|
||||
//! the pad-SPEAKER half of GE only binds to a sink whose name says `Speaker__sink`, and its
|
||||
//! Windows 4-channel format forcing hangs off the same test. So the name says `Speaker__sink`
|
||||
//! and the channels are Pro Audio's. GE explicitly supports that combination on real hardware
|
||||
//! (see [`split_target`] and the node-name comment).
|
||||
//!
|
||||
//! What a pure PipeWire node still cannot satisfy is wine's ContainerId derivation (udev walk to
|
||||
//! a `usb_device` parent → `GUID_NULL`; our pad is uhid and has no USB parent at all) and GE's
|
||||
//! raw-ALSA leg (`snd_pcm_open` on an `api.alsa.path` that must be a real card). Its
|
||||
//! `pipewire:NODE=` leg we *can* satisfy — see [`split_target`]. Every identity string has an
|
||||
//! env override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC` with
|
||||
//! `{pad}` / `{mac}` placeholders, `PUNKTFUNK_PAD_SINK_SPLIT_NAME`).
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
||||
@@ -91,43 +108,118 @@ struct PadSinkIdentity {
|
||||
serial: String,
|
||||
product_id: &'static str,
|
||||
product_name: &'static str,
|
||||
card_name: &'static str,
|
||||
long_card_name: String,
|
||||
/// GE-Proton's `api.alsa.split.name` — the node name it opens as `pipewire:NODE=…` for the
|
||||
/// haptic stream. Empty disables the key. See [`split_target`].
|
||||
split_name: String,
|
||||
}
|
||||
|
||||
/// GE-Proton reads `api.alsa.split.name` off the sink it is about to render haptics into and,
|
||||
/// on its preferred leg, opens *that* node through its bundled pipewire-alsa plugin as
|
||||
/// `pipewire:NODE=<name>` with `aux_channels=1` (patches 0114/0115/0116 of `proton-ds5-haptic`).
|
||||
/// On a real pad the key names the **hidden 4-channel parent** WirePlumber mints for the UCM
|
||||
/// SplitPCM profile — the public mono `Speaker__sink` is only a 1-channel split of it, so
|
||||
/// rendering four channels at the public sink would lose the voice-coil pair.
|
||||
///
|
||||
/// We have no split: the sink IS the four-channel AUX node, so the honest value of the key is
|
||||
/// our own `node.name` — GE then targets us directly instead of falling back to a leg that was
|
||||
/// written to work around a topology we do not have. Without the key that leg cannot engage at
|
||||
/// all (`get_dualsense_haptic_target` returns NULL), which is why titles GE auto-switches into
|
||||
/// "Windows Sony audio mode" (the 8-format-probe games: Assassin's Creed, Death Stranding DC,
|
||||
/// MH Wilds) never reached our sink.
|
||||
///
|
||||
/// `PUNKTFUNK_PAD_SINK_SPLIT_NAME` is the field lever: `0`/`false`/`off` drops the key (GE then
|
||||
/// takes its Pulse leg, which also works for us because our channel positions already match its
|
||||
/// forced `AUX0..AUX3` map), any other value overrides the target verbatim.
|
||||
fn split_target(node_name: &str) -> String {
|
||||
resolve_split_target(
|
||||
node_name,
|
||||
std::env::var("PUNKTFUNK_PAD_SINK_SPLIT_NAME").ok(),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`split_target`]'s decision, with the environment lifted out so it is testable.
|
||||
fn resolve_split_target(node_name: &str, override_var: Option<String>) -> String {
|
||||
match override_var.as_deref().map(str::trim) {
|
||||
Some("0" | "false" | "off" | "no") => String::new(),
|
||||
Some(v) if !v.is_empty() => v.to_string(),
|
||||
_ => node_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl PadSinkIdentity {
|
||||
fn new(pad: u8, edge: bool) -> PadSinkIdentity {
|
||||
let mac = pad_mac(pad);
|
||||
let mac_bare: String = mac.chars().filter(|c| *c != ':').collect();
|
||||
let (model, product_id, product_name) = if edge {
|
||||
// The pad's USB `iProduct` string verbatim — a plain DualSense reports "Wireless
|
||||
// Controller" with NO model word (only the Edge carries one). Getting this wrong is not
|
||||
// cosmetic: udev builds the ALSA name out of manufacturer+product, so an invented
|
||||
// `DualSense_` infix broke the contiguous `Sony_Interactive_Entertainment_Wireless_
|
||||
// Controller` substring that the community WirePlumber rule and GE-Proton's
|
||||
// `alsa_output.usb-Sony_Interactive_Entertainment_…` matchers key on.
|
||||
let (usb_product, product_id, product_name, card_name) = if edge {
|
||||
(
|
||||
"DualSense_Edge",
|
||||
"DualSense_Edge_Wireless_Controller",
|
||||
"0df2",
|
||||
"DualSense Edge Wireless Controller",
|
||||
"DualSense Edge Wireless Controller",
|
||||
)
|
||||
} else {
|
||||
("DualSense", "0ce6", "DualSense Wireless Controller")
|
||||
(
|
||||
"Wireless_Controller",
|
||||
"0ce6",
|
||||
"DualSense Wireless Controller",
|
||||
"Wireless Controller",
|
||||
)
|
||||
};
|
||||
// The ALSA-style name a REAL pad's card gets from udev (vendor_product_serial), which
|
||||
// is what every known name-substring matcher was written against. `-00.analog-surround-40`
|
||||
// = card profile suffix for the quad layout.
|
||||
// udev's `ID_SERIAL`: manufacturer_product_serial. A real pad has no USB serial, so ALSA
|
||||
// falls back to the card index; we carry the pad's virtual MAC there instead, which keeps
|
||||
// multi-pad sinks distinct without disturbing the matched prefix.
|
||||
let serial = format!("Sony_Interactive_Entertainment_{usb_product}_{mac_bare}");
|
||||
// The `…-00.<verb>__Speaker__sink` suffix is LOAD-BEARING, not decoration. GE-Proton's
|
||||
// `is_dualsense_speaker_sink()` is a pure substring test for `Speaker__sink` (plus the
|
||||
// USB ids, or the `alsa_output.usb-Sony_Interactive_Entertainment_` + `Wireless_Controller`
|
||||
// pair we also carry), and three things hang off it: `apply_windows_sony_audio_format()`
|
||||
// forces the wine endpoint to the Windows 4×48 kHz `KSAUDIO_SPEAKER_QUAD` layout DS5
|
||||
// titles probe for, the pad-SPEAKER (mono controller-effect) streams will only bind and
|
||||
// retarget to a sink it accepts, and the whole controller-audio endpoint lands on the
|
||||
// identity Spider-Man's working path used. A suffix naming any other profile — the
|
||||
// `analog-surround-40` we used to mint, or a truthful `pro-output-0` — matches none of
|
||||
// it, which left the speaker half of this feature with nothing to attach to.
|
||||
//
|
||||
// Carrying `Speaker__sink` AND [`split_target`] at once is a real pad's shape, not a
|
||||
// contrivance: GE's own `is_dualsense_endpoint_speaker_sink` notes that "Edge speaker
|
||||
// sinks may also carry raw haptic metadata", and handles the pair by keeping them as
|
||||
// routing targets while withholding the *shared* mono endpoint id (a Spider-Man
|
||||
// enumeration crash). The exclusion GE once had in `is_dualsense_speaker_sink` itself is
|
||||
// gone. What we do NOT copy is a real pad's mono channel count: the sink stays four raw
|
||||
// AUX channels — which is exactly what that endpoint is forced to advertise anyway.
|
||||
let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare),
|
||||
_ => format!(
|
||||
"alsa_output.usb-Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}-00.analog-surround-40"
|
||||
),
|
||||
_ => format!("alsa_output.usb-{serial}-00.HiFi__Speaker__sink"),
|
||||
};
|
||||
// What the community WirePlumber rule renames real pads TO — minted that way directly.
|
||||
// Deliberately NOT the udev/hwdb description a real card gets ("DualSense wireless
|
||||
// controller (PS5)"): wine hands `node.description` straight to the endpoint's
|
||||
// `PKEY_Device_FriendlyName`, and the title matchers do a case-sensitive
|
||||
// `wcsstr(name, L"Wireless Controller")` (FF14, FF7R) that a lowercase "wireless" fails.
|
||||
let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") {
|
||||
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac),
|
||||
_ => "Wireless Controller".to_string(),
|
||||
};
|
||||
let split_name = split_target(&node_name);
|
||||
PadSinkIdentity {
|
||||
long_card_name: format!(
|
||||
"Sony Interactive Entertainment {card_name} at usb-punktfunk-pad{pad}, full speed"
|
||||
),
|
||||
node_name,
|
||||
description,
|
||||
serial: format!(
|
||||
"Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}"
|
||||
),
|
||||
serial,
|
||||
product_id,
|
||||
product_name,
|
||||
card_name,
|
||||
split_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +233,8 @@ pub struct PadSinkCapturer {
|
||||
quit: pipewire::channel::Sender<Terminate>,
|
||||
/// The minted node name, for logs and the devtest.
|
||||
pub node_name: String,
|
||||
/// What GE-Proton will read as `api.alsa.split.name`; empty when the key is suppressed.
|
||||
pub split_name: String,
|
||||
}
|
||||
|
||||
impl PadSinkCapturer {
|
||||
@@ -149,6 +243,7 @@ impl PadSinkCapturer {
|
||||
pub fn open(pad: u8, edge: bool) -> Result<PadSinkCapturer> {
|
||||
let identity = PadSinkIdentity::new(pad, edge);
|
||||
let node_name = identity.node_name.clone();
|
||||
let split_name = identity.split_name.clone();
|
||||
let (tx, rx) = sync_channel::<Vec<f32>>(64);
|
||||
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
||||
// Bring-up handshake (the session capturer's discipline): a PipeWire that isn't running
|
||||
@@ -167,10 +262,26 @@ impl PadSinkCapturer {
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")),
|
||||
}
|
||||
// The identity a title has to match, in the log a field report will carry. Cheap once
|
||||
// per pad, and it is the only place the negotiated strings are visible without a live
|
||||
// `pactl` on the box.
|
||||
let split_log = if split_name.is_empty() {
|
||||
"(suppressed)"
|
||||
} else {
|
||||
split_name.as_str()
|
||||
};
|
||||
tracing::info!(
|
||||
pad,
|
||||
edge,
|
||||
node_name = %node_name,
|
||||
split_name = %split_log,
|
||||
"pad-audio sink minted (Pro Audio shape: 4ch AUX0..AUX3, ch0/1 speaker, ch2/3 coils)"
|
||||
);
|
||||
Ok(PadSinkCapturer {
|
||||
chunks: rx,
|
||||
quit: quit_tx,
|
||||
node_name,
|
||||
split_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -268,11 +379,22 @@ fn pad_sink_thread(
|
||||
// this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either).
|
||||
"priority.session" => "50",
|
||||
// The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus
|
||||
// the human-readable pair pavucontrol and the game view show.
|
||||
// the human-readable pair pavucontrol and the game view show. Every one of these
|
||||
// reaches a wine/Proton client verbatim — pipewire-pulse fills a sink's proplist
|
||||
// from the node's own props (`fill_sink_info_proplist`), it does not curate them.
|
||||
"device.bus" => "usb",
|
||||
"device.vendor.id" => "054c",
|
||||
"device.vendor.name" => "Sony Interactive Entertainment",
|
||||
"device.form_factor" => "gamepad",
|
||||
"device.icon_name" => "audio-card-analog-usb",
|
||||
// The shape, stated as props and not only as a negotiated format: four raw AUX
|
||||
// channels — ch0/1 speaker, ch2/3 voice coils — which is what "Pro Audio" means on a
|
||||
// real pad's card and the only layout that survives GE-Proton's AUX0..AUX3 stream
|
||||
// map unfolded.
|
||||
"audio.channels" => "4",
|
||||
"audio.position" => "AUX0,AUX1,AUX2,AUX3",
|
||||
"api.alsa.pcm.stream" => "playback",
|
||||
"alsa.driver_name" => "snd_usb_audio",
|
||||
};
|
||||
props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str());
|
||||
props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str());
|
||||
@@ -280,6 +402,13 @@ fn pad_sink_thread(
|
||||
props.insert("device.serial", identity.serial.as_str());
|
||||
props.insert("device.product.id", identity.product_id);
|
||||
props.insert("device.product.name", identity.product_name);
|
||||
props.insert("alsa.card_name", identity.card_name);
|
||||
props.insert("alsa.long_card_name", identity.long_card_name.as_str());
|
||||
// GE-Proton's preferred haptic leg; see `split_target`. Omitted (not empty) when the
|
||||
// field lever turns it off, so `pa_proplist_gets` misses rather than returning "".
|
||||
if !identity.split_name.is_empty() {
|
||||
props.insert("api.alsa.split.name", identity.split_name.as_str());
|
||||
}
|
||||
let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props)
|
||||
.context("pw pad-sink Stream")?;
|
||||
|
||||
@@ -428,22 +557,53 @@ mod tests {
|
||||
#[test]
|
||||
fn identity_carries_every_match_surface() {
|
||||
let id = PadSinkIdentity::new(0, false);
|
||||
// The name-substring matchers (GE-Proton + the community WirePlumber rule).
|
||||
// GE-Proton's `string_contains_dualsense_name` legs, each checked separately.
|
||||
assert!(id.node_name.contains("Sony_Interactive_Entertainment"));
|
||||
assert!(id.node_name.contains("Wireless_Controller"));
|
||||
assert!(id.node_name.contains("DualSense"));
|
||||
assert!(id.node_name.ends_with("-00.analog-surround-40"));
|
||||
// …and the CONTIGUOUS form the community WirePlumber rule and GE's
|
||||
// `alsa_output.usb-Sony_Interactive_Entertainment_` prefix test want. An invented
|
||||
// `DualSense_` infix used to split this in two and miss both.
|
||||
assert!(id
|
||||
.node_name
|
||||
.starts_with("alsa_output.usb-Sony_Interactive_Entertainment_Wireless_Controller_"));
|
||||
// The suffix GE's `is_dualsense_speaker_sink` substring-tests for — the pad-speaker
|
||||
// binding and the Windows 4ch format forcing both hang off it (never `analog-*`, which
|
||||
// matches nothing of GE's and names a positioned profile we do not wear).
|
||||
assert!(id.node_name.ends_with("-00.HiFi__Speaker__sink"));
|
||||
assert!(id.node_name.contains("Speaker__sink"));
|
||||
// No colons in a udev-style serial/name.
|
||||
assert!(!id.node_name.contains(':'));
|
||||
// Case-sensitive `wcsstr(FriendlyName, L"Wireless Controller")` (FF14, FF7R).
|
||||
assert_eq!(id.description, "Wireless Controller");
|
||||
assert_eq!(id.product_id, "0ce6");
|
||||
assert_eq!(id.card_name, "Wireless Controller");
|
||||
assert!(id.long_card_name.contains("Sony Interactive Entertainment"));
|
||||
let edge = PadSinkIdentity::new(1, true);
|
||||
assert!(edge.node_name.contains("DualSense_Edge"));
|
||||
// GE tests the Edge with the full `DualSense_Edge_Wireless_Controller` substring.
|
||||
assert!(edge
|
||||
.node_name
|
||||
.contains("DualSense_Edge_Wireless_Controller"));
|
||||
assert!(edge.node_name.contains("Speaker__sink"));
|
||||
assert_eq!(edge.product_id, "0df2");
|
||||
// Distinct pads mint distinct names (the serial octet).
|
||||
assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_target_points_at_the_node_itself_unless_overridden() {
|
||||
// No split on our side: the sink IS the four-channel parent GE wants to open as
|
||||
// `pipewire:NODE=…`, so the honest target is our own name.
|
||||
let id = PadSinkIdentity::new(0, false);
|
||||
assert_eq!(id.split_name, id.node_name);
|
||||
// The field lever, both ways — through the pure form, so no test mutates the process
|
||||
// environment out from under a parallel test runner.
|
||||
assert_eq!(resolve_split_target("n", None), "n");
|
||||
assert_eq!(resolve_split_target("n", Some(" ".into())), "n");
|
||||
assert!(resolve_split_target("n", Some("0".into())).is_empty());
|
||||
assert!(resolve_split_target("n", Some("off".into())).is_empty());
|
||||
assert_eq!(resolve_split_target("n", Some(" other ".into())), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_expansion() {
|
||||
assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB");
|
||||
|
||||
@@ -256,9 +256,18 @@ pub fn pad_sink_test(args: &[String]) -> Result<()> {
|
||||
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
|
||||
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
|
||||
println!(
|
||||
"pad sink minted: node.name = {}\n inspect: pactl list sinks | grep -A20 punktfunk-pad\n \
|
||||
drive it: pw-play --target '{}' <48k-file>\nCapturing for {secs}s…",
|
||||
cap.node_name, cap.node_name
|
||||
"pad sink minted: node.name = {}\n api.alsa.split.name = {} (what GE-Proton opens as \
|
||||
pipewire:NODE=…)\n inspect: pactl list sinks | grep -A25 Speaker__sink\n \
|
||||
drive it: pw-play --target '{}' --channel-map 'AUX0,AUX1,AUX2,AUX3' <48k-file>\n \
|
||||
(a POSITIONED wav folds into the speaker pair and never reaches the coils — the \
|
||||
channel-map is not optional)\nCapturing for {secs}s…",
|
||||
cap.node_name,
|
||||
if cap.split_name.is_empty() {
|
||||
"(suppressed)"
|
||||
} else {
|
||||
cap.split_name.as_str()
|
||||
},
|
||||
cap.node_name
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||
let (mut chunks, mut samples) = (0u64, 0u64);
|
||||
|
||||
@@ -146,9 +146,10 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
|
||||
|---|---|---|
|
||||
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. |
|
||||
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. On Windows the pad's audio device is a pre-provisioned virtual endpoint; on Linux it is a per-pad PipeWire sink minted with the DualSense identity games match on. |
|
||||
| `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. On Windows the pad's audio device is a pre-provisioned virtual endpoint; on Linux it is a per-pad PipeWire sink minted with the DualSense identity games match on — see [Controller speaker and haptics](/docs/controller-audio). |
|
||||
| `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1`–`4` *(default: Windows `1`, Linux `4`)* | How many controllers can have their own audio at once. On Windows each slot is a pre-provisioned virtual endpoint, so the default stays at one; a Linux sink is minted lazily and costs nothing idle, so every slot is on. |
|
||||
| `PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC` | templates | **(Linux, field debugging)** Override the minted pad sink's `node.name` / `node.description`. `{pad}` and `{mac}` expand per pad. Only for chasing a title whose device matcher wants different strings — the defaults carry every known match surface. |
|
||||
| `PUNKTFUNK_PAD_SINK_SPLIT_NAME` | node name · `0` *(default: the sink's own name)* | **(Linux, field debugging)** The `api.alsa.split.name` the pad sink advertises. GE-Proton opens that node as `pipewire:NODE=…` with AUX channels for its preferred haptic path; on a real pad it names the hidden 4-channel parent behind the mono speaker split, and our sink has no split, so it names itself. `0` drops the key, which pushes GE onto its Pulse-routed leg instead. |
|
||||
|
||||
## Audio / microphone
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Controller speaker and haptics
|
||||
description: DualSense voice-coil haptics and the pad's built-in speaker, streamed from the host to the controller in your hands — what to enable, and what "set it to Pro Audio" means on a Linux host.
|
||||
---
|
||||
|
||||
A DualSense is partly an audio device. Its little speaker and its two voice-coil motors — the
|
||||
actuators that make a PS5 pad feel like sand, rain or a bowstring instead of a buzzing phone —
|
||||
are all driven by a four-channel audio stream, not by rumble commands. Games that support them
|
||||
write PCM into "the controller's audio device".
|
||||
|
||||
Punktfunk gives that device to the game on the host, captures what the game writes, and streams
|
||||
it to the controller physically in your hands, on its own low-latency plane. Channels 1–2 are the
|
||||
pad's speaker, channels 3–4 are the voice coils.
|
||||
|
||||
## What you need
|
||||
|
||||
- **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 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`).
|
||||
|
||||
Nothing is sent while the pad is quiet, so leaving it on costs nothing.
|
||||
|
||||
## "Set the controller audio to Pro Audio" — you don't have to
|
||||
|
||||
If you have looked into DualSense haptics on Linux before, you have probably run into this
|
||||
advice: plug the pad into the Linux box, open your sound settings, find *DualSense wireless
|
||||
controller (PS5)*, and switch its **Profile** to **Pro Audio**. That advice is real and it is
|
||||
correct — for a pad plugged directly into the host.
|
||||
|
||||
The reason is channel layout. A pad's other profiles present it as a mono speaker, a stereo
|
||||
headphone jack, or a positioned four-channel "surround" device. Games write their haptics as four
|
||||
*unpositioned* channels, so on any of those profiles the audio system helpfully re-mixes them into
|
||||
the speaker pair and the voice-coil channels are folded away. You feel nothing. Pro Audio is the
|
||||
one profile that hands the four channels through untouched, in order.
|
||||
|
||||
**Punktfunk's controller audio device is already in that shape.** It is created as four raw
|
||||
channels with no re-mixing, which is exactly what Pro Audio produces — so there is nothing to
|
||||
switch, and no switch to make.
|
||||
|
||||
That is also why it looks different in your sound settings. A real pad is a USB sound card, so it
|
||||
gets a **Profile** dropdown; Punktfunk's is a software device, so it has no card and no dropdown.
|
||||
Seeing **Wireless Controller** with a volume slider and no profile selector is what a correctly
|
||||
minted controller-audio device looks like. It is not a sign that something is missing.
|
||||
|
||||
## Checking it is working
|
||||
|
||||
On the host, one line per pad is logged when the device is created:
|
||||
|
||||
```
|
||||
pad-audio sink minted (Pro Audio shape: 4ch AUX0..AUX3, ch0/1 speaker, ch2/3 coils)
|
||||
```
|
||||
|
||||
and, once a client that can render it connects:
|
||||
|
||||
```
|
||||
pad audio streaming (0xD1, Opus 48 kHz, silence-gated)
|
||||
```
|
||||
|
||||
When a game actually starts driving the actuators, the pad's own driver reports it:
|
||||
|
||||
```
|
||||
DS5 title asserted haptics-select (audio haptics) pad=0
|
||||
```
|
||||
|
||||
That last line is the one that matters: it means a title recognised the controller as an audio
|
||||
device and switched the pad out of plain rumble. If you see it and still feel nothing, the problem
|
||||
is downstream — on the client or the pad. If you never see it, the game never found the device.
|
||||
|
||||
You can also look at the device directly:
|
||||
|
||||
```sh
|
||||
pactl list sinks | grep -A25 Speaker__sink
|
||||
```
|
||||
|
||||
The line to check is `audio.position = "AUX0,AUX1,AUX2,AUX3"` — four unpositioned channels is the
|
||||
layout that reaches the voice coils. Anything positioned (`FL,FR,RL,RR`) would not.
|
||||
|
||||
## If a game does not find it
|
||||
|
||||
Games identify the controller's audio device by name and by USB ids, and different titles check
|
||||
different things. GE-Proton has several routes to the pad, and a couple of them are opt-in per
|
||||
game. Add these as launch options if a title is not cooperating:
|
||||
|
||||
```
|
||||
PROTON_DUALSENSE_HAPTICS_PREFER_NON_EVENT=1 %command%
|
||||
```
|
||||
|
||||
This forces GE onto its most direct route — it opens Punktfunk's controller-audio device by name
|
||||
and writes the four channels straight into it, with no re-mixing anywhere in between. It is the
|
||||
first thing to try.
|
||||
|
||||
Some titles additionally want:
|
||||
|
||||
```
|
||||
PROTON_SONY_WINDOWS_DEVICE_NAMES=1 PROTON_KEEP_SONY_AUDIO_ENDPOINT_VISIBLE=1 %command%
|
||||
```
|
||||
|
||||
and *Death Stranding Director's Cut* has its own:
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Bluetooth client pads get rumble, not haptics.** No audio interface exists over BT.
|
||||
- **Titles that match the controller by container ID** — a Windows notion of "these devices are
|
||||
the same physical thing" — will not recognise the pairing on a Linux host, because the virtual
|
||||
pad has no USB device behind it to derive one from. Titles that match by name or by USB ids are
|
||||
unaffected, which is most of them.
|
||||
- **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.
|
||||
@@ -42,6 +42,7 @@
|
||||
"moonlight",
|
||||
"---Using Punktfunk---",
|
||||
"input",
|
||||
"controller-audio",
|
||||
"client-settings",
|
||||
"profiles-and-links",
|
||||
"game-library",
|
||||
|
||||
Reference in New Issue
Block a user