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 2026-08-15 22:56:12 +00:00
worktree-linux-client-pad-audio into main
5
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
9c6e34ab5d | Merge remote-tracking branch 'origin/main' into worktree-linux-client-pad-audio | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |