Skip to content

Pre-fold per-app audio haptics capture + Volume Sync - #30

Merged
LordVicky merged 35 commits into
devfrom
feature/haptics-prefold-capture
Jul 18, 2026
Merged

Pre-fold per-app audio haptics capture + Volume Sync#30
LordVicky merged 35 commits into
devfrom
feature/haptics-prefold-capture

Conversation

@LordVicky

Copy link
Copy Markdown
Owner

Summary

Enhances Audio Haptics on Linux with pre-fold per-app capture and adds a Volume Sync feature, plus three volume-attenuation bug fixes found during hardware verification.

Pre-fold per-app capture

  • Honors the --haptics-app-* flags the engine already sends: hot-attaches (2s pw-dump poll) to the selected app's own PipeWire output stream and re-attaches if the game restarts its stream.
  • Captures at the stream's native channel layout (pre-fold-down), so 5.1/7.1 games drive haptics with their discrete LFE channel even on stereo headphones.
  • Layout-aware summing: left/right = FL/FR + 0.5×FC + 1.0×LFE (rears excluded). Stereo streams and the no-app sink-monitor path are numerically identical to before.
  • Per-app isolation as a side benefit — Discord/music no longer drive haptics when a game is selected.

Volume Sync

Haptics are delivered as audio on the DualSense sink, so listening volume scaled them. Two per-card toggles (both default ON = follow the volume knob, historical behavior):

  • Audio Haptics → Volume Sync: OFF makes synthesized haptics volume-independent (inverse per-channel compensation).
  • HD Haptics → Volume Sync: OFF runs a guard that pins the sink's haptic channels (3–4) at unity while the ear channels track the knob, so game HD haptics stay constant.
  • Compensation is computed from the sink's per-channel volumes, so both toggles behave correctly in all four combinations (guard active or not).

Hardware-verification bug fixes

  • Mic fallback: app capture targeted the stream by object.serial — a plain --target <id> silently fell back to the default microphone.
  • Stream-restore volume: the helper's pw-play stream is pinned at --volume 1 with state.restore-props = false (a saved 50% had halved haptics across sessions).

Testing

  • 745 unit tests pass (npm run test:companion); typecheck + build clean.
  • AppImage rebuilt and used for live verification: sink-monitor and per-app paths both attach; per-app capture verified linked to a running game's stream.

Remaining hardware checks (post-merge, user-assisted)

  • Four-state toggle matrix (esp. Audio ON/HD OFF, and both OFF at low volume — no harsh clipping).
  • Guard re-pin latency (~2s) after a volume change; no respawn churn across controller reconnect.
  • Surround game forced to 5.1: LFE punch vs sink-monitor; 3.5mm sink-monitor feedback regression (02792b1) intact.

Specs/plans: docs/superpowers/specs/2026-07-17-*.md, docs/superpowers/plans/2026-07-17-*.md.

LordVicky added 30 commits July 16, 2026 21:25
Imported/library profiles carrying meta.game landed as game profiles for
games never located on this machine, duplicating real game profiles.
importProfile now drops meta.game so imports arrive as plain trigger
profiles, and resetToLibrary preserves the locally attached game instead
of adopting one from the fetched copy.
Merge upstream hurryman2212/vds 1448bb8 (0.3.0-rc2, our import point)
..2d27ab0 (0.3.0-rc7) for Linux and common code, keeping all companion
features (per-trigger persistent effects, V2 trigger FFB, global trigger
intensity, touchpad grab, RSSI/battery reporting, lightbar handling).

Brings in:
- Linux microphone and headset support (BT mic decode, headset-aware
  audio output path, mute button and mic-select handling)
- logrotate support and packaging/DKMS cleanup fixes
- vds_hcd loaded before vdsd; driver versions reported in logs
- upstream haptic-channel pre-gain back at the pre-0.2.1 value

Our speaker-pacing fix (1d8b97a) was reverted beforehand; upstream's
reworked audio sender replaces it. Windows-only changes (installer,
windrv, win32 daemon) were not taken.

Conflict resolutions: DsOutputState keeps the companion override layer
(recompute_effective_state now also runs after upstream's new audio/mic
state mutations); handle_bt_input applies companion input translation to
the converted USB report after upstream's mic-audio branch; companion
logging moved to the new LogScope enum (new Companion scope).
The companion 0x0B command was acked and discarded, so the app's Audio
Buffer Length slider never changed backend behavior; the speaker/haptics
queue depth was fixed at 8 chunks (80 ms) with a 4-chunk stale-drop
threshold.

Store the slider value (16-128, 3 kHz haptics samples) in
CompanionSettings and derive the per-port pending 0x36 chunk cap from it
in apply_companion_state: one chunk per 10 ms speaker frame (30 samples),
clamped to 2-16 chunks, stale-drop threshold at half the cap. Value 0
(never set) keeps the old 8/4 defaults, and ports reset to those on
disconnect. Out-of-range values return kAckErrInvalidValue.

Adds companion_buffer_length_test covering store, clamps, and rejection.
With SET_HAPTICS_BUFFER_LENGTH now actuated, the old 16-128 range only
reached 43 ms while the daemon's previous fixed queue was 80 ms, so the
whole slider traded resilience for latency with no way back. Raise the
max to 240 samples (8 chunks = the old queue depth) across the renderer
slider, bridge-service clamp, settings store, and the vdsd validator.
Existing zone labels (High stutter / Risky / Safe) keep their sample
thresholds. 692 app tests and the vds companion test pass.
The High stutter / Risky / Safe boundaries (44/63) came from the Windows
WASAPI buffer. The Linux daemon rounds the value to whole 10 ms chunks
(30 samples each) with a 2-chunk floor, so 16-45 all produce the same
2-chunk queue. Move the boundaries to the chunk edges: <=45 stutter
(2-chunk floor), 46-75 risky (3 chunks), >=76 safe (4+ chunks).
…y-out fix

