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).
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 fullApplication::Audiocoder 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.confships that environment:node.always-process+ no-suspend holds the device from card creation;priority.driver = 1keeps 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): cleanKnown follow-ups (documented, deliberately not in this PR)
SpeakerHapticdevice 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).device.routes.default-sink-volumeis global-only, so it can't be scoped in the shipped conf).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.