Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d3de27c
Strip game bindings from imported profiles
LordVicky Jul 16, 2026
15554e8
Revert "Fix vdsd speaker audio pacing drift causing crackle"
LordVicky Jul 17, 2026
59329e5
Sync vds with upstream 0.3.0-rc7 (Linux-relevant changes)
LordVicky Jul 17, 2026
9036c42
Actuate SET_HAPTICS_BUFFER_LENGTH in the Linux daemon
LordVicky Jul 17, 2026
67dc181
Extend audio buffer slider range to 240 samples (80 ms)
LordVicky Jul 17, 2026
05096fb
Align audio buffer zone thresholds with the Linux chunk queue
LordVicky Jul 17, 2026
6ec50bc
Sync mic mute with the controller button; default buffer 120; mic gra…
LordVicky Jul 17, 2026
515dc35
Don't treat a pinned trigger profile as the active game
LordVicky Jul 17, 2026
5454376
Only commit app-initiated mic mute after the BT report sends
LordVicky Jul 17, 2026
41d8173
Merge sync/vds-0.3.0-rc7: upstream vds rc7 sync, working mic/headset …
LordVicky Jul 17, 2026
4bd5381
Light the DualSense mute LED when the mic is muted
LordVicky Jul 17, 2026
a96f26e
Hold off micMuted status reconcile right after an app mute command
LordVicky Jul 17, 2026
3523bc2
Keep queued BT state reports fresh across mic-state changes
LordVicky Jul 17, 2026
9eb5e93
Ignore host mute-LED/mic-mute output-report bits; daemon owns mic mute
LordVicky Jul 17, 2026
588e883
Toast on every mic mute/unmute
LordVicky Jul 17, 2026
62a799e
Fix audio-haptics feedback loop with headset; add capture-device picker
LordVicky Jul 17, 2026
02792b1
Capture 4 discrete channels in the haptics helper to defeat rear fold…
LordVicky Jul 17, 2026
ad70d31
Pin the vds card to the pro-audio profile in the shipped wireplumber …
LordVicky Jul 17, 2026
ac82640
Add design spec for pre-fold per-app haptics capture
LordVicky Jul 17, 2026
12eec32
Note rear-channel tuning checkpoint in haptics capture spec
LordVicky Jul 17, 2026
277358b
Add implementation plan for pre-fold per-app haptics capture
LordVicky Jul 17, 2026
f9acf01
Make Linux audio helper importable for unit tests
LordVicky Jul 17, 2026
9918d95
Parse PipeWire stream channel layouts in the Linux audio helper
LordVicky Jul 17, 2026
10dfb8f
Blend FC and LFE into haptics with layout-aware summing
LordVicky Jul 17, 2026
5ac0e3b
Match the selected app's PipeWire output stream in the Linux helper
LordVicky Jul 17, 2026
9798ac3
Hot-attach pre-fold app stream capture for audio haptics
LordVicky Jul 17, 2026
35c82b6
Target app streams by object.serial to avoid mic fallback
LordVicky Jul 17, 2026
f41b141
Decouple haptics strength from sink volume
LordVicky Jul 17, 2026
daf0283
Pin haptics playback stream at unity volume
LordVicky Jul 17, 2026
34ecdd5
Add Volume Sync spec and implementation plan
LordVicky Jul 17, 2026
0830e92
Add Volume Sync gating to the Linux audio helper and settings store
LordVicky Jul 17, 2026
952e19d
Plumb Audio Haptics Volume Sync through the engine
LordVicky Jul 17, 2026
800bd7d
Pin HD haptic channels at unity when Volume Sync is off
LordVicky Jul 17, 2026
4ff2944
Add Volume Sync toggles to the haptics cards
LordVicky Jul 17, 2026
d15d7c4
Compute haptics compensation from per-channel sink volumes
LordVicky Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
664 changes: 664 additions & 0 deletions docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions docs/superpowers/plans/2026-07-17-volume-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Volume Sync Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Per-feature "Volume Sync" toggles (Audio Haptics + HD Haptics) choosing whether haptics follow the listening volume; both default ON (historical behavior).

**Architecture:** Audio Haptics sync gates the existing `volumeCompensation` inside the Linux helper (new CLI flag + 7th `haptics-config` field). HD Haptics sync-off runs a new helper mode `--volume-guard` that pins the bridge sink's channels 3–4 at unity via `pw-cli set-param`, spawned/killed by the Electron main process. UI: one toggle row per card following existing card toggle patterns.

**Tech Stack:** Node ESM helper (.mjs), Electron main (TypeScript), React renderer, vitest.

**Spec:** `docs/superpowers/specs/2026-07-17-volume-sync-design.md`

## Global Constraints

- Defaults: `audioReactiveHapticsVolumeSync: true`, `hapticsVolumeSync: true`. Missing/garbage stored values normalize to `true`.
- Sync ON = haptics follow volume (compensation off / no guard) — exactly pre-f41b141 behavior for the audio path.
- Helper stays a single file (`ds5-bridge/companion/native/audio-helper-linux.mjs`); Windows helper untouched.
- `haptics-config` stdin line grows to 7 fields: `haptics-config <gain> <bassFocus> <response> <attack> <release> <volumeSync 1|0>`; absent 7th field → sync ON.
- Guard pins ONLY channels 3–4 (indices 2,3) to 1.0; channels 1–2 must keep the user's value.
- Tests from `ds5-bridge/companion`; run full `npx vitest run src` before each commit.
- Commit locally only, never push, no Co-Authored-By/Generated-with trailers, never `git add -A`.

---

### Task 1: Settings store + helper volume-sync gating

**Files:**
- Modify: `ds5-bridge/companion/src/main/settings-store.ts` (add `audioReactiveHapticsVolumeSync: true` and `hapticsVolumeSync: true` to defaults near the other `audioReactiveHaptics*` keys; add boolean normalization following the store's existing boolean pattern)
- Modify: `ds5-bridge/companion/src/shared/types.ts` (the settings type carrying `audioReactiveHapticsEnabled` gains both booleans)
- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs`
- Test: `ds5-bridge/companion/src/main/settings-store.test.ts` (follow existing normalizer tests), `ds5-bridge/companion/src/main/audio-helper-linux.test.ts`

**Interfaces:**
- Produces: settings keys `audioReactiveHapticsVolumeSync`, `hapticsVolumeSync` (booleans, default true). Helper: `--haptics-volume-sync 1|0` CLI flag; `HapticsProcessor.setVolumeSync(boolean)`; 7-field `haptics-config` line; when sync ON the applied output compensation is exactly 1 regardless of polled volume.

- [ ] **Step 1 (TDD):** settings-store tests: both keys default true; stored `false` survives; garbage (string/number/undefined) → true. Helper tests: `readHapticsConfig` includes `volumeSync` (flag `--haptics-volume-sync 0` → false, absent → true); a processor with `setOutputCompensation(6)` then `setVolumeSync(true)` produces output identical to compensation 1; `setVolumeSync(false)` restores the 6× (clamped) output; `haptics-config 100 balanced balanced balanced balanced 0` parsing accepted at 7 fields (extend the existing stdin-parse branch condition and test via exported pieces, matching how existing config parsing is tested).
- [ ] **Step 2:** Run targeted tests, verify RED.
- [ ] **Step 3:** Implement: store defaults + normalizers; helper — `readHapticsConfig` reads the flag; `HapticsProcessor` gains `this.volumeSync = true`, `setVolumeSync(v)`, and applies `const comp = this.volumeSync ? 1 : this.outputCompensation;` in `process()`; the stdin `haptics-config` handler passes `parts[6] === undefined ? true : parts[6] === '1'` to `setVolumeSync` and keeps working with 6 fields; `runRenderLoopbackHaptics` calls `processor.setVolumeSync(...)` from the CLI flag at startup (volume polling keeps running either way — cheap, and toggling OFF then applies instantly).
- [ ] **Step 4:** Targeted tests GREEN; full `npx vitest run src` passes.
- [ ] **Step 5:** Commit `Add Volume Sync gating to the Linux audio helper and settings store`.

### Task 2: Engine plumbing (audio-helper.ts)

**Files:**
- Modify: `ds5-bridge/companion/src/main/audio-helper.ts` (`SystemAudioHapticsConfig` gains `volumeSync: boolean`; `normalizeSystemAudioHapticsConfig` defaults it true; `startInternal` appends `'--haptics-volume-sync', config.volumeSync ? '1' : '0'`; `setConfig`'s stdin line becomes the 7-field form)
- Modify: whatever main-process call site builds `SystemAudioHapticsConfig` from settings (grep `gainPercent: settings.audioReactiveHapticsGainPercent` / `audioReactiveHapticsBassFocus`) — pass `volumeSync: settings.audioReactiveHapticsVolumeSync`.
- Test: `ds5-bridge/companion/src/main/audio-helper.test.ts` (follow existing arg/stdin assertions; update every existing fixture config to include `volumeSync`).

**Interfaces:**
- Consumes: Task 1's settings keys and helper flag/stdin contract.
- Produces: engine always sends the flag and the 7-field config line.

- [ ] **Step 1 (TDD):** extend existing audio-helper tests: spawn args include the flag both ways; `setConfig` line ends with ` 1`/` 0`.
- [ ] **Step 2:** RED. **Step 3:** implement. **Step 4:** GREEN + full suite. **Step 5:** Commit `Plumb Audio Haptics Volume Sync through the engine`.

### Task 3: HD volume guard (helper mode + main lifecycle)

**Files:**
- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs`: export
```js
// Desktop volume controls rewrite all four channels of the bridge sink;
// when HD Volume Sync is off the haptic pair (3-4) must stay at unity so
// game HD haptics don't fade with the listening volume.
export function pinnedChannelVolumes(current) {
if (!Array.isArray(current) || current.length < 4) {
return null;
}
if (current[2] === 1 && current[3] === 1) {
return null;
}
return [current[0], current[1], 1, 1, ...current.slice(4)];
}
```
plus `runVolumeGuard()`: every 2000 ms, `requireBridgeSink()` on first tick then re-find on failure; read `channelVolumes` from the sink node's `Props` param (pw-dump object: `info.params.Props?.[0]?.channelVolumes`); if `pinnedChannelVolumes` returns non-null, `execFile('pw-cli', ['set-param', `${sink.id}`, 'Props', `{ channelVolumes: [ ${vols.join(', ')} ] }`])`. Errors → stderr, retry next tick. `stop` stdin line / SIGTERM / SIGINT exit cleanly (mirror `runMonitorAudioSessions`). Dispatch in `main()` via `--volume-guard`.
- Modify: Electron main — a small `VolumeGuard` wrapper next to the existing helper-lifecycle code (grep `mic-keepalive-only` / `helperLaunch` in `src/main` for the established spawn pattern): start when settings say `hapticsVolumeSync === false` (on boot and on settings change), stop on toggle ON and app quit. Linux only (`process.platform === 'linux'`).
- Test: helper — `pinnedChannelVolumes` (short array → null, already pinned → null, `[0.3,0.3,0.3,0.3]` → `[0.3,0.3,1,1]`, 6ch preserved tail); main wrapper — start/stop decisions per settings (mock spawn, follow existing helper lifecycle tests).

- [ ] **Step 1 (TDD)** → **Step 2 RED** → **Step 3 implement** → **Step 4 GREEN + full suite** → **Step 5:** Commit `Pin HD haptic channels at unity when Volume Sync is off`.

### Task 4: UI toggles

**Files:**
- Modify: `ds5-bridge/companion/src/renderer/App.tsx` — one "Volume Sync" toggle row on the Audio Haptics card (near the gain slider controls, pattern-match the card's existing toggle rows) bound to `audioReactiveHapticsVolumeSync`, and one on the HD Haptics card bound to `hapticsVolumeSync`. Label: `Volume Sync`; description text: `Haptics follow the listening volume`. Follow the exact markup/state/IPC-update pattern of a neighboring boolean toggle on the same card (do not invent new styles).
- Test: `ds5-bridge/companion/src/renderer/app-behavior.test.ts` if it covers card toggles (follow an existing toggle's test); otherwise rely on typecheck + suite.

- [ ] **Step 1:** implement both rows. **Step 2:** full `npx vitest run src` + `tsc` build passes (`npm run build:app` compiles). **Step 3:** Commit `Add Volume Sync toggles to the haptics cards`.

### Task 5: Verification

- [ ] Full suite `npm run test:companion`; rebuild AppImage (`npm run installer:linux`), deploy to `~/Applications/OpenDS5.AppImage`.
- [ ] Hardware (user): Audio Haptics sync ON → rumble fades with volume knob; OFF → constant. HD card sync OFF → game haptics constant while ears follow the knob (guard re-pins within ~2 s of a volume change); ON → today's coupled behavior.
116 changes: 116 additions & 0 deletions docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Pre-fold Per-App Haptics Capture — Design

Date: 2026-07-17
Branch: `feature/haptics-prefold-capture` (off `dev`)
Status: approved by user (LFE weight hardcoded at 1.0, no new UI)

## Problem

Audio Haptics on Linux always captures the default sink's monitor
(`audio-helper-linux.mjs`, render-loopback path). That signal is
post-fold-down: when a game renders 5.1/7.1 and the user listens on stereo
headphones, PipeWire's mixdown attenuates or drops the LFE channel — the
channel sound designers use for exactly the content haptics should convey.
It also mixes every app's audio together (Discord, music) into the haptics.

The engine already sends `--haptics-app-process-id`,
`--haptics-app-process-path`, and `--haptics-app-executable` when the user
picks an app in the Audio Haptics source picker (`audio-helper.ts`
startInternal), but the Linux helper ignores them.

## Goals

1. When an app source is selected, capture that app's own
`Stream/Output/Audio` node pre-fold, preserving its native channel
layout (stereo, 5.1, 7.1, …).
2. Sum channels deliberately: `left = FL + 0.5×FC + 1.0×LFE`,
`right = FR + 0.5×FC + 1.0×LFE`. Rears/sides are ignored (they rarely
carry bass; excluding them also avoids re-inviting the fold-down
feedback loop fixed in 02792b1). Stereo streams reduce exactly to
today's FL+FR behavior.
3. Per-app isolation as a side benefit even for stereo games.
4. No app selected → current sink-monitor path, byte-for-byte unchanged.

Non-goals: LFE emphasis UI (deferred — hardcode weight 1.0; revisit after
hardware tuning shows a boost is distinguishable), Windows changes,
forcing games to render surround (user does that in game settings).

## Design

### Helper: app-source capture (`audio-helper-linux.mjs`)

- `runRenderLoopbackHaptics` reads the three `--haptics-app-*` flags.
- **Node resolution:** poll `pw-dump` (2 s cadence, same as the existing
session monitor) for a `Stream/Output/Audio` node whose
`application.process.id` matches; fall back to matching
`application.process.binary` against the executable name. Prefer a
`running` node when several match.
- **Format discovery:** read the node's negotiated channel count and
position map from its `Format` param in the pw-dump output. If the
format cannot be determined, assume stereo FL,FR.
- **Capture:** spawn `pw-record --target <node id/name>` with the stream's
native `--channels`/`--channel-map` (no `stream.capture.sink`, since the
target is an output stream, not a sink monitor).
- **Hot-attach lifecycle:** helper starts in "waiting" state and emits
`status: recording-started` on first attach (the app already tolerates
the 8 s startup window). If the record process exits or the node
vanishes (game recreates its stream on level load), reap the process,
return to polling, re-attach when the node reappears. Playback side
(`pw-play` to the bridge sink) persists across re-attaches.
- No app flags present → existing sink-monitor code path runs unchanged.

### Processor: layout-aware summing (`HapticsProcessor`)

- `process()` gains knowledge of the input layout: frame stride and the
indices of FL, FR, FC, LFE derived from the negotiated channel map
(set when a capture attaches; re-set on re-attach).
- Per frame: `left = FL + 0.5×FC + LFE`, `right = FR + 0.5×FC + LFE`
(missing channels contribute 0). Downstream pipeline (low-pass,
envelope, gate, gain, tanh) unchanged. Output stays 4ch with haptics on
channels 3–4.
- Sink-monitor path keeps the current fixed 4ch FL,FR,RL,RR input with
fronts only — identical numerics to today.

### Engine / UI

No changes. The app picker, flag plumbing, and Audio Haptics card already
exist; this makes the Linux helper honor what they send.

## Error handling

- App selected but never appears: helper stays in waiting state, keeps
polling; no audio flows (correct — the chosen source is silent/absent).
Existing `status:` lines on stderr report state transitions for logs.
- `pw-dump` failure during polling: log to stderr, retry next tick.
- Malformed/unsupported channel map: fall back to stereo interpretation
of the first two channels.

## Testing

- Unit (helper is plain .mjs; tests colocated with the existing companion
test suite pattern):
- node matching: pid hit, binary fallback, multiple matches, no match;
- channel-map parsing → FL/FR/FC/LFE indices for stereo, 5.1, 7.1, and
an unknown map (stereo fallback);
- summing math for stereo (identical to current fronts-only) and 5.1
(FC/LFE weights applied);
- hot-attach state machine with mocked pw-dump/process events.
- Hardware verification:
- surround-rendering game forced to 5.1: LFE-driven punch present while
listening on stereo headphones; per-app isolation (Discord/music do
not drive haptics);
- regression: sink-monitor path with 3.5 mm headset shows no feedback
buzz (02792b1 fix intact);
- stream-restart mid-game (level load / game restart) re-attaches.

## Caveats

- Games must actually render surround; PipeWire may offer stereo when the
default output is stereo, so users force 5.1/7.1 in game settings.
- LFE weight (1.0) and the FC weight (0.5) get sanity-tuned during
hardware verification; a Normal/Boosted toggle is a small follow-up if
tuning shows a boost is worth exposing.
- Tuning checkpoint: rears/sides are excluded from the summing by design
(bass management routes low end to LFE/fronts). During hardware tuning,
if a surround game demonstrably puts felt-worthy bass in RL/RR, add
them at a small weight (~0.3×) — a one-line change in the summing.
69 changes: 69 additions & 0 deletions docs/superpowers/specs/2026-07-17-volume-sync-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Volume Sync — Design

Date: 2026-07-17
Branch: `feature/haptics-prefold-capture` (continues the pre-fold capture work)
Status: approved by user (defaults: both toggles ON)

## Problem

Haptics on Linux are delivered as audio through the bridge sink, so the
sink's volume scales them: game HD haptics (channels 3–4 of the game's own
stream) and the Audio Haptics helper output both fade with the listening
volume. Tonight's `volumeCompensation` work decoupled the Audio Haptics
path unconditionally — but the user sometimes *wants* volume-coupled
haptics. Make coupling a per-feature choice.

## Feature: "Volume Sync" toggles

Two independent toggles, both **default ON** (ON = today's historical
behavior: haptics follow the listening volume).

1. **Audio Haptics card → Volume Sync**
- ON: helper output follows sink volume (output compensation disabled,
factor fixed at 1).
- OFF: helper compensates by the inverse sink volume (the mechanism
shipped in f41b141), making synthesized haptics volume-independent.
- Setting: `audioReactiveHapticsVolumeSync: boolean` (default true).
- Plumbing: new helper CLI flag `--haptics-volume-sync 1|0` and a 7th
field on the live `haptics-config` stdin line, so toggling applies
without restarting capture.

2. **HD Haptics card → Volume Sync**
- ON: nothing runs; sink volume scales the game's haptic channels as
today.
- OFF: a volume guard pins the bridge sink's haptic channels (3–4) at
unity while channels 1–2 keep tracking the user's volume. Implemented
as a new helper mode `--volume-guard`: every 2 s read the bridge
sink's `Props.channelVolumes`; if channels 3–4 differ from 1.0,
rewrite them to 1.0 via `pw-cli set-param <sink id> Props`, leaving
channels 1–2 untouched. Desktop volume changes rewrite all four
channels, so drift is corrected within one poll (brief dip accepted).
- Setting: `hapticsVolumeSync: boolean` (default true).
- Lifecycle: the app's main process spawns the guard while
`hapticsVolumeSync === false` (regardless of hapticsEnabled — classic
rumble-over-audio also benefits), kills it when toggled back ON or on
quit. Linux only; Windows is untouched.

## UI

One toggle row on each card, labeled "Volume Sync", subtitle
"Haptics follow the listening volume". Follows the cards' existing toggle
row pattern. No other UI.

## Error handling

- Guard: pw-cli/pw-dump failures log to stderr and retry next tick; sink
absent → keep polling (controller may reconnect).
- Helper: unknown/absent 7th config field defaults to sync ON (safe,
historical behavior).

## Testing

- Settings store: both booleans normalize (garbage → default true).
- Helper: volume-sync flag parsing; compensation factor forced to 1 when
sync ON; live toggle via 7-field haptics-config line; guard's
channelVolumes patch computation as an exported pure function
(`pinnedChannelVolumes(current) -> array | null` — null when already
pinned, no-op).
- Engine: config type/args/stdin line carry the new field.
- Hardware: toggle each and confirm feel changes with the volume knob.
Loading
Loading