Controller haptics and speaker were dead on the Linux client — it streamed a quad into whatever sink was named like a DualSense #261

Merged
enricobuehler merged 5 commits from worktree-linux-client-pad-audio into main 2026-08-15 22:56:12 +00:00
Owner

Field report: DualSense audio haptics and speaker do nothing on the Linux client, against a host the Android client drives fine. Root-caused, fixed, and measured on glass with a wired DS5 on a Steam Deck.

What was wrong

The voice coils are channels 3 and 4 of the pad's USB audio function. The Linux renderer matched a PipeWire sink by name signature alone — no channel check — and streamed a positioned FL/FR/RL/RR quad at it.

A DualSense almost never presents four channels by default. On this Deck the card publishes three nodes, and the old matcher took whichever of the two public ones the registry happened to replay first:

node media.class ch audio.position
alsa_output.hw_Controller_0 Audio/Sink/Internal 4 AUX0..AUX3
…HiFi__SpeakerHaptic__sink Audio/Sink 4 FL,FR,RL,RR
…HiFi__Speaker__sink Audio/Sink 1 MONO

One of those is a mono node that cannot carry the coils at all — a coin flip against the registry. Where PipeWire's ACP picks a stereo profile instead, there is no four-channel node at all. Either way the quad is position-remixed on arrival and the coil pair is folded away: audible-looking, felt as nothing.

The Windows half of the same module has required a 4-channel endpoint since it was written. Only Linux never did, and it had only ever been compile-verified — every on-glass run was Android → Linux host, which renders over raw USB and never touches an audio graph.

The fix

Correlation now walks nodes and devices and picks a four-channel node belonging to a DualSense card:

  • Identity from the USB ids (base 16, either 0x spelling) with the name signature as fallback, on the node or its card — a split card's public sinks publish neither.
  • device.id is required. That is what keeps a Punktfunk host's own minted pad sink out of the match — it carries the full DualSense identity on purpose, and rendering into it would loop the plane back at the host.
  • Order: public quad (unpositioned, then positioned) → the hidden split parent → api.alsa.split.name by name → move the card's profile.
  • With no four-channel node anywhere, the card goes to Pro Audio for the session and is restored at the end (never saved; PUNKTFUNK_PAD_AUDIO_PROFILE=0 opts out; a sandboxed client that is refused the write is told to do it by hand).

The stream now sets stream.dont-remix and AUX0..AUX3 instead of FL,FR,RL,RR, so channel k reaches channel k whatever the node advertises — the same shape the host sink mints and GE-Proton forces on its own haptic streams.

Measured, not reasoned

Capturing the target sink's own monitor index-exact while the real client binary renders:

run ch0 ch1 ch2 ch3
--coils 0.0000 0.0000 0.5000 0.5000
--speaker 0.5000 0.5000 0.0000 0.0000

Bit-exact, zero cross-talk both directions, card profile untouched. Haptics confirmed physically felt on the pad.

Two defects that only on-glass could catch

  1. Registry global events carry only a subset of an object's proplist. media.class, node.name and device.id are in it — audio.channels and audio.position are not. Reading them there returned 0 channels for every node on the real box, so the matcher could not see the four-channel sink sitting right there and went off changing the user's card profile to fix a problem that did not exist. pw-dump/pactl show those fields because they bind every object, which is what made the registry-only version look plausible. The walk now binds each sink and awaits its info.
  2. Preferring the "unpositioned" node was wrong. It picks the hidden parent, whose AUX0 is a dead hardware channel, so index-exact into it throws away half the speaker signal. A card's public four-channel sink now wins; the parent is the fallback.

Also: a failed profile swap now restores immediately and is not retried (it had left a real Deck sitting on Pro Audio).

The speaker needed more than routing

Haptics were felt; the speaker was not — with the routing already proven correct above. Channel 1 of the pad's audio function is the headphone jack's right channel and the built-in speaker, and the pad powers up pointing at the jack, so with nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are unaffected by that select — exactly why haptics worked instantly and the speaker did not. #259 reads the same thing out of the UCM from the host side.

We only wrote those bytes when a host forwarded a game's AudioCtl, so a title that sets no audio of its own got silence. A tier-A slot with the speaker capability now sends a default speaker-enable packet.

The one thing still owed. That path byte is empirical — SDL's vendored source pins the struct but never writes these fields. Measured with the pad's own microphone as detector: 0x20 loudest (~5× noise floor), 0x30 also sounds, 0x10 silent. Thin evidence for a constant, so both it and the volume are field levers (PUNKTFUNK_PAD_SPEAKER_PATH / PUNKTFUNK_PAD_SPEAKER_VOLUME). A human confirming which value they actually hear is still outstanding. Everything else in this PR is measured.

Also here

  • punktfunk-session --pad-audio-test — prints every DualSense object in the graph, the node it chose, and drives a tone into the coils. Separates "the plane never arrived" from "it arrived and the graph folded it away" with no host, no game and no pairing. This diagnostic not existing is why this became a field report.
  • Controller haptics / Controller speaker rows in the GTK settings — reachable only from Android and the settings file before. Not profileable (which pad is in your hands is a fact about this device), and a stored "mix" survives the round trip.
  • The tier-A activation packet no longer swallows its error, where SDL does not own the pad's HID link.
  • Docs: the client half of controller-audio, the settings rows, and the new levers. The "speaker is opt-in" line was only ever true of Android.

Relationship to #259

No file overlap (that one is host-side pad_sink.rs + devtest.rs), merged clean, re-gated on top. The two agree on the hardware map from opposite directions — my reading of api.alsa.split.position on a real pad and #259's reading of the UCM both give ch0 = headphone L, ch1 = headphone R + speaker, ch2/ch3 = coils.

⚠ Worth knowing: now that the host mirrors a real pad exactly — same names, classes, split keys, channel counts and positions — device.id is the only discriminator left between "the pad in my hands" and "the sink my own host minted". Verified on the Deck with a decoy null-sink wearing the host's identity: listed (virtual), skipped, real pad still picked. Please don't weaken that filter.

Gates

clippy --all-targets -D warnings over pf-client-core, pf-presenter, punktfunk-client-session, punktfunk-client-linux; plain build; 207 tests green (204 in pf-client-core, 7 new); cargo fmt --check — all in punktfunk-rust-ci linux/amd64, on top of current main.

⚠ Note for anyone reproducing on a Deck: the CI image is Ubuntu 26.04 / glibc 2.43 and its binaries cannot start on SteamOS 3.7 (glibc 2.41). A debian:trixie-slim builder matches exactly; it needs libfreetype-dev + libfontconfig-dev on top of the obvious deps (skia, via pf-console-ui).

