feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3) #182

Merged
enricobuehler merged 2 commits from worktree-linux-pad-audio into main 2026-08-12 16:55:13 +00:00
Owner

Closes the last hole in DualSense support on Linux hosts: the 0xD1 pad-audio plane was Windows-only — host_cap() answered false and spawn() was a stub off-Windows, so a tier-A client (Android USB DS5) against a Linux host negotiated the cap off and stayed on wire rumble.

What ships

  • audio/linux/pad_sink.rs — one Audio/Sink stream node per DualSense-family pad, minted with the identity the matchers read: ALSA-style node.name carrying the pad's pairing MAC, node.description "Wireless Controller", device.bus/vendor.id/product.id/form_factor proplist, per-pad serial. No default-sink claim, priority.session 50. The process() callback is the capture (we own the sink). PUNKTFUNK_PAD_SINK_NAME/_DESC override the strings for field debugging ({pad}/{mac} expand).
  • native/pad_audio.rs — the framer/gate/lane machinery compiles on Linux; the streamer thread is generic over the capturer (same open-with-backoff); Linux host_cap = client asked + PUNKTFUNK_PAD_AUDIO≠0 + a reachable PipeWire socket; Linux spawn mints the sink lazily in-thread. spawn gains an edge flag for the Edge identity.
  • pad-sink-test devtest — mints one sink and captures from it with per-pair peak reporting (speaker pair vs coil pair), no client needed.
  • Docs: PUNKTFUNK_PAD_AUDIO{,_SLOTS} lose the "(Windows)" marker; the roadmap non-goal narrows to Bluetooth client pads.

The AUX0–3 channel shape (second commit)

A field report ("GE-Proton 11-5 fixed DualSense audio — you only need the controller audio set to Pro Audio") led into GE's proton-ds5-haptic patch set: GE ≥11-4 opens the pad's node through its bundled pipewire-alsa plugin as pipewire:NODE=<name> with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with stream.dont-remix — "the hidden PipeWire parent for a DualSense output exposes AUX0 through AUX3". Pro Audio is what makes a real pad present that shape; our sink mints it directly, so no profile step exists for users. A positioned FL FR RL RR sink would put those writers through position channelmix instead of index passthrough.

Also confirmed from the patch set: device matching is bus/vid/pid proplist or the Sony name substrings (we carry both), and on GE ≥11-4 the MMDevice ContainerId is synthesized from the wine-side HID USB parent — the pure-PipeWire-node GUID_NULL risk from the design doc no longer applies.

Validation (on-glass, Bazzite 44 / .41)

  • cargo build --release --locked (nvenc+vulkan-encode), clippy --all-targets -D warnings, pad_audio+pad_sink tests 11/11, fmt — in a fedora:44 container on the box.
  • Sink serves the full identity through pipewire-pulse; an AUX-mapped rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair 0.3662 = bit-exact input level) over both the native and pulse legs; a positioned stray stream folds into the speaker pair and never excites the coils.
  • The box currently runs this branch via a systemd drop-in for the upcoming Android-client field test.
Closes the last hole in DualSense support on Linux hosts: the 0xD1 pad-audio plane was Windows-only — `host_cap()` answered `false` and `spawn()` was a stub off-Windows, so a tier-A client (Android USB DS5) against a Linux host negotiated the cap off and stayed on wire rumble. ## What ships - **`audio/linux/pad_sink.rs`** — one `Audio/Sink` stream node per DualSense-family pad, minted with the identity the matchers read: ALSA-style `node.name` carrying the pad's pairing MAC, `node.description "Wireless Controller"`, `device.bus/vendor.id/product.id/form_factor` proplist, per-pad serial. No default-sink claim, `priority.session 50`. The `process()` callback *is* the capture (we own the sink). `PUNKTFUNK_PAD_SINK_NAME/_DESC` override the strings for field debugging (`{pad}`/`{mac}` expand). - **`native/pad_audio.rs`** — the framer/gate/lane machinery compiles on Linux; the streamer thread is generic over the capturer (same open-with-backoff); Linux `host_cap` = client asked + `PUNKTFUNK_PAD_AUDIO`≠0 + a reachable PipeWire socket; Linux `spawn` mints the sink lazily in-thread. `spawn` gains an `edge` flag for the Edge identity. - **`pad-sink-test` devtest** — mints one sink and captures from it with per-pair peak reporting (speaker pair vs coil pair), no client needed. - Docs: `PUNKTFUNK_PAD_AUDIO{,_SLOTS}` lose the "(Windows)" marker; the roadmap non-goal narrows to Bluetooth client pads. ## The AUX0–3 channel shape (second commit) A field report ("GE-Proton 11-5 fixed DualSense audio — you only need the controller audio set to Pro Audio") led into GE's `proton-ds5-haptic` patch set: GE ≥11-4 opens the pad's node through its bundled pipewire-alsa plugin as `pipewire:NODE=<name>` with `aux_channels=1`, and its pulse fallback forces a `PA AUX0..3` map with `stream.dont-remix` — "the hidden PipeWire parent for a DualSense output exposes AUX0 through AUX3". Pro Audio is what makes a *real* pad present that shape; our sink mints it directly, so no profile step exists for users. A positioned FL FR RL RR sink would put those writers through position channelmix instead of index passthrough. Also confirmed from the patch set: device matching is bus/vid/pid proplist **or** the Sony name substrings (we carry both), and on GE ≥11-4 the MMDevice ContainerId is synthesized from the wine-side HID USB parent — the pure-PipeWire-node `GUID_NULL` risk from the design doc no longer applies. ## Validation (on-glass, Bazzite 44 / .41) - `cargo build --release --locked` (nvenc+vulkan-encode), `clippy --all-targets -D warnings`, pad_audio+pad_sink tests 11/11, `fmt` — in a fedora:44 container on the box. - Sink serves the full identity through pipewire-pulse; an AUX-mapped rear-pair-only tone captures **index-exact** (speaker pair 0.0000, coil pair 0.3662 = bit-exact input level) over both the native and pulse legs; a positioned stray stream folds into the speaker pair and never excites the coils. - The box currently runs this branch via a systemd drop-in for the upcoming Android-client field test.
enricobuehler added 2 commits 2026-08-12 16:54:22 +00:00
The 0xD1 plane was Windows-host-only: host_cap() answered false and spawn()
was a stub everywhere else, so an Android tier-A client against a Linux host
negotiated the cap off and stayed on wire rumble. The whole downstream
machinery (framer, silence gate, lanes, 0xD1 send) was already capture-
agnostic — only the capturer was WASAPI.

