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
5 Commits
Author SHA1 Message Date
enricobuehler 23edf4e702 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
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.
2026-08-16 00:53:18 +02:00
enricobuehler 9c6e34ab5d Merge remote-tracking branch 'origin/main' into worktree-linux-client-pad-audio 2026-08-16 00:43:31 +02:00
enricobuehler db8874f944 fix(client/linux): registry globals carry no audio.channels, so every node looked 0-channel
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.
2026-08-16 00:10:50 +02:00
enricobuehler 60d0cdfc0f fix(client/linux): a card's public 4-ch sink beats its hidden parent — the parent's AUX0 is dead
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.
2026-08-15 23:55:09 +02:00
enricobuehler d227db06e8 fix(client/linux): controller audio picked any DualSense sink, so the coils were folded away
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.
2026-08-15 22:42:03 +02:00