Field report: DualSense audio haptics and speaker do nothing on the Linux client, against a host the Android client drives fine. Root-caused, fixed, and measured on glass with a wired DS5 on a Steam Deck. ## What was wrong The voice coils **are** channels 3 and 4 of the pad's USB audio function. The Linux renderer matched a PipeWire sink by **name signature alone — no channel check** — and streamed a *positioned* FL/FR/RL/RR quad at it. A DualSense almost never presents four channels by default. On this Deck the card publishes three nodes, and the old matcher took whichever of the two public ones the registry happened to replay first: | node | media.class | ch | audio.position | |---|---|---|---| | `alsa_output.hw_Controller_0` | `Audio/Sink/Internal` | 4 | `AUX0..AUX3` | | `…HiFi__SpeakerHaptic__sink` | `Audio/Sink` | 4 | `FL,FR,RL,RR` | | `…HiFi__Speaker__sink` | `Audio/Sink` | **1** | `MONO` | One of those is a **mono** node that cannot carry the coils at all — a coin flip against the registry. Where PipeWire's ACP picks a stereo profile instead, there is no four-channel node at all. Either way the quad is position-remixed on arrival and the coil pair is folded away: audible-looking, felt as nothing. The Windows half of the same module has required a 4-channel endpoint since it was written. Only Linux never did, and it had only ever been compile-verified — every on-glass run was Android → Linux *host*, which renders over raw USB and never touches an audio graph. ## The fix Correlation now walks nodes **and** devices and picks a four-channel node belonging to a DualSense **card**: - Identity from the USB ids (base 16, either `0x` spelling) with the name signature as fallback, on the node **or** its card — a split card's public sinks publish neither. - **`device.id` is required.** That is what keeps a Punktfunk *host's* own minted pad sink out of the match — it carries the full DualSense identity on purpose, and rendering into it would loop the plane back at the host. - Order: public quad (unpositioned, then positioned) → the hidden split parent → `api.alsa.split.name` by name → move the card's profile. - With no four-channel node anywhere, the card goes to **Pro Audio** for the session and is restored at the end (never saved; `PUNKTFUNK_PAD_AUDIO_PROFILE=0` opts out; a sandboxed client that is refused the write is told to do it by hand). The stream now sets `stream.dont-remix` and `AUX0..AUX3` instead of `FL,FR,RL,RR`, so channel *k* reaches channel *k* whatever the node advertises — the same shape the host sink mints and GE-Proton forces on its own haptic streams. ## Measured, not reasoned Capturing the target sink's own monitor index-exact while the **real client binary** renders: | run | ch0 | ch1 | ch2 | ch3 | |---|---|---|---|---| | `--coils` | 0.0000 | 0.0000 | **0.5000** | **0.5000** | | `--speaker` | **0.5000** | **0.5000** | 0.0000 | 0.0000 | Bit-exact, zero cross-talk both directions, card profile untouched. **Haptics confirmed physically felt on the pad.** ## Two defects that only on-glass could catch 1. **Registry `global` events carry only a subset of an object's proplist.** `media.class`, `node.name` and `device.id` are in it — `audio.channels` and `audio.position` are not. Reading them there returned **0 channels for every node** on the real box, so the matcher could not see the four-channel sink sitting right there and went off changing the user's card profile to fix a problem that did not exist. `pw-dump`/`pactl` show those fields because they bind every object, which is what made the registry-only version look plausible. The walk now binds each sink and awaits its `info`. 2. **Preferring the "unpositioned" node was wrong.** It picks the hidden parent, whose **AUX0 is a dead hardware channel**, so index-exact into it throws away half the speaker signal. A card's *public* four-channel sink now wins; the parent is the fallback. Also: a failed profile swap now restores immediately and is not retried (it had left a real Deck sitting on Pro Audio). ## The speaker needed more than routing Haptics were felt; the speaker was not — with the routing already proven correct above. Channel 1 of the pad's audio function is the headphone jack's right channel **and** the built-in speaker, and the pad powers up pointing at the jack, so with nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are unaffected by that select — exactly why haptics worked instantly and the speaker did not. #259 reads the same thing out of the UCM from the host side. We only wrote those bytes when a host forwarded a game's `AudioCtl`, so a title that sets no audio of its own got silence. A tier-A slot with the speaker capability now sends a default speaker-enable packet. > ⚠ **The one thing still owed.** That path byte is empirical — SDL's vendored source pins the struct but never writes these fields. Measured with the pad's own microphone as detector: `0x20` loudest (~5× noise floor), `0x30` also sounds, `0x10` silent. Thin evidence for a constant, so both it and the volume are field levers (`PUNKTFUNK_PAD_SPEAKER_PATH` / `PUNKTFUNK_PAD_SPEAKER_VOLUME`). **A human confirming which value they actually hear is still outstanding.** Everything else in this PR is measured. ## Also here - **`punktfunk-session --pad-audio-test`** — prints every DualSense object in the graph, the node it chose, and drives a tone into the coils. Separates "the plane never arrived" from "it arrived and the graph folded it away" with no host, no game and no pairing. This diagnostic not existing is why this became a field report. - **Controller haptics / Controller speaker rows** in the GTK settings — reachable only from Android and the settings file before. Not profileable (which pad is in your hands is a fact about this device), and a stored `"mix"` survives the round trip. - The tier-A activation packet no longer swallows its error, where SDL does not own the pad's HID link. - Docs: the client half of controller-audio, the settings rows, and the new levers. The "speaker is opt-in" line was only ever true of Android. ## Relationship to #259 No file overlap (that one is host-side `pad_sink.rs` + `devtest.rs`), merged clean, re-gated on top. The two agree on the hardware map from opposite directions — my reading of `api.alsa.split.position` on a real pad and #259's reading of the UCM both give **ch0 = headphone L, ch1 = headphone R + speaker, ch2/ch3 = coils**. ⚠ Worth knowing: now that the host mirrors a real pad *exactly* — same names, classes, split keys, channel counts and positions — **`device.id` is the only discriminator left** between "the pad in my hands" and "the sink my own host minted". Verified on the Deck with a decoy null-sink wearing the host's identity: listed `(virtual)`, skipped, real pad still picked. Please don't weaken that filter. ## Gates `clippy --all-targets -D warnings` over `pf-client-core`, `pf-presenter`, `punktfunk-client-session`, `punktfunk-client-linux`; plain build; 207 tests green (204 in `pf-client-core`, 7 new); `cargo fmt --check` — all in `punktfunk-rust-ci` linux/amd64, on top of current main. ⚠ Note for anyone reproducing on a Deck: the CI image is Ubuntu 26.04 / glibc 2.43 and its binaries **cannot start** on SteamOS 3.7 (glibc 2.41). A `debian:trixie-slim` builder matches exactly; it needs `libfreetype-dev` + `libfontconfig-dev` on top of the obvious deps (skia, via `pf-console-ui`).
enricobuehler added 5 commits 2026-08-15 22:54:17 +00:00
The Linux client's pad-audio renderer matched a PipeWire sink by name signature
alone and streamed a positioned FL/FR/RL/RR quad at it. The voice coils ARE
channels 3 and 4 of the pad's USB sound card, and a DualSense almost never
presents four channels by default: PipeWire's ACP picks a stereo profile, and a
modern alsa-ucm-conf splits the card into a mono Speaker and a stereo Headphones
sink instead. Every one of those opens perfectly and then position-remixes our
quad into the speaker pair, so the coils are never excited — nothing is felt, and
nothing looks wrong. The Windows half of the same module has required a 4-channel
endpoint since it was written; only Linux never did, and it was compile-verified
only (the on-glass leg was Android -> Linux host, which renders over raw USB and
never touches a graph).

Correlation now walks nodes AND devices, and picks a four-channel node that
belongs to a DualSense CARD:

- Identity comes from the USB ids (base 16, either 0x spelling) with the old name
  signature as the fallback, and it may sit on either the node or its card — a
  split card's public sinks publish neither.
- `device.id` is required, which is also what keeps a Punktfunk HOST's own minted
  pad sink out: it carries the full DualSense identity on purpose, and rendering
  into it would loop the plane back at the host.
- Unpositioned (AUX) quads are preferred, then positioned ones, then the hidden
  four-channel parent a split sink names in `api.alsa.split.name` — GE-Proton's
  own preferred haptic leg.
- With no four-channel node anywhere, the card's profile is moved to Pro Audio for
  the session and restored when it ends (never saved; `PUNKTFUNK_PAD_AUDIO_PROFILE=0`
  opts out; a sandboxed client that is refused the write gets told to do it by hand).

The stream itself now sets `stream.dont-remix` and AUX0..AUX3 rather than
FL/FR/RL/RR, so channel k reaches channel k whatever the node advertises — the
same shape the host-side sink mints and GE forces on its own haptic streams.

Also here:

- `punktfunk-session --pad-audio-test` prints every DualSense object in the graph,
  the node it chose, and drives a tone into the coils. It separates "the plane
  never arrived" from "it arrived and the graph folded it away" with no host, no
  game and no pairing — the diagnostic whose absence made this a field report.
- The GTK client grows Controller haptics / Controller speaker rows; they were
  reachable from Android and the settings file only. Not profileable (which pad is
  in your hands is a fact about this device), and a stored "mix" survives the
  round trip.
- The tier-A activation packet no longer swallows its error: where SDL does not own
  the pad's HID link (Linux's own hid-playstation has it), that is worth saying,
  because that driver asserts the same audio-haptics disable bit on every rumble.