Mute sync (both directions):
- vdsd: a physical mute-button toggle now mirrors into
  companion.settings.mic_muted (and bumps the settings revision), so the
  STATUS report reflects it and the app's existing micMuted reconcile
  picks it up.
- vdsd: apply_companion_state actuates an app-initiated SET_MIC_MUTE on
  the controller with the same BT mic-state report the button path uses.

Mic grayed out:
- Disabling the audio section turned off mic pass-through, but enabling
  it back only restored the speaker, leaving duplexMicEnabled stuck off
  and every mic control disabled. Re-enabling audio now restores the mic
  too.

Audio buffer default:
- Default hapticsBufferLength 64 -> 120 (4 chunks / 40 ms): 64 sat on
  the daemon's 2-chunk floor. Zone boundaries corrected to the actual
  integer chunk rounding: <=74 floor, 75-104 = 3 chunks, >=105 safe.

692 app tests and the vds companion test pass.
Manually pinning a trigger profile (matchedBy 'pin') set the engine's
activeProfileId, which both the game-profile card and the game-settings
coordinator read as 'this game is running': the card showed the game as
active and the coordinator swapped in the full game settings set.

Require matchedBy 'process' in both places so a manual trigger override
stays a trigger override, and auto switching state follows actual game
detection only. Adds a coordinator regression test (693 total).
Review finding: port.mic_muted was flipped before try_send_output_report,
so a blocked HID queue dropped the mic-state report with no retry and the
controller LED desynced from the app. Keep the mismatch until the report
actually goes out; the next companion write retries.
The BT mic-state report hardcoded the mute LED byte to 0, so app- or
button-initiated mute toggles produced no visible feedback on the
controller: an app-side mute looked like it did nothing. Set the LED
from the mute state (solid while muted), matching native PS5 behavior.
The status poll reads the STATUS report, then does several other feature
reads before reconciling micMuted. A mute toggled in the app inside that
window was compared against the pre-command status and snapped back to
the stale state, fighting the daemon (alternating mute/unmute in the
vdsd log). Skip the reconcile for 2 s after this app sends SET_MIC_MUTE;
controller-button changes still reconcile on the next poll.
Occasional stale mute LED: a 0x31 state report queued while the HID
queue was blocked was built from the pre-toggle output state, and when
it flushed after the mic-state report it re-asserted the old mute LED
(controller showed muted after an unmute). Rebuild any queued state
report from the current state after every mic-state send, and when the
button path's mic report itself fails to send, queue a full state
report so the flush loop delivers the change instead of dropping it.
Root cause of the recurring stale mute LED (trace-confirmed): the host
kernel's hid-playstation driver bound to the virtual DualSense toggles
its own mic-mute state on every mute-button press and sends output
reports with allow_mute_light/allow_audio_mute. It cannot see
companion-initiated mute changes, so after an app mute its phase is
inverted and its next output report re-asserts the old LED and mic-mute
bit over ours.

Drop allow_mute_light entirely and preserve our mic-mute power-save bit
when merging host reports; other power-save bits still pass through.
Emit an 'OpenDS5: Microphone muted/unmuted' toast from a single helper
on all four mute-change paths: app toggle (setMicMute), controller
button adoption via the status reconcile, the controller mic-mute
shortcut event, and the implicit change when mic pass-through is
enabled/disabled. Change-detected so redundant commands stay silent.
Feedback loop: with the headset plugged into the controller the default
sink can be the bridge's own 4-channel device. The haptics helper
captured the default sink monitor without a channel map, so PipeWire's
4ch->2ch downmix folded the rear haptics channels we play back into the
capture - the engine amplified its own output into a constant buzz.
Pin the capture to FL,FR (identical on stereo sinks; front-only on the
bridge sink).

Capture device picker: the Audio Haptics source dropdown now lists the
system's output sinks (bridge sink excluded) alongside app sessions.
Default stays 'System', which follows the default output device; picking
a device pins pw-record --target to that sink's monitor. New
'output-device' source kind plumbed through protocol types, settings
normalization, engine args (--haptics-output-device), Linux helper
(--list-output-sinks mode), IPC, and the renderer picker.
…-down

The FL,FR channel-map alone did not break the feedback loop: PipeWire's
channel mixer still folds the 4ch bridge monitor's rear (haptics)
channels into a narrower capture's fronts (standard rear fold-down), so
the helper kept hearing its own output. Capture 4ch FL,FR,RL,RR - a
matching format links passthrough with no mixing - and read only the
front channels in the processor. Stereo sinks upmix with silent rears,
fronts intact.
…conf

The Direct 4-channel PCM is the device the whole audio stack targets
(daemon, haptics helper, app). Without pinning, WirePlumber can bring
the card up in a convenience profile whose stereo path goes through the
channel mixer and software volume, which audibly degrades the BT audio
and hides the haptics channels. Users previously needed a separate
local pro-audio override (50-vds-proaudio.conf from beta setups); the
shipped 99-vds conf now covers it in the existing card rule.
@LordVicky
LordVicky merged commit d46eefd into dev Jul 18, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant