DualSense haptics + speaker work in-game: four fixes between the game and the pad #302

Merged
enricobuehler merged 4 commits from worktree-pad-audio-fidelity into main 2026-08-18 15:02:20 +00:00
Owner

With #291 merged, GE-Proton could finally put the usbip pad into haptics mode — and everything after that was still silent. This PR carries the four fixes that took it from "enable succeeds" to haptics and speaker felt/heard in-game on glass (Spider-Man Remastered, GE-Proton 11-5, .21 host → Deck client → wired DS5).

1. The usbip capture forwarded the pad's hardware quad as the wire's speaker pair (dff2769b)

The ISO endpoint carries the DualSense's own channel map — ch0 = headphone-left, ch1 = the mono speaker, ch2/3 = the voice coils — because the host's UCM split folds everything to hardware channels before our capture point. The 0xD1 wire contract puts the speaker pair on ch0/1. Verbatim forwarding shipped headphone-left silence as wire speaker-left and the real speaker as speaker-right, which the client rendered onto the one Deck split-sink channel that PipeWire never wires to the physical speaker. (The split declares [AUX1,AUX1,AUX2,AUX3] — both channels to the speaker — but empirically only the first claimant reaches it. Measured, not assumed: a test tone showed up on exactly one channel at each hop.)

Fix: normalize_hw_quad — duplicate the hardware speaker channel across the wire pair, pass coils through, drop headphone-left (a remote pad's jack is not a wire surface). The stream-sink path (uhid pads) already emits the logical layout and is untouched.

2. The simulated ISO clock ran ~26 % slow (f7374149)

sleep(interval × packets) per URB, measured from "now", adds every source of slop on top of the nominal period. The virtual pad's ALSA clock measured 35.7 k frames/s against 48 kHz — the PCM backed up into xruns, and on the test box the pad sink became the PipeWire graph driver and dragged the desktop capture to 50 % delivery (severely garbled stream audio).

Fix: a per-endpoint absolute deadline ledger — late completions catch up instead of accumulating; a stall > 20 ms re-anchors instead of fast-forwarding. Measured after: 48,005 frames/s. Two paused-clock tests pin the exact rate and the re-anchor.

3. The speaker lane sounded "insanely compressed" (e356e354)

Both 0xD1 lanes used Application::LowDelay @ 64 kbps CBR. Right for voice-coil rumble; audibly wrong for programme audio. The speaker lane now runs the full Application::Audio coder at 96 kbps (~120 B per 10 ms frame, still far under one MTU). Haptics unchanged — they need the latency, not the fidelity.

4. A packaged WirePlumber policy holds the pad's device for GE-Proton (0a6a49a9)

GE's DS5 haptic router opens the sink's backing hw: device raw whenever it's free — then its own path re-probe EBUSYs against its own handle, invalidates the stream, and spins a 100 Hz "device generation" refresh loop (and, in one game, a buffer race in the same machinery crashed the title). On SteamOS — where that code was developed — PipeWire always holds the device, so GE immediately lands on its well-tested Pulse fallback and none of this fires.

scripts/60-punktfunk-dualsense.conf ships that environment: node.always-process + no-suspend holds the device from card creation; priority.driver = 1 keeps the pad's USB clock from ever driving the graph. Installed by rpm/deb/arch/nix into /usr/share/wireplumber/wireplumber.conf.d/. Covers physically plugged pads on a headless host identically (same failure, same fix).

Gates

  • cargo clippy --release --all-targets -p usbip-sim -p pf-inject -p punktfunk-host -- -D warnings: clean (Linux, .21)
  • cargo fmt --check (both crates): clean
  • Tests: usbip-sim 13/13 (incl. 2 new paused-clock pacing tests), punktfunk-host pad tests 39/39 (incl. 2 new normalization tests)
  • On glass: full chain verified end-to-end; in-game haptics + speaker confirmed by the operator

Known follow-ups (documented, deliberately not in this PR)

  • UCM: Fedora 44 / Arch alsa-ucm 1.2.16 dropped the SpeakerHaptic device the Deck's 1.2.14 has; the test box runs the Deck's conf files hand-installed. Ship-vs-upstream is an open decision (a sysext can overlay /usr; rpm/deb cannot overwrite another package's files).
  • Default volume: WirePlumber defaults a new pad card's sink to 40 % (−24 dB) on both host and client — stacked, that made working haptics imperceptible. Follow-up: pin 0 dB at client pick / host adoption (device.routes.default-sink-volume is global-only, so it can't be scoped in the shipped conf).
  • DS5 touchpad click isn't forwarded over the wire (game map-button dead) — separate input-plane gap.
With #291 merged, GE-Proton could finally put the usbip pad into haptics mode — and everything after that was still silent. This PR carries the four fixes that took it from "enable succeeds" to **haptics and speaker felt/heard in-game on glass** (Spider-Man Remastered, GE-Proton 11-5, .21 host → Deck client → wired DS5). ## 1. The usbip capture forwarded the pad's *hardware* quad as the wire's *speaker pair* (`dff2769b`) The ISO endpoint carries the DualSense's own channel map — ch0 = headphone-left, **ch1 = the mono speaker**, ch2/3 = the voice coils — because the host's UCM split folds everything to hardware channels before our capture point. The 0xD1 wire contract puts the *speaker pair* on ch0/1. Verbatim forwarding shipped headphone-left silence as wire speaker-left and the real speaker as speaker-right, which the client rendered onto the one Deck split-sink channel that PipeWire never wires to the physical speaker. (The split *declares* `[AUX1,AUX1,AUX2,AUX3]` — both channels to the speaker — but empirically only the first claimant reaches it. Measured, not assumed: a test tone showed up on exactly one channel at each hop.) Fix: `normalize_hw_quad` — duplicate the hardware speaker channel across the wire pair, pass coils through, drop headphone-left (a remote pad's jack is not a wire surface). The stream-sink path (uhid pads) already emits the logical layout and is untouched. ## 2. The simulated ISO clock ran ~26 % slow (`f7374149`) `sleep(interval × packets)` per URB, measured from "now", adds every source of slop *on top of* the nominal period. The virtual pad's ALSA clock measured **35.7 k frames/s against 48 kHz** — the PCM backed up into xruns, and on the test box the pad sink became the PipeWire *graph driver* and dragged the desktop capture to 50 % delivery (severely garbled stream audio). Fix: a per-endpoint absolute deadline ledger — late completions catch up instead of accumulating; a stall > 20 ms re-anchors instead of fast-forwarding. Measured after: **48,005 frames/s**. Two paused-clock tests pin the exact rate and the re-anchor. ## 3. The speaker lane sounded "insanely compressed" (`e356e354`) Both 0xD1 lanes used `Application::LowDelay` @ 64 kbps CBR. Right for voice-coil rumble; audibly wrong for programme audio. The speaker lane now runs the full `Application::Audio` coder at 96 kbps (~120 B per 10 ms frame, still far under one MTU). Haptics unchanged — they need the latency, not the fidelity. ## 4. A packaged WirePlumber policy holds the pad's device for GE-Proton (`0a6a49a9`) GE's DS5 haptic router opens the sink's backing `hw:` device **raw** whenever it's free — then its own path re-probe EBUSYs against its *own handle*, invalidates the stream, and spins a 100 Hz "device generation" refresh loop (and, in one game, a buffer race in the same machinery crashed the title). On SteamOS — where that code was developed — PipeWire always holds the device, so GE immediately lands on its well-tested Pulse fallback and none of this fires. `scripts/60-punktfunk-dualsense.conf` ships that environment: `node.always-process` + no-suspend holds the device from card creation; `priority.driver = 1` keeps the pad's USB clock from ever driving the graph. Installed by rpm/deb/arch/nix into `/usr/share/wireplumber/wireplumber.conf.d/`. Covers physically plugged pads on a headless host identically (same failure, same fix). ## Gates - `cargo clippy --release --all-targets -p usbip-sim -p pf-inject -p punktfunk-host -- -D warnings`: clean (Linux, .21) - `cargo fmt --check` (both crates): clean - Tests: usbip-sim 13/13 (incl. 2 new paused-clock pacing tests), punktfunk-host pad tests 39/39 (incl. 2 new normalization tests) - On glass: full chain verified end-to-end; in-game haptics + speaker confirmed by the operator ## Known follow-ups (documented, deliberately not in this PR) - **UCM**: Fedora 44 / Arch alsa-ucm 1.2.16 dropped the `SpeakerHaptic` device the Deck's 1.2.14 has; the test box runs the Deck's conf files hand-installed. Ship-vs-upstream is an open decision (a sysext can overlay `/usr`; rpm/deb cannot overwrite another package's files). - **Default volume**: WirePlumber defaults a *new* pad card's sink to 40 % (−24 dB) on both host and client — stacked, that made working haptics imperceptible. Follow-up: pin 0 dB at client pick / host adoption (`device.routes.default-sink-volume` is global-only, so it can't be scoped in the shipped conf). - **DS5 touchpad click** isn't forwarded over the wire (game map-button dead) — separate input-plane gap.
enricobuehler added 4 commits 2026-08-18 14:55:59 +00:00
The isochronous endpoint carries the DualSense's own channel map — ch0 = headphone LEFT,
ch1 = headphone RIGHT and the built-in mono speaker, ch2/3 = the voice coils — because
everything a game writes has already been folded to hardware channels by the host's UCM
split (or written there directly) before it reaches the endpoint. The 0xD1 wire contract
instead puts the *speaker pair* on ch0/1. Forwarding the quad verbatim shipped headphone-
left (silence) as wire speaker-left and the actual speaker as wire speaker-right, which
the client renders onto the one split-sink channel that current PipeWire never wires to
the physical speaker. Field-diagnosed on glass end-to-end: haptics felt, speaker dead,
a test tone measured on exactly one channel at each hop.

Duplicate the hardware speaker channel across the wire's speaker pair; pass the coils
through; drop headphone-left deliberately (a remote pad's jack is not a wire surface).
The stream-sink capture path (uhid pads) already emits the logical layout and is
unchanged.
The simulator slept `service_interval × packets` per URB, measured from "now" at handling
time — so tokio timer slop, socket I/O and handler lock waits all added ON TOP of the
nominal period, every URB. The virtual pad's audio clock ran measurably slow under load
(~26 %: hw_ptr advanced ~35.7k frames/s against a 48 kHz stream), the PCM backed up into
xruns, and — because snd-usb-audio's clock IS URB completion — anything clocked off the
device dragged with it. On the test box the pad sink became the PipeWire graph driver and
pulled the desktop capture down to 50 % delivery ("insanely distorted" stream audio).

Pace against a per-endpoint absolute deadline ledger instead: each URB advances the
endpoint's deadline by exactly its nominal duration and sleeps until that instant, so
overhead eats into the next sleep rather than accumulating. A stall beyond 20 ms
re-anchors to now instead of fast-forwarding a burst. Measured after: 48005 frames/s.
Two paused-clock tests pin the exact rate and the re-anchor.
Both 0xD1 lanes encoded with Application::LowDelay at 64 kbps CBR — right for voice-coil
rumble (felt latency, band-limited content), audibly wrong for the pad speaker, which
carries real programme audio: on glass it "sounded insanely compressed". The speaker lane
now uses the full Application::Audio coder at 96 kbps (~120 bytes per 10 ms frame, still
far under one MTU); its few ms of extra algorithmic delay are inaudible on a speaker.
Haptics unchanged.
feat(packaging): a WirePlumber policy holds a DualSense's sound card for GE-Proton
ci / rust-arm64 (pull_request) Successful in 2m34s
ci / web (pull_request) Successful in 2m38s
android / android (pull_request) Successful in 5m32s
ci / docs-site (pull_request) Successful in 1m26s
ci / bun-nix (pull_request) Successful in 52s
ci / rust (pull_request) Successful in 8m25s
nix / flake (pull_request) Failing after 14m42s
0a6a49a9aa
GE-Proton's DS5 haptic router opens the pad sink's backing hw: device RAW whenever it is
free — then its own path re-probe EBUSYs against its own handle, invalidates the stream,
and spins a 100 Hz "device generation" refresh loop: haptics dead, speaker dead, and in
one game a buffer race in the same machinery crashed the title outright. On SteamOS,
where that code was developed, PipeWire always holds the device, so GE lands on its
well-tested Pulse-routing fallback immediately and none of this fires.

Ship the SteamOS-shaped environment: node.always-process + no suspend keeps PipeWire
holding the device from the moment the card appears, and priority.driver = 1 keeps the
pad — whose USB audio clock (virtual or physical) is nobody's idea of a house clock —
from ever driving the graph. Installed by rpm/deb/arch/nix into
/usr/share/wireplumber/wireplumber.conf.d/. Matches both DS5 product-string spellings;
covers physically plugged pads on a headless host identically.
enricobuehler merged commit 412991f6a3 into main 2026-08-18 15:02:20 +00:00
enricobuehler deleted branch worktree-pad-audio-fidelity 2026-08-18 15:02:29 +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#302