- Docs: the client half of controller-audio, the two settings rows, and
  PUNKTFUNK_PAD_AUDIO_PROFILE. The "speaker is opt-in" line was only true of
  Android; desktop has shipped it on.

Gates (Ubuntu 26.04 rust-ci container, linux/amd64): clippy --all-targets -D
warnings over pf-client-core, pf-presenter, punktfunk-client-session and
punktfunk-client-linux; plain build; 204 tests green including 5 new ones for the
matcher, the identity parse and the profile chooser; cargo fmt --check. The graph
walk was smoke-tested against a live PipeWire daemon on home-bazzite-1 (2 sinks,
2 cards enumerated, matching --list-audio; correct "no DualSense" verdict with no
pad attached). NOT yet exercised against a real DualSense — that is the on-glass
step this leaves open.
On-glass on a Steam Deck (SteamOS 3.7, alsa-ucm-conf with DualSense-PS5.conf) with a
wired DualSense. The card publishes three usable-looking nodes, and the previous
commit's "prefer the unpositioned quad" rule picked the wrong one:

  alsa_output.hw_Controller_0            Audio/Sink/Internal  4ch  AUX0,AUX1,AUX2,AUX3
  ....HiFi__SpeakerHaptic__sink          Audio/Sink           4ch  FL,FR,RL,RR
  ....HiFi__Speaker__sink                Audio/Sink           1ch  MONO

The hardware map is in the splits' own `api.alsa.split.position`: the mono Speaker
device is `[AUX1]` and SpeakerHaptic is `[AUX1,AUX1,AUX2,AUX3]`, so AUX1 is the
internal speaker, AUX2/AUX3 are the two voice coils, and **AUX0 is nothing**. Our
stream is speaker on 0/1 and haptics on 2/3, so index-exact into the PARENT puts
speaker-left into the dead channel and only speaker-right into the speaker — half
the speaker thrown away. The public split sink folds BOTH our speaker channels onto
AUX1, which is what its UCM author intended, and passes the coil pair through
untouched. Haptics are identical either way; the speaker is not.

So the order is now public-quad (unpositioned, then positioned) before the internal
parent, with `SinkNode::internal` carrying `media.class == Audio/Sink/Internal` or
`api.alsa.split.parent`. Pro Audio's `pro-output-0` is a PUBLIC AUX quad, so it is
still caught by the first rule and nothing about the no-UCM path changes.

Measured, not reasoned: playing a 200 Hz tone present ONLY in channels 3/4, in the
shape this client now uses (AUX0..AUX3 + `stream.dont-remix`), into SpeakerHaptic
and reading that sink's own monitor back index-exact gives

  ch0 0.0000  ch1 0.0000  ch2 0.5000  ch3 0.5000

— bit-exact on the coil pair, nothing leaking into the speaker pair. The parent
node has no monitor to capture (0 frames), which is why its map is read from the
split properties instead.

The new test transcribes all three real nodes and asserts the pick from every
enumeration order, which also pins the original defect: the old name-only matcher
took whichever public sink the registry replayed first, and one of them is a MONO
node that cannot carry the coils at all.

Gates: clippy -D warnings over the four client packages, build, 205 tests green
(202 in pf-client-core), cargo fmt --check. Also confirmed on the same Deck that
the pad still presents to the input layer as 054c:0ce6 alongside Steam Input's
28de:11ff virtual pad, so tier-A detection has the real ids to match on.
Caught by running --pad-audio-test on a real Steam Deck with a wired DualSense. The
graph walk read `audio.channels` and `audio.position` out of the registry's `global`
event, and a global announce carries only a SUBSET of an object's proplist. The
subset happens to include `media.class`, `node.name` and `device.id` — which is
exactly why this looked like it worked — but not the audio shape. So every sink came
back as 0 channels:

  parent  device.id=140  channels=0  position=-  alsa_output.hw_Controller_0
  sink    device.id=140  channels=0  position=-  ....HiFi__SpeakerHaptic__sink
  sink    device.id=140  channels=0  position=-  ....HiFi__Speaker__sink

  pick: card 140 has no four-channel node — moving it to a four-channel profile