- audio/linux/pad_sink.rs: one Audio/Sink stream node per DualSense-family
  pad, minted with the identity the matchers read (ALSA-style node.name with
  the pad's pairing MAC, description "Wireless Controller", bus/vendor/
  product/form-factor proplist, per-pad serial), 4-ch F32 48 kHz FL FR RL RR,
  no default-sink claim, priority.session 50. The process() callback IS the
  capture. PUNKTFUNK_PAD_SINK_NAME/_DESC override the strings for field
  debugging ({pad}/{mac} expand).
- native/pad_audio.rs: the shared logic and lanes compile on Linux;
  pad_audio_thread is generic over the capturer (open-with-backoff kept);
  host_cap() Linux arm = client asked + PUNKTFUNK_PAD_AUDIO + a reachable
  PipeWire socket; spawn() Linux arm mints the sink lazily in the streamer
  thread. spawn() gains an edge flag (Edge identity; ignored on Windows).
- devtest pad-sink-test: mint one sink and capture from it, no client — the
  WP3 on-glass gate. Verified on a Bazzite 44 host: identity served through
  pipewire-pulse, rear-pair (voice-coil) tone captured bit-exact over both
  the native and pulse legs.
- docs: PUNKTFUNK_PAD_AUDIO{,_SLOTS} are no longer (Windows); the roadmap
  non-goal narrows to Bluetooth client pads.

Gates (fedora:44 container, natively on the .41 box): cargo build --release
--locked (nvenc+vulkan-encode), clippy --all-targets -D warnings, cargo test
pad_audio+pad_sink 11/11, cargo fmt.
fix(pad-audio): the Linux pad sink speaks GE-Proton's AUX0-3 channel shape
ci / bun-nix (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 1m47s
ci / docs-site (pull_request) Successful in 1m54s
apple / swift (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 2m3s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m18s
android / android (pull_request) Successful in 6m59s
118758ff0b
A field report (GE-Proton 11-5, real DualSense on-host) surfaced the missing
constraint: haptics only work when the pad's card runs the Pro Audio profile —
because GE's route opens the node through its bundled pipewire-alsa plugin
with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with
stream.dont-remix (proton-ds5-haptic patches 0013/0115/0116: "the hidden
PipeWire parent for a DualSense output exposes AUX0 through AUX3"). A
positioned FL FR RL RR sink puts those writers through position channelmix
instead of index passthrough.

The sink now advertises AUX0..AUX3. Proven on the box: an AUX-mapped
rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair
0.3662); a positioned stray stream folds into the speaker pair and never
excites the coils. The devtest reports per-pair peaks so exactly this class
of remix bug is visible.

Also confirmed from the GE patch set while here: device matching is
device.bus/vendor.id/product.id + the Sony/Wireless_Controller name
substrings (both of which the sink carries), and the MMDevice container is
now synthesized from the wine-side HID USB parent (patch 0112) — the old
pure-PW-node GUID_NULL concern no longer applies on GE >= 11-4.
enricobuehler scheduled this pull request to auto merge when all checks succeed 2026-08-12 16:55:04 +00:00
enricobuehler merged commit 539236de91 into main 2026-08-12 16:55:13 +00:00
enricobuehler deleted branch worktree-linux-pad-audio 2026-08-12 16:55:22 +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#182