Compare commits

...
Author SHA1 Message Date
enricobuehler 998e5d3379 docs: answer the "set the controller audio to Pro Audio" question our sink cannot offer a switch for
android / android (pull_request) Successful in 4m38s
ci / web (pull_request) Successful in 2m3s
ci / rust-arm64 (pull_request) Successful in 2m6s
ci / docs-site (pull_request) Successful in 1m50s
ci / bun-nix (pull_request) Successful in 4m0s
ci / rust (pull_request) Failing after 15m36s
The community fix for DualSense haptics on Linux is to open sound settings and flip the pad's
card profile to Pro Audio, and a field report compared that against our minted sink: theirs has a
Profile dropdown reading "Pro Audio", ours has no dropdown at all. That reads like something is
missing, and it is worth saying plainly that it is not — a real pad is a USB sound card and gets
a profile selector, ours is a software device with no card, and the layout the selector exists to
reach is the one we are already minted in.

New page covers the whole feature end to end: what a DualSense's speaker and voice coils actually
are (a four-channel audio stream, not rumble), the USB-only requirement on the client, GE-Proton
11-5 on the host, why every other card profile folds the coil channels away, the three host log
lines that say how far it got, and the per-game Proton launch options that force GE onto its most
direct route. That last one now has teeth: with the sink advertising a split parent, GE's
`PROTON_DUALSENSE_HAPTICS_PREFER_NON_EVENT=1` opens our node by name and writes the four channels
in with no remix anywhere in between.

Known limits are stated rather than left to be rediscovered: Bluetooth pads have no audio
interface, container-ID matching cannot work behind a uhid pad with no USB device to derive one
from, and a real pad plugged into the host itself can win the match against the one you are
streaming to.
2026-08-15 17:33:44 +02:00
enricobuehler bec2d193c4 fix(audio): the pad sink wore a profile name GE-Proton matches nothing on, so neither haptics nor speaker could route
Everything a title needs to find the DualSense's audio device on a Linux host is a substring of
the sink's name or a key in its proplist, and ours were chosen against an older reading of
GE-Proton. Three of them were wrong, and each one cost a different piece of the feature.

**The profile suffix.** We minted `-00.analog-surround-40`. GE's `is_dualsense_speaker_sink()` is
a substring test for `Speaker__sink`, and three things hang off it: the pad-SPEAKER (mono
controller-effect) streams only bind and retarget to a sink it accepts, its
`apply_windows_sony_audio_format()` forces the wine endpoint to the Windows 4x48 kHz
`KSAUDIO_SPEAKER_QUAD` layout DS5 titles probe for, and the endpoint lands on the identity
Spider-Man's working path used. `analog-surround-40` matched none of it — the speaker half of
this feature had nothing to attach to at all. Now `-00.HiFi__Speaker__sink`.

The channels stay Pro Audio's four raw AUX, which is deliberately not the same real-pad profile
the name comes from. Since alsa-ucm-conf gained `USB-Audio/Sony/DualSense-PS5` a real pad's
profiles are UCM SplitPCM views of one 4-channel PCM — a mono Speaker, a stereo Headphones, a
4-channel Direct "for wine compatibility" — and all of them are POSITIONED. GE renders haptics as
an `AUX0..AUX3` stream, so on every one of those the graph re-mixes and the voice-coil pair is
folded away; only Pro Audio's raw AUX node passes it through by index. That is the whole content
of the field advice "you only need the controller audio set to Pro Audio". No single real profile
satisfies both halves, so we take the name from one and the layout from the other.

**`api.alsa.split.name` was absent.** GE reads it off the sink it is about to render haptics into
and opens THAT node through its bundled pipewire-alsa plugin as `pipewire:NODE=<name>` with
`aux_channels=1`. Without the key `get_dualsense_haptic_target()` returns NULL and the leg cannot
engage — including for the titles GE auto-switches into "Windows Sony audio mode" after eight
format probes. We have no split, so the honest value is our own node name;
`PUNKTFUNK_PAD_SINK_SPLIT_NAME` drops or overrides it in the field. Carrying `Speaker__sink` and
a split parent at once is a real pad's shape rather than a contrivance: GE's
`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.

**The node name broke its own matcher.** It carried an invented `DualSense_` infix, but a plain
DualSense's USB iProduct string is just "Wireless Controller" — only the Edge has a model word —
and that infix split the contiguous `Sony_Interactive_Entertainment_Wireless_Controller` substring
the community WirePlumber rule and GE's own name-only fallback key on.

The proplist now also states the shape (`audio.position`, `audio.channels`) and the ALSA card
names; pipewire-pulse hands every one of these to a Proton client verbatim. Mint-time logging and
the `pad-sink-test` devtest print the strings a title has to match, plus the channel-map a
hand-driven test file needs to reach the coils at all.
2026-08-15 17:33:39 +02:00
5 changed files with 323 additions and 31 deletions
+187 -27
View File
@@ -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");
+12 -3
View File
@@ -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);
+2 -1
View File
@@ -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
+121
View File
@@ -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 12 are the
pad's speaker, channels 34 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.
+1
View File
@@ -42,6 +42,7 @@
"moonlight",
"---Using Punktfunk---",
"input",
"controller-audio",
"client-settings",
"profiles-and-links",
"game-library",