i.e. the matcher could never see the four-channel sink that was sitting right there,
and then went and changed the user's card profile to fix a problem that did not
exist. `pw-dump` and `pactl` show these fields because they BIND every object and
read its info props; reading their output is what made the registry-only version look
plausible.

The walk is now two rounds: the registry replay binds every `Audio/Sink…` node, and a
second sync collects the `info` events that provoked, whose props are the whole
proplist. Cards need no second round — their identity keys are in the announce, and
nothing else about them is weighed. Node parsing moved into `sink_from_props` so the
two sources cannot drift, and it now strips the `[ ... ]` brackets PipeWire puts
around `audio.position`.

Second defect from the same run, and the reason the Deck was left sitting on Pro
Audio afterwards: the devtest's early `?` returned before `restore_profile()`. The
restore now wraps the whole body. And a profile swap that fails to produce a
four-channel node restores the card immediately and records the card in
PROFILE_TRIED, so the renderer's backoff cannot flip a device in the user's sound
settings back and forth for the length of a session.

Gates: clippy -D warnings over the four client packages, build, 205 tests, fmt.
fix(client): the pad's speaker shares a channel with its headphone jack, and powers up on the jack
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 4m11s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 4m24s
ci / rust-arm64 (pull_request) Successful in 2m50s
android / android (pull_request) Successful in 6m29s
ci / web (pull_request) Successful in 1m53s
ci / docs-site (pull_request) Failing after 45s
ci / rust (pull_request) Successful in 7m2s
ci / bun-nix (pull_request) Successful in 23s
23edf4e702
Field result from the Deck: haptics FELT, speaker inaudible — with the routing
already proven correct. Capturing the sink's own monitor while the client renders
shows the speaker pair carrying full-scale signal:

  --coils    ch0 0.0000  ch1 0.0000  ch2 0.5000  ch3 0.5000
  --speaker  ch0 0.5000  ch1 0.5000  ch2 0.0000  ch3 0.0000

so nothing was lost on the way to the pad. The loss is inside it. Channel 1 of the
DualSense's audio function is the headphone jack's RIGHT channel *and* the built-in
mono speaker — #259 reads the same thing out of the UCM from the host side ("ch1 is
the built-in mono speaker") — and which of the two physically sounds is chosen by
`ucAudioEnableBits`, report byte 8. A pad powers up pointing at the jack, so with
nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are
NOT affected by that select, which is exactly why haptics worked the instant the
samples were routed right and the speaker did not.

We only ever wrote those bytes when a host forwarded a game's `AudioCtl`, so a title
that manages no audio settings of its own — and every standalone test — got silence.
A tier-A slot with the speaker capability now sends a default speaker-enable packet
beside the audio-haptics packet it already sends. A later `AudioCtl` still overrides
it verbatim, so a game driving its own volume still wins.

⚠ The path byte is EMPIRICAL, not documented: SDL's vendored SDL_hidapi_ps5.c pins
the struct layout but never writes these fields. Measured on 054c:0ce6 using the
pad's own microphone as the detector (Goertzel at the test tone): 0x20 loudest at
~5x the noise floor, 0x30 also sounds, 0x10 silent. That is thin evidence for a
constant, so both it and the volume are field levers —
PUNKTFUNK_PAD_SPEAKER_PATH / PUNKTFUNK_PAD_SPEAKER_VOLUME, hex or decimal — and an
on-glass confirmation of which value a human actually hears is still owed.

The test pins what must not regress: the two validity bits are set, volume and path
land at the same offsets the AudioCtl fold uses, every other byte stays zero, and
`ucEnableBits1` bits 0/1 stay CLEAR — asserting either would enable rumble emulation
and disable audio haptics, muting the coils to make the speaker audible.

Gates: clippy -D warnings over the four client packages, build, 207 tests (204 in
pf-client-core), fmt — on top of current main.
enricobuehler merged commit 5c44887732 into main 2026-08-15 22:56:12 +00:00
enricobuehler deleted branch worktree-linux-client-pad-audio 2026-08-15 22:56:16 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#261