diff --git a/docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md b/docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md new file mode 100644 index 0000000..fd8e78f --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-haptics-prefold-capture.md @@ -0,0 +1,664 @@ +# Pre-fold Per-App Haptics Capture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Linux audio helper honor the app-source flags the engine already sends, capturing the selected app's PipeWire stream pre-fold-down and summing FL/FR/FC/LFE deliberately so surround games drive haptics with their discrete LFE channel. + +**Architecture:** All changes live in the single-file Linux helper `ds5-bridge/companion/native/audio-helper-linux.mjs` (it is packaged as one file — do not split it). Pure logic (channel-layout parsing, node matching, summing) is exported for vitest; process orchestration (hot-attach loop) stays in the same file behind an import guard. The engine (`audio-helper.ts`) and UI need no changes. + +**Tech Stack:** Node.js ESM (.mjs), PipeWire CLI tools (`pw-dump`, `pw-record`, `pw-play`), vitest (tests live under `src/` because `test:companion` is `vitest run src`). + +**Spec:** `docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md` + +## Global Constraints + +- Sink-monitor path (no app selected) must stay numerically identical to today (feedback fix 02792b1 intact). +- Summing weights: `left = FL + 0.5×FC + 1.0×LFE`, `right = FR + 0.5×FC + 1.0×LFE`; rears/sides excluded. +- Unknown/missing channel layout falls back to stereo interpretation of the first two channels. +- Hot-attach poll cadence: 2000 ms (matches the existing session monitor). +- Helper output is always 4ch f32 with haptics on channels 3–4. +- Never push to any remote; commit locally only. No Co-Authored-By trailers. +- Run tests from `ds5-bridge/companion/`. + +--- + +### Task 1: Make the helper importable and export its internals + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (bottom of file, `main()` invocation; add `export` keywords) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (create) + +**Interfaces:** +- Produces: named exports from the .mjs — `HapticsProcessor`, plus (added in later tasks) `nodeChannelLayout`, `channelIndices`, `matchAppStreamNode`. Importing the module must have no side effects (no `pw-dump`, no exit). + +- [ ] **Step 1: Write the failing test** + +Create `ds5-bridge/companion/src/main/audio-helper-linux.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { HapticsProcessor } from '../../native/audio-helper-linux.mjs'; + +describe('audio-helper-linux exports', () => { + it('imports without running main and exposes HapticsProcessor', () => { + const processor = new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); + expect(typeof processor.process).toBe('function'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `HapticsProcessor` is not exported (SyntaxError: does not provide an export), or the import hangs/exits because `main()` runs. Either failure mode confirms the gap. + +- [ ] **Step 3: Export internals and guard main()** + +In `native/audio-helper-linux.mjs`: + +Add to the imports at the top: + +```js +import { pathToFileURL } from 'node:url'; +``` + +Change `class HapticsProcessor {` to `export class HapticsProcessor {`. + +Replace the last line of the file: + +```js +main().catch((error) => fail(error.message)); +``` + +with: + +```js +const isCliEntry = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isCliEntry) { + main().catch((error) => fail(error.message)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: PASS (1 test) + +- [ ] **Step 5: Sanity-check the CLI still runs** + +Run: `node ds5-bridge/companion/native/audio-helper-linux.mjs --list-output-sinks` +Expected: a JSON array on stdout (device list; contents depend on machine). This proves the import guard still executes `main()` when invoked as a CLI. + +- [ ] **Step 6: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Make Linux audio helper importable for unit tests" +``` + +--- + +### Task 2: Channel-layout parsing and index mapping + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (add two exported functions near `nodeProps`) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (append) + +**Interfaces:** +- Consumes: pw-dump node objects (`object.info.params.Format[0]` carries `{ channels, position }` for a negotiated audio node). +- Produces: + - `nodeChannelLayout(node) -> { channels: number, position: string[] }` — stereo `{channels: 2, position: ['FL','FR']}` fallback when the format is absent/invalid. + - `channelIndices(position: string[]) -> { stride: number, fl: number, fr: number, fc: number, lfe: number }` — indices are `-1` when the channel is absent; `stride` equals `position.length`; if FL/FR are missing, `fl = 0` and `fr = Math.min(1, stride - 1)` (mono maps both sides to channel 0). + +- [ ] **Step 1: Write the failing tests** + +Append to `src/main/audio-helper-linux.test.ts`: + +```ts +import { channelIndices, nodeChannelLayout } from '../../native/audio-helper-linux.mjs'; + +describe('nodeChannelLayout', () => { + it('reads channels and position from the Format param', () => { + const node = { info: { params: { Format: [{ mediaType: 'audio', channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }); + }); + + it('falls back to stereo when the format is missing', () => { + expect(nodeChannelLayout({ info: { params: {} } })).toEqual({ channels: 2, position: ['FL', 'FR'] }); + expect(nodeChannelLayout(undefined)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); + + it('falls back to stereo when position length disagrees with channels', () => { + const node = { info: { params: { Format: [{ channels: 6, position: ['FL', 'FR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); +}); + +describe('channelIndices', () => { + it('maps stereo', () => { + expect(channelIndices(['FL', 'FR'])).toEqual({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps 5.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'])).toEqual({ stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('maps 7.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR', 'SL', 'SR'])).toEqual({ stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('treats an unknown map as first-two-channels stereo', () => { + expect(channelIndices(['AUX0', 'AUX1', 'AUX2'])).toEqual({ stride: 3, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps mono to both sides', () => { + expect(channelIndices(['MONO'])).toEqual({ stride: 1, fl: 0, fr: 0, fc: -1, lfe: -1 }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `nodeChannelLayout`/`channelIndices` not exported. + +- [ ] **Step 3: Implement** + +Add after `nodeProps` in `native/audio-helper-linux.mjs`: + +```js +const STEREO_LAYOUT = { channels: 2, position: ['FL', 'FR'] }; + +// Negotiated audio format of a pw-dump node; stereo fallback when absent +// or inconsistent (spec: unknown layouts read as first-two-channel stereo). +export function nodeChannelLayout(node) { + const format = node?.info?.params?.Format?.[0]; + const channels = Number(format?.channels ?? 0); + const position = Array.isArray(format?.position) ? format.position : null; + if (!position || channels < 1 || position.length !== channels) { + return { ...STEREO_LAYOUT, position: [...STEREO_LAYOUT.position] }; + } + return { channels, position: [...position] }; +} + +export function channelIndices(position) { + const stride = position.length; + let fl = position.indexOf('FL'); + let fr = position.indexOf('FR'); + if (fl < 0 || fr < 0) { + fl = 0; + fr = Math.min(1, stride - 1); + } + return { stride, fl, fr, fc: position.indexOf('FC'), lfe: position.indexOf('LFE') }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: PASS (9 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Parse PipeWire stream channel layouts in the Linux audio helper" +``` + +--- + +### Task 3: Layout-aware summing in HapticsProcessor + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (`HapticsProcessor`) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (append) + +**Interfaces:** +- Consumes: `channelIndices` result shape from Task 2. +- Produces: `HapticsProcessor.setInputLayout({ stride, fl, fr, fc, lfe })`. Default layout (set in the constructor) is `{ stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }` — exactly today's fixed 4ch fronts-only behavior, so the sink-monitor path needs no call-site change. `process(input)` consumes `stride` channels per frame and still emits 4ch output with haptics on channels 3–4. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/main/audio-helper-linux.test.ts`: + +```ts +function makeProcessor() { + return new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); +} + +describe('HapticsProcessor layouts', () => { + it('default 4ch layout matches explicit FL,FR,RL,RR layout sample-for-sample', () => { + const frames = 64; + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + input[f * 4] = Math.sin(f / 3) * 0.5; // FL + input[f * 4 + 1] = Math.cos(f / 3) * 0.5; // FR + input[f * 4 + 2] = 0.9; // RL: must be ignored + input[f * 4 + 3] = -0.9; // RR: must be ignored + } + const byDefault = makeProcessor().process(input); + const explicit = makeProcessor(); + explicit.setInputLayout({ stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }); + expect(Array.from(explicit.process(input))).toEqual(Array.from(byDefault)); + }); + + it('5.1 layout blends FC at 0.5x and LFE at 1x into both sides', () => { + const frames = 64; + const layout = { stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }; + // Only FC and LFE carry signal: expect output driven purely by the blend. + const surround = new Float32Array(frames * 6); + for (let f = 0; f < frames; f += 1) { + surround[f * 6 + 2] = Math.sin(f / 4) * 0.4; // FC + surround[f * 6 + 3] = Math.sin(f / 4) * 0.4; // LFE + } + // Equivalent stereo signal: FL = FR = 0.5*FC + 1.0*LFE. + const folded = new Float32Array(frames * 2); + for (let f = 0; f < frames; f += 1) { + const blend = 0.5 * surround[f * 6 + 2] + surround[f * 6 + 3]; + folded[f * 2] = blend; + folded[f * 2 + 1] = blend; + } + const surroundProcessor = makeProcessor(); + surroundProcessor.setInputLayout(layout); + const stereoProcessor = makeProcessor(); + stereoProcessor.setInputLayout({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + const a = surroundProcessor.process(surround); + const b = stereoProcessor.process(folded); + expect(a.length).toBe(frames * 4); + for (let i = 0; i < a.length; i += 1) { + expect(a[i]).toBeCloseTo(b[i], 6); + } + }); + + it('rears and sides in a 7.1 stream never reach the output', () => { + const frames = 32; + const quiet = new Float32Array(frames * 8); // silence everywhere + const noisyRears = new Float32Array(frames * 8); + for (let f = 0; f < frames; f += 1) { + for (const ch of [4, 5, 6, 7]) { + noisyRears[f * 8 + ch] = 0.8; + } + } + const layout = { stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }; + const p1 = makeProcessor(); p1.setInputLayout(layout); + const p2 = makeProcessor(); p2.setInputLayout(layout); + expect(Array.from(p1.process(noisyRears))).toEqual(Array.from(p2.process(quiet))); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `setInputLayout` is not a function. + +- [ ] **Step 3: Implement** + +In `HapticsProcessor`: + +Add to the constructor, before `this.setConfig(config)`: + +```js + this.layout = { stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }; +``` + +Add the method after `setConfig`: + +```js + setInputLayout(layout) { + this.layout = layout; + } +``` + +Replace the `process` method (keep the existing gate/drive comment block): + +```js + // Layout-aware input (see setInputLayout) -> 4ch f32 out + // (speaker channels silent, haptics on 3-4). Blend per spec: + // left/right = FL/FR + 0.5*FC + 1.0*LFE; rears and sides ignored. + process(input) { + const { stride, fl, fr, fc, lfe } = this.layout; + const frames = Math.floor(input.length / stride); + const output = new Float32Array(frames * 4); + for (let frame = 0; frame < frames; frame += 1) { + const base = frame * stride; + const center = fc >= 0 ? input[base + fc] * 0.5 : 0; + const bass = lfe >= 0 ? input[base + lfe] : 0; + const left = biquadStep(this.lowpassLeft, input[base + fl] + center + bass); + const right = biquadStep(this.lowpassRight, input[base + fr] + center + bass); + const peak = Math.max(Math.abs(left), Math.abs(right)); + const coeff = peak > this.envelope ? this.attackCoeff : this.releaseCoeff; + this.envelope = coeff * this.envelope + (1 - coeff) * peak; + // Music bass peaks well below full scale and the daemon quantizes + // haptics to 8 bits, so drive hard into the tanh limiter; gate the + // noise floor so silence does not buzz the actuators. + const gate = this.envelope < 0.003 ? 0 : 1; + const drive = this.gain * this.responseGain * 4 * gate; + output[frame * 4 + 2] = Math.tanh(left * drive); + output[frame * 4 + 3] = Math.tanh(right * drive); + } + return output; + } +``` + +- [ ] **Step 4: Run the full companion suite** + +Run: `cd ds5-bridge/companion && npx vitest run src` +Expected: all tests PASS (the new layout tests plus the existing suite — proves the default layout keeps sink-monitor numerics identical). + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Blend FC and LFE into haptics with layout-aware summing" +``` + +--- + +### Task 4: App stream node matching + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (add exported function near `listOutputStreamSessions`) +- Test: `ds5-bridge/companion/src/main/audio-helper-linux.test.ts` (append) + +**Interfaces:** +- Consumes: pw-dump object array; app source descriptor `{ processId, executableName, processPath }` (any field may be null/0 — mirrors the optional CLI flags in `audio-helper.ts:216-226`). +- Produces: `matchAppStreamNode(objects, appSource) -> node | null`. Match priority: `application.process.id` equals `processId`; else `application.process.binary` equals `executableName` or the basename of `processPath`. Among several matches, prefer `info.state === 'running'`; otherwise first match. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/main/audio-helper-linux.test.ts`: + +```ts +import { matchAppStreamNode } from '../../native/audio-helper-linux.mjs'; + +function streamNode(id: number, props: Record, state = 'running') { + return { + id, + type: 'PipeWire:Interface:Node', + info: { state, props: { 'media.class': 'Stream/Output/Audio', ...props } } + }; +} + +describe('matchAppStreamNode', () => { + const game = streamNode(40, { 'application.process.id': 1234, 'application.process.binary': 'game-bin' }); + const music = streamNode(41, { 'application.process.id': 999, 'application.process.binary': 'spotify' }); + const sinkNode = { id: 50, type: 'PipeWire:Interface:Node', info: { state: 'running', props: { 'media.class': 'Audio/Sink' } } }; + + it('matches by process id', () => { + expect(matchAppStreamNode([music, game, sinkNode], { processId: 1234, executableName: null, processPath: null })?.id).toBe(40); + }); + + it('falls back to the executable name', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(40); + }); + + it('falls back to the process path basename', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: null, processPath: '/opt/game/game-bin' })?.id).toBe(40); + }); + + it('prefers a running node over an idle one', () => { + const idle = streamNode(42, { 'application.process.binary': 'game-bin' }, 'idle'); + const running = streamNode(43, { 'application.process.binary': 'game-bin' }, 'running'); + expect(matchAppStreamNode([idle, running], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(43); + }); + + it('returns null when nothing matches or only non-streams exist', () => { + expect(matchAppStreamNode([music, sinkNode], { processId: 1234, executableName: 'game-bin', processPath: null })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: FAIL — `matchAppStreamNode` not exported. + +- [ ] **Step 3: Implement** + +Add before `listOutputStreamSessions` in `native/audio-helper-linux.mjs`: + +```js +export function matchAppStreamNode(objects, { processId, executableName, processPath }) { + const pathBasename = processPath ? processPath.split('/').pop() : null; + const matches = objects.filter((object) => { + if (object.type !== 'PipeWire:Interface:Node') { + return false; + } + const props = nodeProps(object); + if (props['media.class'] !== 'Stream/Output/Audio') { + return false; + } + if (processId > 0 && Number(props['application.process.id'] ?? 0) === processId) { + return true; + } + const binary = props['application.process.binary'] ?? null; + return Boolean(binary && (binary === executableName || binary === pathBasename)); + }); + return matches.find((object) => object.info?.state === 'running') ?? matches[0] ?? null; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd ds5-bridge/companion && npx vitest run src/main/audio-helper-linux.test.ts` +Expected: PASS (17 tests) + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs ds5-bridge/companion/src/main/audio-helper-linux.test.ts +git commit -m "Match the selected app's PipeWire output stream in the Linux helper" +``` + +--- + +### Task 5: Hot-attach app capture loop + +**Files:** +- Modify: `ds5-bridge/companion/native/audio-helper-linux.mjs` (`runRenderLoopbackHaptics`) + +**Interfaces:** +- Consumes: `matchAppStreamNode`, `nodeChannelLayout`, `channelIndices`, `HapticsProcessor.setInputLayout` from Tasks 2–4; CLI flags `--haptics-app-process-id`, `--haptics-app-process-path`, `--haptics-app-executable` (sent by `audio-helper.ts` startInternal). +- Produces: when any app flag is present, the helper hot-attaches to the app's stream instead of the sink monitor. Stderr status lines: `status: waiting-for-app` on entering polling, `status: recording-started` on first attach (the app waits up to 8 s for this — emitting it on the *record spawn*, as today, is preserved). Playback pipe to the bridge sink persists across re-attaches; `stop`/SIGTERM tear everything down. + +- [ ] **Step 1: Restructure runRenderLoopbackHaptics** + +Replace the body of `runRenderLoopbackHaptics` from the `const captureDevice = ...` line through the `record.on('exit', ...)` / `play.on('exit', ...)` lines (keep the sink lookup, processor construction, and the trailing control/SIGTERM block) with: + +```js + const appSource = { + processId: Number(argValue(args, '--haptics-app-process-id') ?? 0), + processPath: argValue(args, '--haptics-app-process-path'), + executableName: argValue(args, '--haptics-app-executable') + }; + const hasAppSource = appSource.processId > 0 || Boolean(appSource.processPath) + || Boolean(appSource.executableName); + + // Optional capture pin: monitor a specific output device instead of + // following the system default sink. + const captureDevice = argValue(args, '--haptics-output-device'); + + const play = spawn('pw-play', [ + '--raw', + '--target', target, + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ], { stdio: ['pipe', 'ignore', 'pipe'] }); + play.stderr.on('data', (chunk) => process.stderr.write(chunk)); + + let record = null; + let attachTimer = null; + let stopping = false; + let announcedRecording = false; + + const shutdown = (code, detail) => { + stopping = true; + if (detail) { + process.stderr.write(`${detail}\n`); + } + clearTimeout(attachTimer); + record?.kill(); + play.kill(); + process.exit(code); + }; + play.on('exit', (code) => shutdown(code ?? 1, 'playback stream ended')); + + const pipeRecordToProcessor = (proc, stride) => { + let carry = Buffer.alloc(0); + proc.stdout.on('data', (chunk) => { + let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; + const frameBytes = stride * 4; // stride channels x f32 + const usable = data.length - (data.length % frameBytes); + carry = data.subarray(usable); + if (usable === 0) { + return; + } + const input = new Float32Array(data.buffer, data.byteOffset, usable / 4); + const output = processor.process(input); + if (play.stdin.writable) { + play.stdin.write(Buffer.from(output.buffer, 0, output.byteLength)); + } + }); + proc.stderr.on('data', (chunk) => process.stderr.write(chunk)); + proc.on('spawn', () => { + if (!announcedRecording) { + announcedRecording = true; + process.stderr.write('status: recording-started\n'); + } + }); + }; + + const startSinkMonitorCapture = () => { + record = spawn('pw-record', [ + '--raw', + '-P', '{ stream.capture.sink = true }', + ...(captureDevice ? ['--target', captureDevice] : []), + // Capture four discrete channels and let the processor use the fronts + // only. When the headset is plugged in, the default sink can be the + // bridge's own 4-channel device; any narrower capture goes through + // PipeWire's channel mixer, which folds the rear haptics channels we + // play back into the fronts (constant buzz / self-oscillation). With a + // matching 4ch format no mixing happens; stereo sinks upmix with + // silent rears, leaving the fronts intact either way. + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, 4); + record.on('exit', (code) => { + if (!stopping) { + shutdown(code ?? 1, 'capture stream ended'); + } + }); + }; + + // Pre-fold app capture: target the app's own output stream so discrete + // surround channels (especially LFE) survive even when the listening + // device is stereo. The node comes and goes with the game, so poll and + // re-attach instead of failing hard. + const APP_POLL_MS = 2000; + const pollForAppNode = async () => { + if (stopping) { + return; + } + let node = null; + try { + node = matchAppStreamNode(await pwDump(), appSource); + } catch (error) { + process.stderr.write(`app node poll failed: ${error.message}\n`); + } + if (!node) { + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + return; + } + const layout = nodeChannelLayout(node); + processor.setInputLayout(channelIndices(layout.position)); + record = spawn('pw-record', [ + '--raw', + '--target', `${node.id}`, + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, + '--channels', `${layout.channels}`, + '--channel-map', layout.position.join(','), + '--latency', '256', + '-' + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, layout.channels); + record.on('exit', () => { + // Game restarted its stream (level load, restart): go back to polling. + record = null; + if (!stopping) { + process.stderr.write('status: waiting-for-app\n'); + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + } + }); + }; + + if (hasAppSource) { + process.stderr.write('status: waiting-for-app\n'); + await pollForAppNode(); + } else { + startSinkMonitorCapture(); + } +``` + +The existing `control`/`SIGTERM`/`SIGINT` block at the end of the function stays as-is (it already calls `shutdown`), and the old top-level `record`/`carry`/`shutdown` definitions it replaced must be deleted. + +- [ ] **Step 2: Run the full companion suite** + +Run: `cd ds5-bridge/companion && npx vitest run src` +Expected: all tests PASS (no regressions; this task is exercised by hand next). + +- [ ] **Step 3: Manual smoke — sink-monitor path unchanged** + +With the controller connected and no app flags: + +Run: `DS5_BRIDGE_ALLOW_PARALLEL_AUTOMATION_INSTANCE=1 node ds5-bridge/companion/native/audio-helper-linux.mjs --source render-loopback --haptics-only --haptics-gain 100 --haptics-bass-focus balanced --haptics-response balanced --haptics-attack balanced --haptics-release balanced` +Expected: `status: recording-started` on stderr; playing music makes the controller rumble; Ctrl-C exits cleanly. + +- [ ] **Step 4: Manual smoke — app capture and hot-attach** + +Start the helper with an app flag before the app plays audio: + +Run: `DS5_BRIDGE_ALLOW_PARALLEL_AUTOMATION_INSTANCE=1 node ds5-bridge/companion/native/audio-helper-linux.mjs --source render-loopback --haptics-only --haptics-gain 100 --haptics-bass-focus balanced --haptics-response balanced --haptics-attack balanced --haptics-release balanced --haptics-app-executable ` +Expected: `status: waiting-for-app`, then `status: recording-started` once the app starts playing; only that app's audio drives haptics (music from another app does not). Kill and restart the app's audio: helper prints `status: waiting-for-app` and re-attaches. + +- [ ] **Step 5: Commit** + +```bash +git add ds5-bridge/companion/native/audio-helper-linux.mjs +git commit -m "Hot-attach pre-fold app stream capture for audio haptics" +``` + +--- + +### Task 6: Hardware verification (user-assisted) + +**Files:** none (verification only) + +- [ ] **Step 1: Full test suite** + +Run: `cd ds5-bridge/companion && npm run test:companion` +Expected: all PASS. + +- [ ] **Step 2: Surround LFE verification** + +In the app, select a surround-capable game (forced to 5.1/7.1 in its own audio settings) as the Audio Haptics source while listening on stereo headphones. Confirm with `pw-dump` that the game's stream negotiated >2 channels, and that LFE-heavy moments (explosions) produce a stronger punch than the sink-monitor source does. Tuning checkpoints from the spec: sanity-check the 0.5× FC weight, and check whether any felt-worthy bass lives in RL/RR (if so, consider adding rears at ~0.3× as a follow-up). + +- [ ] **Step 3: Regression — 3.5 mm feedback fix** + +Switch the source back to system audio (sink monitor) with the 3.5 mm headset plugged in. Expected: no constant buzz (02792b1 fix intact). + +- [ ] **Step 4: Isolation check** + +With the game selected as source, play music/Discord audio from another app. Expected: haptics respond only to the game. diff --git a/docs/superpowers/plans/2026-07-17-volume-sync.md b/docs/superpowers/plans/2026-07-17-volume-sync.md new file mode 100644 index 0000000..c4d875a --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-volume-sync.md @@ -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 `; 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. diff --git a/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md b/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md new file mode 100644 index 0000000..5bc57f2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-haptics-prefold-capture-design.md @@ -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 ` 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. diff --git a/docs/superpowers/specs/2026-07-17-volume-sync-design.md b/docs/superpowers/specs/2026-07-17-volume-sync-design.md new file mode 100644 index 0000000..ef30fbf --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-volume-sync-design.md @@ -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 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. diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index a925822..ce395ef 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -8,6 +8,7 @@ import { spawn, execFile } from 'node:child_process'; import { createInterface } from 'node:readline'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; const SAMPLE_RATE = 48000; const BRIDGE_NODE_PATTERN = /dualsense|vds/i; @@ -47,6 +48,31 @@ function nodeProps(object) { return object?.info?.props ?? {}; } +const STEREO_LAYOUT = { channels: 2, position: ['FL', 'FR'] }; + +// Negotiated audio format of a pw-dump node; stereo fallback when absent +// or inconsistent (spec: unknown layouts read as first-two-channel stereo). +export function nodeChannelLayout(node) { + const format = node?.info?.params?.Format?.[0]; + const channels = Number(format?.channels ?? 0); + const position = Array.isArray(format?.position) ? format.position : null; + if (!position || channels < 1 || position.length !== channels) { + return { ...STEREO_LAYOUT, position: [...STEREO_LAYOUT.position] }; + } + return { channels, position: [...position] }; +} + +export function channelIndices(position) { + const stride = position.length; + let fl = position.indexOf('FL'); + let fr = position.indexOf('FR'); + if (fl < 0 || fr < 0) { + fl = 0; + fr = Math.min(1, stride - 1); + } + return { stride, fl, fr, fc: position.indexOf('FC'), lfe: position.indexOf('LFE') }; +} + function isAudioSink(object) { return object.type === 'PipeWire:Interface:Node' && nodeProps(object)['media.class'] === 'Audio/Sink'; @@ -104,12 +130,40 @@ function envelopeCoefficient(milliseconds) { return Math.exp(-1 / (SAMPLE_RATE * (milliseconds / 1000))); } -class HapticsProcessor { +// The guard pins the haptic channels while the ear channels track the +// knob, so compensation must be computed from the sink's real per-channel +// volumes: desired scaling (ears when syncing, unity when not) over what +// the sink actually applies to the haptic pair. Correct in all four +// toggle states, guard running or not. +export function channelCompensation(earsLinear, hapticLinear, volumeSync) { + const desired = volumeSync + ? (Number.isFinite(earsLinear) && earsLinear > 0 ? earsLinear : 1) + : 1; + const actual = Number.isFinite(hapticLinear) && hapticLinear > 0 ? hapticLinear : 1; + return Math.min(32, Math.max(1 / 32, desired / actual)); +} + +export class HapticsProcessor { constructor(config) { this.envelope = 0; + this.layout = { stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }; + this.outputCompensation = 1; + // Volume Sync (default ON): haptics follow the listening volume. This is + // an input to the per-channel compensation math (see channelCompensation), + // which the poll reads via processor.volumeSync; process() always applies + // the resulting outputCompensation. + this.volumeSync = true; this.setConfig(config); } + setOutputCompensation(compensation) { + this.outputCompensation = compensation; + } + + setVolumeSync(enabled) { + this.volumeSync = enabled; + } + setConfig({ gainPercent, bassFocus, response, attack, release }) { this.gain = Math.max(0, Math.min(400, gainPercent)) / 100; this.responseGain = RESPONSE_GAIN[response] ?? 1.0; @@ -120,13 +174,23 @@ class HapticsProcessor { this.lowpassRight = biquadLowpass(cutoff); } - // stereo f32 in -> 4ch f32 out (speaker channels silent, haptics on 3-4) + setInputLayout(layout) { + this.layout = layout; + } + + // Layout-aware input (see setInputLayout) -> 4ch f32 out + // (speaker channels silent, haptics on 3-4). Blend per spec: + // left/right = FL/FR + 0.5*FC + 1.0*LFE; rears and sides ignored. process(input) { - const frames = input.length / 2; + const { stride, fl, fr, fc, lfe } = this.layout; + const frames = Math.floor(input.length / stride); const output = new Float32Array(frames * 4); for (let frame = 0; frame < frames; frame += 1) { - const left = biquadStep(this.lowpassLeft, input[frame * 2]); - const right = biquadStep(this.lowpassRight, input[frame * 2 + 1]); + const base = frame * stride; + const center = fc >= 0 ? input[base + fc] * 0.5 : 0; + const bass = lfe >= 0 ? input[base + lfe] : 0; + const left = biquadStep(this.lowpassLeft, input[base + fl] + center + bass); + const right = biquadStep(this.lowpassRight, input[base + fr] + center + bass); const peak = Math.max(Math.abs(left), Math.abs(right)); const coeff = peak > this.envelope ? this.attackCoeff : this.releaseCoeff; this.envelope = coeff * this.envelope + (1 - coeff) * peak; @@ -135,79 +199,198 @@ class HapticsProcessor { // noise floor so silence does not buzz the actuators. const gate = this.envelope < 0.003 ? 0 : 1; const drive = this.gain * this.responseGain * 4 * gate; - output[frame * 4 + 2] = Math.tanh(left * drive); - output[frame * 4 + 3] = Math.tanh(right * drive); + // Compensation is always applied: the poll computes ears/haptic from + // the sink's real per-channel volumes (unity when no guard runs and + // sync ON, the ear volume when the guard pins the haptic pair). + const comp = this.outputCompensation; + output[frame * 4 + 2] = Math.max(-1, Math.min(1, Math.tanh(left * drive) * comp)); + output[frame * 4 + 3] = Math.max(-1, Math.min(1, Math.tanh(right * drive) * comp)); } return output; } } -function readHapticsConfig(args) { +export function readHapticsConfig(args) { return { gainPercent: Number(argValue(args, '--haptics-gain') ?? 100), bassFocus: argValue(args, '--haptics-bass-focus') ?? 'balanced', response: argValue(args, '--haptics-response') ?? 'balanced', attack: argValue(args, '--haptics-attack') ?? 'balanced', - release: argValue(args, '--haptics-release') ?? 'balanced' + release: argValue(args, '--haptics-release') ?? 'balanced', + // Volume Sync defaults ON: only an explicit "0" disables it. + volumeSync: argValue(args, '--haptics-volume-sync') !== '0' }; } async function runRenderLoopbackHaptics(args) { const sink = await requireBridgeSink(); const target = nodeProps(sink)['node.name']; - const processor = new HapticsProcessor(readHapticsConfig(args)); + const config = readHapticsConfig(args); + const processor = new HapticsProcessor(config); + processor.setVolumeSync(config.volumeSync); + + const appSource = { + processId: Number(argValue(args, '--haptics-app-process-id') ?? 0), + processPath: argValue(args, '--haptics-app-process-path'), + executableName: argValue(args, '--haptics-app-executable') + }; + const hasAppSource = appSource.processId > 0 || Boolean(appSource.processPath) + || Boolean(appSource.executableName); - const record = spawn('pw-record', [ - '--raw', - '-P', '{ stream.capture.sink = true }', - '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '2', - '--latency', '256', - '-' - ], { stdio: ['ignore', 'pipe', 'pipe'] }); - const play = spawn('pw-play', [ - '--raw', - '--target', target, - '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', - '--channel-map', 'FL,FR,RL,RR', - '--latency', '256', - '-' - ], { stdio: ['pipe', 'ignore', 'pipe'] }); + // Optional capture pin: monitor a specific output device instead of + // following the system default sink. + const captureDevice = argValue(args, '--haptics-output-device'); - // Report readiness on stream startup: a suspended default sink delivers no - // monitor frames until something plays, and the app only waits 8 s. - record.on('spawn', () => { - process.stderr.write('status: recording-started\n'); - }); + const play = spawn('pw-play', hapticsPlaybackArgs(target), { stdio: ['pipe', 'ignore', 'pipe'] }); + play.stderr.on('data', (chunk) => process.stderr.write(chunk)); - let carry = Buffer.alloc(0); - record.stdout.on('data', (chunk) => { - let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; - const frameBytes = 2 * 4; - const usable = data.length - (data.length % frameBytes); - carry = data.subarray(usable); - if (usable === 0) { - return; + let record = null; + let attachTimer = null; + let volumeTimer = null; + let stopping = false; + let announcedRecording = false; + + // Playback always goes to the bridge sink. Its per-channel volumes + // (ears = channel 0, haptic = channel 2) drive the compensation: the + // volume guard may pin the haptic pair at unity while the ear channels + // track the knob, so a single scalar is not enough. Poll pw-dump, cache + // the channel volumes, and recompute so a live sync toggle applies at + // once instead of waiting for the next poll. + let lastChannelVolumes = null; + let lastCompError = null; + const applyCompensation = () => { + if (lastChannelVolumes) { + processor.setOutputCompensation( + channelCompensation(lastChannelVolumes[0], lastChannelVolumes[2], processor.volumeSync) + ); } - const input = new Float32Array(data.buffer, data.byteOffset, usable / 4); - const output = processor.process(input); - if (play.stdin.writable) { - play.stdin.write(Buffer.from(output.buffer, 0, output.byteLength)); + }; + const refreshVolumeCompensation = async () => { + try { + const objects = await pwDump(); + const found = objects.find((object) => object.id === sink.id) + ?? objects.filter(isBridgeSink).find((s) => (nodeProps(s)['node.name'] ?? '').startsWith('alsa_output')) + ?? objects.filter(isBridgeSink)[0] + ?? null; + const cv = found?.info?.params?.Props?.[0]?.channelVolumes; + if (Array.isArray(cv) && cv.length >= 3) { + lastChannelVolumes = cv; + applyCompensation(); + } + // channelVolumes unavailable: keep the last compensation. + } catch (error) { + if (error.message !== lastCompError) { + lastCompError = error.message; + process.stderr.write(`volume compensation poll failed: ${error.message}\n`); + } } - }); + }; + refreshVolumeCompensation(); + volumeTimer = setInterval(refreshVolumeCompensation, 2000); const shutdown = (code, detail) => { + stopping = true; if (detail) { process.stderr.write(`${detail}\n`); } - record.kill(); + clearTimeout(attachTimer); + clearInterval(volumeTimer); + record?.kill(); play.kill(); process.exit(code); }; - record.stderr.on('data', (chunk) => process.stderr.write(chunk)); - play.stderr.on('data', (chunk) => process.stderr.write(chunk)); - record.on('exit', (code) => shutdown(code ?? 1, 'capture stream ended')); play.on('exit', (code) => shutdown(code ?? 1, 'playback stream ended')); + const pipeRecordToProcessor = (proc, stride) => { + let carry = Buffer.alloc(0); + proc.stdout.on('data', (chunk) => { + let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; + const frameBytes = stride * 4; // stride channels x f32 + const usable = data.length - (data.length % frameBytes); + carry = data.subarray(usable); + if (usable === 0) { + return; + } + const input = new Float32Array(data.buffer, data.byteOffset, usable / 4); + const output = processor.process(input); + if (play.stdin.writable) { + play.stdin.write(Buffer.from(output.buffer, 0, output.byteLength)); + } + }); + proc.stderr.on('data', (chunk) => process.stderr.write(chunk)); + proc.on('spawn', () => { + if (!announcedRecording) { + announcedRecording = true; + process.stderr.write('status: recording-started\n'); + } + }); + }; + + const startSinkMonitorCapture = () => { + record = spawn('pw-record', [ + '--raw', + '-P', '{ stream.capture.sink = true }', + ...(captureDevice ? ['--target', captureDevice] : []), + // Capture four discrete channels and let the processor use the fronts + // only. When the headset is plugged in, the default sink can be the + // bridge's own 4-channel device; any narrower capture goes through + // PipeWire's channel mixer, which folds the rear haptics channels we + // play back into the fronts (constant buzz / self-oscillation). With a + // matching 4ch format no mixing happens; stereo sinks upmix with + // silent rears, leaving the fronts intact either way. + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, 4); + record.on('exit', (code) => { + if (!stopping) { + shutdown(code ?? 1, 'capture stream ended'); + } + }); + }; + + // Pre-fold app capture: target the app's own output stream so discrete + // surround channels (especially LFE) survive even when the listening + // device is stereo. The node comes and goes with the game, so poll and + // re-attach instead of failing hard. + const APP_POLL_MS = 2000; + const pollForAppNode = async () => { + if (stopping) { + return; + } + let node = null; + try { + node = matchAppStreamNode(await pwDump(), appSource); + } catch (error) { + process.stderr.write(`app node poll failed: ${error.message}\n`); + } + if (!node) { + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + return; + } + const layout = nodeChannelLayout(node); + processor.setInputLayout(channelIndices(layout.position)); + record = spawn('pw-record', appCaptureRecordArgs(node, layout), { stdio: ['ignore', 'pipe', 'pipe'] }); + pipeRecordToProcessor(record, layout.channels); + record.on('exit', () => { + // Game restarted its stream (level load, restart): go back to polling. + record = null; + if (!stopping) { + process.stderr.write('status: waiting-for-app\n'); + attachTimer = setTimeout(pollForAppNode, APP_POLL_MS); + } + }); + }; + + if (hasAppSource) { + process.stderr.write('status: waiting-for-app\n'); + await pollForAppNode(); + } else { + startSinkMonitorCapture(); + } + const control = createInterface({ input: process.stdin }); control.on('line', (line) => { const parts = line.trim().split(/\s+/); @@ -219,6 +402,10 @@ async function runRenderLoopbackHaptics(args) { attack: parts[4], release: parts[5] }); + // 7th field is optional so 6-field lines keep working: absent → sync ON. + processor.setVolumeSync(parts[6] === undefined ? true : parts[6] === '1'); + // Recompute from the cached channel volumes so the toggle applies now. + applyCompensation(); } else if (parts[0] === 'stop') { shutdown(0); } @@ -303,6 +490,27 @@ async function defaultSinkName() { }); } +// Prints the audio output sinks as a JSON array for the Audio Haptics +// capture-device picker. The bridge's own sink is excluded: capturing it +// would feed the haptics we play back into the processor. +async function runListOutputSinks() { + const objects = await pwDump(); + const current = await defaultSinkName(); + const devices = objects + .filter((object) => isAudioSink(object) && !isBridgeSink(object)) + .map((object) => { + const props = nodeProps(object); + const nodeName = props['node.name'] ?? ''; + return { + nodeName, + displayName: props['node.description'] || nodeName, + isDefault: nodeName === (current?.name ?? '') + }; + }) + .filter((device) => device.nodeName); + process.stdout.write(`${JSON.stringify(devices)}\n`); +} + async function runDefaultRenderStatus() { const current = await defaultSinkName(); const deviceName = current?.description || current?.name || ''; @@ -318,6 +526,58 @@ async function runSetDefaultRenderBridge() { }); } +// The helper owns its output level (gain, limiter, sink-volume +// compensation), so pin the playback stream at unity and opt out of +// WirePlumber's stream-restore: a remembered mixer tweak on "pw-play" +// must not silently scale the haptics. +export function hapticsPlaybackArgs(target) { + return [ + '--raw', + '--target', target, + '--volume', '1', + '-P', '{ state.restore-props = false }', + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '4', + '--channel-map', 'FL,FR,RL,RR', + '--latency', '256', + '-' + ]; +} + +export function appCaptureRecordArgs(node, layout) { + const serial = nodeProps(node)['object.serial']; + return [ + '--raw', + // Target the app's own output stream by serial: WirePlumber ignores a + // plain --target for playback streams and falls back to the + // default source (the microphone), which is silence for haptics. + '-P', `{ target.object = ${serial ?? node.id} }`, + '--format', 'f32', '--rate', `${SAMPLE_RATE}`, + '--channels', `${layout.channels}`, + '--channel-map', layout.position.join(','), + '--latency', '256', + '-' + ]; +} + +export function matchAppStreamNode(objects, { processId, executableName, processPath }) { + const pathBasename = processPath ? processPath.split('/').pop() : null; + const matches = objects.filter((object) => { + if (object.type !== 'PipeWire:Interface:Node') { + return false; + } + const props = nodeProps(object); + if (props['media.class'] !== 'Stream/Output/Audio') { + return false; + } + if (processId > 0 && Number(props['application.process.id'] ?? 0) === processId) { + return true; + } + const binary = props['application.process.binary'] ?? null; + return Boolean(binary && (binary === executableName || binary === pathBasename)); + }); + return matches.find((object) => object.info?.state === 'running') ?? matches[0] ?? null; +} + async function listOutputStreamSessions() { const objects = await pwDump(); const bridge = await findBridgeSink().catch(() => null); @@ -385,6 +645,81 @@ async function runMonitorAudioSessions() { process.on('SIGTERM', stop); } +// 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)]; +} + +const VOLUME_GUARD_INTERVAL_MS = 2000; + +async function runVolumeGuard() { + let stopping = false; + // Hard-fail the first tick when the sink is missing (mirrors the other + // helper modes): the parent only starts the guard once the controller + // audio path is ready, so a missing sink at boot is a real setup error. + let sink = await requireBridgeSink(); + + const pinTick = async () => { + if (stopping) { + return; + } + try { + // Re-find the sink each tick so the guard survives the sink coming + // and going with the controller. channelVolumes change as the user + // adjusts volume, so always read them fresh from a live pw-dump. + const found = await findBridgeSink(); + if (!found) { + process.stderr.write('volume guard: bridge sink not found, retrying\n'); + return; + } + sink = found; + const vols = sink.info?.params?.Props?.[0]?.channelVolumes; + const pinned = pinnedChannelVolumes(vols); + if (!pinned) { + return; + } + await new Promise((resolve) => { + execFile('pw-cli', [ + 'set-param', `${sink.id}`, 'Props', + `{ channelVolumes: [ ${pinned.join(', ')} ] }` + ], (error) => { + if (error) { + process.stderr.write(`volume guard: set-param failed: ${error.message}\n`); + } + resolve(); + }); + }); + } catch (error) { + process.stderr.write(`volume guard tick failed: ${error.message}\n`); + } + }; + + await pinTick(); + const timer = setInterval(pinTick, VOLUME_GUARD_INTERVAL_MS); + const control = createInterface({ input: process.stdin }); + const stop = () => { + stopping = true; + clearInterval(timer); + process.exit(0); + }; + control.on('line', (line) => { + if (line.trim() === 'stop') { + stop(); + } + }); + control.on('close', stop); + process.on('SIGTERM', stop); + process.on('SIGINT', stop); +} + function runMicKeepalive() { // Microphone input is unsupported over the vds Bluetooth transport; stay // alive so the engine's lifecycle management works, but do nothing. @@ -400,12 +735,16 @@ async function main() { await runPlayTestTone(args); } else if (args.includes('--play-test-haptics')) { await runPlayTestHaptics(args); + } else if (args.includes('--list-output-sinks')) { + await runListOutputSinks(); } else if (args.includes('--default-render-status')) { await runDefaultRenderStatus(); } else if (args.includes('--set-default-render-bridge')) { await runSetDefaultRenderBridge(); } else if (args.includes('--monitor-audio-sessions')) { await runMonitorAudioSessions(); + } else if (args.includes('--volume-guard')) { + await runVolumeGuard(); } else if (args.includes('--mic-keepalive-only')) { runMicKeepalive(); } else if (argValue(args, '--source') === 'render-loopback') { @@ -415,4 +754,8 @@ async function main() { } } -main().catch((error) => fail(error.message)); +const isCliEntry = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isCliEntry) { + main().catch((error) => fail(error.message)); +} diff --git a/ds5-bridge/companion/src/main/audio-helper-linux.test.ts b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts new file mode 100644 index 0000000..69a6a77 --- /dev/null +++ b/ds5-bridge/companion/src/main/audio-helper-linux.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from 'vitest'; +import { channelIndices, hapticsPlaybackArgs, HapticsProcessor, nodeChannelLayout, pinnedChannelVolumes } from '../../native/audio-helper-linux.mjs'; + +describe('audio-helper-linux exports', () => { + it('imports without running main and exposes HapticsProcessor', () => { + const processor = new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); + expect(typeof processor.process).toBe('function'); + }); +}); + +describe('nodeChannelLayout', () => { + it('reads channels and position from the Format param', () => { + const node = { info: { params: { Format: [{ mediaType: 'audio', channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 6, position: ['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'] }); + }); + + it('falls back to stereo when the format is missing', () => { + expect(nodeChannelLayout({ info: { params: {} } })).toEqual({ channels: 2, position: ['FL', 'FR'] }); + expect(nodeChannelLayout(undefined)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); + + it('falls back to stereo when position length disagrees with channels', () => { + const node = { info: { params: { Format: [{ channels: 6, position: ['FL', 'FR'] }] } } }; + expect(nodeChannelLayout(node)).toEqual({ channels: 2, position: ['FL', 'FR'] }); + }); +}); + +describe('channelIndices', () => { + it('maps stereo', () => { + expect(channelIndices(['FL', 'FR'])).toEqual({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps 5.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR'])).toEqual({ stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('maps 7.1', () => { + expect(channelIndices(['FL', 'FR', 'FC', 'LFE', 'RL', 'RR', 'SL', 'SR'])).toEqual({ stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }); + }); + + it('treats an unknown map as first-two-channels stereo', () => { + expect(channelIndices(['AUX0', 'AUX1', 'AUX2'])).toEqual({ stride: 3, fl: 0, fr: 1, fc: -1, lfe: -1 }); + }); + + it('maps mono to both sides', () => { + expect(channelIndices(['MONO'])).toEqual({ stride: 1, fl: 0, fr: 0, fc: -1, lfe: -1 }); + }); +}); + +function makeProcessor() { + return new HapticsProcessor({ + gainPercent: 100, bassFocus: 'balanced', response: 'balanced', + attack: 'balanced', release: 'balanced' + }); +} + +describe('HapticsProcessor layouts', () => { + it('default 4ch layout matches explicit FL,FR,RL,RR layout sample-for-sample', () => { + const frames = 64; + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + input[f * 4] = Math.sin(f / 3) * 0.5; // FL + input[f * 4 + 1] = Math.cos(f / 3) * 0.5; // FR + input[f * 4 + 2] = 0.9; // RL: must be ignored + input[f * 4 + 3] = -0.9; // RR: must be ignored + } + const byDefault = makeProcessor().process(input); + const explicit = makeProcessor(); + explicit.setInputLayout({ stride: 4, fl: 0, fr: 1, fc: -1, lfe: -1 }); + expect(Array.from(explicit.process(input))).toEqual(Array.from(byDefault)); + }); + + it('5.1 layout blends FC at 0.5x and LFE at 1x into both sides', () => { + const frames = 64; + const layout = { stride: 6, fl: 0, fr: 1, fc: 2, lfe: 3 }; + // Only FC and LFE carry signal: expect output driven purely by the blend. + const surround = new Float32Array(frames * 6); + for (let f = 0; f < frames; f += 1) { + surround[f * 6 + 2] = Math.sin(f / 4) * 0.4; // FC + surround[f * 6 + 3] = Math.sin(f / 4) * 0.4; // LFE + } + // Equivalent stereo signal: FL = FR = 0.5*FC + 1.0*LFE. + const folded = new Float32Array(frames * 2); + for (let f = 0; f < frames; f += 1) { + const blend = 0.5 * surround[f * 6 + 2] + surround[f * 6 + 3]; + folded[f * 2] = blend; + folded[f * 2 + 1] = blend; + } + const surroundProcessor = makeProcessor(); + surroundProcessor.setInputLayout(layout); + const stereoProcessor = makeProcessor(); + stereoProcessor.setInputLayout({ stride: 2, fl: 0, fr: 1, fc: -1, lfe: -1 }); + const a = surroundProcessor.process(surround); + const b = stereoProcessor.process(folded); + expect(a.length).toBe(frames * 4); + for (let i = 0; i < a.length; i += 1) { + expect(a[i]).toBeCloseTo(b[i], 6); + } + }); + + it('rears and sides in a 7.1 stream never reach the output', () => { + const frames = 32; + const quiet = new Float32Array(frames * 8); // silence everywhere + const noisyRears = new Float32Array(frames * 8); + for (let f = 0; f < frames; f += 1) { + for (const ch of [4, 5, 6, 7]) { + noisyRears[f * 8 + ch] = 0.8; + } + } + const layout = { stride: 8, fl: 0, fr: 1, fc: 2, lfe: 3 }; + const p1 = makeProcessor(); p1.setInputLayout(layout); + const p2 = makeProcessor(); p2.setInputLayout(layout); + expect(Array.from(p1.process(noisyRears))).toEqual(Array.from(p2.process(quiet))); + }); +}); + +import { appCaptureRecordArgs, channelCompensation, matchAppStreamNode, readHapticsConfig } from '../../native/audio-helper-linux.mjs'; + +describe('channelCompensation', () => { + it('is unity when ears and haptic match and sync is ON', () => { + expect(channelCompensation(1, 1, true)).toBe(1); + }); + + it('follows the knob when the guard pins the haptic pair and sync is ON', () => { + // ears track the volume (0.166), haptic pinned at unity by the guard. + expect(channelCompensation(0.166, 1, true)).toBeCloseTo(0.166, 6); + }); + + it('is unity with no guard when sync is ON (ears == haptic)', () => { + expect(channelCompensation(0.166, 0.166, true)).toBeCloseTo(1, 6); + }); + + it('boosts to 1/haptic with no guard when sync is OFF', () => { + // desired unity over haptic 0.166 -> ~6.02, holding strength constant. + expect(channelCompensation(0.166, 0.166, false)).toBeCloseTo(1 / 0.166, 4); + }); + + it('does not double-boost when the guard pins the pair and sync is OFF', () => { + // haptic pinned at unity, desired unity -> no compensation. + expect(channelCompensation(0.166, 1, false)).toBe(1); + }); + + it('treats garbage, zero, or non-finite inputs as unity', () => { + expect(channelCompensation(0, 1, true)).toBe(1); + expect(channelCompensation(-0.5, 1, true)).toBe(1); + expect(channelCompensation(NaN, 1, true)).toBe(1); + // A garbage haptic denominator falls back to actual = 1. + expect(channelCompensation(0.5, 0, true)).toBeCloseTo(0.5, 6); + expect(channelCompensation(0.5, NaN, true)).toBeCloseTo(0.5, 6); + expect(channelCompensation(NaN, NaN, false)).toBe(1); + }); + + it('clamps at 32 and 1/32', () => { + expect(channelCompensation(1, 0.001, true)).toBe(32); + expect(channelCompensation(1, 0.001, false)).toBe(32); + expect(channelCompensation(0.1, 1, true)).toBeCloseTo(0.1, 6); + expect(channelCompensation(1, 100, false)).toBe(1 / 32); + }); +}); + +describe('HapticsProcessor output compensation', () => { + function loudInput(frames: number) { + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + const s = Math.sin(2 * Math.PI * 40 * (f / 48000)) * 0.9; + input[f * 4] = s; + input[f * 4 + 1] = s; + } + return input; + } + + it('doubles nonzero samples at compensation 2 but clamps to unit magnitude', () => { + const frames = 256; + const input = loudInput(frames); + const base = makeProcessor().process(input); + const boosted = makeProcessor(); + boosted.setOutputCompensation(2); + const out = boosted.process(input); + for (let i = 0; i < out.length; i += 1) { + if (base[i] === 0) { + expect(out[i]).toBe(0); + } else { + expect(out[i]).toBeCloseTo(Math.max(-1, Math.min(1, base[i] * 2)), 6); + expect(Math.abs(out[i])).toBeLessThanOrEqual(1); + } + } + }); + + it('clips to exactly unit magnitude with a strong input and high compensation', () => { + const frames = 256; + const input = loudInput(frames); + const clipped = makeProcessor(); + clipped.setOutputCompensation(8); + const out = clipped.process(input); + const peak = Math.max(...Array.from(out).map((s) => Math.abs(s))); + expect(peak).toBe(1); + }); + + it('leaves output byte-identical to a fresh default processor at compensation 1', () => { + const frames = 256; + const input = loudInput(frames); + const withComp = makeProcessor(); + withComp.setOutputCompensation(1); + expect(Array.from(withComp.process(input))).toEqual(Array.from(makeProcessor().process(input))); + }); +}); + +describe('readHapticsConfig volume sync flag', () => { + it('defaults volumeSync to true when the flag is absent', () => { + expect(readHapticsConfig([]).volumeSync).toBe(true); + expect(readHapticsConfig(['--haptics-gain', '120']).volumeSync).toBe(true); + }); + + it('reads volumeSync true when the flag is 1', () => { + expect(readHapticsConfig(['--haptics-volume-sync', '1']).volumeSync).toBe(true); + }); + + it('reads volumeSync false when the flag is 0', () => { + expect(readHapticsConfig(['--haptics-volume-sync', '0']).volumeSync).toBe(false); + }); +}); + +describe('HapticsProcessor always-applied compensation', () => { + function loudInput(frames: number) { + const input = new Float32Array(frames * 4); + for (let f = 0; f < frames; f += 1) { + const s = Math.sin(2 * Math.PI * 40 * (f / 48000)) * 0.9; + input[f * 4] = s; + input[f * 4 + 1] = s; + } + return input; + } + + it('always applies the output compensation regardless of the volumeSync flag', () => { + // With per-channel compensation the poll folds volumeSync into the + // computed scaling, so process() must apply it in every toggle state. + const frames = 256; + const input = loudInput(frames); + const syncOn = makeProcessor(); + syncOn.setVolumeSync(true); + syncOn.setOutputCompensation(6); + const syncOff = makeProcessor(); + syncOff.setVolumeSync(false); + syncOff.setOutputCompensation(6); + // Both apply comp 6 identically; the flag no longer gates process(). + expect(Array.from(syncOn.process(input))).toEqual(Array.from(syncOff.process(input))); + }); + + it('is byte-identical to a fresh processor when compensation is unity (sync ON, no guard)', () => { + const frames = 256; + const input = loudInput(frames); + const synced = makeProcessor(); + synced.setVolumeSync(true); + synced.setOutputCompensation(channelCompensation(0.166, 0.166, true)); // ~1 + expect(Array.from(synced.process(input))).toEqual(Array.from(makeProcessor().process(input))); + }); + + it('applies the compensated (clamped) output for a nonzero compensation', () => { + const frames = 256; + const input = loudInput(frames); + const base = makeProcessor(); + base.setOutputCompensation(1); + const baseOut = base.process(input); + const boosted = makeProcessor(); + boosted.setOutputCompensation(6); + const out = boosted.process(input); + let sawNonzero = false; + for (let i = 0; i < out.length; i += 1) { + if (baseOut[i] === 0) { + expect(out[i]).toBe(0); + } else { + sawNonzero = true; + expect(out[i]).toBeCloseTo(Math.max(-1, Math.min(1, baseOut[i] * 6)), 6); + } + } + expect(sawNonzero).toBe(true); + }); +}); + +describe('appCaptureRecordArgs', () => { + it('targets the app stream by object.serial, not by --target id (mic fallback)', () => { + const node = { id: 297, info: { props: { 'object.serial': 48540 } } }; + const args = appCaptureRecordArgs(node, { channels: 4, position: ['FL', 'FR', 'RL', 'RR'] }); + const pIndex = args.indexOf('-P'); + expect(pIndex).toBeGreaterThanOrEqual(0); + expect(args[pIndex + 1]).toBe('{ target.object = 48540 }'); + const channelsIndex = args.indexOf('--channels'); + expect(args[channelsIndex + 1]).toBe('4'); + const mapIndex = args.indexOf('--channel-map'); + expect(args[mapIndex + 1]).toBe('FL,FR,RL,RR'); + expect(args).not.toContain('--target'); + }); + + it('falls back to node.id when object.serial is missing', () => { + const node = { id: 297, info: { props: {} } }; + const args = appCaptureRecordArgs(node, { channels: 4, position: ['FL', 'FR', 'RL', 'RR'] }); + const pIndex = args.indexOf('-P'); + expect(args[pIndex + 1]).toBe('{ target.object = 297 }'); + }); +}); + +function streamNode(id: number, props: Record, state = 'running') { + return { + id, + type: 'PipeWire:Interface:Node', + info: { state, props: { 'media.class': 'Stream/Output/Audio', ...props } } + }; +} + +describe('matchAppStreamNode', () => { + const game = streamNode(40, { 'application.process.id': 1234, 'application.process.binary': 'game-bin' }); + const music = streamNode(41, { 'application.process.id': 999, 'application.process.binary': 'spotify' }); + const sinkNode = { id: 50, type: 'PipeWire:Interface:Node', info: { state: 'running', props: { 'media.class': 'Audio/Sink' } } }; + + it('matches by process id', () => { + expect(matchAppStreamNode([music, game, sinkNode], { processId: 1234, executableName: null, processPath: null })?.id).toBe(40); + }); + + it('falls back to the executable name', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(40); + }); + + it('falls back to the process path basename', () => { + expect(matchAppStreamNode([music, game], { processId: 0, executableName: null, processPath: '/opt/game/game-bin' })?.id).toBe(40); + }); + + it('prefers a running node over an idle one', () => { + const idle = streamNode(42, { 'application.process.binary': 'game-bin' }, 'idle'); + const running = streamNode(43, { 'application.process.binary': 'game-bin' }, 'running'); + expect(matchAppStreamNode([idle, running], { processId: 0, executableName: 'game-bin', processPath: null })?.id).toBe(43); + }); + + it('returns null when nothing matches or only non-streams exist', () => { + expect(matchAppStreamNode([music, sinkNode], { processId: 1234, executableName: 'game-bin', processPath: null })).toBeNull(); + }); +}); + +describe('hapticsPlaybackArgs', () => { + const hasPair = (args: string[], a: string, b: string) => { + for (let i = 0; i < args.length - 1; i += 1) { + if (args[i] === a && args[i + 1] === b) { + return true; + } + } + return false; + }; + + it('pins the playback stream at unity and opts out of stream-restore', () => { + const args = hapticsPlaybackArgs('sinkname'); + expect(hasPair(args, '--target', 'sinkname')).toBe(true); + expect(hasPair(args, '--volume', '1')).toBe(true); + expect(hasPair(args, '-P', '{ state.restore-props = false }')).toBe(true); + expect(hasPair(args, '--channels', '4')).toBe(true); + expect(hasPair(args, '--channel-map', 'FL,FR,RL,RR')).toBe(true); + expect(args[args.length - 1]).toBe('-'); + }); +}); + +describe('pinnedChannelVolumes', () => { + it('returns null for a non-array or too-short input', () => { + expect(pinnedChannelVolumes(undefined)).toBeNull(); + expect(pinnedChannelVolumes(null)).toBeNull(); + expect(pinnedChannelVolumes([0.3, 0.3, 0.3])).toBeNull(); + }); + + it('returns null when the haptic pair is already pinned at unity', () => { + expect(pinnedChannelVolumes([0.3, 0.3, 1, 1])).toBeNull(); + expect(pinnedChannelVolumes([1, 1, 1, 1])).toBeNull(); + }); + + it('pins the haptic pair to unity while keeping the speaker channels', () => { + expect(pinnedChannelVolumes([0.3, 0.3, 0.3, 0.3])).toEqual([0.3, 0.3, 1, 1]); + }); + + it('preserves any trailing channels beyond the first four', () => { + expect(pinnedChannelVolumes([0.3, 0.3, 0.3, 0.3, 0.5, 0.5])) + .toEqual([0.3, 0.3, 1, 1, 0.5, 0.5]); + }); +}); diff --git a/ds5-bridge/companion/src/main/audio-helper.test.ts b/ds5-bridge/companion/src/main/audio-helper.test.ts index b0a844e..9adae0a 100644 --- a/ds5-bridge/companion/src/main/audio-helper.test.ts +++ b/ds5-bridge/companion/src/main/audio-helper.test.ts @@ -55,7 +55,8 @@ import { AudioHapticsSessionMonitor, playBridgeHapticsTestPattern, playBridgeSpeakerTestTone, - SystemAudioHapticsEngine + SystemAudioHapticsEngine, + VolumeGuardEngine } from './audio-helper'; beforeEach(() => { @@ -80,7 +81,8 @@ describe('SystemAudioHapticsEngine app source', () => { bassFocus: 'punchy', response: 'strong', attack: 'fast', - release: 'smooth' + release: 'smooth', + volumeSync: true }, 'xbox'); const helper = childProcessMock.processes[0]!; @@ -97,6 +99,8 @@ describe('SystemAudioHapticsEngine app source', () => { expect(args).toContain('Game.exe'); expect(args).toContain('--bridge-persona'); expect(args).toContain('xbox'); + expect(args).toContain('--haptics-volume-sync'); + expect(args[args.indexOf('--haptics-volume-sync') + 1]).toBe('1'); expect(args).not.toContain('--device-name'); expect(args).not.toContain('DS5 Bridge'); expect(args).not.toContain('--stdout-only'); @@ -104,6 +108,46 @@ describe('SystemAudioHapticsEngine app source', () => { await engine.stop(); }); + it('passes the volume-sync flag as 0 when disabled and reconfigures with a 7-field line', async () => { + const engine = new SystemAudioHapticsEngine(); + const start = engine.start({ + source: 'system-audio', + gainPercent: 100, + bassFocus: 'balanced', + response: 'balanced', + attack: 'balanced', + release: 'balanced', + volumeSync: false + }, 'dualsense'); + + const helper = childProcessMock.processes[0]!; + helper.stderr.emit('data', Buffer.from('status: recording-started\n')); + await start; + + const args = childProcessMock.spawn.mock.calls[0]![1] as string[]; + expect(args).toContain('--haptics-volume-sync'); + expect(args[args.indexOf('--haptics-volume-sync') + 1]).toBe('0'); + + engine.setConfig({ + source: 'system-audio', + gainPercent: 100, + bassFocus: 'balanced', + response: 'balanced', + attack: 'balanced', + release: 'balanced', + volumeSync: false + }); + + const writes = (helper.stdin.write as ReturnType).mock.calls + .map((call) => call[0] as string) + .filter((line) => line.startsWith('haptics-config ')); + const lastLine = writes[writes.length - 1]!.trim(); + expect(lastLine).toBe('haptics-config 100 balanced balanced balanced balanced 0'); + expect(lastLine.split(' ')).toHaveLength(7); + + await engine.stop(); + }); + }); describe('bridge haptics test', () => { @@ -150,6 +194,39 @@ describe('bridge speaker test', () => { }); }); +describe('VolumeGuardEngine', () => { + it('spawns the helper in volume-guard mode once and is idempotent', async () => { + const engine = new VolumeGuardEngine(); + await engine.start(); + await engine.start(); + + expect(childProcessMock.spawn).toHaveBeenCalledTimes(1); + const rawArgs = childProcessMock.spawn.mock.calls[0]![1] as string[]; + const args = process.platform === 'win32' ? rawArgs : rawArgs.slice(1); + expect(args).toEqual(['--volume-guard']); + expect(engine.isActive()).toBe(true); + + await engine.stop(); + }); + + it('stops the helper and becomes inactive', async () => { + const engine = new VolumeGuardEngine(); + await engine.start(); + const helper = childProcessMock.processes[0]!; + + await engine.stop(); + + expect(helper.kill).toHaveBeenCalled(); + expect(engine.isActive()).toBe(false); + }); + + it('stop is a no-op when never started', async () => { + const engine = new VolumeGuardEngine(); + await engine.stop(); + expect(childProcessMock.spawn).not.toHaveBeenCalled(); + }); +}); + describe('audio haptics session listing', () => { it('keeps one monitor helper alive and returns cached snapshots', async () => { const monitor = new AudioHapticsSessionMonitor(); diff --git a/ds5-bridge/companion/src/main/audio-helper.ts b/ds5-bridge/companion/src/main/audio-helper.ts index d2390c0..4c6167c 100644 --- a/ds5-bridge/companion/src/main/audio-helper.ts +++ b/ds5-bridge/companion/src/main/audio-helper.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { EventEmitter } from 'node:events'; import { CompanionDebugConfig, DEBUG_ENV } from './debug-config'; import type { + AudioOutputDevice, AudioReactiveHapticsSource, AudioReactiveHapticsAttack, AudioReactiveHapticsBassFocus, @@ -20,6 +21,7 @@ export type SystemAudioHapticsConfig = { response: AudioReactiveHapticsResponse; attack: AudioReactiveHapticsAttack; release: AudioReactiveHapticsRelease; + volumeSync: boolean; }; export type DefaultRenderEndpointStatus = { @@ -96,7 +98,8 @@ export class SystemAudioHapticsEngine extends EventEmitter { bassFocus: 'balanced', response: 'balanced', attack: 'balanced', - release: 'balanced' + release: 'balanced', + volumeSync: true }; async start(config: SystemAudioHapticsConfig, hostPersonaMode: HostPersonaMode = 'dualsense'): Promise { @@ -137,7 +140,7 @@ export class SystemAudioHapticsEngine extends EventEmitter { setConfig(config: SystemAudioHapticsConfig): void { this.activeConfig = normalizeSystemAudioHapticsConfig(config); this.writeControlLine( - `haptics-config ${this.activeConfig.gainPercent} ${this.activeConfig.bassFocus} ${this.activeConfig.response} ${this.activeConfig.attack} ${this.activeConfig.release}` + `haptics-config ${this.activeConfig.gainPercent} ${this.activeConfig.bassFocus} ${this.activeConfig.response} ${this.activeConfig.attack} ${this.activeConfig.release} ${this.activeConfig.volumeSync ? '1' : '0'}` ); } @@ -205,8 +208,14 @@ export class SystemAudioHapticsEngine extends EventEmitter { '--haptics-attack', config.attack, '--haptics-release', - config.release + config.release, + '--haptics-volume-sync', + config.volumeSync ? '1' : '0' ]; + const deviceSource = audioReactiveHapticsOutputDeviceSource(config.source); + if (deviceSource) { + args.push('--haptics-output-device', deviceSource.nodeName); + } const appSource = audioReactiveHapticsAppSource(config.source); if (appSource) { if (Number.isFinite(appSource.processId) && appSource.processId > 0) { @@ -454,7 +463,8 @@ function normalizeSystemAudioHapticsConfig(config: SystemAudioHapticsConfig): Sy : 'balanced', release: config.release === 'tight' || config.release === 'smooth' || config.release === 'long' ? config.release - : 'balanced' + : 'balanced', + volumeSync: config.volumeSync !== false }; } @@ -517,6 +527,18 @@ function normalizeAudioReactiveHapticsSource(source: AudioReactiveHapticsSource if (source === 'controller-audio' || source === 'system-audio') { return source; } + const deviceSource = audioReactiveHapticsOutputDeviceSource(source); + if (deviceSource) { + const nodeName = normalizeOptionalText(deviceSource.nodeName); + if (!nodeName) { + return 'system-audio'; + } + return { + kind: 'output-device', + nodeName, + displayName: normalizeOptionalText(deviceSource.displayName) + }; + } const appSource = audioReactiveHapticsAppSource(source); if (!appSource) { return 'system-audio'; @@ -539,7 +561,17 @@ function audioReactiveHapticsAppSource(source: AudioReactiveHapticsSource | unde : null; } +function audioReactiveHapticsOutputDeviceSource(source: AudioReactiveHapticsSource | undefined) { + return source && typeof source === 'object' && source.kind === 'output-device' + ? source + : null; +} + function audioReactiveHapticsSourceKey(source: AudioReactiveHapticsSource): string { + const deviceSource = audioReactiveHapticsOutputDeviceSource(source); + if (deviceSource) { + return `output-device:${deviceSource.nodeName}`; + } const appSource = audioReactiveHapticsAppSource(source); if (!appSource) { return source === 'controller-audio' ? 'controller-audio' : 'system-audio'; @@ -763,6 +795,53 @@ async function waitForHelperRecordingStarted( }); } +// Lists system audio output devices (sinks) for the Audio Haptics capture +// picker. The helper prints one JSON array on stdout and exits. +export async function listAudioOutputDevices(): Promise { + const launch = helperLaunch(['--list-output-sinks']); + const helper = spawn(launch.command, launch.args, { + env: launch.env, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + return new Promise((resolve) => { + let stdout = ''; + const timeout = setTimeout(() => { + if (!helper.killed) { + helper.kill('SIGKILL'); + } + resolve([]); + }, 5000); + helper.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + }); + helper.on('error', () => { + clearTimeout(timeout); + resolve([]); + }); + helper.on('exit', () => { + clearTimeout(timeout); + try { + const parsed = JSON.parse(stdout); + resolve(Array.isArray(parsed) + ? parsed.filter((device): device is AudioOutputDevice => ( + Boolean(device) && typeof device.nodeName === 'string' && device.nodeName.length > 0 + )).map((device) => ({ + nodeName: device.nodeName, + displayName: typeof device.displayName === 'string' && device.displayName + ? device.displayName + : device.nodeName, + isDefault: Boolean(device.isDefault) + })) + : []); + } catch { + resolve([]); + } + }); + }); +} + export async function playBridgeSpeakerTestTone( speakerVolumePercent = 100, hostPersonaMode: HostPersonaMode = 'dualsense' @@ -960,6 +1039,79 @@ export class MicKeepaliveEngine extends EventEmitter { } } +// Holds the bridge sink's haptic channels (3-4) at unity while HD Volume +// Sync is off. Desktop volume controls rewrite all four channels, so this +// re-pins the haptic pair without touching the speaker channels the user is +// adjusting. Linux only; a no-op mode elsewhere is never started. +export class VolumeGuardEngine extends EventEmitter { + private process: ChildProcess | null = null; + private starting: Promise | null = null; + + async start(): Promise { + if (this.process) { + return; + } + if (this.starting) { + return this.starting; + } + + this.starting = this.startInternal().finally(() => { + this.starting = null; + }); + return this.starting; + } + + async stop(): Promise { + const helper = this.process; + this.process = null; + if (!helper) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (!helper.killed) { + helper.kill('SIGKILL'); + } + resolve(); + }, 500); + + helper.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + + helper.stdin?.end(); + helper.kill(); + }); + } + + isActive(): boolean { + return this.process !== null; + } + + private async startInternal(): Promise { + const launch = helperLaunch(['--volume-guard']); + const helper = spawn(launch.command, launch.args, { + env: launch.env, + windowsHide: true, + stdio: ['pipe', 'ignore', 'pipe'] + }); + + this.process = helper; + helper.stderr.on('data', (chunk: Buffer) => { + this.emit('status', chunk.toString('utf8').trim()); + }); + helper.on('error', (error) => this.emit('error', error)); + helper.on('exit', (code, signal) => { + if (this.process === helper) { + this.process = null; + this.emit('status', `volume guard helper exited (${signal ?? code ?? 'unknown'})`); + } + }); + } +} + export function resolveAudioHelperPath(): string { const packagedCandidate = process.resourcesPath ? path.join(process.resourcesPath, HELPER_RELATIVE_PATH) : null; const devHelperRelativePath = process.platform === 'win32' diff --git a/ds5-bridge/companion/src/main/bridge-service.test.ts b/ds5-bridge/companion/src/main/bridge-service.test.ts index 9b18237..bde360e 100644 --- a/ds5-bridge/companion/src/main/bridge-service.test.ts +++ b/ds5-bridge/companion/src/main/bridge-service.test.ts @@ -1101,8 +1101,8 @@ describe('BridgeService', () => { command = device.sentReports.at(-1); expect(command?.[7]).toBe(COMMAND_ID.SET_HAPTICS_BUFFER_LENGTH); - expect(command?.[9]).toBe(128); - expect(snapshot.settings.hapticsBufferLength).toBe(128); + expect(command?.[9]).toBe(240); + expect(snapshot.settings.hapticsBufferLength).toBe(240); }); it('sends and stores adaptive trigger intensity', async () => { @@ -1771,6 +1771,70 @@ describe('BridgeService', () => { await flushImmediate(); }); + it.runIf(process.platform === 'linux')( + 'starts the volume guard when HD Volume Sync is off and stops it when toggled on', + async () => { + const service = serviceFixture({ hapticsVolumeSync: false }); + const guard = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), isActive: () => false }; + (service as unknown as { volumeGuardEngine: typeof guard }).volumeGuardEngine = guard; + + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: true }); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + + await poll(service); + expect(guard.start).toHaveBeenCalledTimes(1); + expect(guard.stop).not.toHaveBeenCalled(); + + (service as unknown as { settingsStore: SettingsStore }).settingsStore.update({ hapticsVolumeSync: true }); + guard.start.mockClear(); + await poll(service); + expect(guard.stop).toHaveBeenCalled(); + expect(guard.start).not.toHaveBeenCalled(); + } + ); + + it.runIf(process.platform === 'linux')( + 'setHapticsVolumeSync persists the setting and reconciles the volume guard immediately', + async () => { + const service = serviceFixture({ hapticsVolumeSync: false }); + const guard = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), isActive: () => false }; + (service as unknown as { volumeGuardEngine: typeof guard }).volumeGuardEngine = guard; + + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: true }); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + + await poll(service); + expect(guard.start).toHaveBeenCalledTimes(1); + + guard.start.mockClear(); + const snapshot = await service.setHapticsVolumeSync(true); + expect(snapshot.settings.hapticsVolumeSync).toBe(true); + expect(guard.stop).toHaveBeenCalled(); + expect(guard.start).not.toHaveBeenCalled(); + } + ); + + it.runIf(process.platform === 'linux')( + 'stops the volume guard when the controller audio path is not ready', + async () => { + const service = serviceFixture({ hapticsVolumeSync: false }); + const guard = { start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), isActive: () => false }; + (service as unknown as { volumeGuardEngine: typeof guard }).volumeGuardEngine = guard; + + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: false }); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + + await poll(service); + expect(guard.start).not.toHaveBeenCalled(); + } + ); + it('ignores controller mic mute events when mic pass-through is not armed', async () => { const service = serviceFixture({ duplexMicEnabled: true, @@ -2214,6 +2278,28 @@ describe('BridgeService', () => { expect(snapshot.settings.notifyLowBattery).toBe(true); }); + it('emits mic mute toasts for app toggles', async () => { + const service = serviceFixture(); + const device = new MockHidDevice(); + device.status = statusReport({ controllerConnected: true }); + const toasts: Array<{ title: string; body: string }> = []; + service.on('toast', (toast) => toasts.push(toast)); + hidMock.state.devicesList = [companionDeviceInfo()]; + hidMock.state.openDevices.set('companion-path', device); + await poll(service); + + await service.setMicMute(true); + expect(toasts.at(-1)?.body).toBe('Microphone muted'); + + await service.setMicMute(false); + expect(toasts.at(-1)?.body).toBe('Microphone unmuted'); + expect(toasts).toHaveLength(2); + + // No state change, no toast. + await service.setMicMute(false); + expect(toasts).toHaveLength(2); + }); + it('emits controller connect and disconnect toasts on status transitions', async () => { const service = serviceFixture(); const device = new MockHidDevice(); diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 981d120..0fed3a0 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -43,6 +43,7 @@ import type { AudioReactiveHapticsBassFocus, AudioReactiveHapticsConfig, AudioReactiveHapticsMode, + AudioOutputDevice, AudioReactiveHapticsRelease, AudioReactiveHapticsResponse, AudioReactiveHapticsSource, @@ -81,6 +82,8 @@ import { AudioHapticsSessionMonitor, MicKeepaliveEngine, SystemAudioHapticsEngine, + VolumeGuardEngine, + listAudioOutputDevices, playBridgeHapticsTestPattern, playBridgeSpeakerTestTone, getDefaultRenderEndpointStatus, @@ -97,6 +100,7 @@ const POLL_INTERVAL_MS = 500; const SHORTCUT_POLL_INTERVAL_MS = 50; const SHORTCUT_POLL_ERROR_RETRY_MS = 250; const AUDIO_STATUS_READ_INTERVAL_MS = 500; +const MIC_MUTE_RECONCILE_HOLDOFF_MS = 2000; const AUDIO_DEBUG_READ_INTERVAL_MS = 500; const TRIGGER_TRACE_READ_INTERVAL_MS = 250; const FEEDBACK_TRACE_READ_INTERVAL_MS = 250; @@ -506,6 +510,15 @@ function normalizeAudioReactiveHapticsSource(source: unknown): AudioReactiveHapt if (!source || typeof source !== 'object') { return 'system-audio'; } + const deviceCandidate = source as Partial>; + if (deviceCandidate.kind === 'output-device') { + const nodeName = normalizeOptionalString(deviceCandidate.nodeName); + if (!nodeName) { + return 'system-audio'; + } + const displayName = normalizeOptionalString(deviceCandidate.displayName); + return { kind: 'output-device', nodeName, ...(displayName ? { displayName } : {}) }; + } const candidate = source as Partial>; if (candidate.kind !== 'app-session') { return 'system-audio'; @@ -538,6 +551,9 @@ function audioReactiveHapticsSourceKey(source: AudioReactiveHapticsSource): stri if (source === 'controller-audio' || source === 'system-audio') { return source; } + if (source.kind === 'output-device') { + return `output-device:${source.nodeName}`; + } if (source.processPath) { return `app-path:${source.processPath.toLowerCase()}`; } @@ -593,7 +609,8 @@ function normalizeAudioReactiveHapticsConfig( bassFocus: normalizeAudioReactiveHapticsBassFocus(config.bassFocus ?? settings.audioReactiveHapticsBassFocus), response: normalizeAudioReactiveHapticsResponse(config.response ?? settings.audioReactiveHapticsResponse), attack: normalizeAudioReactiveHapticsAttack(config.attack ?? settings.audioReactiveHapticsAttack), - release: normalizeAudioReactiveHapticsRelease(config.release ?? settings.audioReactiveHapticsRelease) + release: normalizeAudioReactiveHapticsRelease(config.release ?? settings.audioReactiveHapticsRelease), + volumeSync: typeof config.volumeSync === 'boolean' ? config.volumeSync : settings.audioReactiveHapticsVolumeSync }; } @@ -1363,6 +1380,7 @@ export class BridgeService extends EventEmitter { private readonly systemAudioHapticsEngine = new SystemAudioHapticsEngine(); private readonly audioHapticsSessionMonitor = new AudioHapticsSessionMonitor(); private readonly micKeepaliveEngine = new MicKeepaliveEngine(); + private readonly volumeGuardEngine = new VolumeGuardEngine(); private readonly hidDiscovery = new HidDiscoveryClient(); private audioHapticsSessionCache: { key: string; expiresAt: number; sessions: AudioHapticsSession[] } | null = null; private audioHapticsSessionListInFlight: Promise | null = null; @@ -1396,6 +1414,10 @@ export class BridgeService extends EventEmitter { private systemAudioHapticsPassthroughActive = false; private commandQueue: Promise = Promise.resolve(); private lastAudioStatusReadAt = 0; + // Set when this app sends SET_MIC_MUTE. The status-poll micMuted reconcile + // is skipped inside this window: the polled status report may predate the + // command, and adopting it would snap the UI back to the stale state. + private lastMicMuteCommandAt = 0; private lastAudioDebugReadAt = 0; private lastTriggerTraceReadAt = 0; private lastFeedbackTraceReadAt = 0; @@ -1459,6 +1481,16 @@ export class BridgeService extends EventEmitter { } this.emitSnapshot(); }); + this.volumeGuardEngine.on('error', (error: Error) => { + this.appendAudioDebugLines([`[VolumeGuard] error: ${error.message}`]); + this.emitSnapshot(); + }); + this.volumeGuardEngine.on('status', (line: string) => { + if (line) { + this.appendAudioDebugLines([`[VolumeGuard] ${line}`]); + } + this.emitSnapshot(); + }); } private enqueueShortcutEvent(event: InputShortcutEvent): void { @@ -1548,6 +1580,11 @@ export class BridgeService extends EventEmitter { await this.systemAudioHapticsEngine.stop(); await this.stopAudioHapticsSessionPolling(); await this.micKeepaliveEngine.stop(); + await this.volumeGuardEngine.stop(); + } + + async listAudioOutputDevices(): Promise { + return listAudioOutputDevices(); } async listAudioHapticsSessions(): Promise { @@ -2183,7 +2220,8 @@ export class BridgeService extends EventEmitter { bassFocus: settings.audioReactiveHapticsBassFocus, response: settings.audioReactiveHapticsResponse, attack: settings.audioReactiveHapticsAttack, - release: settings.audioReactiveHapticsRelease + release: settings.audioReactiveHapticsRelease, + volumeSync: settings.audioReactiveHapticsVolumeSync }; } @@ -2292,7 +2330,7 @@ export class BridgeService extends EventEmitter { } async setHapticsBufferLength(length: number): Promise { - const value = Math.max(16, Math.min(128, Math.round(length))); + const value = Math.max(16, Math.min(240, Math.round(length))); await this.sendSettingCommand(COMMAND_ID.SET_HAPTICS_BUFFER_LENGTH, value, { hapticsBufferLength: value }); return this.getSnapshot(); } @@ -2333,6 +2371,13 @@ export class BridgeService extends EventEmitter { return this.getSnapshot(); } + async setHapticsVolumeSync(enabled: boolean): Promise { + this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ hapticsVolumeSync: enabled })); + await this.updateVolumeGuardEngine(this.controllerAudioReady()); + this.emitSnapshot(); + return this.getSnapshot(); + } + async setClassicRumbleEnabled(enabled: boolean): Promise { const settings = { ...this.settingsStore.get(), classicRumbleEnabled: enabled }; await this.sendSettingCommand( @@ -2433,9 +2478,13 @@ export class BridgeService extends EventEmitter { } async setMicMute(enabled: boolean): Promise { + this.lastMicMuteCommandAt = Date.now(); await this.sendCommand(COMMAND_ID.SET_MIC_MUTE, enabled ? 1 : 0, { expectSettingsRevisionChange: true }); + if (this.settingsStore.get().micMuted !== enabled) { + this.emitMicMuteToast(enabled); + } this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ micMuted: enabled })); @@ -2456,7 +2505,8 @@ export class BridgeService extends EventEmitter { audioReactiveHapticsBassFocus: normalized.bassFocus, audioReactiveHapticsResponse: normalized.response, audioReactiveHapticsAttack: normalized.attack, - audioReactiveHapticsRelease: normalized.release + audioReactiveHapticsRelease: normalized.release, + audioReactiveHapticsVolumeSync: normalized.volumeSync }; if (!this.audioReactiveHapticsSupported()) { throw new Error('Audio reactive haptics require updated bridge firmware.'); @@ -2491,7 +2541,8 @@ export class BridgeService extends EventEmitter { audioReactiveHapticsBassFocus: normalized.bassFocus, audioReactiveHapticsResponse: normalized.response, audioReactiveHapticsAttack: normalized.attack, - audioReactiveHapticsRelease: normalized.release + audioReactiveHapticsRelease: normalized.release, + audioReactiveHapticsVolumeSync: normalized.volumeSync })); await this.updateSystemAudioHapticsEngine(); this.emitSnapshot(); @@ -2500,6 +2551,7 @@ export class BridgeService extends EventEmitter { async setDuplexMicEnabled(enabled: boolean): Promise { const nextEnabled = enabled; + this.lastMicMuteCommandAt = Date.now(); if (!nextEnabled) { await this.sendCommand(COMMAND_ID.SET_MIC_MUTE, 1, { expectSettingsRevisionChange: true @@ -2513,6 +2565,9 @@ export class BridgeService extends EventEmitter { expectSettingsRevisionChange: true }); } + if (this.settingsStore.get().micMuted !== !nextEnabled) { + this.emitMicMuteToast(!nextEnabled); + } this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ duplexMicEnabled: nextEnabled, micMuted: !nextEnabled @@ -2841,11 +2896,21 @@ export class BridgeService extends EventEmitter { await this.sleepController(); } + private emitMicMuteToast(muted: boolean): void { + this.emit('toast', { + title: 'OpenDS5', + body: muted ? 'Microphone muted' : 'Microphone unmuted' + } satisfies BridgeToast); + } + private async applyControllerMicMuteEvent(micMuted: boolean): Promise { const settings = this.settingsStore.get(); if (!settings.duplexMicEnabled || settings.muteButtonMode !== 'normal') { return; } + if (settings.micMuted !== micMuted) { + this.emitMicMuteToast(micMuted); + } this.snapshot.settings = this.settingsStore.update(customSettingUpdate({ micMuted })); if (this.snapshot.status) { this.snapshot.status.micMuted = micMuted; @@ -3513,6 +3578,26 @@ export class BridgeService extends EventEmitter { } } + // Pins the bridge sink's haptic channels at unity while HD Volume Sync is + // off (Linux only). Reconciled from the poll loop when controller audio is + // ready, and immediately by setHapticsVolumeSync on toggle (as + // setDuplexMicEnabled does for mic keepalive). The guard is gated on + // controller readiness so the helper never hard-fails on a missing sink at + // boot and respawn-loops. + private async updateVolumeGuardEngine(controllerAudioReady: boolean): Promise { + try { + const settings = this.settingsStore.get(); + if (process.platform !== 'linux' || !controllerAudioReady || settings.hapticsVolumeSync) { + await this.volumeGuardEngine.stop(); + return; + } + await this.volumeGuardEngine.start(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.appendAudioDebugLines([`[VolumeGuard] error: ${message}`]); + } + } + private async pulseSystemAudioHaptics(): Promise { const settings = this.settingsStore.get(); if (!this.systemAudioHapticsDesired(settings)) { @@ -3620,8 +3705,10 @@ export class BridgeService extends EventEmitter { this.reappliedSessionKey === this.sessionKey && settings.duplexMicEnabled && settings.micMuted !== status.micMuted + && Date.now() - this.lastMicMuteCommandAt > MIC_MUTE_RECONCILE_HOLDOFF_MS ) { settings = this.settingsStore.update(customSettingUpdate({ micMuted: status.micMuted })); + this.emitMicMuteToast(status.micMuted); } const state = transition ? 'transitioning' : 'connected'; @@ -3655,6 +3742,7 @@ export class BridgeService extends EventEmitter { await this.restartSystemAudioHapticsAfterPersonaTransition(completedHostPersonaMode); } await this.updateMicKeepaliveEngine(status.controllerConnected); + await this.updateVolumeGuardEngine(this.controllerAudioReady(status)); await this.syncControllerPowerSavingState(settings); if (status.controllerConnected) { diff --git a/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts b/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts index f9d9b36..d456e2b 100644 --- a/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts +++ b/ds5-bridge/companion/src/main/game-settings-coordinator.test.ts @@ -84,6 +84,21 @@ describe('GameSettingsCoordinator', () => { expect(coordinator.getStatus().appliedProfileId).toBeNull(); }); + it('ignores a manually pinned trigger profile (pin is not the game running)', async () => { + service.controllerProfileId = 'my-profile'; + service.gameSettings.add('cyberpunk'); + const coordinator = new GameSettingsCoordinator(service, dir); + + coordinator.onEngineStatus({ + ...engineStatus('cyberpunk'), + matchedBy: 'pin', + matchedName: null + }); + await settle(coordinator); + expect(service.controllerProfileId).toBe('my-profile'); + expect(coordinator.getStatus().appliedProfileId).toBeNull(); + }); + it('leaves selections alone for a game without game settings', async () => { const coordinator = new GameSettingsCoordinator(service, dir); coordinator.onEngineStatus(engineStatus('elden-ring')); diff --git a/ds5-bridge/companion/src/main/game-settings-coordinator.ts b/ds5-bridge/companion/src/main/game-settings-coordinator.ts index 882b1db..d63e212 100644 --- a/ds5-bridge/companion/src/main/game-settings-coordinator.ts +++ b/ds5-bridge/companion/src/main/game-settings-coordinator.ts @@ -95,7 +95,11 @@ export class GameSettingsCoordinator extends EventEmitter { * running, and flapping the whole settings set for that would be wrong. */ onEngineStatus(status: EngineStatus): void { - const gameId = status.enabled && status.activeProfileId !== DEFAULT_PROFILE_ID + // Only a detected running game activates the game scope. A manual pin + // (matchedBy 'pin') is a trigger-profile override, not the game running, + // so it must not swap the full settings set or mark the game active. + const gameId = status.enabled && status.matchedBy === 'process' + && status.activeProfileId !== DEFAULT_PROFILE_ID ? status.activeProfileId : null; if (gameId === this.activeGameProfileId) return; diff --git a/ds5-bridge/companion/src/main/ipc-contract.test.ts b/ds5-bridge/companion/src/main/ipc-contract.test.ts index 3b072fd..1551838 100644 --- a/ds5-bridge/companion/src/main/ipc-contract.test.ts +++ b/ds5-bridge/companion/src/main/ipc-contract.test.ts @@ -107,6 +107,13 @@ describe('IPC contract', () => { expect(mainSource).toContain('rawPowerState === 0x01 || rawPowerState === 0x02'); }); + it('exposes the HD haptics Volume Sync channel', () => { + expect(preloadSource).toContain("ipcRenderer.invoke('bridge:setHapticsVolumeSync', value)"); + expect(mainSource).toContain("ipcMain.handle('bridge:setHapticsVolumeSync'"); + expect(bridgeServiceSource).toContain('async setHapticsVolumeSync(enabled: boolean): Promise'); + expect(bridgeServiceSource).toContain('hapticsVolumeSync: enabled'); + }); + it('exposes trigger profile engine channels', () => { expect(preloadSource).toContain("ipcRenderer.invoke('bridge:listTriggerProfiles')"); expect(preloadSource).toContain("ipcRenderer.invoke('bridge:saveTriggerProfile', profile)"); diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index 1237ef5..54bd28a 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -1333,6 +1333,7 @@ function registerIpc( ipcMain.handle('bridge:getStatus', () => service.getSnapshot()); ipcMain.handle('bridge:listDevices', () => service.listDevices()); + ipcMain.handle('bridge:listAudioOutputDevices', async () => service.listAudioOutputDevices()); ipcMain.handle('bridge:listAudioHapticsSessions', async () => ( addAudioHapticsSessionIcons(await service.listAudioHapticsSessions()) )); @@ -1355,6 +1356,7 @@ function registerIpc( ipcMain.handle('bridge:setFeedbackBoostEnabled', (_event, value: boolean) => ( service.setFeedbackBoostEnabled(value) )); + ipcMain.handle('bridge:setHapticsVolumeSync', (_event, value: boolean) => service.setHapticsVolumeSync(value)); ipcMain.handle('bridge:setHapticsBufferLength', (_event, value: number) => service.setHapticsBufferLength(value)); ipcMain.handle('bridge:setClassicRumbleGain', (_event, value: number) => service.setClassicRumbleGain(value)); ipcMain.handle('bridge:setClassicRumbleEnabled', (_event, value: boolean) => service.setClassicRumbleEnabled(value)); diff --git a/ds5-bridge/companion/src/main/settings-store.test.ts b/ds5-bridge/companion/src/main/settings-store.test.ts index 7cf49d5..c83bed7 100644 --- a/ds5-bridge/companion/src/main/settings-store.test.ts +++ b/ds5-bridge/companion/src/main/settings-store.test.ts @@ -430,8 +430,8 @@ describe('SettingsStore', () => { expect(store.update({ hapticsBufferLength: 2 }).hapticsBufferLength).toBe(16); expect(store.update({ hapticsBufferLength: 44.4 }).hapticsBufferLength).toBe(44); - expect(store.update({ hapticsBufferLength: 255 }).hapticsBufferLength).toBe(128); - expect(persistedSettings(userDataPath).hapticsBufferLength).toBe(128); + expect(store.update({ hapticsBufferLength: 255 }).hapticsBufferLength).toBe(240); + expect(persistedSettings(userDataPath).hapticsBufferLength).toBe(240); }); it('persists audio haptics app-session sources', () => { @@ -533,6 +533,36 @@ describe('SettingsStore', () => { expect(settings.controllerProfiles[1]?.settings.lightbarColor).toBe('#123456'); }); + it('defaults both volume-sync toggles to true', () => { + const settings = new SettingsStore(tempUserDataPath()).get(); + expect(settings.audioReactiveHapticsVolumeSync).toBe(true); + expect(settings.hapticsVolumeSync).toBe(true); + expect(DEFAULT_SETTINGS.audioReactiveHapticsVolumeSync).toBe(true); + expect(DEFAULT_SETTINGS.hapticsVolumeSync).toBe(true); + }); + + it('persists a stored false for the volume-sync toggles', () => { + const userDataPath = tempUserDataPath(); + writeFileSync(path.join(userDataPath, 'settings.json'), JSON.stringify({ + audioReactiveHapticsVolumeSync: false, + hapticsVolumeSync: false + }), 'utf8'); + const settings = new SettingsStore(userDataPath).get(); + expect(settings.audioReactiveHapticsVolumeSync).toBe(false); + expect(settings.hapticsVolumeSync).toBe(false); + }); + + it('coerces non-boolean volume-sync values back to true', () => { + const userDataPath = tempUserDataPath(); + writeFileSync(path.join(userDataPath, 'settings.json'), JSON.stringify({ + audioReactiveHapticsVolumeSync: 'nope', + hapticsVolumeSync: 0 + }), 'utf8'); + const settings = new SettingsStore(userDataPath).get(); + expect(settings.audioReactiveHapticsVolumeSync).toBe(true); + expect(settings.hapticsVolumeSync).toBe(true); + }); + it('persists the selected UI theme preset', () => { const userDataPath = tempUserDataPath(); const store = new SettingsStore(userDataPath); diff --git a/ds5-bridge/companion/src/main/settings-store.ts b/ds5-bridge/companion/src/main/settings-store.ts index 51a7877..1cf570f 100644 --- a/ds5-bridge/companion/src/main/settings-store.ts +++ b/ds5-bridge/companion/src/main/settings-store.ts @@ -150,7 +150,7 @@ export const DEFAULT_SETTINGS: CompanionSettings = { hapticsEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.hapticsEnabled, hapticsGainPercent: DEFAULT_CONTROLLER_PROFILE_SETTINGS.hapticsGainPercent, feedbackBoostEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.feedbackBoostEnabled, - hapticsBufferLength: 64, + hapticsBufferLength: 120, classicRumbleEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.classicRumbleEnabled, classicRumbleGainPercent: DEFAULT_CONTROLLER_PROFILE_SETTINGS.classicRumbleGainPercent, classicRumbleV1Enabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.classicRumbleV1Enabled, @@ -170,6 +170,8 @@ export const DEFAULT_SETTINGS: CompanionSettings = { audioReactiveHapticsResponse: DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsResponse, audioReactiveHapticsAttack: DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsAttack, audioReactiveHapticsRelease: DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsRelease, + audioReactiveHapticsVolumeSync: true, + hapticsVolumeSync: true, lightbarEnabled: DEFAULT_CONTROLLER_PROFILE_SETTINGS.lightbarEnabled, lightbarColor: DEFAULT_CONTROLLER_PROFILE_SETTINGS.lightbarColor, lightbarBrightnessPercent: DEFAULT_CONTROLLER_PROFILE_SETTINGS.lightbarBrightnessPercent, @@ -245,6 +247,18 @@ function normalizeAudioReactiveHapticsSource(value: unknown): AudioReactiveHapti if (!value || typeof value !== 'object') { return DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsSource; } + const deviceCandidate = value as Partial>; + if (deviceCandidate.kind === 'output-device') { + const nodeName = normalizeOptionalString(deviceCandidate.nodeName); + if (!nodeName) { + return DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsSource; + } + return { + kind: 'output-device', + nodeName, + displayName: normalizeOptionalString(deviceCandidate.displayName) + }; + } const candidate = value as Partial>; if (candidate.kind !== 'app-session') { return DEFAULT_CONTROLLER_PROFILE_SETTINGS.audioReactiveHapticsSource; @@ -897,7 +911,7 @@ function normalizeSettings(value: Partial | null | undefined) ? value.feedbackBoostEnabled : DEFAULT_SETTINGS.feedbackBoostEnabled, hapticsBufferLength: Number.isFinite(value?.hapticsBufferLength) - ? Math.max(16, Math.min(128, Math.round(value!.hapticsBufferLength!))) + ? Math.max(16, Math.min(240, Math.round(value!.hapticsBufferLength!))) : DEFAULT_SETTINGS.hapticsBufferLength, classicRumbleEnabled: typeof value?.classicRumbleEnabled === 'boolean' ? value.classicRumbleEnabled @@ -944,6 +958,12 @@ function normalizeSettings(value: Partial | null | undefined) audioReactiveHapticsResponse: normalizeAudioReactiveHapticsResponse(value?.audioReactiveHapticsResponse), audioReactiveHapticsAttack: normalizeAudioReactiveHapticsAttack(value?.audioReactiveHapticsAttack), audioReactiveHapticsRelease: normalizeAudioReactiveHapticsRelease(value?.audioReactiveHapticsRelease), + audioReactiveHapticsVolumeSync: typeof value?.audioReactiveHapticsVolumeSync === 'boolean' + ? value.audioReactiveHapticsVolumeSync + : DEFAULT_SETTINGS.audioReactiveHapticsVolumeSync, + hapticsVolumeSync: typeof value?.hapticsVolumeSync === 'boolean' + ? value.hapticsVolumeSync + : DEFAULT_SETTINGS.hapticsVolumeSync, lightbarEnabled: typeof value?.lightbarEnabled === 'boolean' ? value.lightbarEnabled : DEFAULT_SETTINGS.lightbarEnabled, diff --git a/ds5-bridge/companion/src/main/trigger-profile-store.test.ts b/ds5-bridge/companion/src/main/trigger-profile-store.test.ts index 58d815c..6ecc541 100644 --- a/ds5-bridge/companion/src/main/trigger-profile-store.test.ts +++ b/ds5-bridge/companion/src/main/trigger-profile-store.test.ts @@ -119,6 +119,25 @@ describe('TriggerProfileStore importProfile', () => { expect(result.ok).toBe(false); }); + it('strips meta.game so an import never lands as a game profile', () => { + const result = store.importProfile({ ...profile, meta: { game: 'Cyberpunk 2077', author: 'me' } }, 'import'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.profile.meta?.game).toBeUndefined(); + expect(result.profile.meta?.author).toBe('me'); + } + }); + + it('strips meta.game on library installs too', () => { + const result = store.importProfile( + { ...profile, meta: { game: 'Cyberpunk 2077' } }, + 'library', + 'generic-shooter.json' + ); + expect(result.ok).toBe(true); + if (result.ok) expect(result.profile.meta?.game).toBeUndefined(); + }); + it('records the library file a library install came from', () => { const result = store.importProfile(profile, 'library', 'generic-shooter.json'); expect(result.ok).toBe(true); @@ -169,6 +188,24 @@ describe('TriggerProfileStore resetToLibrary', () => { expect(store.list().filter((p) => p.id !== 'default')).toHaveLength(1); }); + it('keeps a locally attached game binding instead of taking meta.game from the fetched copy', () => { + const original = install(); + // The user attached the installed profile to a local game. + store.save({ ...original, meta: { ...original.meta, game: 'My Local Game' } }); + + const result = store.resetToLibrary(original.id, { ...installed, meta: { game: 'Publisher Game' } }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.profile.meta?.game).toBe('My Local Game'); + }); + + it('does not adopt a game binding from the fetched copy when none exists locally', () => { + const original = install(); + const result = store.resetToLibrary(original.id, { ...installed, meta: { game: 'Publisher Game' } }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.profile.meta?.game).toBeUndefined(); + }); + it('keeps the libraryFile so the profile can be reset again', () => { const original = install(); const result = store.resetToLibrary(original.id, installed); diff --git a/ds5-bridge/companion/src/main/trigger-profile-store.ts b/ds5-bridge/companion/src/main/trigger-profile-store.ts index 6151b48..04de81a 100644 --- a/ds5-bridge/companion/src/main/trigger-profile-store.ts +++ b/ds5-bridge/companion/src/main/trigger-profile-store.ts @@ -125,11 +125,16 @@ export class TriggerProfileStore { const existingNames = new Set(existing.map((entry) => entry.name)); const name = this.uniqueName(result.profile.name, existingNames); const id = uniqueTriggerProfileId(name, existingIds); + // Imported files can claim a game via meta.game, but the game was never + // located on this machine — keeping it would mint a pathless game profile + // that duplicates any real one. Imports always land as plain trigger + // profiles; the user attaches a game locally. + const { game: _droppedGame, ...incomingMeta } = result.profile.meta ?? {}; const copy: TriggerProfile = { ...result.profile, id, name, - meta: { ...result.profile.meta, source, ...(libraryFile ? { libraryFile } : {}) } + meta: { ...incomingMeta, source, ...(libraryFile ? { libraryFile } : {}) } }; return { ok: true, profile: this.save(copy) }; } @@ -145,13 +150,17 @@ export class TriggerProfileStore { if (!current) return { ok: false, error: `No profile with id ${id}` }; const result = validateTriggerProfile(parsed); if (!result.ok) return { ok: false, error: result.error }; + // A game binding is local state (attached on this machine), never part of + // the published profile — keep ours, ignore any meta.game in the fetch. + const { game: _fetchedGame, ...fetchedMeta } = result.profile.meta ?? {}; const restored: TriggerProfile = { ...result.profile, id: current.id, name: current.name, meta: { - ...result.profile.meta, + ...fetchedMeta, source: 'library', + ...(current.meta?.game ? { game: current.meta.game } : {}), ...(current.meta?.libraryFile ? { libraryFile: current.meta.libraryFile } : {}) } }; diff --git a/ds5-bridge/companion/src/preload.ts b/ds5-bridge/companion/src/preload.ts index fcf76d0..5cdd231 100644 --- a/ds5-bridge/companion/src/preload.ts +++ b/ds5-bridge/companion/src/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { AdaptiveTriggerPreviewEffect, + AudioOutputDevice, AudioReactiveHapticsConfig, BridgePresetId, ChordAssignment, @@ -56,6 +57,9 @@ const api = { listAudioHapticsSessions: (): Promise => ( ipcRenderer.invoke('bridge:listAudioHapticsSessions') ), + listAudioOutputDevices: (): Promise => ( + ipcRenderer.invoke('bridge:listAudioOutputDevices') + ), applyPreset: (value: BridgePresetId): Promise => ipcRenderer.invoke('bridge:applyPreset', value), selectControllerProfile: (profileId: string): Promise => ( ipcRenderer.invoke('bridge:selectControllerProfile', profileId) @@ -77,6 +81,9 @@ const api = { setFeedbackBoostEnabled: (value: boolean): Promise => ( ipcRenderer.invoke('bridge:setFeedbackBoostEnabled', value) ), + setHapticsVolumeSync: (value: boolean): Promise => ( + ipcRenderer.invoke('bridge:setHapticsVolumeSync', value) + ), setHapticsBufferLength: (value: number): Promise => ( ipcRenderer.invoke('bridge:setHapticsBufferLength', value) ), diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 1c5b300..ae7f5cf 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -116,6 +116,7 @@ import type { AudioReactiveHapticsBassFocus, AudioReactiveHapticsConfig, AudioReactiveHapticsMode, + AudioOutputDevice, AudioReactiveHapticsSource, AudioReactiveHapticsAttack, AudioReactiveHapticsRelease, @@ -280,9 +281,13 @@ const BOOSTED_FEEDBACK_GAIN_PERCENT = 500; const SPEAKER_VOLUME_STEP = 10; const MIC_VOLUME_STEP = 10; const AUDIO_BUFFER_LENGTH_MIN = 16; -const AUDIO_BUFFER_LENGTH_MAX = 128; -const AUDIO_BUFFER_LENGTH_HIGH_STUTTER_MAX = 44; -const AUDIO_BUFFER_LENGTH_RISKY_MAX = 63; +const AUDIO_BUFFER_LENGTH_MAX = 240; +// Zone boundaries follow the Linux daemon's 10 ms chunk queue: it rounds the +// value to whole chunks (30 samples each, floor of 2), so <=74 all map to the +// 2-chunk floor, 75-104 to 3 chunks, and 4+ chunks (>=105) give enough +// headroom for bursty USB arrival. +const AUDIO_BUFFER_LENGTH_HIGH_STUTTER_MAX = 74; +const AUDIO_BUFFER_LENGTH_RISKY_MAX = 104; const LIGHTBAR_BRIGHTNESS_STEP = 10; const TRIGGER_EFFECT_STEP = 10; const CONTROLLER_POWER_SAVING_CAP_PERCENT = 60; @@ -823,7 +828,17 @@ function audioHapticsSessionKey(session: AudioHapticsSession): string { return `app-pid:${session.processId}`; } +function audioHapticsOutputDeviceSource(source: AudioReactiveHapticsSource | null | undefined) { + return source && typeof source === 'object' && source.kind === 'output-device' + ? source + : null; +} + function audioHapticsSourceKey(source: AudioReactiveHapticsSource | null | undefined): string { + const deviceSource = audioHapticsOutputDeviceSource(source); + if (deviceSource) { + return `output-device:${deviceSource.nodeName}`; + } const appSource = audioHapticsAppSource(source); if (!appSource) { return 'system-audio'; @@ -850,6 +865,10 @@ function audioHapticsSourceFromSession(session: AudioHapticsSession): AudioReact } function audioHapticsSourceDisplayName(source: AudioReactiveHapticsSource | null | undefined): string { + const deviceSource = audioHapticsOutputDeviceSource(source); + if (deviceSource) { + return deviceSource.displayName || deviceSource.nodeName; + } const appSource = audioHapticsAppSource(source); if (!appSource) { return 'System'; @@ -869,7 +888,7 @@ function snapMicVolume(value: number): number { function clampAudioBufferLength(value: number): number { if (!Number.isFinite(value)) { - return 64; + return 120; } return Math.max(AUDIO_BUFFER_LENGTH_MIN, Math.min(AUDIO_BUFFER_LENGTH_MAX, Math.round(value))); } @@ -2811,6 +2830,7 @@ export function App() { const [triggerEffectIntensityValue, setTriggerEffectIntensityValue] = useState(100); const [audioHapticsOpen, setAudioHapticsOpen] = useState(false); const [audioHapticsSessions, setAudioHapticsSessions] = useState([]); + const [audioOutputDevices, setAudioOutputDevices] = useState([]); const [audioHapticsSessionsLoading, setAudioHapticsSessionsLoading] = useState(false); const [triggerProfiles, setTriggerProfiles] = useState([]); const [triggerProfilesEnabled, setTriggerProfilesEnabled] = useState(false); @@ -2961,6 +2981,7 @@ export function App() { const [classicRumbleCommitPending, setClassicRumbleCommitPending] = useState(false); const [classicRumbleV1CommitPending, setClassicRumbleV1CommitPending] = useState(false); const [feedbackBoostCommitPending, setFeedbackBoostCommitPending] = useState(false); + const [hapticsVolumeSyncCommitPending, setHapticsVolumeSyncCommitPending] = useState(false); const [speakerVolumeCommitPending, setSpeakerVolumeCommitPending] = useState(false); const [micVolumeCommitPending, setMicVolumeCommitPending] = useState(false); const [audioBufferLengthCommitPending, setAudioBufferLengthCommitPending] = useState(false); @@ -3164,8 +3185,12 @@ export function App() { const openGameProfileEntry = openGameProfileId ? gameProfiles.find((profile) => profile.id === openGameProfileId) ?? null : null; + // A manually pinned trigger profile (matchedBy 'pin') is an override of the + // trigger engine only — it must not present the game profile card as the + // active game. const activeGameProfile = triggerProfileEngineStatus && triggerProfileEngineStatus.enabled + && triggerProfileEngineStatus.matchedBy === 'process' && triggerProfileEngineStatus.activeProfileId !== DEFAULT_PROFILE_ID ? gameProfiles.find((profile) => profile.id === triggerProfileEngineStatus.activeProfileId) ?? null : null; @@ -3563,13 +3588,18 @@ export function App() { refreshInFlight = true; setAudioHapticsSessionsLoading(true); try { - const sessions = await window.bridge.listAudioHapticsSessions(); + const [sessions, devices] = await Promise.all([ + window.bridge.listAudioHapticsSessions(), + window.bridge.listAudioOutputDevices() + ]); if (!cancelled) { setAudioHapticsSessions(sessions); + setAudioOutputDevices(devices); } } catch { if (!cancelled) { setAudioHapticsSessions([]); + setAudioOutputDevices([]); } } finally { refreshInFlight = false; @@ -3946,14 +3976,28 @@ export function App() { }, [audioHapticsSessions]); const selectedAudioHapticsSourceDisplayName = audioHapticsSessionByKey.get(audioReactiveHapticsSourceKey)?.displayName ?? audioHapticsSourceDisplayName(audioReactiveHapticsSource); + const audioOutputDeviceByKey = useMemo(() => { + const devices = new Map(); + for (const device of audioOutputDevices) { + devices.set(`output-device:${device.nodeName}`, device); + } + return devices; + }, [audioOutputDevices]); const audioHapticsSourceOptions = useMemo>(() => { - const options: Array<[string, string]> = [['System', 'system-audio']]; + const options: Array<[string, string]> = [['System (follows default output)', 'system-audio']]; + for (const device of audioOutputDevices) { + options.push([ + device.isDefault ? `${device.displayName} (default)` : device.displayName, + `output-device:${device.nodeName}` + ]); + } for (const session of audioHapticsSessions) { options.push([session.displayName, audioHapticsSessionKey(session)]); } if ( audioReactiveHapticsSourceKey !== 'system-audio' && !audioHapticsSessionByKey.has(audioReactiveHapticsSourceKey) + && !audioOutputDeviceByKey.has(audioReactiveHapticsSourceKey) ) { options.push([`${audioHapticsSourceDisplayName(audioReactiveHapticsSource)} unavailable`, audioReactiveHapticsSourceKey]); } @@ -3961,6 +4005,8 @@ export function App() { }, [ audioHapticsSessionByKey, audioHapticsSessions, + audioOutputDeviceByKey, + audioOutputDevices, audioReactiveHapticsSource, audioReactiveHapticsSourceKey ]); @@ -3983,6 +4029,8 @@ export function App() { const headsetOutputDetected = Boolean(audioStatus?.headsetPlugged); const controllerPowerSavingActive = controllerPowerSavingActiveFromSnapshot(snapshot); const feedbackBoostEnabled = Boolean(snapshot?.settings.feedbackBoostEnabled); + const hapticsVolumeSync = Boolean(snapshot?.settings.hapticsVolumeSync); + const audioReactiveHapticsVolumeSync = Boolean(snapshot?.settings.audioReactiveHapticsVolumeSync); const hapticsSliderMax = feedbackSliderMaxFromSnapshot(snapshot); const hapticsSliderTicks = feedbackSliderTicks(hapticsSliderMax); const percentSliderMax = controllerPowerSavingActive ? CONTROLLER_POWER_SAVING_CAP_PERCENT : 100; @@ -4795,6 +4843,13 @@ export function App() { void commitAudioReactiveHapticsConfig({ source: 'system-audio' }); return; } + const device = audioOutputDeviceByKey.get(value); + if (device) { + void commitAudioReactiveHapticsConfig({ + source: { kind: 'output-device', nodeName: device.nodeName, displayName: device.displayName } + }); + return; + } const session = audioHapticsSessionByKey.get(value); if (!session) { return; @@ -5664,6 +5719,30 @@ export function App() { })(); } + function toggleHapticsVolumeSync() { + if (!snapshot || hapticsVolumeSyncCommitPending) return; + + setHapticsVolumeSyncCommitPending(true); + void (async () => { + try { + const next = await window.bridge.setHapticsVolumeSync(!snapshot.settings.hapticsVolumeSync); + setSnapshot(next); + } catch { + const next = await window.bridge.getStatus(); + setSnapshot(next); + } finally { + setHapticsVolumeSyncCommitPending(false); + } + })(); + } + + function toggleAudioReactiveHapticsVolumeSync() { + if (!snapshot) return; + void commitAudioReactiveHapticsConfig({ + volumeSync: !snapshot.settings.audioReactiveHapticsVolumeSync + }); + } + function toggleSpeakerEnabled() { if (!snapshot) return; void runAction('speaker-enabled', () => window.bridge.setSpeakerEnabled(!snapshot.settings.speakerEnabled)); @@ -5721,6 +5800,12 @@ export function App() { if (!enabled && next.settings.duplexMicEnabled) { next = await window.bridge.setDuplexMicEnabled(false); } + if (enabled && !next.settings.duplexMicEnabled) { + // Re-enabling the audio section restores mic pass-through too; + // otherwise the mic stays disabled (and its controls grayed out) + // until the mic toggle is found and pressed separately. + next = await window.bridge.setDuplexMicEnabled(true); + } return next; }); } @@ -7959,6 +8044,23 @@ export function App() { /> +
+ Volume Sync + +
@@ -8128,6 +8230,23 @@ export function App() {
+
+ Volume Sync + +
{showClassicRumbleControl ? (
{ it('exposes the firmware-gated audio buffer length control', () => { expect(appSource).toContain('const AUDIO_BUFFER_LENGTH_MIN = 16;'); - expect(appSource).toContain('const AUDIO_BUFFER_LENGTH_MAX = 128;'); + expect(appSource).toContain('const AUDIO_BUFFER_LENGTH_MAX = 240;'); expect(appSource).toContain('audioBufferLengthControlSupported'); expect(appSource).toContain('firmwareFlags.hapticsBufferLengthControl'); expect(appSource).toContain('window.bridge.setHapticsBufferLength(snappedValue)'); diff --git a/ds5-bridge/companion/src/shared/protocol.ts b/ds5-bridge/companion/src/shared/protocol.ts index 1f3ae96..091b2b2 100644 --- a/ds5-bridge/companion/src/shared/protocol.ts +++ b/ds5-bridge/companion/src/shared/protocol.ts @@ -153,7 +153,21 @@ export interface AudioReactiveHapticsAppSource { sessionIdentifier?: string; sessionInstanceIdentifier?: string; } -export type AudioReactiveHapticsSource = 'controller-audio' | 'system-audio' | AudioReactiveHapticsAppSource; +export interface AudioReactiveHapticsOutputDeviceSource { + kind: 'output-device'; + nodeName: string; + displayName?: string; +} +export interface AudioOutputDevice { + nodeName: string; + displayName: string; + isDefault: boolean; +} +export type AudioReactiveHapticsSource = + | 'controller-audio' + | 'system-audio' + | AudioReactiveHapticsAppSource + | AudioReactiveHapticsOutputDeviceSource; export type AudioReactiveHapticsMode = 'mix' | 'replace'; export type AudioReactiveHapticsBassFocus = 'deep' | 'balanced' | 'punchy' | 'wide'; export type AudioReactiveHapticsResponse = 'subtle' | 'balanced' | 'strong'; @@ -168,6 +182,7 @@ export interface AudioReactiveHapticsConfig { response: AudioReactiveHapticsResponse; attack: AudioReactiveHapticsAttack; release: AudioReactiveHapticsRelease; + volumeSync: boolean; } export const BRIDGE_PRESET_IDS = [ 'custom', diff --git a/ds5-bridge/companion/src/shared/types.ts b/ds5-bridge/companion/src/shared/types.ts index 4de94fc..d115e7c 100644 --- a/ds5-bridge/companion/src/shared/types.ts +++ b/ds5-bridge/companion/src/shared/types.ts @@ -59,6 +59,8 @@ export interface CompanionSettings { audioReactiveHapticsResponse: AudioReactiveHapticsResponse; audioReactiveHapticsAttack: AudioReactiveHapticsAttack; audioReactiveHapticsRelease: AudioReactiveHapticsRelease; + audioReactiveHapticsVolumeSync: boolean; + hapticsVolumeSync: boolean; lightbarEnabled: boolean; lightbarColor: string; lightbarBrightnessPercent: number; diff --git a/vds/.github/workflows/build-all.yaml b/vds/.github/workflows/build-all.yaml index c577436..c6c6ab4 100644 --- a/vds/.github/workflows/build-all.yaml +++ b/vds/.github/workflows/build-all.yaml @@ -260,14 +260,34 @@ jobs: prerelease=true fi + release_notes="$(mktemp)" + git log -1 --format=%B "$GITHUB_SHA" > "$release_notes" + cat >> "$release_notes" <<'RELEASE_NOTES' + + > [!CAUTION] + > + > The vDS Windows drivers are experimental. Installing or running them can cause + > unexpected system crashes or make Windows fail to boot. Use at your own risk. + > + > Open an elevated PowerShell session, enable Windows test signing, and reboot + > before installing the driver package: + > + > ```powershell + > bcdedit /set testsigning on + > shutdown /r /t 0 + > ``` + RELEASE_NOTES + if gh release view "$RAW_VERSION" >/dev/null 2>&1; then - gh release edit "$RAW_VERSION" --title "$RAW_VERSION" + gh release edit "$RAW_VERSION" \ + --title "$RAW_VERSION" \ + --notes-file "$release_notes" gh release upload "$RAW_VERSION" "${assets[@]}" --clobber else gh release create "$RAW_VERSION" "${assets[@]}" \ --target "$GITHUB_SHA" \ --title "$RAW_VERSION" \ - --notes "" + --notes-file "$release_notes" fi release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RAW_VERSION}" --jq .id)" diff --git a/vds/99-vds-dualsense-wireplumber.conf b/vds/99-vds-dualsense-wireplumber.conf index 4d126ea..3ebf452 100644 --- a/vds/99-vds-dualsense-wireplumber.conf +++ b/vds/99-vds-dualsense-wireplumber.conf @@ -9,6 +9,12 @@ monitor.alsa.rules = [ "update-props": { "device.description": "DualSense Wireless Controller" "device.nick": "DualSense" + # Always bring the card up in the pro-audio profile so the raw + # 4-channel speaker+haptics PCM ("Direct DualSense Wireless + # Controller") exists deterministically after every reconnect. + # Convenience profiles route stereo through PipeWire's channel + # mixer and software volume, which audibly degrades the BT path. + "device.profile": "pro-audio" } } } @@ -40,13 +46,24 @@ monitor.alsa.rules = [ "update-props": { "node.description": "Wireless Controller Mic" "node.nick": "Wireless Controller Mic" - # vDS does not support microphone input yet. Disable this source because - # capture traffic can disrupt the controller speaker, haptics output, - # and main audio playback. - "node.disabled": true + # Use passive links so idle mic monitors do not keep this source busy; + # otherwise the virtual USB mic can stay active after capture stops. + "node.passive": true + # Give this source no graph-driver priority because vDS mic pacing is + # bridged through Bluetooth and should not clock unrelated playback. + "priority.driver": 0 + # Keep this source visible but avoid selecting it as the default input. "priority.session": 1 - "priority.driver": 1 - "session.suspend-timeout-seconds": 0 + # Match PipeWire's default 128-frame graph quantum. vDS buffers the + # 10 ms Bluetooth mic packets in the virtual HCD, so forcing a 480-frame + # ALSA period makes PipeWire repeatedly resync this capture node. + "api.alsa.period-size": 128 + "api.alsa.period-num": 8 + "node.latency": "128/48000" + # Tell WirePlumber to suspend this source after 1 second of inactivity. + # This closes ALSA so idle level meters do not keep the controller mic + # stream active when no application is actually recording from it. + "session.suspend-timeout-seconds": 1 } } } diff --git a/vds/CMakeLists.txt b/vds/CMakeLists.txt index cc44ad4..7f72388 100644 --- a/vds/CMakeLists.txt +++ b/vds/CMakeLists.txt @@ -58,7 +58,9 @@ target_include_directories(jsonl PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) if(WIN32) add_library(vds_win32 OBJECT src/platform/win32/vds_win32.cc) - target_include_directories(vds_win32 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_include_directories( + vds_win32 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/src) add_library(vds_io OBJECT src/platform/win32/vds_io.cc) target_include_directories(vds_io PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include @@ -177,6 +179,11 @@ endif() add_executable(trigger_effect_v2_test tests/trigger_effect_v2_test.cc) target_link_libraries(trigger_effect_v2_test PRIVATE vds_protocol) +add_executable(companion_buffer_length_test tests/companion_buffer_length_test.cc) +target_link_libraries(companion_buffer_length_test + PRIVATE vdsd_common vds_protocol vds_log vds_config + vds_common jsonl) + if(WIN32) set(VDS_RUNTIME_INSTALL_DIR ".") set(VDS_INSTALLED_VDSD "${CMAKE_INSTALL_PREFIX}/vdsd.exe") diff --git a/vds/README-LINUX.md b/vds/README-LINUX.md index 5a18dc6..70b2a91 100644 --- a/vds/README-LINUX.md +++ b/vds/README-LINUX.md @@ -212,18 +212,10 @@ Set the `pro-audio` profile: wpctl set-profile ``` -> [!WARNING] -> -> The microphone/source node is disabled on purpose. vDS currently supports -> controller speaker and haptics output, but not microphone input. If -> WirePlumber, games, or tools such as `pavucontrol` open the capture endpoint, -> the extra USB audio traffic can disrupt controller speaker and haptics output, -> as well as main audio playback. - After setting the `pro-audio` profile, install the included WirePlumber rule. It gives the virtual controller stable display names, sets the output node to 4 -channels, disables channelmix normalization, disables the unsupported microphone -source node, and lowers its priority: +channels, disables channelmix normalization, and gives the microphone source a +low priority: ```sh mkdir -p ~/.config/wireplumber/wireplumber.conf.d diff --git a/vds/README.md b/vds/README.md index 8167c98..e6567e5 100644 --- a/vds/README.md +++ b/vds/README.md @@ -3,14 +3,17 @@ [![Sponsor](https://img.shields.io/badge/Sponsor-hurryman2212-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/hurryman2212) Virtual USB-to-Bluetooth bridge for DualSense and DualSense Edge Wireless -Controllers. Through Linux `vds_hcd.ko` and Windows `vds_usb.sys` kernel -drivers, vDS exposes a Bluetooth-connected controller as a virtual USB -DualSense-class device so games and applications can use features normally -available only over USB. The common userspace daemon `vdsd` translates the -virtual USB traffic to and from the physical controller's Bluetooth protocol. -Currently, vDS supports USB-based features that can be carried over Bluetooth, -except headset output, microphone input, and firmware updates (firmware updates -is not possible). +Controllers. vDS currently supports all USB-based DualSense features over +Bluetooth (except firmware update), including quadraphonic haptic feedback +(vibration and speaker output), adaptive triggers, microphone input, and +headphone output; microphone and headphone support was added thanks to +[@TechAntohere](https://github.com/TechAntohere). + +Through Linux `vds_hcd.ko` and Windows `vds_usb.sys` kernel drivers, vDS exposes +a Bluetooth-connected controller as a virtual USB DualSense-class device so +games and applications can use features normally available only over USB. The +common userspace daemon `vdsd` translates the virtual USB traffic to and from +the physical controller's Bluetooth protocol. Detailed DualSense output and haptics packet handling is based on [DS5Dongle](https://github.com/awalol/DS5Dongle) and protocol capture research. @@ -33,8 +36,8 @@ their addresses. ```sh vdsctl list-targets -vdsctl attach aa:bb:cc:dd:ee:01 --profile ds5 --ports 0 -vdsctl attach aa:bb:cc:dd:ee:02 --profile dse --ports 1 +vdsctl attach aa:bb:cc:dd:ee:01 --profile ds5 --ports 0 # Connect as DualSense +vdsctl attach aa:bb:cc:dd:ee:02 --profile dse --ports 1 # Connect as DualSense Edge vdsctl detach aa:bb:cc:dd:ee:02 # Detach aa:bb:cc:dd:ee:02 vdsctl list # Show registered controllers ``` diff --git a/vds/include/uapi/vds.h b/vds/include/uapi/vds.h index 6edb56b..ecd9246 100644 --- a/vds/include/uapi/vds.h +++ b/vds/include/uapi/vds.h @@ -45,6 +45,7 @@ enum vds_frame_type { VDS_FRAME_USB_INTERFACE = 7, VDS_FRAME_BT_CONTROL_PACKET = 8, VDS_FRAME_BT_INTERRUPT_PACKET = 9, + VDS_FRAME_USB_AUDIO_IN = 10, }; enum vds_status_flags { @@ -64,16 +65,19 @@ enum vds_port_info_flags { VDS_PORT_INFO_USB_PROFILE_VALID = 1u << 5, }; -enum vds_usb_interface_kind { +enum vds_usb_interface_type { VDS_USB_INTERFACE_HID = 0, VDS_USB_INTERFACE_AUDIO_OUT = 1, VDS_USB_INTERFACE_AUDIO_IN = 2, }; enum { + VDS_DRIVER_INFO_VERSION = 1, + VDS_DRIVER_VERSION_MAX = 64, VDS_PORT_INFO_VERSION = 2, VDS_PORT_BIND_VERSION = 1, VDS_FILTER_DEVICE_LIST_VERSION = 1, + VDS_FILTER_DEVICE_CHANGE_VERSION = 1, VDS_FILTER_MAX_DEVICES = 8, }; @@ -93,7 +97,7 @@ struct vds_frame_header { struct vds_usb_interface_event { __u8 interface_number; __u8 altsetting; - __u8 interface_kind; + __u8 interface_type; __u8 reserved; }; @@ -104,6 +108,12 @@ struct vds_status { __u64 frames_from_user; }; +struct vds_driver_info { + __u32 version; + __u32 size; + char driver_version[VDS_DRIVER_VERSION_MAX]; +}; + struct vds_profile_config { __u32 profile; __u32 polling_rate_mode; @@ -136,10 +146,17 @@ struct vds_filter_device_list { __u32 version; __u32 size; __u32 count; - __u32 reserved; + __u32 generation; struct vds_filter_device_info devices[VDS_FILTER_MAX_DEVICES]; }; +struct vds_filter_device_change { + __u32 version; + __u32 size; + __u32 generation; + __u32 reserved; +}; + #ifdef _WIN32 #define VDS_WIN_FILE_DEVICE_UNKNOWN 0x00000022 #define VDS_WIN_METHOD_BUFFERED 0 @@ -149,6 +166,9 @@ struct vds_filter_device_list { (((device_type) << 16) | ((access) << 14) | ((function) << 2) | \ (method)) +#define VDS_IOCTL_GET_DRIVER_INFO \ + VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x800, \ + VDS_WIN_METHOD_BUFFERED, VDS_WIN_FILE_READ_DATA) #define VDS_IOCTL_GET_PORT_INFO \ VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x802, \ VDS_WIN_METHOD_BUFFERED, VDS_WIN_FILE_READ_DATA) @@ -163,11 +183,17 @@ struct vds_filter_device_list { #define VDS_FILTER_IOCTL_GET_DEVICES \ VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x900, \ VDS_WIN_METHOD_BUFFERED, VDS_WIN_FILE_READ_DATA) +#define VDS_FILTER_IOCTL_WAIT_DEVICE_CHANGE \ + VDS_WIN_CTL_CODE(VDS_WIN_FILE_DEVICE_UNKNOWN, 0x901, \ + VDS_WIN_METHOD_BUFFERED, \ + VDS_WIN_FILE_READ_DATA | VDS_WIN_FILE_WRITE_DATA) #else #define VDS_IOC_GET_STATUS _IOR(VDS_IOC_MAGIC, 0x01, struct vds_status) #define VDS_IOC_SET_PROFILE _IOW(VDS_IOC_MAGIC, 0x02, struct vds_profile_config) #define VDS_IOC_CONNECT _IO(VDS_IOC_MAGIC, 0x03) #define VDS_IOC_DISCONNECT _IO(VDS_IOC_MAGIC, 0x04) +#define VDS_IOC_GET_DRIVER_INFO \ + _IOR(VDS_IOC_MAGIC, 0x05, struct vds_driver_info) #endif #endif /* _UAPI_VDS_H */ diff --git a/vds/install-service.sh b/vds/install-service.sh index 7d973f8..cf6cee2 100755 --- a/vds/install-service.sh +++ b/vds/install-service.sh @@ -60,10 +60,10 @@ fi systemctl daemon-reload -if [ "$enable_service" -eq 1 ]; then +if [ "$enable_service" -eq 1 ] && [ "$start_service" -eq 1 ]; then + systemctl enable --now "$service_name" +elif [ "$enable_service" -eq 1 ]; then systemctl enable "$service_name" -fi - -if [ "$start_service" -eq 1 ]; then +elif [ "$start_service" -eq 1 ]; then systemctl start "$service_name" fi diff --git a/vds/logrotate-vds.conf b/vds/logrotate-vds.conf new file mode 100644 index 0000000..87e436c --- /dev/null +++ b/vds/logrotate-vds.conf @@ -0,0 +1,12 @@ +/var/log/vdsd.log { + weekly + rotate 4 + missingok + notifempty + compress + delaycompress + create 0660 root root + postrotate + systemctl kill -s HUP vdsd.service >/dev/null 2>&1 || true + endscript +} diff --git a/vds/module/vds_controller.h b/vds/module/vds_controller.h index e6bc585..187d824 100644 --- a/vds/module/vds_controller.h +++ b/vds/module/vds_controller.h @@ -5,18 +5,9 @@ #include -#ifndef USB_DT_HID -#define USB_DT_HID 0x21 -#endif -#ifndef USB_DT_REPORT -#define USB_DT_REPORT 0x22 -#endif - -#define VDS_CONTROLLER_NO_INTERFACE 0xff -#define VDS_CONTROLLER_NO_ENDPOINT 0xff #define VDS_CONTROLLER_NO_AUDIO_FEATURE 0xff + #define VDS_CONTROLLER_HID_PACKET_SIZE 64 -#define VDS_CONTROLLER_AUDIO_FEATURE_COUNT 2 enum vds_controller_audio_feature { VDS_CONTROLLER_AUDIO_FEATURE_SPEAKER = 0, diff --git a/vds/module/vds_hcd_core.c b/vds/module/vds_hcd_core.c index 95e34c2..9909e13 100644 --- a/vds/module/vds_hcd_core.c +++ b/vds/module/vds_hcd_core.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +35,12 @@ #endif #define VDS_HCD_DRIVER_NAME "vds_hcd" +#define VDS_AUDIO_IN_BYTES_PER_MS 192 +#define VDS_AUDIO_OUT_BYTES_PER_MS 384 +#define VDS_AUDIO_IN_QUEUE_MS 100 +#define VDS_AUDIO_IN_QUEUE_MAX_BYTES \ + (VDS_AUDIO_IN_BYTES_PER_MS * VDS_AUDIO_IN_QUEUE_MS) + static unsigned int max_port = VDS_MAX_PORT_COUNT; module_param(max_port, uint, 0444); MODULE_PARM_DESC(max_port, "Number of virtual DualSense ports to create (1-4)"); @@ -56,6 +64,13 @@ struct vds_input_packet { u8 payload[VDS_CONTROLLER_HID_PACKET_SIZE]; }; +struct vds_audio_packet { + struct list_head list; + u32 offset; + u32 length; + u8 payload[VDS_FRAME_MAX_PAYLOAD]; +}; + enum vds_urb_context_state { VDS_URB_ACTIVE = 0, VDS_URB_PENDING_HID_IN, @@ -72,6 +87,7 @@ struct vds_urb_context { u8 feature_report_id; enum vds_urb_context_state state; bool deliver_iso_out; + bool deliver_iso_in; }; struct vds_hcd_dev { @@ -87,6 +103,7 @@ struct vds_hcd_dev { struct list_head pending_feature_get; struct list_head completed_urbs; struct list_head input_packets; + struct list_head audio_in_packets; struct task_struct *giveback_thread; u64 frames_to_user; u64 frames_from_user; @@ -101,6 +118,8 @@ struct vds_hcd_dev { bool opened; unsigned int port_index; u32 input_packet_count; + u32 audio_in_packet_count; + u32 audio_in_queued_bytes; struct vds_usb_device usb; }; @@ -394,6 +413,7 @@ static void vds_flush_buffered_frames_locked(struct vds_hcd_dev *dev) { struct vds_event *event, *event_tmp; struct vds_input_packet *packet, *packet_tmp; + struct vds_audio_packet *audio, *audio_tmp; list_for_each_entry_safe(packet, packet_tmp, &dev->input_packets, list) { @@ -402,6 +422,14 @@ static void vds_flush_buffered_frames_locked(struct vds_hcd_dev *dev) } dev->input_packet_count = 0; + list_for_each_entry_safe(audio, audio_tmp, &dev->audio_in_packets, + list) { + list_del(&audio->list); + kfree(audio); + } + dev->audio_in_packet_count = 0; + dev->audio_in_queued_bytes = 0; + list_for_each_entry_safe(event, event_tmp, &dev->events, list) { list_del(&event->list); kfree(event); @@ -488,6 +516,76 @@ static int vds_iso_out_urb(struct vds_hcd_dev *dev, struct urb *urb) return 0; } +static void vds_fill_audio_in_locked(struct vds_hcd_dev *dev, u8 *data, + u32 length) +{ + u32 copied = 0; + + while (copied < length && !list_empty(&dev->audio_in_packets)) { + struct vds_audio_packet *packet; + u32 available; + u32 copy_len; + + packet = list_first_entry(&dev->audio_in_packets, + struct vds_audio_packet, list); + available = packet->length - packet->offset; + copy_len = min(length - copied, available); + memcpy(data + copied, packet->payload + packet->offset, + copy_len); + copied += copy_len; + packet->offset += copy_len; + if (dev->audio_in_queued_bytes > copy_len) + dev->audio_in_queued_bytes -= copy_len; + else + dev->audio_in_queued_bytes = 0; + if (packet->offset == packet->length) { + list_del(&packet->list); + dev->audio_in_packet_count--; + kfree(packet); + } + } + + if (copied < length) + memset(data + copied, 0, length - copied); +} + +static int vds_iso_in_urb(struct vds_hcd_dev *dev, struct urb *urb) +{ + unsigned long flags; + u32 total = 0; + int i; + + if (!urb->transfer_buffer) + return -EPIPE; + + spin_lock_irqsave(&dev->lock, flags); + urb->error_count = 0; + for (i = 0; i < urb->number_of_packets; i++) { + struct usb_iso_packet_descriptor *desc = + &urb->iso_frame_desc[i]; + u8 *data; + + if (desc->offset > urb->transfer_buffer_length || + desc->length > urb->transfer_buffer_length - desc->offset) { + desc->status = -EOVERFLOW; + desc->actual_length = 0; + urb->error_count++; + continue; + } + + data = (u8 *)urb->transfer_buffer + desc->offset; + desc->actual_length = + min_t(u32, desc->length, VDS_AUDIO_IN_BYTES_PER_MS); + vds_fill_audio_in_locked(dev, data, desc->actual_length); + desc->status = 0; + total += desc->actual_length; + } + spin_unlock_irqrestore(&dev->lock, flags); + + urb->actual_length = total; + return 0; +} + static int vds_giveback_thread(void *data) { struct vds_hcd_dev *dev = data; @@ -516,15 +614,17 @@ static int vds_giveback_thread(void *data) ktime_t now = ktime_get(); s64 delay_us = ktime_us_delta(context->ready_time, now); - /* - * ISO audio URBs must complete at roughly realtime - * pace. Sleep in short chunks so module removal is not - * delayed by a long prequeued audio burst. - */ if (delay_us > 0) { + ktime_t expires = context->ready_time; + spin_unlock_irqrestore(&dev->lock, flags); - usleep_range(min_t(s64, delay_us, 5000), - min_t(s64, delay_us + 500, 5500)); + set_current_state(TASK_INTERRUPTIBLE); + if (!kthread_should_stop()) + schedule_hrtimeout_range_clock(&expires, + 50 * NSEC_PER_USEC, + HRTIMER_MODE_ABS, + CLOCK_MONOTONIC); + __set_current_state(TASK_RUNNING); continue; } } @@ -538,6 +638,8 @@ static int vds_giveback_thread(void *data) if (!status && context->deliver_iso_out) status = vds_iso_out_urb(dev, urb); + if (!status && context->deliver_iso_in) + status = vds_iso_in_urb(dev, urb); usb_hcd_giveback_urb(dev->hcd, urb, status); kfree(context); } @@ -701,6 +803,57 @@ static int vds_write_feature_reply(struct vds_hcd_dev *dev, const u8 *payload, return 0; } +static int vds_write_audio_in_frame(struct vds_hcd_dev *dev, const u8 *payload, + u32 length) +{ + struct vds_audio_packet *packet, *oldest; + unsigned long flags; + int ret = 0; + + if (!length) + return -EINVAL; + + packet = kmalloc_obj(*packet, GFP_KERNEL); + if (!packet) + return -ENOMEM; + + packet->offset = 0; + packet->length = length; + memcpy(packet->payload, payload, length); + + spin_lock_irqsave(&dev->lock, flags); + if (dev->stopping) { + ret = -ESHUTDOWN; + } else { + while (!list_empty(&dev->audio_in_packets) && + dev->audio_in_queued_bytes + length > + VDS_AUDIO_IN_QUEUE_MAX_BYTES) { + u32 available; + + oldest = list_first_entry(&dev->audio_in_packets, + struct vds_audio_packet, + list); + available = oldest->length - oldest->offset; + list_del(&oldest->list); + dev->audio_in_packet_count--; + if (dev->audio_in_queued_bytes > available) + dev->audio_in_queued_bytes -= available; + else + dev->audio_in_queued_bytes = 0; + kfree(oldest); + } + list_add_tail(&packet->list, &dev->audio_in_packets); + dev->audio_in_packet_count++; + dev->audio_in_queued_bytes += length; + dev->frames_from_user++; + packet = NULL; + } + spin_unlock_irqrestore(&dev->lock, flags); + + kfree(packet); + return ret; +} + static ssize_t vds_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { @@ -723,7 +876,8 @@ static ssize_t vds_write(struct file *file, const char __user *buf, if (header.flags) return -EINVAL; if (header.type != VDS_FRAME_USB_HID_IN && - header.type != VDS_FRAME_USB_FEATURE_REPLY) + header.type != VDS_FRAME_USB_FEATURE_REPLY && + header.type != VDS_FRAME_USB_AUDIO_IN) return -EINVAL; if (header.length) { @@ -736,6 +890,8 @@ static ssize_t vds_write(struct file *file, const char __user *buf, ret = vds_write_input_frame(dev, payload, header.length); else if (header.type == VDS_FRAME_USB_FEATURE_REPLY) ret = vds_write_feature_reply(dev, payload, header.length); + else if (header.type == VDS_FRAME_USB_AUDIO_IN) + ret = vds_write_audio_in_frame(dev, payload, header.length); kfree(payload); return ret ? ret : count; @@ -793,12 +949,14 @@ static void vds_set_connection(struct vds_hcd_dev *dev, bool connected) static long vds_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct vds_hcd_dev *dev = vds_from_file(file); + struct vds_driver_info driver_info; struct vds_profile_config profile; struct vds_status status; unsigned long flags; int ret; - if (cmd != VDS_IOC_GET_STATUS && vds_is_stopping(dev)) + if (cmd != VDS_IOC_GET_STATUS && cmd != VDS_IOC_GET_DRIVER_INFO && + vds_is_stopping(dev)) return -ESHUTDOWN; switch (cmd) { @@ -813,6 +971,16 @@ static long vds_ioctl(struct file *file, unsigned int cmd, unsigned long arg) if (copy_to_user((void __user *)arg, &status, sizeof(status))) return -EFAULT; return 0; + case VDS_IOC_GET_DRIVER_INFO: + memset(&driver_info, 0, sizeof(driver_info)); + driver_info.version = VDS_DRIVER_INFO_VERSION; + driver_info.size = sizeof(driver_info); + strscpy(driver_info.driver_version, VDS_VERSION, + sizeof(driver_info.driver_version)); + if (copy_to_user((void __user *)arg, &driver_info, + sizeof(driver_info))) + return -EFAULT; + return 0; case VDS_IOC_SET_PROFILE: if (copy_from_user(&profile, (void __user *)arg, sizeof(profile))) @@ -895,7 +1063,7 @@ static const struct file_operations vds_fops = { static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, struct vds_urb_context *context, - const struct urb *urb, bool input) + struct urb *urb, bool input) { unsigned int packets = max_t(unsigned int, urb->number_of_packets, 1); unsigned int duration_us = packets * 1000U; @@ -906,8 +1074,13 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, unsigned long flags; int i; - for (i = 0; i < urb->number_of_packets; i++) - total += urb->iso_frame_desc[i].length; + for (i = 0; i < urb->number_of_packets; i++) { + u32 length = urb->iso_frame_desc[i].length; + + if (input) + length = min_t(u32, length, VDS_AUDIO_IN_BYTES_PER_MS); + total += length; + } if (total) { /* * ALSA may submit a single descriptor containing multiple @@ -917,7 +1090,8 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, * or drops. Both virtual UAC1 streams are 48 kHz S16_LE: * OUT is 4ch (384 bytes/ms), IN is 2ch (192 bytes/ms). */ - u32 bytes_per_ms = input ? 192 : 384; + u32 bytes_per_ms = input ? VDS_AUDIO_IN_BYTES_PER_MS : + VDS_AUDIO_OUT_BYTES_PER_MS; u64 duration_by_bytes; duration_by_bytes = @@ -930,6 +1104,7 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, spin_lock_irqsave(&dev->lock, flags); next_ready = input ? &dev->next_iso_in_ready : &dev->next_iso_out_ready; base = ktime_after(*next_ready, now) ? *next_ready : now; + urb->start_frame = (int)ktime_to_ms(base); /* * Serialize ISO completion by the amount of PCM carried by each URB so * the host audio stack observes a realtime USB sink/source. @@ -939,39 +1114,6 @@ static void vds_schedule_iso_giveback(struct vds_hcd_dev *dev, spin_unlock_irqrestore(&dev->lock, flags); } -static int vds_iso_in_urb(struct urb *urb) -{ - u32 total = 0; - int i; - - if (!urb->transfer_buffer) - return -EPIPE; - - urb->error_count = 0; - for (i = 0; i < urb->number_of_packets; i++) { - struct usb_iso_packet_descriptor *desc = - &urb->iso_frame_desc[i]; - u8 *data; - - if (desc->offset > urb->transfer_buffer_length || - desc->length > urb->transfer_buffer_length - desc->offset) { - desc->status = -EOVERFLOW; - desc->actual_length = 0; - urb->error_count++; - continue; - } - - data = (u8 *)urb->transfer_buffer + desc->offset; - memset(data, 0, desc->length); - desc->status = 0; - desc->actual_length = desc->length; - total += desc->actual_length; - } - - urb->actual_length = total; - return 0; -} - static int vds_interrupt_out_urb(struct vds_hcd_dev *dev, struct urb *urb) { if (!urb->transfer_buffer_length) { @@ -1036,6 +1178,7 @@ static int vds_urb_enqueue(struct usb_hcd *hcd, struct urb *urb, context->feature_report_id = 0; context->state = VDS_URB_ACTIVE; context->deliver_iso_out = false; + context->deliver_iso_in = false; INIT_LIST_HEAD(&context->list); spin_lock_irqsave(&dev->lock, flags); @@ -1085,8 +1228,13 @@ static int vds_urb_enqueue(struct usb_hcd *hcd, struct urb *urb, } } else if (usb_urb_dir_in(urb) && vds_usb_device_is_audio_in(&dev->usb, endpoint)) { - ret = vds_iso_in_urb(urb); - timed_iso = ret == 0; + if (!urb->transfer_buffer) { + ret = -EPIPE; + } else { + ret = 0; + timed_iso = true; + context->deliver_iso_in = true; + } iso_input = true; } else { ret = -EPIPE; @@ -1120,12 +1268,12 @@ static int vds_urb_dequeue(struct usb_hcd *hcd, struct urb *urb, int status) context->status = status; context->ready_time = 0; wake_giveback = true; - } else { - wake_giveback = - vds_queue_urb_giveback_locked(dev, - context, - status); - } + } else { + wake_giveback = + vds_queue_urb_giveback_locked(dev, + context, + status); + } } } spin_unlock_irqrestore(&dev->lock, flags); @@ -1354,6 +1502,7 @@ static int vds_hcd_probe(struct platform_device *pdev) INIT_LIST_HEAD(&dev->pending_feature_get); INIT_LIST_HEAD(&dev->completed_urbs); INIT_LIST_HEAD(&dev->input_packets); + INIT_LIST_HEAD(&dev->audio_in_packets); dev->misc.minor = MISC_DYNAMIC_MINOR; snprintf(dev->misc_name, sizeof(dev->misc_name), "vds%u", diff --git a/vds/module/vds_usb.h b/vds/module/vds_usb.h index 0bf7ed9..92827ae 100644 --- a/vds/module/vds_usb.h +++ b/vds/module/vds_usb.h @@ -9,6 +9,8 @@ #include "vds_controller.h" +#define VDS_USB_AUDIO_FEATURE_COUNT 2 + struct vds_usb_device { /* Protects address/configuration/interface state and feature cache. */ spinlock_t lock; @@ -19,8 +21,8 @@ struct vds_usb_device { u8 audio_in_altsetting; u8 hid_idle; u8 hid_protocol; - u8 audio_mute[VDS_CONTROLLER_AUDIO_FEATURE_COUNT]; - s16 audio_volume[VDS_CONTROLLER_AUDIO_FEATURE_COUNT]; + u8 audio_mute[VDS_USB_AUDIO_FEATURE_COUNT]; + s16 audio_volume[VDS_USB_AUDIO_FEATURE_COUNT]; u16 feature_cache_len[256]; u8 feature_cache[256][VDS_CONTROLLER_HID_PACKET_SIZE]; }; diff --git a/vds/module/vds_usb_core.c b/vds/module/vds_usb_core.c index 220cd0e..ed3f58d 100644 --- a/vds/module/vds_usb_core.c +++ b/vds/module/vds_usb_core.c @@ -9,6 +9,7 @@ */ #include +#include #include #include "uapi/vds.h" @@ -31,6 +32,9 @@ #define UAC_FU_CTRL_MUTE 0x01 #define UAC_FU_CTRL_VOLUME 0x02 +#define VDS_USB_NO_INTERFACE 0xff +#define VDS_USB_NO_ENDPOINT 0xff + /* UAC1 volume values are signed 8.8 dB fixed-point units. */ #define VDS_USB_SPK_VOLUME_MIN (-100 * 256) #define VDS_USB_SPK_VOLUME_MAX 0 @@ -170,7 +174,7 @@ bool vds_usb_device_is_audio_in(const struct vds_usb_device *dev, int endpoint) const struct vds_controller_profile *profile = vds_usb_device_profile(dev); - return profile->audio_in_endpoint != VDS_CONTROLLER_NO_ENDPOINT && + return profile->audio_in_endpoint != VDS_USB_NO_ENDPOINT && endpoint == vds_usb_endpoint_number(profile->audio_in_endpoint); } @@ -249,12 +253,12 @@ static int vds_usb_enqueue_frame(const struct vds_usb_device_ops *ops, static void vds_usb_enqueue_interface_event(const struct vds_usb_device_ops *ops, void *context, u8 interface_number, - u8 altsetting, u8 interface_kind, gfp_t gfp) + u8 altsetting, u8 interface_type, gfp_t gfp) { struct vds_usb_interface_event event = { .interface_number = interface_number, .altsetting = altsetting, - .interface_kind = interface_kind, + .interface_type = interface_type, }; (void)vds_usb_enqueue_frame(ops, context, VDS_FRAME_USB_INTERFACE, @@ -304,7 +308,8 @@ static int vds_usb_audio_control(struct vds_usb_device *dev, struct urb *urb, switch (setup->bRequest) { case UAC_CS_REQ_GET_CUR: spin_lock_irqsave(&dev->lock, flags); - volume = cpu_to_le16(dev->audio_volume[feature_index]); + volume = + cpu_to_le16(dev->audio_volume[feature_index]); spin_unlock_irqrestore(&dev->lock, flags); break; case UAC_CS_REQ_GET_MIN: @@ -481,8 +486,10 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, device_desc.bDeviceProtocol = profile->device_protocol; device_desc.bMaxPacketSize0 = profile->max_packet_size0; device_desc.idVendor = cpu_to_le16(VDS_SONY_VENDOR_ID); - device_desc.idProduct = cpu_to_le16(profile->product_id); - device_desc.bcdDevice = cpu_to_le16(profile->device_version); + device_desc.idProduct = + cpu_to_le16(profile->product_id); + device_desc.bcdDevice = + cpu_to_le16(profile->device_version); device_desc.iManufacturer = 1; device_desc.iProduct = 2; device_desc.bNumConfigurations = @@ -491,8 +498,8 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, sizeof(device_desc)); case USB_DT_CONFIG: return vds_usb_copy_to_urb(urb, - profile->configuration_descriptor, - profile->configuration_descriptor_size); + profile->configuration_descriptor, + profile->configuration_descriptor_size); case USB_DT_DEVICE_QUALIFIER: return vds_usb_copy_to_urb(urb, &vds_qualifier_desc, sizeof(vds_qualifier_desc)); @@ -503,24 +510,25 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, NULL); case 1: return vds_usb_copy_string_descriptor(urb, - profile->manufacturer); + profile->manufacturer); case 2: return vds_usb_copy_string_descriptor(urb, - profile->product); + profile->product); default: return -EPIPE; } - case USB_DT_REPORT: + case HID_DT_REPORT: if ((index & 0xff) != profile->hid_interface) return -EPIPE; return vds_usb_copy_to_urb(urb, - profile->hid_report_descriptor, - profile->hid_report_descriptor_size); - case USB_DT_HID: + profile->hid_report_descriptor, + profile->hid_report_descriptor_size); + case HID_DT_HID: if ((index & 0xff) != profile->hid_interface) return -EPIPE; - return vds_usb_copy_to_urb(urb, profile->hid_descriptor, - profile->hid_descriptor_size); + return vds_usb_copy_to_urb(urb, + profile->hid_descriptor, + profile->hid_descriptor_size); default: return -EPIPE; } @@ -556,8 +564,10 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, if (value) return -EPIPE; vds_usb_enqueue_interface_event(ops, context, - profile->hid_interface, 0, - VDS_USB_INTERFACE_HID, gfp); + profile->hid_interface, + 0, + VDS_USB_INTERFACE_HID, + gfp); return 0; } if (value > 1) @@ -570,24 +580,23 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, vds_usb_set_status(ops, context, VDS_STATUS_AUDIO_ENABLED, audio_enabled); - vds_usb_enqueue_interface_event(ops, context, - profile->audio_out_interface, - value & 0xff, - VDS_USB_INTERFACE_AUDIO_OUT, - gfp); + vds_usb_enqueue_interface_event(ops, context, + profile->audio_out_interface, + value & 0xff, + VDS_USB_INTERFACE_AUDIO_OUT, + gfp); return 0; } - if (profile->audio_in_interface != - VDS_CONTROLLER_NO_INTERFACE && + if (profile->audio_in_interface != VDS_USB_NO_INTERFACE && (index & 0xff) == profile->audio_in_interface) { spin_lock_irqsave(&dev->lock, flags); dev->audio_in_altsetting = value & 0xff; spin_unlock_irqrestore(&dev->lock, flags); - vds_usb_enqueue_interface_event(ops, context, - profile->audio_in_interface, - value & 0xff, - VDS_USB_INTERFACE_AUDIO_IN, - gfp); + vds_usb_enqueue_interface_event(ops, context, + profile->audio_in_interface, + value & 0xff, + VDS_USB_INTERFACE_AUDIO_IN, + gfp); return 0; } return -EPIPE; @@ -596,7 +605,7 @@ vds_usb_standard_control(struct vds_usb_device *dev, struct urb *urb, if ((index & 0xff) == profile->audio_out_interface) { configuration = dev->audio_out_altsetting; } else if (profile->audio_in_interface != - VDS_CONTROLLER_NO_INTERFACE && + VDS_USB_NO_INTERFACE && (index & 0xff) == profile->audio_in_interface) { configuration = dev->audio_in_altsetting; } else if ((index & 0xff) == profile->hid_interface) { @@ -639,7 +648,8 @@ int vds_usb_device_control_urb(struct vds_usb_device *dev, struct urb *urb, context, gfp); if (type == USB_TYPE_CLASS && (setup.bRequestType & USB_RECIP_MASK) == USB_RECIP_INTERFACE) { - if ((le16_to_cpu(setup.wIndex) & 0xff) == profile->hid_interface) + if ((le16_to_cpu(setup.wIndex) & 0xff) == + profile->hid_interface) return vds_usb_hid_control(dev, urb, &setup, profile, ops, context, gfp); return vds_usb_audio_control(dev, urb, &setup, profile); diff --git a/vds/packaging/ArchLinux/PKGINFO.in b/vds/packaging/ArchLinux/PKGINFO.in index b36f8e8..e8967af 100644 --- a/vds/packaging/ArchLinux/PKGINFO.in +++ b/vds/packaging/ArchLinux/PKGINFO.in @@ -21,6 +21,7 @@ optdepend = base-devel optdepend = clang optdepend = linux-headers optdepend = linux-lts-headers +optdepend = logrotate optdepend = pipewire optdepend = systemd optdepend = wireplumber diff --git a/vds/packaging/ArchLinux/build-arch.sh b/vds/packaging/ArchLinux/build-arch.sh index 22ece0d..cad4d3d 100755 --- a/vds/packaging/ArchLinux/build-arch.sh +++ b/vds/packaging/ArchLinux/build-arch.sh @@ -164,6 +164,8 @@ install -Dm0755 "${repo_root}/install-service.sh" \ "${package_root}/usr/share/vds/install-service.sh" install -Dm0755 "${repo_root}/uninstall-service.sh" \ "${package_root}/usr/share/vds/uninstall-service.sh" +install -Dm0644 "${repo_root}/logrotate-vds.conf" \ + "${package_root}/etc/logrotate.d/vds" install_dkms_sources "$dkms_source_dir" @@ -195,8 +197,8 @@ substitute_template "${script_dir}/vds.install.in" "${package_root}/.INSTALL" . | gzip -c >"$mtree_tmp" mv "$mtree_tmp" .MTREE trap - EXIT - bsdtar -cf - .PKGINFO .BUILDINFO .INSTALL .MTREE usr | - zstd -T0 -q -c >"$package_path" -) + bsdtar -cf - .PKGINFO .BUILDINFO .INSTALL .MTREE etc usr | + zstd -T0 -q -c >"$package_path" + ) echo "$package_path" diff --git a/vds/packaging/ArchLinux/vds.install.in b/vds/packaging/ArchLinux/vds.install.in index 90ef094..a50714d 100644 --- a/vds/packaging/ArchLinux/vds.install.in +++ b/vds/packaging/ArchLinux/vds.install.in @@ -12,38 +12,44 @@ _is_udev_running() { command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] } -_package_version_without_release() { - local version=$1 - - version=${version%-*} - version=${version#*:} - printf '%s' "$version" +_unload_vds_hcd_module() { + if [ -d "/sys/module/$_dkms_name" ]; then + modprobe -r "$_dkms_name" + fi } -_dkms_registered() { - local version=$1 - - dkms status -m "$_dkms_name" -v "$version" 2>/dev/null | - grep -Fq "$_dkms_name/$version" +_dkms_remove_all_versions() { + local ignore_errors=${1:-} + local line + local module + local module_version + local version + + while IFS= read -r line; do + module_version=${line%%,*} + module_version=${module_version%%:*} + module=${module_version%%/*} + version=${module_version#*/} + case "$module" in + "$_dkms_name" | "$_dkms_name"-*) + if [ -n "$version" ] && [ "$version" != "$module_version" ]; then + if ! dkms remove -m "$module" -v "$version" --all; then + [ "$ignore_errors" = "--ignore-errors" ] || return 1 + fi + fi + ;; + esac + done < <(dkms status 2>/dev/null || true) } _dkms_install_current() { - if _dkms_registered "$_dkms_version"; then - dkms remove -m "$_dkms_name" -v "$_dkms_version" --all - fi + _unload_vds_hcd_module || return 1 + _dkms_remove_all_versions dkms add -m "$_dkms_name" -v "$_dkms_version" dkms build -m "$_dkms_name" -v "$_dkms_version" dkms install -m "$_dkms_name" -v "$_dkms_version" } -_dkms_remove_version() { - local version=$1 - - if [ -n "$version" ] && _dkms_registered "$version"; then - dkms remove -m "$_dkms_name" -v "$version" --all || true - fi -} - _reload_udev_rules() { if _is_udev_running; then udevadm control --reload-rules || true @@ -53,7 +59,7 @@ _reload_udev_rules() { _install_vdsd_service() { if [ -x "$_install_service" ]; then - "$_install_service" --start || true + "$_install_service" --enable --start fi } @@ -71,87 +77,92 @@ _reload_systemd() { _print_bluetooth_pairing_notice() { cat <<'NOTICE' - -vDS initial Bluetooth pairing ------------------------------ - -Current limitation: run bluetoothd with the input plugin disabled -(--noplugin=input). vDS needs direct ownership of the Bluetooth HID Control -and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it -can claim the controller first and expose it as a regular Bluetooth gamepad -instead of letting vDS bridge it as a virtual USB controller. - -In simpler terms, using vDS on Linux currently means giving up Bluetooth -devices like mice or keyboards. A BlueZ userspace stack patch is being prepared -to remove this limitation. - -In the meantime, the helper script can install or remove the required -bluetoothd systemd drop-in override: - - sudo /usr/share/vds/override-bluetoothd.sh disable-input --restart - sudo /usr/share/vds/override-bluetoothd.sh enable-input --restart - -Pair each physical controller once before registering it with vdsctl. Start -bluetoothctl, put the controller in Bluetooth pairing mode by holding Create +## Initial Bluetooth Pairing + +> [!IMPORTANT] +> +> Current limitation: run `bluetoothd` with the input plugin disabled +> (`--noplugin=input`). vDS needs direct ownership of the Bluetooth HID Control +> and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it +> can claim the controller first and expose it as a regular Bluetooth gamepad +> instead of letting vDS bridge it as a virtual USB controller. +> +> In simpler terms, using vDS on Linux currently means giving up Bluetooth +> devices like mice or keyboards. :( A BlueZ userspace stack patch is being +> prepared to remove this limitation. +> +> In the meantime, the helper script `override-bluetoothd.sh` can install or +> remove the required `bluetooth.service` drop-in override: +> +> ```sh +> sudo ./override-bluetoothd.sh disable-input --restart +> sudo ./override-bluetoothd.sh enable-input --restart +> ``` + +Pair each physical controller once before registering it with `vdsctl`. Start +`bluetoothctl`, put the controller in Bluetooth pairing mode by holding Create and PS until the light bar blinks rapidly, then use the MAC address printed by -scan on: - - agent NoInputNoOutput - default-agent - pairable on - scan on - pair XX:XX:XX:XX:XX:XX - trust XX:XX:XX:XX:XX:XX - scan off - quit +`scan on`: + +```text +agent NoInputNoOutput +default-agent +pairable on +scan on +pair XX:XX:XX:XX:XX:XX +trust XX:XX:XX:XX:XX:XX +scan off +quit +``` When re-pairing a controller that BlueZ already knows, run -remove XX:XX:XX:XX:XX:XX before scan on. +`remove XX:XX:XX:XX:XX:XX` before `scan on`. NOTICE } _print_audio_setup_notice() { cat <<'NOTICE' - -vDS audio setup ---------------- +## Audio Setup Configure the virtual controller audio output as 48 kHz 4-channel S16_LE PCM. The exact setup differs by audio stack, such as PipeWire, PulseAudio, ALSA, or JACK. -For PipeWire/WirePlumber, set the controller card profile to pro-audio. Find +For PipeWire/WirePlumber, set the controller card profile to `pro-audio`. Find the WirePlumber device id: - wpctl status +```sh +wpctl status +``` List the available profile indexes for that device: - pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' +```sh +pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' +``` -Set the pro-audio profile: +Set the `pro-audio` profile: - wpctl set-profile +```sh +wpctl set-profile +``` -WARNING: -The microphone/source node is disabled on purpose. vDS currently supports -controller speaker and haptics output, but not microphone input. If -WirePlumber, games, or tools such as pavucontrol open the capture endpoint, -the extra USB audio traffic can disrupt controller speaker and haptics output, -as well as main audio playback. - -After setting the pro-audio profile, install the included WirePlumber rule. It +After setting the `pro-audio` profile, install the included WirePlumber rule. It gives the virtual controller stable display names, sets the output node to 4 -channels, disables channelmix normalization, disables the unsupported microphone -source node, and lowers its priority: +channels, disables channelmix normalization, and gives the microphone source a +low priority: - mkdir -p ~/.config/wireplumber/wireplumber.conf.d - cp /usr/share/doc/vds/examples/99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +```sh +mkdir -p ~/.config/wireplumber/wireplumber.conf.d +cp 99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +``` Restart the user audio services after changing this file: - systemctl --user restart pipewire pipewire-pulse wireplumber +```sh +systemctl --user restart pipewire pipewire-pulse wireplumber +``` NOTICE } @@ -165,11 +176,9 @@ post_install() { } pre_upgrade() { - local old_version - - old_version=$(_package_version_without_release "$2") _stop_vdsd_service - _dkms_remove_version "$old_version" + _unload_vds_hcd_module || return 1 + _dkms_remove_all_versions --ignore-errors } post_upgrade() { @@ -184,7 +193,8 @@ pre_remove() { if [ -x "$_uninstall_service" ]; then "$_uninstall_service" --stop --disable || true fi - _dkms_remove_version "$_dkms_version" + _unload_vds_hcd_module || return 1 + _dkms_remove_all_versions --ignore-errors } post_remove() { diff --git a/vds/packaging/debian/build-deb.sh b/vds/packaging/debian/build-deb.sh index cb6ab1c..f27c095 100755 --- a/vds/packaging/debian/build-deb.sh +++ b/vds/packaging/debian/build-deb.sh @@ -133,6 +133,8 @@ install -Dm0755 "${repo_root}/install-service.sh" \ "${package_root}/usr/share/vds/install-service.sh" install -Dm0755 "${repo_root}/uninstall-service.sh" \ "${package_root}/usr/share/vds/uninstall-service.sh" +install -Dm0644 "${repo_root}/logrotate-vds.conf" \ + "${package_root}/etc/logrotate.d/vds" install -d "$dkms_source_dir/include" install -m0644 "${repo_root}/module/Makefile" \ diff --git a/vds/packaging/debian/control.in b/vds/packaging/debian/control.in index 0fabd34..d78e034 100644 --- a/vds/packaging/debian/control.in +++ b/vds/packaging/debian/control.in @@ -6,6 +6,7 @@ Architecture: @ARCH@ Maintainer: Jihong Min Installed-Size: @INSTALLED_SIZE@ Depends: bluez, dkms, kmod, make, libc6, libbluetooth3, libdbus-1-3, libopus0, libstdc++6, libudev1 +Recommends: logrotate Suggests: linux-headers-amd64 | linux-headers-generic, clang, build-essential, pipewire, systemd, udev, wireplumber Description: Virtual DualSense Bluetooth-to-USB bridge vDS exposes Bluetooth-connected DualSense-class controllers through a virtual diff --git a/vds/packaging/debian/postinst b/vds/packaging/debian/postinst index 9d278d6..d3015a8 100755 --- a/vds/packaging/debian/postinst +++ b/vds/packaging/debian/postinst @@ -9,98 +9,125 @@ is_udev_running() { command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] } -print_bluetooth_pairing_notice() { - cat <<'NOTICE' - -vDS initial Bluetooth pairing ------------------------------ - -Current limitation: run bluetoothd with the input plugin disabled -(--noplugin=input). vDS needs direct ownership of the Bluetooth HID Control -and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it -can claim the controller first and expose it as a regular Bluetooth gamepad -instead of letting vDS bridge it as a virtual USB controller. - -In simpler terms, using vDS on Linux currently means giving up Bluetooth -devices like mice or keyboards. A BlueZ userspace stack patch is being prepared -to remove this limitation. - -In the meantime, the helper script can install or remove the required -bluetoothd systemd drop-in override: +unload_vds_hcd_module() { + if [ -d "/sys/module/$DKMS_NAME" ]; then + modprobe -r "$DKMS_NAME" + fi +} - sudo /usr/share/vds/override-bluetoothd.sh disable-input --restart - sudo /usr/share/vds/override-bluetoothd.sh enable-input --restart +remove_all_dkms_versions() { + dkms status 2>/dev/null | + while IFS= read -r line; do + module_version=${line%%,*} + module_version=${module_version%%:*} + module=${module_version%%/*} + version=${module_version#*/} + case "$module" in + "$DKMS_NAME" | "$DKMS_NAME"-*) + if [ -n "$version" ] && [ "$version" != "$module_version" ]; then + dkms remove -m "$module" -v "$version" --all + fi + ;; + esac + done +} -Pair each physical controller once before registering it with vdsctl. Start -bluetoothctl, put the controller in Bluetooth pairing mode by holding Create +print_bluetooth_pairing_notice() { + cat <<'NOTICE' +## Initial Bluetooth Pairing + +> [!IMPORTANT] +> +> Current limitation: run `bluetoothd` with the input plugin disabled +> (`--noplugin=input`). vDS needs direct ownership of the Bluetooth HID Control +> and Interrupt L2CAP channels. If the normal BlueZ input plugin is active, it +> can claim the controller first and expose it as a regular Bluetooth gamepad +> instead of letting vDS bridge it as a virtual USB controller. +> +> In simpler terms, using vDS on Linux currently means giving up Bluetooth +> devices like mice or keyboards. :( A BlueZ userspace stack patch is being +> prepared to remove this limitation. +> +> In the meantime, the helper script `override-bluetoothd.sh` can install or +> remove the required `bluetooth.service` drop-in override: +> +> ```sh +> sudo ./override-bluetoothd.sh disable-input --restart +> sudo ./override-bluetoothd.sh enable-input --restart +> ``` + +Pair each physical controller once before registering it with `vdsctl`. Start +`bluetoothctl`, put the controller in Bluetooth pairing mode by holding Create and PS until the light bar blinks rapidly, then use the MAC address printed by -scan on: - - agent NoInputNoOutput - default-agent - pairable on - scan on - pair XX:XX:XX:XX:XX:XX - trust XX:XX:XX:XX:XX:XX - scan off - quit +`scan on`: + +```text +agent NoInputNoOutput +default-agent +pairable on +scan on +pair XX:XX:XX:XX:XX:XX +trust XX:XX:XX:XX:XX:XX +scan off +quit +``` When re-pairing a controller that BlueZ already knows, run -remove XX:XX:XX:XX:XX:XX before scan on. +`remove XX:XX:XX:XX:XX:XX` before `scan on`. NOTICE } print_audio_setup_notice() { cat <<'NOTICE' - -vDS audio setup ---------------- +## Audio Setup Configure the virtual controller audio output as 48 kHz 4-channel S16_LE PCM. The exact setup differs by audio stack, such as PipeWire, PulseAudio, ALSA, or JACK. -For PipeWire/WirePlumber, set the controller card profile to pro-audio. Find +For PipeWire/WirePlumber, set the controller card profile to `pro-audio`. Find the WirePlumber device id: - wpctl status +```sh +wpctl status +``` List the available profile indexes for that device: - pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' - -Set the pro-audio profile: +```sh +pw-cli e EnumProfile | awk '/Profile:index/{getline; idx=$2} /Profile:name/{getline; gsub(/"/, "", $2); print idx, $2}' +``` - wpctl set-profile +Set the `pro-audio` profile: -WARNING: -The microphone/source node is disabled on purpose. vDS currently supports -controller speaker and haptics output, but not microphone input. If -WirePlumber, games, or tools such as pavucontrol open the capture endpoint, -the extra USB audio traffic can disrupt controller speaker and haptics output, -as well as main audio playback. +```sh +wpctl set-profile +``` -After setting the pro-audio profile, install the included WirePlumber rule. It +After setting the `pro-audio` profile, install the included WirePlumber rule. It gives the virtual controller stable display names, sets the output node to 4 -channels, disables channelmix normalization, disables the unsupported microphone -source node, and lowers its priority: +channels, disables channelmix normalization, and gives the microphone source a +low priority: - mkdir -p ~/.config/wireplumber/wireplumber.conf.d - cp /usr/share/doc/vds/examples/99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +```sh +mkdir -p ~/.config/wireplumber/wireplumber.conf.d +cp 99-vds-dualsense-wireplumber.conf ~/.config/wireplumber/wireplumber.conf.d/ +``` Restart the user audio services after changing this file: - systemctl --user restart pipewire pipewire-pulse wireplumber +```sh +systemctl --user restart pipewire pipewire-pulse wireplumber +``` NOTICE } case "$1" in configure) - if dkms status -m "$DKMS_NAME" -v "$DKMS_VERSION" 2>/dev/null | grep -Fq "$DKMS_NAME/$DKMS_VERSION"; then - dkms remove -m "$DKMS_NAME" -v "$DKMS_VERSION" --all - fi + unload_vds_hcd_module + remove_all_dkms_versions dkms add -m "$DKMS_NAME" -v "$DKMS_VERSION" dkms build -m "$DKMS_NAME" -v "$DKMS_VERSION" dkms install -m "$DKMS_NAME" -v "$DKMS_VERSION" @@ -110,7 +137,7 @@ configure) udevadm trigger --subsystem-match=input || true fi if [ -x "$INSTALL_SERVICE" ]; then - "$INSTALL_SERVICE" --start || true + "$INSTALL_SERVICE" --enable --start fi print_bluetooth_pairing_notice print_audio_setup_notice diff --git a/vds/packaging/debian/postrm b/vds/packaging/debian/postrm index ea65834..6f48627 100755 --- a/vds/packaging/debian/postrm +++ b/vds/packaging/debian/postrm @@ -9,6 +9,11 @@ is_udev_running() { command -v udevadm >/dev/null 2>&1 && [ -S /run/udev/control ] } +purge_runtime_state() { + rm -rf /var/lib/vds + rm -f /var/log/vdsd.log /var/log/vdsd.log.* +} + case "$1" in remove | purge | upgrade | failed-upgrade | abort-install | abort-upgrade | disappear) if is_systemd_running; then @@ -21,4 +26,10 @@ remove | purge | upgrade | failed-upgrade | abort-install | abort-upgrade | disa ;; esac +case "$1" in +purge) + purge_runtime_state + ;; +esac + exit 0 diff --git a/vds/packaging/debian/prerm b/vds/packaging/debian/prerm index 6d392e5..1449057 100755 --- a/vds/packaging/debian/prerm +++ b/vds/packaging/debian/prerm @@ -2,9 +2,31 @@ set -e DKMS_NAME="vds_hcd" -DKMS_VERSION="@DKMS_VERSION@" UNINSTALL_SERVICE="/usr/share/vds/uninstall-service.sh" +unload_vds_hcd_module() { + if [ -d "/sys/module/$DKMS_NAME" ]; then + modprobe -r "$DKMS_NAME" + fi +} + +remove_all_dkms_versions() { + dkms status 2>/dev/null | + while IFS= read -r line; do + module_version=${line%%,*} + module_version=${module_version%%:*} + module=${module_version%%/*} + version=${module_version#*/} + case "$module" in + "$DKMS_NAME" | "$DKMS_NAME"-*) + if [ -n "$version" ] && [ "$version" != "$module_version" ]; then + dkms remove -m "$module" -v "$version" --all || true + fi + ;; + esac + done +} + case "$1" in remove | deconfigure) if [ -x "$UNINSTALL_SERVICE" ]; then @@ -20,9 +42,8 @@ esac case "$1" in remove | upgrade | deconfigure) - if dkms status -m "$DKMS_NAME" -v "$DKMS_VERSION" 2>/dev/null | grep -Fq "$DKMS_NAME/$DKMS_VERSION"; then - dkms remove -m "$DKMS_NAME" -v "$DKMS_VERSION" --all || true - fi + unload_vds_hcd_module + remove_all_dkms_versions ;; esac diff --git a/vds/src/platform/linux/vds_bluez.cc b/vds/src/platform/linux/vds_bluez.cc index 63ad629..a5cf8a1 100644 --- a/vds/src/platform/linux/vds_bluez.cc +++ b/vds/src/platform/linux/vds_bluez.cc @@ -55,8 +55,6 @@ class DBusErrorGuard { return ::dbus_error_has_name(&error_, name) != 0; } - bool is_set() const { return ::dbus_error_is_set(&error_) != 0; } - std::string message() const { if (error_.message != nullptr) { return error_.message; diff --git a/vds/src/platform/linux/vds_bt.cc b/vds/src/platform/linux/vds_bt.cc index d5e4f15..8cfcec5 100644 --- a/vds/src/platform/linux/vds_bt.cc +++ b/vds/src/platform/linux/vds_bt.cc @@ -23,6 +23,7 @@ #include "unique_fd.hh" #include "vds_bt.hh" #include "vds_io.hh" +#include "vds_protocol.hh" namespace vds { @@ -269,7 +270,8 @@ std::optional> BtL2capBackend::read_feature_report() { std::span(buffer.data(), static_cast(got))); } -std::optional BtL2capBackend::read_input_report() { +std::optional> +BtL2capBackend::read_interrupt_packet() { std::array buffer{}; const ssize_t got = ::read(interrupt_fd_, buffer.data(), buffer.size()); if (got < 0) { @@ -282,8 +284,8 @@ std::optional BtL2capBackend::read_input_report() { if (got == 0) { throw std::runtime_error("Bluetooth L2CAP interrupt channel closed"); } - return bt_input_to_usb_input( - std::span(buffer.data(), static_cast(got))); + return std::vector( + buffer.begin(), buffer.begin() + static_cast(got)); } } // namespace vds diff --git a/vds/src/platform/linux/vds_bt.hh b/vds/src/platform/linux/vds_bt.hh index d0adfcc..22f351c 100644 --- a/vds/src/platform/linux/vds_bt.hh +++ b/vds/src/platform/linux/vds_bt.hh @@ -9,7 +9,6 @@ #include #include "unique_fd.hh" -#include "vds_protocol.hh" namespace vds { @@ -50,13 +49,12 @@ public: int control_fd() const { return control_fd_; } int interrupt_fd() const { return interrupt_fd_; } - const std::string &address() const { return address_; } void send_output_report(std::span report); bool try_send_output_report(std::span report); void send_feature_get(std::uint8_t report_id); void send_feature_set(std::span report); std::optional> read_feature_report(); - std::optional read_input_report(); + std::optional> read_interrupt_packet(); private: std::string address_; diff --git a/vds/src/platform/linux/vdsd.cc b/vds/src/platform/linux/vdsd.cc index 7275404..ac1e714 100644 --- a/vds/src/platform/linux/vdsd.cc +++ b/vds/src/platform/linux/vdsd.cc @@ -58,7 +58,6 @@ namespace { using Clock = std::chrono::steady_clock; using vds::duration_us; using vds::hex_bytes; -using vds::hex_u16; using vds::hex_u8; constexpr const char *kDefaultControlSocket = "/run/vdsd.sock"; @@ -69,6 +68,11 @@ constexpr const char *kLinuxVirtualPortProviderUnavailable = constexpr std::size_t kTraceDumpMaxBytes = 96; constexpr std::size_t kUsbInputButtonsOffset = 8; constexpr std::size_t kUsbInputButtonsSize = 4; +constexpr std::size_t kUsbInputMuteButtonOffset = 10; +constexpr std::size_t kUsbInputHeadsetOffset = 54; +constexpr std::uint8_t kUsbInputHeadphonesPluggedMask = 0x01; +constexpr std::uint8_t kUsbInputMicPluggedMask = 0x02; +constexpr std::uint8_t kUsbInputMuteButtonMask = 0x04; constexpr std::uint64_t kInputTraceSummaryInterval = 1000; constexpr std::uint64_t kOutputTraceSummaryInterval = 250; constexpr int kMaxPortFramesPerWake = 64; @@ -85,19 +89,12 @@ constexpr auto kOutputTraceFeatureSlowWarn = std::chrono::milliseconds(20); * seconds. */ constexpr auto kAudioOutputInterval = std::chrono::milliseconds(10); -/* - * Cap on how far behind the fixed-cadence speaker deadline may fall before we - * resync instead of draining back-to-back. The producer (USB isochronous - * audio) delivers ~one 0x36 chunk per 10 ms; if we ever fall more than a few - * intervals behind (e.g. the speaker stream paused), replaying that backlog - * would just add latency, so we drop it and realign to the wall clock. - */ -constexpr auto kMaxAudioCatchup = std::chrono::milliseconds(50); constexpr auto kHapticsOutputBlockedRetry = std::chrono::milliseconds(2); constexpr auto kBluetoothPreemptWait = std::chrono::milliseconds(2000); constexpr auto kBluetoothPreemptPoll = std::chrono::milliseconds(100); constexpr int kInitialFeatureReportPollMs = 5000; constexpr std::size_t kMaxPendingAudioChunks = 8; +constexpr std::size_t kFreshPendingAudioChunks = 4; constexpr std::uint8_t kTestCommandReportId = 0x80; constexpr std::uint8_t kTestCommandResultReportId = 0x81; constexpr std::uint8_t kTestCommandCompleteStatus = 0x02; @@ -109,12 +106,13 @@ constexpr std::uint32_t kSpeakerWaveoutFrequencyHz = 1000; constexpr std::uint32_t kSpeakerWaveoutPeriodFrames = VDS_AUDIO_SAMPLE_RATE / kSpeakerWaveoutFrequencyHz; constexpr double kSpeakerWaveoutTwoPi = 6.28318530717958647692; -/* Arbitrary fixed amplitude used only for synthetic speaker test output. */ +/* Arbitrary fixed amplitude used only for synthetic WebHID speaker waveout. */ constexpr std::int16_t kSpeakerWaveoutAmplitude = 12000; constexpr std::array kInitialFeatureReportIds = {0x09, 0x20, 0x05}; volatile sig_atomic_t g_stop_requested = 0; +volatile sig_atomic_t g_log_reopen_requested = 0; struct Options { std::string socket = kDefaultControlSocket; @@ -133,12 +131,17 @@ struct TraceState { std::uint64_t dropped_usb_frame_count = 0; std::uint64_t dropped_audio_haptics_count = 0; std::uint64_t queue_dropped_audio_haptics_count = 0; + std::uint64_t stale_audio_haptics_count = 0; std::uint64_t blocked_audio_haptics_count = 0; std::uint64_t deferred_bt_state_count = 0; std::uint64_t coalesced_bt_state_count = 0; std::uint64_t blocked_bt_state_count = 0; std::uint64_t audio_usb_frame_count = 0; std::uint64_t bt_input_count = 0; + std::uint64_t bt_mic_packet_count = 0; + std::uint64_t bt_mic_drop_count = 0; + std::uint64_t bt_mic_decode_fail_count = 0; + std::uint64_t audio_in_forward_count = 0; LatencyTraceStats output_hid_latency; LatencyTraceStats output_feature_latency; LatencyTraceStats output_audio_latency; @@ -157,12 +160,10 @@ struct TraceState { std::uint64_t haptics_burst_chunks = 0; }; -using vds::active_trace_name; -using vds::kTraceAll; -using vds::kTraceInput; +using vds::kTraceInputAudio; +using vds::kTraceInputControl; using vds::kTraceOutput; using vds::trace_enabled; -using vds::trace_scope_name; using vds::trim_command; struct VirtualPort { @@ -170,6 +171,7 @@ struct VirtualPort { vds::UniqueFd fd; vds::PcmAudioExtractor extractor; vds::PcmAudioExtractor waveout_extractor; + vds::MicAudioDecoder mic_decoder; vds::HapticsPacketBuilder haptics_builder; vds::DsOutputState output_state; std::deque pending_audio_chunks; @@ -177,10 +179,20 @@ struct VirtualPort { std::optional pending_bt_state; std::optional pending_bt_state_report; Clock::time_point next_haptics_send_time{}; + bool audio_out_stream_active = false; + bool audio_in_stream_active = false; + bool mic_muted = false; + bool mute_button_down = false; + bool headset_plugged = false; + bool headset_mic_plugged = false; bool speaker_waveout_selected = true; bool speaker_waveout_active = false; std::uint32_t speaker_waveout_phase = 0; std::uint16_t haptics_gain_percent = 100; + // Speaker/haptics queue depth in 10 ms chunks, from the companion + // SET_HAPTICS_BUFFER_LENGTH setting; defaults match the old constants. + std::size_t max_pending_audio_chunks = kMaxPendingAudioChunks; + std::size_t fresh_pending_audio_chunks = kFreshPendingAudioChunks; std::uint8_t battery_status = 0xff; vds::CompanionInputState companion_input; std::array, 256> feature_cache; @@ -200,7 +212,7 @@ struct ControllerRuntime { std::string last_error; }; -enum class EventKind : std::uint32_t { +enum class EventType : std::uint32_t { Control = 1, Port = 2, BtControl = 3, @@ -211,7 +223,7 @@ enum class EventKind : std::uint32_t { }; struct EventSource { - EventKind kind; + EventType type; std::size_t index; }; @@ -227,7 +239,23 @@ class SocketPathGuard { std::string path_; }; -void signal_handler(int) { g_stop_requested = 1; } +void signal_handler(int signal) { + if (signal == SIGHUP) { + g_log_reopen_requested = 1; + return; + } + g_stop_requested = 1; +} + +void install_signal_handler(int signal) { + struct sigaction action{}; + action.sa_handler = signal_handler; + sigemptyset(&action.sa_mask); + if (::sigaction(signal, &action, nullptr) < 0) { + throw std::runtime_error("sigaction failed: " + + std::string(std::strerror(errno))); + } +} Options parse_platform_args(int argc, char **argv) { Options options; @@ -278,13 +306,13 @@ void trace_output_latency(const std::string &device, std::string_view label, stats.max_us = std::max(stats.max_us, elapsed_us); if (duration >= slow_threshold) { - logger.log("output", vds::LogLevel::Warn, + logger.log(vds::LogScope::Output, vds::LogLevel::Warn, device + " slow " + std::string(label) + " latency count=" + std::to_string(stats.count) + " latency_us=" + std::to_string(elapsed_us)); } if (stats.count == 1 || stats.count % kOutputTraceSummaryInterval == 0) { - logger.log("output", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, device + " " + std::string(label) + " latency summary count=" + std::to_string(stats.count) + " avg_us=" + std::to_string(stats.total_us / stats.count) + @@ -313,7 +341,7 @@ void trace_hid_out_report(const std::string &device, if (payload.size() >= 40) { line << " light_flags=" << hex_u8(payload[39]); } - logger.log("hid", vds::LogLevel::Debug, line.str()); + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, line.str()); const bool hid_out_changed = trace_state.last_hid_out.size() != payload.size() || @@ -335,11 +363,11 @@ void trace_hid_out_report(const std::string &device, changed << " right_trigger=" << hex_bytes(payload.subspan(11, 11), 11) << " left_trigger=" << hex_bytes(payload.subspan(22, 11), 11); } - logger.log("hid", vds::LogLevel::Debug, changed.str()); + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, changed.str()); } -const char *usb_interface_kind_name(std::uint8_t kind) { - switch (kind) { +const char *usb_interface_type_name(std::uint8_t type) { + switch (type) { case VDS_USB_INTERFACE_HID: return "hid"; case VDS_USB_INTERFACE_AUDIO_OUT: @@ -438,7 +466,7 @@ bool write_vds_frame(VirtualPort &port, std::span bytes, if (port.trace_state.dropped_usb_frame_count == 1 || port.trace_state.dropped_usb_frame_count % 1000 == 0) { logger.log( - "port", vds::LogLevel::Warn, + vds::LogScope::Port, vds::LogLevel::Warn, port.path + " vDS write queue full count=" + std::to_string(port.trace_state.dropped_usb_frame_count)); } @@ -481,7 +509,7 @@ bool flush_pending_bt_state_report(VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " flushed pending BT 0x31 state report"); } return true; @@ -490,7 +518,7 @@ bool flush_pending_bt_state_report(VirtualPort &port, ++port.trace_state.blocked_bt_state_count; if (output_trace && (port.trace_state.blocked_bt_state_count == 1 || port.trace_state.blocked_bt_state_count % 1000 == 0)) { - logger.log("hid", vds::LogLevel::Warn, + logger.log(vds::LogScope::Hid, vds::LogLevel::Warn, port.path + " pending BT 0x31 state report still blocked " "count=" + @@ -515,7 +543,7 @@ void forward_bt_state_if_changed(VirtualPort &port, ++port.trace_state.coalesced_bt_state_count; } if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " " + std::string(reason) + " skipped: BT state unchanged"); } @@ -523,7 +551,7 @@ void forward_bt_state_if_changed(VirtualPort &port, } if (port.pending_bt_state && *port.pending_bt_state == state) { if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " " + std::string(reason) + " skipped: BT state already pending"); } @@ -536,7 +564,7 @@ void forward_bt_state_if_changed(VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " " + std::string(reason) + " forwarded as BT 0x31 state report"); } @@ -551,7 +579,7 @@ void forward_bt_state_if_changed(VirtualPort &port, port.pending_bt_state_report = packet; if (output_trace && (port.trace_state.deferred_bt_state_count == 1 || port.trace_state.deferred_bt_state_count % 1000 == 0)) { - logger.log("hid", vds::LogLevel::Warn, + logger.log(vds::LogScope::Hid, vds::LogLevel::Warn, port.path + " deferred BT 0x31 state report count=" + std::to_string(port.trace_state.deferred_bt_state_count) + " coalesced=" + @@ -559,6 +587,20 @@ void forward_bt_state_if_changed(VirtualPort &port, } } +/* + * A mic-state report mutates the output state (mute LED, mic volume, power + * save). Any 0x31 state report queued while the HID queue was blocked was + * built from the pre-change state; if it flushed later it would re-assert + * the stale mute LED. Rebuild the queued report from the current state. + */ +void refresh_pending_bt_state(VirtualPort &port) { + if (!port.pending_bt_state) { + return; + } + port.pending_bt_state = port.output_state.state(); + port.pending_bt_state_report = port.output_state.build_bt_state_report(); +} + void ioctl_noarg(int fd, unsigned long request, const char *name) { if (::ioctl(fd, request) < 0) { throw std::runtime_error(std::string(name) + @@ -580,6 +622,7 @@ void ioctl_set_profile(int fd, std::uint32_t profile) { void reset_virtual_port(VirtualPort &port) { port.extractor = vds::PcmAudioExtractor{}; port.waveout_extractor = vds::PcmAudioExtractor{}; + port.mic_decoder = vds::MicAudioDecoder{}; port.haptics_builder = vds::HapticsPacketBuilder{}; port.output_state = vds::DsOutputState{}; port.pending_audio_chunks.clear(); @@ -587,10 +630,18 @@ void reset_virtual_port(VirtualPort &port) { port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); port.next_haptics_send_time = {}; + port.audio_out_stream_active = false; + port.audio_in_stream_active = false; + port.mic_muted = false; + port.mute_button_down = false; + port.headset_plugged = false; + port.headset_mic_plugged = false; port.speaker_waveout_selected = true; port.speaker_waveout_active = false; port.speaker_waveout_phase = 0; port.haptics_gain_percent = 100; + port.max_pending_audio_chunks = kMaxPendingAudioChunks; + port.fresh_pending_audio_chunks = kFreshPendingAudioChunks; port.battery_status = 0xff; port.companion_input = {}; port.feature_cache = {}; @@ -602,14 +653,18 @@ void reset_virtual_port(VirtualPort &port) { void disconnect_virtual_port(VirtualPort &port, vds::Logger &logger) { port.pending_audio_chunks.clear(); port.next_haptics_send_time = {}; + port.audio_out_stream_active = false; + port.audio_in_stream_active = false; + port.mic_muted = false; + port.mute_button_down = false; port.speaker_waveout_active = false; port.speaker_waveout_phase = 0; try { ioctl_noarg(port.fd.get(), VDS_IOC_DISCONNECT, "VDS_IOC_DISCONNECT"); - logger.log("usb", vds::LogLevel::Info, + logger.log(vds::LogScope::Usb, vds::LogLevel::Info, port.path + " virtual USB disconnected"); } catch (const std::exception &error) { - logger.log("usb", vds::LogLevel::Warn, + logger.log(vds::LogScope::Usb, vds::LogLevel::Warn, port.path + " disconnect failed: " + error.what()); } } @@ -620,7 +675,7 @@ void handle_frame(const vds_frame_header &header, VirtualPort &port, vds::Logger &logger) { if (header.type == VDS_FRAME_USB_INTERFACE) { if (payload.size() != sizeof(vds_usb_interface_event)) { - logger.log("usb", vds::LogLevel::Warn, + logger.log(vds::LogScope::Usb, vds::LogLevel::Warn, port.path + " malformed USB interface event len=" + std::to_string(payload.size())); return; @@ -630,15 +685,48 @@ void handle_frame(const vds_frame_header &header, std::memcpy(&event, payload.data(), sizeof(event)); std::ostringstream line; - line << port.path << " USB set_interface kind=" - << usb_interface_kind_name(event.interface_kind) + line << port.path << " USB set_interface type=" + << usb_interface_type_name(event.interface_type) << " number=" << static_cast(event.interface_number) << " alt=" << static_cast(event.altsetting); - if (event.interface_kind == VDS_USB_INTERFACE_AUDIO_OUT || - event.interface_kind == VDS_USB_INTERFACE_AUDIO_IN) { + if (event.interface_type == VDS_USB_INTERFACE_AUDIO_OUT || + event.interface_type == VDS_USB_INTERFACE_AUDIO_IN) { line << " stream=" << (event.altsetting != 0 ? "on" : "off"); } - logger.log("usb", vds::LogLevel::Info, line.str()); + logger.log(vds::LogScope::Usb, vds::LogLevel::Info, line.str()); + if (event.interface_type == VDS_USB_INTERFACE_AUDIO_OUT) { + port.audio_out_stream_active = event.altsetting != 0; + if (!port.audio_out_stream_active) { + port.pending_audio_chunks.clear(); + port.extractor = vds::PcmAudioExtractor{}; + } else { + port.output_state.set_audio_out_stream_active(true, + port.headset_plugged); + if (bt_backend) { + forward_bt_state_if_changed(port, *bt_backend, trace_flags, logger, + "audio out interface"); + } + } + } else if (event.interface_type == VDS_USB_INTERFACE_AUDIO_IN) { + port.audio_in_stream_active = event.altsetting != 0; + port.mic_decoder = vds::MicAudioDecoder{}; + if (bt_backend) { + const auto state_report = port.output_state.build_bt_mic_state_report( + port.audio_in_stream_active, port.mic_muted); + bt_backend->try_send_output_report(state_report); + refresh_pending_bt_state(port); + const auto report = + port.output_state.build_bt_mic_report(port.audio_in_stream_active); + const bool sent = bt_backend->try_send_output_report(report); + if (trace_enabled(trace_flags, kTraceInputAudio)) { + logger.log( + vds::LogScope::InputAudio, vds::LogLevel::Info, + port.path + " mic " + + std::string(port.audio_in_stream_active ? "open" : "close") + + " sent=" + (sent ? "yes" : "no")); + } + } + } return; } @@ -680,7 +768,7 @@ void handle_frame(const vds_frame_header &header, } if (emit_trace) { - logger.log("usb", vds::LogLevel::Debug, line.str()); + logger.log(vds::LogScope::Usb, vds::LogLevel::Debug, line.str()); } } @@ -691,13 +779,16 @@ void handle_frame(const vds_frame_header &header, } if (!port.output_state.apply_usb_output_report(payload)) { if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " hid out ignored: unsupported output report"); } return; } + if (port.audio_out_stream_active || port.speaker_waveout_active) { + port.output_state.set_audio_out_stream_active(true, port.headset_plugged); + } if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " hid out accepted for BT state update"); } @@ -727,7 +818,7 @@ void handle_frame(const vds_frame_header &header, : (payload.size() >= 2 ? static_cast(payload[1]) : 0); const bool cache_hit = port.feature_cached[report_id]; if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " feature get report=" + hex_u8(report_id) + " request_len=" + std::to_string(requested_length) + " cache=" + (cache_hit ? "hit" : "miss") + " forwarded=" + @@ -763,11 +854,11 @@ void handle_frame(const vds_frame_header &header, } const std::uint8_t report_id = payload[0]; if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " feature set report=" + hex_u8(report_id) + " len=" + std::to_string(payload.size()) + " forwarded=" + (bt_backend ? "yes" : "no")); - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " feature set payload=" + hex_bytes(payload, kTraceDumpMaxBytes)); } @@ -789,7 +880,7 @@ void handle_frame(const vds_frame_header &header, test_result[3] = kTestCommandCompleteStatus; cache_feature_report(port, test_result, output_trace, logger); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " cached WebHID test result device=" + hex_u8(command_device) + " action=" + hex_u8(command_action) + " status=complete"); @@ -801,7 +892,7 @@ void handle_frame(const vds_frame_header &header, payload.size() > command_data_offset + 2 && payload[command_data_offset + 2] == kTestCommandSpeakerParam; if (output_trace) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " WebHID waveout target=" + std::string(port.speaker_waveout_selected ? "speaker" @@ -815,7 +906,9 @@ void handle_frame(const vds_frame_header &header, port.speaker_waveout_active = speaker_waveout; port.speaker_waveout_phase = 0; port.waveout_extractor = vds::PcmAudioExtractor{}; - port.output_state.set_audio_out_stream_active(speaker_waveout); + port.output_state.set_audio_out_stream_active(speaker_waveout, + port.headset_plugged); + port.audio_out_stream_active = speaker_waveout; if (!speaker_waveout) { port.pending_audio_chunks.clear(); } @@ -826,7 +919,7 @@ void handle_frame(const vds_frame_header &header, : "WebHID waveout route off"); } if (output_trace) { - logger.log("audio", vds::LogLevel::Info, + logger.log(vds::LogScope::Output, vds::LogLevel::Info, port.path + " WebHID waveout " + std::string(enable ? "on" : "off") + " target=" + std::string(port.speaker_waveout_selected @@ -866,14 +959,14 @@ void handle_frame(const vds_frame_header &header, if (output_trace) { if (chunk.has_haptics_signal) { if (!port.trace_state.haptics_burst_active) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " haptics burst start"); port.trace_state.haptics_burst_active = true; port.trace_state.haptics_burst_chunks = 0; } ++port.trace_state.haptics_burst_chunks; } else if (port.trace_state.haptics_burst_active) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " haptics burst end chunks=" + std::to_string(port.trace_state.haptics_burst_chunks)); port.trace_state.haptics_burst_active = false; @@ -887,7 +980,7 @@ void handle_frame(const vds_frame_header &header, * speaker frame interval, otherwise speaker/haptics audio turns into * audible bursts. */ - if (port.pending_audio_chunks.size() >= kMaxPendingAudioChunks) { + if (port.pending_audio_chunks.size() >= port.max_pending_audio_chunks) { port.pending_audio_chunks.pop_front(); ++dropped_chunks; ++port.trace_state.dropped_audio_haptics_count; @@ -901,7 +994,7 @@ void handle_frame(const vds_frame_header &header, (port.trace_state.dropped_audio_haptics_count == dropped_chunks || port.trace_state.dropped_audio_haptics_count % 1000 == 0)) { logger.log( - "audio", vds::LogLevel::Warn, + vds::LogScope::Output, vds::LogLevel::Warn, port.path + " dropped BT 0x36 haptics packets count=" + std::to_string(port.trace_state.dropped_audio_haptics_count) + " queue=" + @@ -911,7 +1004,7 @@ void handle_frame(const vds_frame_header &header, } if (output_trace && queued_chunks > 0) { logger.log( - "audio", vds::LogLevel::Debug, + vds::LogScope::Output, vds::LogLevel::Debug, port.path + " audio out queued " + std::to_string(queued_chunks) + " BT 0x36 haptics packets pending=" + std::to_string(port.pending_audio_chunks.size()) + @@ -964,17 +1057,78 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, vds::CompanionRuntime &companion, std::uint32_t trace_flags, vds::Logger &logger) { const auto read_time = Clock::now(); - auto report = bt_backend.read_input_report(); - if (!report) { + const auto packet = bt_backend.read_interrupt_packet(); + if (!packet) { return false; } + + if (vds::bt_input_payload_type(*packet) == vds::BtInputPayloadType::Audio) { + const bool input_audio_trace = trace_enabled(trace_flags, kTraceInputAudio); + ++port.trace_state.bt_mic_packet_count; + if (!port.audio_in_stream_active) { + ++port.trace_state.bt_mic_drop_count; + if (input_audio_trace && + (port.trace_state.bt_mic_drop_count == 1 || + port.trace_state.bt_mic_drop_count % 1000 == 0)) { + logger.log(vds::LogScope::InputAudio, vds::LogLevel::Debug, + port.path + " dropped mic packet count=" + + std::to_string(port.trace_state.bt_mic_drop_count) + + " reason=audio_in_stream_off"); + } + return true; + } + + const auto opus_payload = vds::bt_mic_opus_payload(*packet); + if (!opus_payload) { + ++port.trace_state.bt_mic_decode_fail_count; + if (input_audio_trace) { + logger.log(vds::LogScope::InputAudio, vds::LogLevel::Warn, + port.path + " malformed mic packet len=" + + std::to_string(packet->size())); + } + return true; + } + + try { + auto pcm = port.mic_decoder.decode(*opus_payload); + const auto frame = vds::frame_bytes(VDS_FRAME_USB_AUDIO_IN, pcm); + const bool wrote = + write_vds_frame(port, frame, input_audio_trace, logger); + ++port.trace_state.audio_in_forward_count; + if (input_audio_trace && (port.trace_state.audio_in_forward_count == 1 || + port.trace_state.audio_in_forward_count % + kInputTraceSummaryInterval == + 0)) { + logger.log(vds::LogScope::InputAudio, vds::LogLevel::Debug, + port.path + " mic forwarded count=" + + std::to_string(port.trace_state.audio_in_forward_count) + + " len=" + std::to_string(pcm.size()) + + " written=" + (wrote ? "yes" : "no")); + } + } catch (const std::exception &error) { + ++port.trace_state.bt_mic_decode_fail_count; + if (input_audio_trace) { + logger.log( + vds::LogScope::InputAudio, vds::LogLevel::Warn, + port.path + " mic decode failed count=" + + std::to_string(port.trace_state.bt_mic_decode_fail_count) + + ": " + error.what()); + } + } + return true; + } + + auto report = vds::bt_input_to_usb_input(*packet); + if (!report) { + return true; + } vds::companion_translate_input(companion, port.companion_input, std::span(*report)); // USB input payload offset 52 (report byte 53) carries the DualSense // power status: low nibble battery capacity 0-10, high nibble state. port.battery_status = (*report)[53]; - const bool input_trace = trace_enabled(trace_flags, kTraceInput); + const bool input_trace = trace_enabled(trace_flags, kTraceInputControl); if (input_trace) { ++port.trace_state.bt_input_count; if (port.trace_state.have_last_input_time) { @@ -984,7 +1138,7 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, port.trace_state.input_gap_max_us = std::max(port.trace_state.input_gap_max_us, gap_us); if (gap >= kInputTraceGapWarn) { - logger.log("input", vds::LogLevel::Warn, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Warn, port.path + " input gap count=" + std::to_string(port.trace_state.bt_input_count) + " gap_us=" + std::to_string(gap_us)); @@ -1003,12 +1157,80 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, std::copy(buttons.begin(), buttons.end(), port.trace_state.last_input_buttons.begin()); port.trace_state.have_last_input_buttons = true; - logger.log("input", vds::LogLevel::Debug, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Debug, port.path + " buttons changed raw=" + hex_bytes(buttons, kUsbInputButtonsSize)); } } + const std::uint8_t headset_status = report->at(kUsbInputHeadsetOffset); + const bool headset_plugged = + (headset_status & kUsbInputHeadphonesPluggedMask) != 0; + const bool headset_mic_plugged = + (headset_status & kUsbInputMicPluggedMask) != 0; + const bool mute_button_down = + (report->at(kUsbInputMuteButtonOffset) & kUsbInputMuteButtonMask) != 0; + bool headset_mic_changed = false; + bool mic_mute_changed = false; + bool output_state_changed = false; + if (mute_button_down && !port.mute_button_down) { + port.mic_muted = !port.mic_muted; + mic_mute_changed = true; + } + port.mute_button_down = mute_button_down; + if (headset_mic_plugged != port.headset_mic_plugged) { + port.headset_mic_plugged = headset_mic_plugged; + headset_mic_changed = true; + } + if (mic_mute_changed) { + // Mirror the physical mute-button toggle into the companion settings so + // the app's STATUS poll picks it up (the app reconciles micMuted from + // status when it did not initiate the change). + companion.settings.mic_muted = port.mic_muted; + ++companion.settings_revision; + } + if (mic_mute_changed || + (headset_mic_changed && port.audio_in_stream_active)) { + const auto report = port.output_state.build_bt_mic_state_report( + port.audio_in_stream_active, port.mic_muted); + if (bt_backend.try_send_output_report(report)) { + refresh_pending_bt_state(port); + } else { + // HID queue blocked: queue a full state report (it carries the mute + // LED and mic flags) so the flush loop delivers the change. + port.pending_bt_state = port.output_state.state(); + port.pending_bt_state_report = port.output_state.build_bt_state_report(); + } + if (input_trace) { + logger.log(vds::LogScope::InputControl, vds::LogLevel::Info, + port.path + " mic " + + std::string(port.mic_muted ? "muted" : "unmuted")); + } + } + if (headset_plugged != port.headset_plugged) { + port.headset_plugged = headset_plugged; + if (port.audio_out_stream_active || port.speaker_waveout_active) { + port.output_state.set_audio_out_stream_active(true, port.headset_plugged); + output_state_changed = true; + } + if (input_trace) { + logger.log( + vds::LogScope::InputControl, vds::LogLevel::Info, + port.path + " headset " + + std::string(port.headset_plugged ? "plugged" : "unplugged")); + } + } + if (output_state_changed) { + forward_bt_state_if_changed(port, bt_backend, trace_flags, logger, + "headset route change"); + } + if (headset_mic_changed && input_trace) { + logger.log( + vds::LogScope::InputControl, vds::LogLevel::Info, + port.path + " headset mic " + + std::string(port.headset_mic_plugged ? "plugged" : "unplugged")); + } + vds_frame_header header{}; header.type = VDS_FRAME_USB_HID_IN; header.length = static_cast(report->size()); @@ -1031,7 +1253,7 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, std::max(port.trace_state.input_write_max_us, write_us); if (write_duration >= kInputTraceSlowWriteWarn) { - logger.log("input", vds::LogLevel::Warn, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Warn, port.path + " slow input write count=" + std::to_string(port.trace_state.bt_input_count) + " write_us=" + std::to_string(write_us) + @@ -1044,7 +1266,7 @@ bool handle_bt_input(VirtualPort &port, vds::BtL2capBackend &bt_backend, ? 0 : port.trace_state.input_write_total_us / port.trace_state.input_write_count; - logger.log("input", vds::LogLevel::Debug, + logger.log(vds::LogScope::InputControl, vds::LogLevel::Debug, port.path + " input summary count=" + std::to_string(port.trace_state.bt_input_count) + " len=" + std::to_string(report->size()) + @@ -1077,7 +1299,7 @@ bool handle_bt_control(VirtualPort &port, vds::BtL2capBackend &bt_backend, const bool usb_waiting = pending != port.pending_feature_reports.end(); const bool output_trace = trace_enabled(trace_flags, kTraceOutput); if (output_trace) { - logger.log("hid", vds::LogLevel::Debug, + logger.log(vds::LogScope::Hid, vds::LogLevel::Debug, port.path + " bt feature cached report=" + hex_u8(report_id) + " len=" + std::to_string(report->size()) + " usb_waiting=" + (usb_waiting ? "yes" : "no")); @@ -1134,7 +1356,7 @@ void initialize_bt_controller(vds::BtL2capBackend &bt_backend, } } - logger.log("hid", vds::LogLevel::Info, + logger.log(vds::LogScope::Hid, vds::LogLevel::Info, port.path + " primed initial Bluetooth feature cache"); } @@ -1244,7 +1466,7 @@ void drop_bt_backend(ControllerRuntime &controller, VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "backend disconnected address=" + controller.config.address + " device=" + controller.device + " reason=" + reason); controller.backend.reset(); @@ -1276,7 +1498,28 @@ void sync_virtual_ports(std::vector &ports, } vds::UniqueFd fd(open_required(device, O_RDWR | O_NONBLOCK | O_CLOEXEC)); - logger.log("port", vds::LogLevel::Info, "opened " + device); + vds_driver_info driver_info{}; + std::string driver_message = "driver connected name=vds_hcd path=" + device; + vds::LogLevel driver_log_level = vds::LogLevel::Info; + if (::ioctl(fd.get(), VDS_IOC_GET_DRIVER_INFO, &driver_info) < 0) { + driver_log_level = vds::LogLevel::Warn; + driver_message = + "driver version unavailable name=vds_hcd path=" + device + + " detail=" + std::strerror(errno); + } else if (driver_info.version != VDS_DRIVER_INFO_VERSION || + driver_info.size != sizeof(driver_info) || + driver_info.driver_version[0] == '\0' || + driver_info.driver_version[VDS_DRIVER_VERSION_MAX - 1] != '\0') { + driver_log_level = vds::LogLevel::Warn; + driver_message = + "driver version unavailable name=vds_hcd path=" + device + + " detail=invalid driver version reply"; + } else { + driver_message = "driver connected name=vds_hcd version=" + + std::string(driver_info.driver_version) + + " path=" + device; + } + logger.log(vds::LogScope::Port, driver_log_level, driver_message); updated.push_back(VirtualPort{ .path = device, .fd = std::move(fd), @@ -1301,11 +1544,13 @@ void sync_virtual_ports(std::vector &ports, for (std::size_t i = 0; i < ports.size(); ++i) { if (!moved[i]) { - logger.log("port", vds::LogLevel::Info, "closed " + ports[i].path); + logger.log(vds::LogScope::Port, vds::LogLevel::Info, + "closed " + ports[i].path); } } if (updated.empty()) { - logger.log("port", vds::LogLevel::Warn, "no /dev/vds* endpoints found"); + logger.log(vds::LogScope::Port, vds::LogLevel::Warn, + "no /dev/vds* endpoints found"); } ports = std::move(updated); } @@ -1316,15 +1561,15 @@ void preempt_default_bluetooth_owner(const std::string &address, return; } - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "preempting default Bluetooth HID owner address=" + address); try { if (!vds::disconnect_bluez_device(address)) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "BlueZ device not found for disconnect address=" + address); } } catch (const std::exception &error) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "BlueZ disconnect failed address=" + address + " error=" + error.what()); } @@ -1332,14 +1577,14 @@ void preempt_default_bluetooth_owner(const std::string &address, const auto deadline = Clock::now() + kBluetoothPreemptWait; while (Clock::now() < deadline) { if (!vds::bluetooth_hid_device_present(address)) { - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "default stack released address=" + address); return; } std::this_thread::sleep_for(kBluetoothPreemptPoll); } - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "default Bluetooth HID owner still present address=" + address); } @@ -1358,7 +1603,7 @@ void complete_pending_controller(ControllerRuntime &controller, initialize_bt_controller(candidate, port, logger); candidate.send_output_report(port.output_state.build_bt_init_report()); port.last_sent_bt_state = port.output_state.state(); - logger.log("hid", vds::LogLevel::Info, + logger.log(vds::LogScope::Hid, vds::LogLevel::Info, port.path + " sent initial Bluetooth state report"); controller.backend = std::move(candidate); @@ -1368,7 +1613,7 @@ void complete_pending_controller(ControllerRuntime &controller, controller.last_error.clear(); logger.log( - "bluetooth", vds::LogLevel::Info, + vds::LogScope::Bluetooth, vds::LogLevel::Info, "raw L2CAP backend connected address=" + controller.config.address + " device=" + port.path + " profile=" + profile_name(profile)); } @@ -1387,13 +1632,13 @@ void handle_bt_accept(std::vector &ports, ControllerRuntime *controller = controller_for_address(controllers, accepted->address); if (!controller) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "rejected raw HID channel from unregistered address=" + accepted->address); continue; } if (controller->backend) { - logger.log("bluetooth", vds::LogLevel::Warn, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Warn, "rejected duplicate raw HID channel address=" + accepted->address); continue; @@ -1410,7 +1655,7 @@ void handle_bt_accept(std::vector &ports, if (!port_index) { if (ports.empty()) { controller->last_error = kLinuxVirtualPortProviderUnavailable; - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, "rejected raw HID channel address=" + accepted->address + " reason=" + kVirtualPortProviderUnavailableReason + " detail=" + kLinuxVirtualPortProviderUnavailable); @@ -1419,13 +1664,13 @@ void handle_bt_accept(std::vector &ports, port_index = available_port_index(ports, controllers, *controller); if (!port_index) { controller->last_error = "no available virtual port"; - logger.log("port", vds::LogLevel::Warn, + logger.log(vds::LogScope::Port, vds::LogLevel::Warn, "rejected raw HID channel address=" + accepted->address + " reason=no available virtual port"); continue; } controller->device = ports[*port_index].path; - logger.log("config", vds::LogLevel::Info, + logger.log(vds::LogScope::Config, vds::LogLevel::Info, "controller assigned address=" + controller->config.address + " device=" + controller->device + " profile=\"" + vds::controller_profile_name(controller->config.profile) + @@ -1434,12 +1679,12 @@ void handle_bt_accept(std::vector &ports, if (control_channel) { controller->pending_control_fd = std::move(accepted->fd); - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "accepted raw HID control channel address=" + accepted->address); } else { controller->pending_interrupt_fd = std::move(accepted->fd); - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "accepted raw HID interrupt channel address=" + accepted->address); } @@ -1454,7 +1699,7 @@ void handle_bt_accept(std::vector &ports, controller->pending_interrupt_fd.reset(); controller->device.clear(); controller->last_error = "assigned virtual port disappeared"; - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, "controller inactive address=" + controller->config.address + " reason=assigned port disappeared"); continue; @@ -1470,7 +1715,7 @@ void handle_bt_accept(std::vector &ports, controller->virtual_connected = false; controller->device.clear(); controller->last_error = error.what(); - logger.log("bluetooth", vds::LogLevel::Error, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Error, "connect failed address=" + controller->config.address + " device=" + ports[*port_index].path + " error=" + error.what()); @@ -1521,24 +1766,6 @@ int next_wakeup_timeout_ms(std::span ports, return timeout_ms; } -/* - * Advance a fixed-cadence send deadline without accumulating scheduler jitter. - * Stepping from the previous deadline (prev + interval) keeps the long-run - * average at exactly one send per interval, so the 100 Hz speaker consumer - * never drifts below the 100 Hz USB producer and stops overflowing the pending - * queue. A fresh deadline (prev == {}) or one that has fallen more than - * max_catchup behind resyncs to now + interval instead of replaying stale audio. - */ -inline Clock::time_point next_audio_deadline(Clock::time_point prev, - Clock::time_point now, - Clock::duration interval, - Clock::duration max_catchup) { - if (prev == Clock::time_point{} || now - prev > max_catchup) { - return now + interval; - } - return prev + interval; -} - bool flush_pending_audio_chunk(VirtualPort &port, vds::BtL2capBackend &bt_backend, std::uint32_t trace_flags, vds::Logger &logger) { @@ -1553,6 +1780,28 @@ bool flush_pending_audio_chunk(VirtualPort &port, } const bool output_trace = trace_enabled(trace_flags, kTraceOutput); + std::size_t stale_dropped = 0; + while (port.pending_audio_chunks.size() > port.fresh_pending_audio_chunks) { + port.pending_audio_chunks.pop_front(); + ++stale_dropped; + ++port.trace_state.dropped_audio_haptics_count; + ++port.trace_state.queue_dropped_audio_haptics_count; + ++port.trace_state.stale_audio_haptics_count; + } + if (stale_dropped > 0 && + (port.trace_state.stale_audio_haptics_count == stale_dropped || + port.trace_state.stale_audio_haptics_count % 1000 == 0)) { + logger.log( + vds::LogScope::Output, vds::LogLevel::Warn, + port.path + " dropped stale BT 0x36 audio packets count=" + + std::to_string(port.trace_state.stale_audio_haptics_count) + + " pending=" + std::to_string(port.pending_audio_chunks.size()) + + " dropped=" + + std::to_string(port.trace_state.dropped_audio_haptics_count) + + " blocked=" + + std::to_string(port.trace_state.blocked_audio_haptics_count)); + } + const auto &chunk = port.pending_audio_chunks.front(); vds::HapticsChunk haptics = chunk.haptics; if (port.haptics_gain_percent != 100) { @@ -1563,19 +1812,27 @@ bool flush_pending_audio_chunk(VirtualPort &port, } } const auto packet = port.haptics_builder.build_packet( - haptics, chunk.speaker, port.output_state.state()); + haptics, chunk.speaker, port.output_state.state(), true, + port.headset_plugged); const auto send_start = Clock::now(); if (!bt_backend.try_send_output_report(packet)) { ++port.trace_state.dropped_audio_haptics_count; ++port.trace_state.blocked_audio_haptics_count; + port.pending_audio_chunks.pop_front(); port.next_haptics_send_time = Clock::now() + kHapticsOutputBlockedRetry; - if (output_trace && - (port.trace_state.blocked_audio_haptics_count == 1 || - port.trace_state.blocked_audio_haptics_count % 1000 == 0)) { + if (port.trace_state.blocked_audio_haptics_count == 1 || + port.trace_state.blocked_audio_haptics_count % 1000 == 0) { logger.log( - "audio", vds::LogLevel::Warn, - port.path + " pending BT 0x36 haptics packet still blocked count=" + - std::to_string(port.trace_state.blocked_audio_haptics_count)); + vds::LogScope::Output, vds::LogLevel::Warn, + port.path + + " dropped BT 0x36 audio packet after HID queue blocked " + "count=" + + std::to_string(port.trace_state.blocked_audio_haptics_count) + + " pending=" + std::to_string(port.pending_audio_chunks.size()) + + " dropped=" + + std::to_string(port.trace_state.dropped_audio_haptics_count) + + " stale=" + + std::to_string(port.trace_state.stale_audio_haptics_count)); } return false; } @@ -1588,8 +1845,7 @@ bool flush_pending_audio_chunk(VirtualPort &port, port.pending_bt_state.reset(); port.pending_bt_state_report.reset(); } - port.next_haptics_send_time = next_audio_deadline( - port.next_haptics_send_time, now, kAudioOutputInterval, kMaxAudioCatchup); + port.next_haptics_send_time = now + kAudioOutputInterval; if (output_trace) { trace_output_latency(port.path, "audio_bt_send", @@ -1605,10 +1861,10 @@ void enqueue_speaker_waveout_chunk(VirtualPort &port, std::uint32_t trace_flags, return; } - std::array + std::array pcm{}; - for (std::size_t frame = 0; frame < vds::kSpeakerInputFrames; ++frame) { + for (std::size_t frame = 0; frame < vds::kPcmWindowFrames; ++frame) { const double angle = kSpeakerWaveoutTwoPi * static_cast(port.speaker_waveout_phase) / static_cast(kSpeakerWaveoutPeriodFrames); @@ -1628,13 +1884,13 @@ void enqueue_speaker_waveout_chunk(VirtualPort &port, std::uint32_t trace_flags, const auto chunks = port.waveout_extractor.push_usb_audio(pcm); for (const auto &chunk : chunks) { - if (port.pending_audio_chunks.size() >= kMaxPendingAudioChunks) { + if (port.pending_audio_chunks.size() >= port.max_pending_audio_chunks) { break; } port.pending_audio_chunks.push_back(chunk); } if (trace_enabled(trace_flags, kTraceOutput) && !chunks.empty()) { - logger.log("audio", vds::LogLevel::Debug, + logger.log(vds::LogScope::Output, vds::LogLevel::Debug, port.path + " queued WebHID speaker waveout chunk pending=" + std::to_string(port.pending_audio_chunks.size())); } @@ -1701,12 +1957,12 @@ void reconcile_controller_configs(std::vector &ports, sync_virtual_ports(ports, devices, logger); if (!virtual_port_provider_available) { - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, std::string(kVirtualPortProviderUnavailableReason) + " detail=" + kLinuxVirtualPortProviderUnavailable); } const vds::ConfigDb db = vds::load_config_db(db_path); - logger.log("config", vds::LogLevel::Info, + logger.log(vds::LogScope::Config, vds::LogLevel::Info, "loaded controller config count=" + std::to_string(db.controllers.size())); @@ -1717,11 +1973,11 @@ void reconcile_controller_configs(std::vector &ports, for (const auto &config : db.controllers) { if (!virtual_port_provider_available) { - logger.log("config", vds::LogLevel::Warn, + logger.log(vds::LogScope::Config, vds::LogLevel::Warn, "controller binding unavailable address=" + config.address + " reason=" + kVirtualPortProviderUnavailableReason); } else if (!controller_has_present_allowed_port(ports, config)) { - logger.log("config", vds::LogLevel::Warn, + logger.log(vds::LogScope::Config, vds::LogLevel::Warn, "controller binding unavailable address=" + config.address + " reason=no allowed virtual port present ports=[" + vds::format_ports(config.ports) + "]"); @@ -1773,7 +2029,7 @@ void reconcile_controller_configs(std::vector &ports, disconnect_virtual_port(ports[*old_port_index], logger); } if (old.backend) { - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "raw backend removed address=" + old.config.address + " device=" + old.device); } @@ -1787,7 +2043,7 @@ void reconcile_controller_configs(std::vector &ports, break; } - logger.log("config", vds::LogLevel::Info, + logger.log(vds::LogScope::Config, vds::LogLevel::Info, "controller registered address=" + config.address + " profile=\"" + vds::controller_profile_name(config.profile) + "\""); @@ -1806,7 +2062,7 @@ void reconcile_controller_configs(std::vector &ports, } if (controllers[i].backend) { logger.log( - "bluetooth", vds::LogLevel::Info, + vds::LogScope::Bluetooth, vds::LogLevel::Info, "raw backend removed address=" + controllers[i].config.address + " device=" + controllers[i].device); } @@ -1848,8 +2104,8 @@ std::optional read_controller_rssi(const std::string &address) { return result; } -void handle_control_client(int control_fd, std::span ports, - std::span controllers, +void handle_control_client(int control_fd, std::span ports, + std::span controllers, const std::string &db_path, std::uint32_t &trace_flags, bool &reload_requested, vds::CompanionRuntime &companion, @@ -1959,7 +2215,7 @@ void handle_control_client(int control_fd, std::span ports, vds::write_full(client_fd.get(), reply); } catch (const std::exception &error) { if (trace_flags != 0) { - logger.log("control", vds::LogLevel::Error, + logger.log(vds::LogScope::Control, vds::LogLevel::Error, std::string("reply failed: ") + error.what()); } } @@ -1992,12 +2248,12 @@ std::vector grab_dualsense_touchpads(vds::Logger &logger) { continue; } if (::ioctl(fd.get(), EVIOCGRAB, 1) < 0) { - logger.log("companion", vds::LogLevel::Warn, + logger.log(vds::LogScope::Companion, vds::LogLevel::Warn, node + " touchpad grab failed: " + std::string(std::strerror(errno))); continue; } - logger.log("companion", vds::LogLevel::Info, + logger.log(vds::LogScope::Companion, vds::LogLevel::Info, node + " touchpad pointer grabbed (" + name + ")"); grabs.push_back(std::move(fd)); } @@ -2082,7 +2338,7 @@ void apply_companion_state(std::vector &ports, } } else if (!touchpad_grabs.empty()) { touchpad_grabs.clear(); - logger.log("companion", vds::LogLevel::Info, + logger.log(vds::LogScope::Companion, vds::LogLevel::Info, "touchpad pointer released"); } @@ -2094,33 +2350,58 @@ void apply_companion_state(std::vector &ports, continue; } port.haptics_gain_percent = settings.haptics_gain_percent; + if (settings.haptics_buffer_samples != 0) { + // Slider value is 3 kHz haptics samples; each queued chunk covers a + // 10 ms speaker frame (30 samples). Keep at least two chunks so a + // single late URB burst does not immediately drop audio. + const std::size_t chunks = std::clamp( + (settings.haptics_buffer_samples + 15) / 30, 2, 16); + port.max_pending_audio_chunks = chunks; + port.fresh_pending_audio_chunks = std::max(1, chunks / 2); + } + if (port.mic_muted != settings.mic_muted) { + // App-initiated mute toggle (SET_MIC_MUTE): actuate it on the + // controller the same way the physical mute button does. Only commit + // the new state when the report went out, so a blocked HID queue + // leaves the mismatch in place and the next companion write retries. + const auto mic_report = port.output_state.build_bt_mic_state_report( + port.audio_in_stream_active, settings.mic_muted); + if (controller->backend->try_send_output_report(mic_report)) { + port.mic_muted = settings.mic_muted; + refresh_pending_bt_state(port); + logger.log(vds::LogScope::Companion, vds::LogLevel::Info, + port.path + " mic " + + std::string(port.mic_muted ? "muted" : "unmuted") + + " via companion"); + } + } port.output_state.set_companion_overrides(overrides); try { forward_bt_state_if_changed(port, *controller->backend, trace_flags, logger, "companion update"); } catch (const std::exception &error) { - logger.log("companion", vds::LogLevel::Warn, + logger.log(vds::LogScope::Companion, vds::LogLevel::Warn, port.path + " companion state send failed: " + error.what()); } } } -std::uint64_t encode_event(EventKind kind, std::size_t index) { - return (static_cast(kind) << 32) | +std::uint64_t encode_event(EventType type, std::size_t index) { + return (static_cast(type) << 32) | static_cast(index); } EventSource decode_event(std::uint64_t data) { return EventSource{ - .kind = static_cast(data >> 32), + .type = static_cast(data >> 32), .index = static_cast(data), }; } -void add_epoll_fd(int epoll_fd, int fd, EventKind kind, std::size_t index) { +void add_epoll_fd(int epoll_fd, int fd, EventType type, std::size_t index) { epoll_event event{}; event.events = EPOLLIN | EPOLLERR | EPOLLHUP; - event.data.u64 = encode_event(kind, index); + event.data.u64 = encode_event(type, index); if (::epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) < 0) { throw std::runtime_error("epoll add failed: " + std::string(std::strerror(errno))); @@ -2137,23 +2418,23 @@ vds::UniqueFd rebuild_epoll(int control_fd, vds::BtL2capAcceptor &bt_acceptor, std::string(std::strerror(errno))); } - add_epoll_fd(epoll_fd.get(), control_fd, EventKind::Control, 0); - add_epoll_fd(epoll_fd.get(), vds_monitor.fd(), EventKind::Udev, 0); + add_epoll_fd(epoll_fd.get(), control_fd, EventType::Control, 0); + add_epoll_fd(epoll_fd.get(), vds_monitor.fd(), EventType::Udev, 0); add_epoll_fd(epoll_fd.get(), bt_acceptor.control_listener_fd(), - EventKind::BtAcceptControl, 0); + EventType::BtAcceptControl, 0); add_epoll_fd(epoll_fd.get(), bt_acceptor.interrupt_listener_fd(), - EventKind::BtAcceptInterrupt, 0); + EventType::BtAcceptInterrupt, 0); for (std::size_t i = 0; i < ports.size(); ++i) { - add_epoll_fd(epoll_fd.get(), ports[i].fd.get(), EventKind::Port, i); + add_epoll_fd(epoll_fd.get(), ports[i].fd.get(), EventType::Port, i); } for (std::size_t i = 0; i < controllers.size(); ++i) { if (!controllers[i].backend) { continue; } add_epoll_fd(epoll_fd.get(), controllers[i].backend->control_fd(), - EventKind::BtControl, i); + EventType::BtControl, i); add_epoll_fd(epoll_fd.get(), controllers[i].backend->interrupt_fd(), - EventKind::BtInterrupt, i); + EventType::BtInterrupt, i); } return epoll_fd; } @@ -2178,16 +2459,17 @@ void disconnect_all(std::vector &ports, int main(int argc, char **argv) { try { ::signal(SIGPIPE, SIG_IGN); - ::signal(SIGINT, signal_handler); - ::signal(SIGTERM, signal_handler); + install_signal_handler(SIGINT); + install_signal_handler(SIGTERM); + install_signal_handler(SIGHUP); const Options options = parse_platform_args(argc, argv); vds::Logger logger(options.log_path); - logger.log("daemon", vds::LogLevel::Info, + logger.log(vds::LogScope::Daemon, vds::LogLevel::Info, "started socket=" + options.socket + " log=" + options.log_path + " db=" + options.db_path); if (vds::discover_vds_devices().empty()) { - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, std::string(kVirtualPortProviderUnavailableReason) + " detail=" + kLinuxVirtualPortProviderUnavailable); throw std::runtime_error(kLinuxVirtualPortProviderUnavailable); @@ -2196,7 +2478,7 @@ int main(int argc, char **argv) { vds::UniqueFd control_fd(open_control_socket(options.socket)); vds::BtL2capAcceptor bt_acceptor; vds::VdsDeviceMonitor vds_monitor; - logger.log("bluetooth", vds::LogLevel::Info, + logger.log(vds::LogScope::Bluetooth, vds::LogLevel::Info, "listening for controller-initiated raw HID channels"); SocketPathGuard control_socket_path(options.socket); std::vector ports; @@ -2209,13 +2491,25 @@ int main(int argc, char **argv) { std::uint64_t applied_companion_version = 0; while (g_stop_requested == 0) { + if (g_log_reopen_requested != 0) { + g_log_reopen_requested = 0; + try { + logger.reopen(); + logger.log(vds::LogScope::Daemon, vds::LogLevel::Info, + "reopened log file"); + } catch (const std::exception &error) { + logger.log(vds::LogScope::Daemon, vds::LogLevel::Error, + std::string("log reopen failed: ") + error.what()); + } + } + if (reload_requested) { try { reconcile_controller_configs(ports, controllers, options.db_path, logger); epoll_dirty = true; } catch (const std::exception &error) { - logger.log("config", vds::LogLevel::Error, + logger.log(vds::LogScope::Config, vds::LogLevel::Error, std::string("reload failed: ") + error.what()); } reload_requested = false; @@ -2271,7 +2565,7 @@ int main(int argc, char **argv) { const EventSource source = decode_event(events[i].data.u64); const std::uint32_t revents = events[i].events; - if (source.kind == EventKind::Control) { + if (source.type == EventType::Control) { if ((revents & EPOLLIN) != 0) { handle_control_client(control_fd.get(), ports, controllers, options.db_path, trace_flags, @@ -2283,11 +2577,11 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::BtAcceptControl || - source.kind == EventKind::BtAcceptInterrupt) { + if (source.type == EventType::BtAcceptControl || + source.type == EventType::BtAcceptInterrupt) { if ((revents & EPOLLIN) != 0) { handle_bt_accept(ports, controllers, bt_acceptor, - source.kind == EventKind::BtAcceptControl, logger, + source.type == EventType::BtAcceptControl, logger, epoll_dirty); } if ((revents & (EPOLLERR | EPOLLHUP)) != 0) { @@ -2296,9 +2590,9 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::Udev) { + if (source.type == EventType::Udev) { if ((revents & EPOLLIN) != 0 && vds_monitor.drain()) { - logger.log("port", vds::LogLevel::Info, + logger.log(vds::LogScope::Port, vds::LogLevel::Info, "virtual endpoint udev event; reloading"); reload_requested = true; epoll_dirty = true; @@ -2309,7 +2603,7 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::Port) { + if (source.type == EventType::Port) { if (source.index >= ports.size()) { continue; } @@ -2336,7 +2630,7 @@ int main(int argc, char **argv) { } } if ((revents & (EPOLLERR | EPOLLHUP)) != 0) { - logger.log("port", vds::LogLevel::Error, + logger.log(vds::LogScope::Port, vds::LogLevel::Error, port.path + " epoll error; reloading ports"); reload_requested = true; epoll_dirty = true; @@ -2358,7 +2652,7 @@ int main(int argc, char **argv) { } auto &port = ports[*port_index]; - if (source.kind == EventKind::BtControl) { + if (source.type == EventType::BtControl) { if ((revents & EPOLLIN) != 0) { for (int packet = 0; packet < kMaxBtPacketsPerWake; ++packet) { try { @@ -2384,7 +2678,7 @@ int main(int argc, char **argv) { continue; } - if (source.kind == EventKind::BtInterrupt) { + if (source.type == EventType::BtInterrupt) { if ((revents & EPOLLIN) != 0) { for (int packet = 0; packet < kMaxBtPacketsPerWake; ++packet) { try { @@ -2423,7 +2717,7 @@ int main(int argc, char **argv) { epoll_dirty); } - logger.log("daemon", vds::LogLevel::Info, "stopping"); + logger.log(vds::LogScope::Daemon, vds::LogLevel::Info, "stopping"); disconnect_all(ports, controllers, logger); return 0; } catch (const std::exception &error) { diff --git a/vds/src/vds_companion.cc b/vds/src/vds_companion.cc index 53908ba..85245f9 100644 --- a/vds/src/vds_companion.cc +++ b/vds/src/vds_companion.cc @@ -302,8 +302,13 @@ std::uint8_t apply_command(CompanionRuntime &runtime, runtime.pending_input_events.clear(); return kAckOk; } + case 0x0B: // SET_HAPTICS_BUFFER_LENGTH (3 kHz haptics samples) + if (value < 16 || value > 240) { + return kAckErrInvalidValue; + } + settings.haptics_buffer_samples = value; + return kAckOk; // Settings accepted and stored by the app but not yet actuated here. - case 0x0B: // SET_HAPTICS_BUFFER_LENGTH case 0x12: // SET_POLLING_RATE_MODE case 0x19: // SET_DUPLEX_ENABLED case 0x1D: // SET_SPEAKER_VOLUME_SHORTCUT_ENABLED @@ -474,7 +479,7 @@ std::uint8_t apply_command(CompanionRuntime &runtime, settings.touchpad_pointer_enabled = value != 0; return kAckOk; default: - logger.log("companion", LogLevel::Warn, + logger.log(LogScope::Companion, LogLevel::Warn, "unknown companion command id " + std::to_string(command_id)); return kAckErrUnknownCommand; } diff --git a/vds/src/vds_companion.hh b/vds/src/vds_companion.hh index f206f6b..988f730 100644 --- a/vds/src/vds_companion.hh +++ b/vds/src/vds_companion.hh @@ -31,6 +31,9 @@ struct CompanionSettings { bool idle_disconnect_enabled = false; std::uint16_t idle_disconnect_timeout_minutes = 10; std::uint16_t speaker_volume_percent = 100; + // Speaker/haptics buffering in 3 kHz haptics samples (app slider 16-240, + // about 5-80 ms). 0 keeps the daemon's built-in queue depth. + std::uint16_t haptics_buffer_samples = 0; std::uint8_t speaker_gain_level = 4; std::uint8_t lightbar_red = 0; std::uint8_t lightbar_green = 0; diff --git a/vds/src/vds_log.cc b/vds/src/vds_log.cc index e91eb53..e5af600 100644 --- a/vds/src/vds_log.cc +++ b/vds/src/vds_log.cc @@ -11,6 +11,7 @@ #include #include #include +#include #ifndef _WIN32 #include @@ -107,24 +108,35 @@ void prepare_log_file(const std::string &path) { namespace vds { -Logger::Logger(const std::string &path) { - const std::filesystem::path log_path(path); +Logger::Logger(const std::string &path) : path_(path) { file_ = open_file(); } + +std::ofstream Logger::open_file() const { + const std::filesystem::path log_path(path_); const std::filesystem::path directory = log_path.parent_path(); if (!directory.empty()) { std::filesystem::create_directories(directory); } - prepare_log_file(path); - file_.open(path, std::ios::app); - if (!file_) { - throw std::runtime_error("failed to open log file: " + path); + prepare_log_file(path_); + std::ofstream file(path_, std::ios::app); + if (!file) { + throw std::runtime_error("failed to open log file: " + path_); } + return file; } -void Logger::log(std::string_view scope, LogLevel level, - std::string_view message) { +void Logger::reopen() { + std::ofstream file = open_file(); + std::lock_guard guard(mutex_); - file_ << local_timestamp() << '|' << scope << '|' << log_level_name(level) - << '|'; + file_.flush(); + file_.close(); + file_ = std::move(file); +} + +void Logger::log(LogScope scope, LogLevel level, std::string_view message) { + std::lock_guard guard(mutex_); + file_ << local_timestamp() << '|' << log_scope_name(scope) << '|' + << log_level_name(level) << '|'; write_log_message(file_, message); file_ << '\n'; file_.flush(); @@ -144,4 +156,32 @@ const char *log_level_name(LogLevel level) { return "ERROR"; } +const char *log_scope_name(LogScope scope) { + switch (scope) { + case LogScope::Bluetooth: + return "bluetooth"; + case LogScope::Companion: + return "companion"; + case LogScope::Config: + return "config"; + case LogScope::Control: + return "control"; + case LogScope::Daemon: + return "daemon"; + case LogScope::Hid: + return "hid"; + case LogScope::InputAudio: + return "input-audio"; + case LogScope::InputControl: + return "input-control"; + case LogScope::Output: + return "output"; + case LogScope::Port: + return "port"; + case LogScope::Usb: + return "usb"; + } + return "daemon"; +} + } // namespace vds diff --git a/vds/src/vds_log.hh b/vds/src/vds_log.hh index d862945..be53b6b 100644 --- a/vds/src/vds_log.hh +++ b/vds/src/vds_log.hh @@ -22,17 +22,36 @@ enum class LogLevel { Error, }; +enum class LogScope { + Bluetooth, + Companion, + Config, + Control, + Daemon, + Hid, + InputAudio, + InputControl, + Output, + Port, + Usb, +}; + class Logger { public: explicit Logger(const std::string &path); - void log(std::string_view scope, LogLevel level, std::string_view message); + void reopen(); + void log(LogScope scope, LogLevel level, std::string_view message); private: + std::ofstream open_file() const; + std::mutex mutex_; + std::string path_; std::ofstream file_; }; const char *log_level_name(LogLevel level); +const char *log_scope_name(LogScope scope); } // namespace vds diff --git a/vds/src/vds_protocol.cc b/vds/src/vds_protocol.cc index 2bb5765..df45594 100644 --- a/vds/src/vds_protocol.cc +++ b/vds/src/vds_protocol.cc @@ -4,8 +4,7 @@ // Partly influenced by DS5Dongle source: // Copyright (c) 2026 awalol, released under the MIT license. // -// DualSense USB/Bluetooth protocol helpers: CRC, output state merging, HID -// report framing, and USB audio to Bluetooth haptics/speaker packet conversion. +// Thanks to @TechAntohere for Microphone and Headphones support. #include #include @@ -38,13 +37,18 @@ constexpr std::size_t kSetStateSize = VDS_SET_STATE_SIZE; constexpr std::size_t kBtStateOffset = 3; constexpr std::size_t kSpeakerBlockOffset = 142; constexpr std::size_t kSpeakerDataOffset = 144; +constexpr std::uint8_t kSpeakerBlockControllerSpeaker = 0x13; +constexpr std::uint8_t kSpeakerBlockHeadphones = 0x16; constexpr int kSpeakerOpusBitrate = kSpeakerOpusSize * 8 * 100; -constexpr std::uint8_t kBtHapticsAudioBufferLength = 64; +constexpr std::uint8_t kBtAudioBufferLength = 64; constexpr std::size_t kOutputFlag0Offset = 0; constexpr std::size_t kOutputFlag1Offset = 1; constexpr std::size_t kOutputHeadphoneVolumeOffset = 4; constexpr std::size_t kOutputSpeakerVolumeOffset = 5; +constexpr std::size_t kOutputMicVolumeOffset = 6; constexpr std::size_t kOutputAudioControlOffset = 7; +constexpr std::size_t kOutputMuteLedOffset = 8; +constexpr std::size_t kOutputPowerSaveControlOffset = 9; constexpr std::size_t kOutputAudioControl2Offset = 37; constexpr std::size_t kOutputLightBrightnessOffset = offsetof(vds_set_state_data, light_brightness); @@ -56,23 +60,95 @@ constexpr std::uint8_t kBtHidpFeaturePrefix = 0xa3; constexpr std::uint8_t kBtHidpGetFeaturePrefix = 0x43; constexpr std::uint8_t kBtHidpSetFeaturePrefix = 0x53; constexpr std::size_t kBtInputReportIdOffset = 1; +constexpr std::size_t kBtInputHeaderOffset = 2; constexpr std::size_t kBtInputUsbPayloadOffset = 3; constexpr std::size_t kBtInputMinimumSize = kBtInputUsbPayloadOffset + VDS_USB_INPUT_PAYLOAD_SIZE; +constexpr std::size_t kBtMicOpusOffset = 4; +constexpr std::uint8_t kBtInputPayloadTypeMask = 0x0f; +constexpr std::uint8_t kBtInputPayloadTypeControl = 0x01; +constexpr std::uint8_t kBtInputPayloadTypeAudio = 0x02; constexpr std::uint8_t kOutputFlag0HeadphoneVolumeEnable = 0x10; constexpr std::uint8_t kOutputFlag0SpeakerVolumeEnable = 0x20; +constexpr std::uint8_t kOutputFlag0MicVolumeEnable = 0x40; constexpr std::uint8_t kOutputFlag0AudioControlEnable = 0x80; +constexpr std::uint8_t kOutputFlag1MicMuteLedControlEnable = 0x01; +constexpr std::uint8_t kOutputFlag1PowerSaveControlEnable = 0x02; constexpr std::uint8_t kOutputFlag1AudioControl2Enable = 0x80; -constexpr std::uint8_t kOutputHeadphoneVolumeMax = 0x7f; -constexpr std::uint8_t kOutputSpeakerVolumeMax = 0x64; +constexpr std::uint8_t kOutputMicSelectMask = 0x03; +constexpr std::uint8_t kOutputMicSelectAuto = 0x00; +constexpr std::uint8_t kOutputMicSelectInternal = 0x01; +constexpr std::uint8_t kOutputMicAudioControl = 0x09; +constexpr std::uint8_t kOutputMicVolumeDefault = 0x08; +constexpr std::uint8_t kOutputAudioControl2Default = 0x01; +constexpr std::uint8_t kOutputAudioControlNonPathMask = 0xcf; constexpr std::uint8_t kOutputPathHeadphones = 0x00; constexpr std::uint8_t kOutputPathSpeaker = 0x30; -constexpr std::uint8_t kOutputSpeakerPreampGain = 0x02; -constexpr DsState kInitialDsState{ - 0xfd, 0xf7, 0x00, 0x00, 0x7f, 0x64, 0xff, 0x09, 0x00, 0x0f, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x0a, 0x07, 0x00, 0x00, 0x02, 0x01, 0x00, 0xff, 0xd7, 0x00}; +constexpr std::uint8_t kOutputPathMask = 0x30; +constexpr std::uint8_t kOutputPowerSaveMicMute = 0x10; +constexpr std::uint8_t kBtAudioSectionsEnable = 0xff; +constexpr std::uint8_t kBtAudioSectionsDisableMic = 0xfe; +constexpr std::uint8_t kBtMicReportId = 0x32; +constexpr std::uint8_t kBtMicOpen = 0xff; +constexpr std::uint8_t kBtMicClose = 0xfe; +constexpr std::array kInitialSetStateData{ + 0xfd, + 0xf7, + 0x00, + 0x00, + 0x7f, + 0x64, + kOutputMicVolumeDefault, + kOutputMicAudioControl, + 0x00, + 0x0f, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + kOutputAudioControl2Default, + 0x07, + 0x00, + 0x00, + 0x02, + 0x01, + 0x00, + 0xff, + 0xd7, + 0x00}; + +constexpr DsState initial_ds_state() { + DsState state{}; + for (std::size_t i = 0; i < kInitialSetStateData.size(); ++i) { + state[i] = kInitialSetStateData[i]; + } + return state; +} + +constexpr DsState kInitialDsState = initial_ds_state(); static_assert(kHapticsSampleSize % kSpeakerChannels == 0); constexpr std::uint32_t crc32_table_entry(std::uint32_t index) { @@ -103,6 +179,13 @@ std::int16_t read_le_s16(const std::uint8_t *data) { return static_cast(lo | hi); } +std::uint8_t audio_control_with_output_path(std::uint8_t audio_control, + std::uint8_t output_path) { + return static_cast( + (audio_control & static_cast(~kOutputPathMask)) | + output_path); +} + std::int16_t lerp_i16(std::int16_t left, std::int16_t right, std::uint32_t numerator, std::uint32_t denominator) { const auto left_part = static_cast(left) * @@ -115,55 +198,55 @@ std::int16_t lerp_i16(std::int16_t left, std::int16_t right, } std::array -resample_speaker_input(std::span input, - std::size_t input_frames) { - std::array output{}; +resample_speaker_pcm(std::span pcm, + std::size_t pcm_frames) { + std::array opus_pcm{}; // USB audio is accumulated in platform-sized speaker windows, then resampled // one 10 ms, 480-frame Opus speaker block for the 0x36 packet. for (std::size_t frame = 0; frame < kSpeakerOpusFrames; ++frame) { - const std::size_t source_numerator = frame * input_frames; + const std::size_t source_numerator = frame * pcm_frames; const std::size_t source_frame = source_numerator / kSpeakerOpusFrames; const auto fraction = static_cast(source_numerator % kSpeakerOpusFrames); - const std::size_t next_frame = std::min(source_frame + 1, input_frames - 1); + const std::size_t next_frame = std::min(source_frame + 1, pcm_frames - 1); for (std::size_t channel = 0; channel < kSpeakerChannels; ++channel) { - output[frame * kSpeakerChannels + channel] = - lerp_i16(input[source_frame * kSpeakerChannels + channel], - input[next_frame * kSpeakerChannels + channel], fraction, + opus_pcm[frame * kSpeakerChannels + channel] = + lerp_i16(pcm[source_frame * kSpeakerChannels + channel], + pcm[next_frame * kSpeakerChannels + channel], fraction, kSpeakerOpusFrames); } } - return output; + return opus_pcm; } -HapticsChunk resample_haptics_input(std::span input, - std::size_t input_frames) { - HapticsChunk output{}; - constexpr std::size_t output_frames = kHapticsSampleSize / kSpeakerChannels; +HapticsChunk resample_haptics_pcm(std::span pcm, + std::size_t pcm_frames) { + HapticsChunk chunk{}; + constexpr std::size_t haptics_frames = kHapticsSampleSize / kSpeakerChannels; - for (std::size_t frame = 0; frame < output_frames; ++frame) { - const std::size_t begin = frame * input_frames / output_frames; - const std::size_t end = (frame + 1) * input_frames / output_frames; + for (std::size_t frame = 0; frame < haptics_frames; ++frame) { + const std::size_t begin = frame * pcm_frames / haptics_frames; + const std::size_t end = (frame + 1) * pcm_frames / haptics_frames; const std::size_t count = std::max(1, end - begin); std::int32_t left_sum = 0; std::int32_t right_sum = 0; for (std::size_t sample = begin; sample < end; ++sample) { - left_sum += input[sample * kSpeakerChannels + 0]; - right_sum += input[sample * kSpeakerChannels + 1]; + left_sum += pcm[sample * kSpeakerChannels + 0]; + right_sum += pcm[sample * kSpeakerChannels + 1]; } const auto left = static_cast(left_sum / static_cast(count)); const auto right = static_cast(right_sum / static_cast(count)); - output[frame * kSpeakerChannels + 0] = s16_to_s8_haptic(left); - output[frame * kSpeakerChannels + 1] = s16_to_s8_haptic(right); + chunk[frame * kSpeakerChannels + 0] = s16_to_s8_haptic(left); + chunk[frame * kSpeakerChannels + 1] = s16_to_s8_haptic(right); } - return output; + return chunk; } void opus_ctl_checked(int result, const char *name) { @@ -256,34 +339,82 @@ struct PcmAudioExtractor::SpeakerEncoder { SpeakerEncoder(const SpeakerEncoder &) = delete; SpeakerEncoder &operator=(const SpeakerEncoder &) = delete; - SpeakerChunk encode(std::span input, - std::size_t input_frames) { - SpeakerChunk output{}; - const auto opus_input = resample_speaker_input(input, input_frames); + SpeakerChunk encode(std::span pcm, + std::size_t pcm_frames) { + SpeakerChunk chunk{}; + const auto opus_pcm = resample_speaker_pcm(pcm, pcm_frames); const int bytes = - opus_encode(encoder, opus_input.data(), kSpeakerOpusFrames, - output.data(), static_cast(output.size())); + opus_encode(encoder, opus_pcm.data(), kSpeakerOpusFrames, chunk.data(), + static_cast(chunk.size())); if (bytes < 0) { throw std::runtime_error("opus_encode failed: " + std::string(opus_strerror(bytes))); } - if (static_cast(bytes) < output.size()) { - std::fill(output.begin() + static_cast(bytes), - output.end(), 0); + if (static_cast(bytes) < chunk.size()) { + std::fill(chunk.begin() + static_cast(bytes), chunk.end(), + 0); } - return output; + return chunk; } OpusEncoder *encoder = nullptr; }; -PcmAudioExtractor::PcmAudioExtractor(std::size_t speaker_input_frames) +struct MicAudioDecoder::Decoder { + Decoder() { + int error = OPUS_OK; + decoder = opus_decoder_create(VDS_AUDIO_SAMPLE_RATE, 1, &error); + if (!decoder || error != OPUS_OK) { + throw std::runtime_error("opus_decoder_create failed: " + + std::string(opus_strerror(error))); + } + } + + ~Decoder() { + if (decoder) { + opus_decoder_destroy(decoder); + } + } + + Decoder(const Decoder &) = delete; + Decoder &operator=(const Decoder &) = delete; + + std::vector + decode(std::span payload) { + std::array mono{}; + const int frames = opus_decode( + decoder, payload.data(), static_cast(payload.size()), + mono.data(), static_cast(mono.size()), 0); + if (frames < 0) { + throw std::runtime_error("opus_decode failed: " + + std::string(opus_strerror(frames))); + } + + std::vector pcm(static_cast(frames) * + kMicUsbChannels * sizeof(std::int16_t)); + for (int frame = 0; frame < frames; ++frame) { + const auto sample = static_cast(mono[frame]); + const auto low = static_cast(sample & 0xff); + const auto high = static_cast((sample >> 8) & 0xff); + const std::size_t offset = static_cast(frame) * + kMicUsbChannels * sizeof(std::int16_t); + pcm[offset + 0] = 0; + pcm[offset + 1] = 0; + pcm[offset + 2] = low; + pcm[offset + 3] = high; + } + return pcm; + } + + OpusDecoder *decoder = nullptr; +}; + +PcmAudioExtractor::PcmAudioExtractor(std::size_t pcm_window_frames) : speaker_encoder_(std::make_unique()), - speaker_input_frames_(speaker_input_frames) { - if (speaker_input_frames_ == 0 || - speaker_input_frames_ > kSpeakerInputFrames || - speaker_input_frames_ % kHapticsDecimation != 0) { - throw std::invalid_argument("unsupported speaker input frame window"); + pcm_window_frames_(pcm_window_frames) { + if (pcm_window_frames_ == 0 || pcm_window_frames_ > kPcmWindowFrames || + pcm_window_frames_ % kHapticsDecimation != 0) { + throw std::invalid_argument("unsupported PCM frame window"); } } @@ -354,7 +485,8 @@ feature_set_packet(std::span report) { std::optional bt_input_to_usb_input(std::span packet) { if (packet.size() < kBtInputMinimumSize || packet[0] != kBtHidpInputPrefix || - packet[kBtInputReportIdOffset] != VDS_BT_STATE_REPORT_ID) { + packet[kBtInputReportIdOffset] != VDS_BT_STATE_REPORT_ID || + bt_input_payload_type(packet) != BtInputPayloadType::Control) { return std::nullopt; } @@ -367,6 +499,33 @@ bt_input_to_usb_input(std::span packet) { return report; } +BtInputPayloadType bt_input_payload_type(std::span packet) { + if (packet.size() <= kBtInputHeaderOffset || + packet[0] != kBtHidpInputPrefix || + packet[kBtInputReportIdOffset] != VDS_BT_STATE_REPORT_ID) { + return BtInputPayloadType::Unknown; + } + + switch (packet[kBtInputHeaderOffset] & kBtInputPayloadTypeMask) { + case kBtInputPayloadTypeControl: + return BtInputPayloadType::Control; + case kBtInputPayloadTypeAudio: + return BtInputPayloadType::Audio; + default: + return BtInputPayloadType::Unknown; + } +} + +std::optional> +bt_mic_opus_payload(std::span packet) { + if (bt_input_payload_type(packet) != BtInputPayloadType::Audio || + packet.size() < kBtMicOpusOffset + kMicOpusSize) { + return std::nullopt; + } + return std::span( + packet.data() + kBtMicOpusOffset, kMicOpusSize); +} + std::optional> bt_feature_to_usb_feature_reply(std::span packet) { if (packet.size() < 2 || packet[0] != kBtHidpFeaturePrefix) { @@ -648,10 +807,32 @@ void encode_companion_trigger_effect_v2( } } +MicAudioDecoder::MicAudioDecoder() : decoder_(std::make_unique()) {} + +MicAudioDecoder::~MicAudioDecoder() = default; + +MicAudioDecoder::MicAudioDecoder(MicAudioDecoder &&) noexcept = default; + +MicAudioDecoder & +MicAudioDecoder::operator=(MicAudioDecoder &&) noexcept = default; + +std::vector +MicAudioDecoder::decode(std::span payload) { + return decoder_->decode(payload); +} + DsOutputState::DsOutputState() : state_(kInitialDsState), light_color_{state_[kOutputLedColorOffset + 0], state_[kOutputLedColorOffset + 1], state_[kOutputLedColorOffset + 2]}, + headphones_volume_(state_[kOutputHeadphoneVolumeOffset]), + speaker_volume_(state_[kOutputSpeakerVolumeOffset]), + mic_volume_(state_[kOutputMicVolumeOffset]), + audio_control_(state_[kOutputAudioControlOffset] & + kOutputAudioControlNonPathMask), + audio_output_path_(state_[kOutputAudioControlOffset] & kOutputPathMask), + audio_control2_(state_[kOutputAudioControl2Offset]), + power_save_control_(state_[kOutputPowerSaveControlOffset]), light_brightness_(state_[kOutputLightBrightnessOffset]) { recompute_effective_state(); } @@ -740,6 +921,16 @@ void DsOutputState::recompute_effective_state() { } } +void DsOutputState::apply_mic_select() { + audio_control_ = + static_cast(audio_control_ & ~kOutputMicSelectMask); + audio_control_ |= + headset_mic_plugged_ ? kOutputMicSelectAuto : kOutputMicSelectInternal; + state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); +} + BtInitReport DsOutputState::build_bt_init_report() { BtInitReport report{}; report[0] = 0x32; @@ -778,36 +969,65 @@ bool DsOutputState::apply_usb_output_report( } if (decoded.allow_headphone_volume) { state_[kOutputFlag0Offset] |= kOutputFlag0HeadphoneVolumeEnable; + headphones_volume_ = decoded.volume_headphones; copy_state_bytes(state_, update, offsetof(vds_set_state_data, volume_headphones), sizeof(decoded.volume_headphones)); } if (decoded.allow_speaker_volume) { state_[kOutputFlag0Offset] |= kOutputFlag0SpeakerVolumeEnable; + speaker_volume_ = decoded.volume_speaker; copy_state_bytes(state_, update, offsetof(vds_set_state_data, volume_speaker), sizeof(decoded.volume_speaker)); } + if (decoded.allow_mic_volume) { + state_[kOutputFlag0Offset] |= kOutputFlag0MicVolumeEnable; + mic_volume_ = decoded.volume_mic; + state_[kOutputMicVolumeOffset] = mic_volume_; + } if (decoded.allow_audio_control) { state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable; - copy_state_bytes(state_, update, kOutputAudioControlOffset, 1); + const std::uint8_t requested_control = update[kOutputAudioControlOffset]; + const std::uint8_t requested_non_path = + requested_control & kOutputAudioControlNonPathMask; + if (requested_non_path != 0) { + audio_control_ = requested_non_path; + } + audio_output_path_ = requested_control & kOutputPathMask; + apply_mic_select(); } if (decoded.allow_audio_control2) { state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; + audio_control2_ = update[kOutputAudioControl2Offset]; copy_state_bytes(state_, update, kOutputAudioControl2Offset, 1); } + if (decoded.allow_audio_mute) { + // The host-side hid-playstation driver toggles its own mic-mute state on + // every mute-button press, but it cannot see companion-initiated mute + // changes, so its phase drifts from ours. The daemon (companion + button + // handling) owns mic mute: take the host's other power-save bits but + // preserve our mic-mute bit. + state_[kOutputFlag1Offset] |= kOutputFlag1PowerSaveControlEnable; + const std::uint8_t preserved_mic_mute = + state_[kOutputPowerSaveControlOffset] & kOutputPowerSaveMicMute; + power_save_control_ = static_cast( + (update[kOutputPowerSaveControlOffset] & + static_cast(~kOutputPowerSaveMicMute)) | + preserved_mic_mute); + state_[kOutputPowerSaveControlOffset] = power_save_control_; + } if (decoded.allow_speaker_volume && decoded.volume_speaker != 0) { state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable | kOutputFlag0SpeakerVolumeEnable; state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; - state_[kOutputAudioControlOffset] = kOutputPathSpeaker; - state_[kOutputAudioControl2Offset] = kOutputSpeakerPreampGain; - } - if (decoded.allow_mute_light) { - copy_state_bytes(state_, update, - offsetof(vds_set_state_data, mute_light_mode), - sizeof(decoded.mute_light_mode)); + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, kOutputPathSpeaker); + state_[kOutputAudioControl2Offset] = audio_control2_; } + // decoded.allow_mute_light is deliberately ignored: the mute LED mirrors + // the daemon-owned mic-mute state (build_bt_mic_state_report), and the + // host driver's out-of-phase toggles would otherwise override it. if (decoded.allow_right_trigger_ffb) { copy_state_bytes(state_, update, offsetof(vds_set_state_data, right_trigger_ffb), @@ -852,16 +1072,30 @@ bool DsOutputState::apply_usb_output_report( return true; } -void DsOutputState::set_audio_out_stream_active(bool active) { +void DsOutputState::set_audio_out_stream_active(bool active, + bool headset_plugged) { state_[kOutputFlag0Offset] |= kOutputFlag0AudioControlEnable; - state_[kOutputHeadphoneVolumeOffset] = kOutputHeadphoneVolumeMax; + state_[kOutputHeadphoneVolumeOffset] = headphones_volume_; if (active) { - state_[kOutputFlag0Offset] |= kOutputFlag0SpeakerVolumeEnable; - state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; - state_[kOutputSpeakerVolumeOffset] = kOutputSpeakerVolumeMax; - state_[kOutputAudioControlOffset] = kOutputPathSpeaker; - state_[kOutputAudioControl2Offset] = kOutputSpeakerPreampGain; + if (headset_plugged) { + state_[kOutputFlag0Offset] |= kOutputFlag0HeadphoneVolumeEnable; + state_[kOutputFlag0Offset] &= ~kOutputFlag0SpeakerVolumeEnable; + state_[kOutputFlag1Offset] &= ~kOutputFlag1AudioControl2Enable; + state_[kOutputSpeakerVolumeOffset] = 0; + state_[kOutputAudioControl2Offset] = 0; + audio_output_path_ = kOutputPathHeadphones; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); + } else { + state_[kOutputFlag0Offset] |= kOutputFlag0SpeakerVolumeEnable; + state_[kOutputFlag1Offset] |= kOutputFlag1AudioControl2Enable; + state_[kOutputSpeakerVolumeOffset] = speaker_volume_; + state_[kOutputAudioControl2Offset] = audio_control2_; + audio_output_path_ = kOutputPathSpeaker; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); + } recompute_effective_state(); return; } @@ -869,11 +1103,90 @@ void DsOutputState::set_audio_out_stream_active(bool active) { state_[kOutputFlag0Offset] &= ~kOutputFlag0SpeakerVolumeEnable; state_[kOutputFlag1Offset] &= ~kOutputFlag1AudioControl2Enable; state_[kOutputSpeakerVolumeOffset] = 0; - state_[kOutputAudioControlOffset] = kOutputPathHeadphones; state_[kOutputAudioControl2Offset] = 0; + audio_output_path_ = kOutputPathHeadphones; + state_[kOutputAudioControlOffset] = + audio_control_with_output_path(audio_control_, audio_output_path_); recompute_effective_state(); } +void DsOutputState::set_headset_mic_plugged(bool plugged) { + headset_mic_plugged_ = plugged; + state_[kOutputFlag0Offset] |= + kOutputFlag0MicVolumeEnable | kOutputFlag0AudioControlEnable; + state_[kOutputMicVolumeOffset] = mic_volume_; + apply_mic_select(); + recompute_effective_state(); +} + +BtStateReport DsOutputState::build_bt_mic_state_report(bool active, + bool muted) { + const std::uint8_t power_save_control = + active && !muted + ? static_cast( + power_save_control_ & + static_cast(~kOutputPowerSaveMicMute)) + : static_cast(power_save_control_ | + kOutputPowerSaveMicMute); + + state_[kOutputFlag0Offset] |= + kOutputFlag0MicVolumeEnable | kOutputFlag0AudioControlEnable; + state_[kOutputFlag1Offset] |= kOutputFlag1MicMuteLedControlEnable | + kOutputFlag1PowerSaveControlEnable | + kOutputFlag1AudioControl2Enable; + const std::uint8_t audio_control = + audio_control_with_output_path(audio_control_, audio_output_path_); + state_[kOutputMicVolumeOffset] = active && !muted ? mic_volume_ : 0; + state_[kOutputAudioControlOffset] = audio_control; + state_[kOutputAudioControl2Offset] = audio_control2_; + // Drive the DualSense mute LED from the mute state so toggles initiated by + // the host (companion app) are visible on the controller, matching native + // PS5 behavior. 1 = solid on while muted. + const std::uint8_t mute_led = muted ? 1 : 0; + state_[kOutputMuteLedOffset] = mute_led; + state_[kOutputPowerSaveControlOffset] = power_save_control; + recompute_effective_state(); + + BtStateReport report{}; + report[0] = VDS_BT_STATE_REPORT_ID; + report[1] = report_sequence_ << 4; + report_sequence_ = (report_sequence_ + 1) & 0x0f; + report[2] = 0x10; + report[kBtStateOffset + kOutputFlag0Offset] = + kOutputFlag0MicVolumeEnable | kOutputFlag0AudioControlEnable; + report[kBtStateOffset + kOutputFlag1Offset] = + kOutputFlag1MicMuteLedControlEnable | kOutputFlag1PowerSaveControlEnable | + kOutputFlag1AudioControl2Enable; + report[kBtStateOffset + kOutputMicVolumeOffset] = + active && !muted ? mic_volume_ : 0; + report[kBtStateOffset + kOutputAudioControlOffset] = audio_control; + report[kBtStateOffset + kOutputMuteLedOffset] = mute_led; + report[kBtStateOffset + kOutputPowerSaveControlOffset] = power_save_control; + report[kBtStateOffset + kOutputAudioControl2Offset] = audio_control2_; + fill_output_report_checksum(report); + return report; +} + +BtInitReport DsOutputState::build_bt_mic_report(bool active) { + BtInitReport report{}; + report[0] = kBtMicReportId; + report[1] = mic_sequence_ << 4; + report[2] = 0x91; + report[3] = 0x07; + report[4] = active ? kBtMicOpen : kBtMicClose; + report[5] = kBtAudioBufferLength; + report[6] = kBtAudioBufferLength; + report[7] = kBtAudioBufferLength; + report[8] = kBtAudioBufferLength; + report[9] = kBtAudioBufferLength; + report[10] = mic_sequence_; + report[11] = 0x92; + report[12] = 0x40; + mic_sequence_ = (mic_sequence_ + 1) & 0x0f; + fill_output_report_checksum(report); + return report; +} + BtStateReport DsOutputState::build_bt_state_report() { BtStateReport report{}; report[0] = VDS_BT_STATE_REPORT_ID; @@ -890,19 +1203,21 @@ BtStateReport DsOutputState::build_bt_state_report() { BtReport HapticsPacketBuilder::build_packet( std::span haptics, std::span speaker, - std::span state) { + std::span state, + bool audio_sections_enabled, bool headset_plugged) { BtReport packet{}; packet[0] = VDS_BT_HAPTICS_REPORT_ID; packet[1] = report_sequence_ << 4; report_sequence_ = (report_sequence_ + 1) & 0x0f; packet[2] = 0x11 | (1 << 7); packet[3] = 7; - packet[4] = 0xfe; - packet[5] = kBtHapticsAudioBufferLength; - packet[6] = kBtHapticsAudioBufferLength; - packet[7] = kBtHapticsAudioBufferLength; - packet[8] = kBtHapticsAudioBufferLength; - packet[9] = kBtHapticsAudioBufferLength; + packet[4] = audio_sections_enabled ? kBtAudioSectionsEnable + : kBtAudioSectionsDisableMic; + packet[5] = kBtAudioBufferLength; + packet[6] = kBtAudioBufferLength; + packet[7] = kBtAudioBufferLength; + packet[8] = kBtAudioBufferLength; + packet[9] = kBtAudioBufferLength; packet[10] = packet_sequence_++; packet[11] = 0x10 | (1 << 7); packet[12] = kDsStateSize; @@ -910,7 +1225,10 @@ BtReport HapticsPacketBuilder::build_packet( packet[76] = 0x12 | (1 << 7); packet[77] = kHapticsSampleSize; std::memcpy(packet.data() + 78, haptics.data(), haptics.size()); - packet[kSpeakerBlockOffset] = 0x13 | (1 << 7); + packet[kSpeakerBlockOffset] = + (headset_plugged ? kSpeakerBlockHeadphones + : kSpeakerBlockControllerSpeaker) | + (1 << 7); packet[kSpeakerBlockOffset + 1] = static_cast(kSpeakerOpusSize); std::memcpy(packet.data() + kSpeakerDataOffset, speaker.data(), speaker.size()); @@ -921,8 +1239,7 @@ BtReport HapticsPacketBuilder::build_packet( std::vector PcmAudioExtractor::push_usb_audio(std::span pcm_bytes) { std::vector chunks; - chunks.reserve(pcm_bytes.size() / (kPcmFrameSize * speaker_input_frames_) + - 1); + chunks.reserve(pcm_bytes.size() / (kPcmFrameSize * pcm_window_frames_) + 1); const auto consume_frame = [this, &chunks](const std::uint8_t *base) { std::array samples{}; @@ -931,27 +1248,27 @@ PcmAudioExtractor::push_usb_audio(std::span pcm_bytes) { chunk_has_signal_ |= samples[channel] != 0; } - speaker_input_[speaker_frame_pos_ * kSpeakerChannels + 0] = samples[0]; - speaker_input_[speaker_frame_pos_ * kSpeakerChannels + 1] = samples[1]; - haptics_input_[speaker_frame_pos_ * kSpeakerChannels + 0] = samples[2]; - haptics_input_[speaker_frame_pos_ * kSpeakerChannels + 1] = samples[3]; + speaker_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 0] = samples[0]; + speaker_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 1] = samples[1]; + haptics_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 0] = samples[2]; + haptics_pcm_[pcm_window_frame_pos_ * kSpeakerChannels + 1] = samples[3]; chunk_has_haptics_signal_ |= samples[2] != 0 || samples[3] != 0; - ++speaker_frame_pos_; + ++pcm_window_frame_pos_; - if (speaker_frame_pos_ != speaker_input_frames_) { + if (pcm_window_frame_pos_ != pcm_window_frames_) { return; } - speaker_frame_pos_ = 0; + pcm_window_frame_pos_ = 0; AudioChunk chunk{}; - chunk.haptics = resample_haptics_input( - std::span(haptics_input_.data(), - speaker_input_frames_ * kSpeakerChannels), - speaker_input_frames_); + chunk.haptics = resample_haptics_pcm( + std::span(haptics_pcm_.data(), + pcm_window_frames_ * kSpeakerChannels), + pcm_window_frames_); chunk.speaker = speaker_encoder_->encode( - std::span(speaker_input_.data(), - speaker_input_frames_ * kSpeakerChannels), - speaker_input_frames_); + std::span(speaker_pcm_.data(), + pcm_window_frames_ * kSpeakerChannels), + pcm_window_frames_); chunk.has_signal = chunk_has_signal_; chunk.has_haptics_signal = chunk_has_haptics_signal_; chunks.push_back(chunk); @@ -1013,6 +1330,8 @@ std::string frame_type_name(std::uint16_t type) { return "USB_FEATURE_SET"; case VDS_FRAME_USB_AUDIO_OUT: return "USB_AUDIO_OUT"; + case VDS_FRAME_USB_AUDIO_IN: + return "USB_AUDIO_IN"; case VDS_FRAME_USB_HID_IN: return "USB_HID_IN"; case VDS_FRAME_USB_FEATURE_REPLY: diff --git a/vds/src/vds_protocol.hh b/vds/src/vds_protocol.hh index 146dbc4..4b9aaf8 100644 --- a/vds/src/vds_protocol.hh +++ b/vds/src/vds_protocol.hh @@ -22,20 +22,30 @@ constexpr std::size_t kHapticsSampleSize = VDS_HAPTICS_SAMPLE_SIZE; constexpr std::size_t kUsbInputReportSize = VDS_USB_INPUT_REPORT_SIZE; constexpr std::size_t kDsStateSize = 63; constexpr std::size_t kSpeakerChannels = 2; -constexpr std::size_t kSpeakerInputFrames = 512; +constexpr std::size_t kPcmWindowFrames = 512; constexpr std::size_t kSpeakerOpusFrames = 480; constexpr std::size_t kSpeakerOpusSize = 200; +constexpr std::size_t kMicOpusFrames = 480; +constexpr std::size_t kMicOpusSize = 71; +constexpr std::size_t kMicUsbChannels = 2; +constexpr std::size_t kMicPcmSize = + kMicOpusFrames * kMicUsbChannels * sizeof(std::int16_t); using BtReport = std::array; using BtInitReport = std::array; using BtStateReport = std::array; using HapticsChunk = std::array; -using SpeakerInput = - std::array; +using PcmWindow = std::array; using SpeakerChunk = std::array; using DsState = std::array; using UsbInputReport = std::array; +enum class BtInputPayloadType { + Unknown, + Control, + Audio, +}; + struct AudioChunk { HapticsChunk haptics; SpeakerChunk speaker; @@ -50,17 +60,41 @@ hidp_output_packet(std::span report); std::vector feature_get_packet(std::uint8_t report_id); std::vector feature_set_packet(std::span report); +BtInputPayloadType bt_input_payload_type(std::span packet); +std::optional> +bt_mic_opus_payload(std::span packet); std::optional bt_input_to_usb_input(std::span packet); std::optional> bt_feature_to_usb_feature_reply(std::span packet); +class MicAudioDecoder { +public: + MicAudioDecoder(); + ~MicAudioDecoder(); + + MicAudioDecoder(MicAudioDecoder &&) noexcept; + MicAudioDecoder &operator=(MicAudioDecoder &&) noexcept; + + MicAudioDecoder(const MicAudioDecoder &) = delete; + MicAudioDecoder &operator=(const MicAudioDecoder &) = delete; + + std::vector + decode(std::span payload); + +private: + struct Decoder; + + std::unique_ptr decoder_; +}; + class HapticsPacketBuilder { public: BtReport build_packet(std::span haptics, std::span speaker, - std::span state); + std::span state, + bool audio_sections_enabled, bool headset_plugged); private: std::uint8_t report_sequence_ = 0; @@ -117,7 +151,10 @@ public: DsOutputState(); bool apply_usb_output_report(std::span report); - void set_audio_out_stream_active(bool active); + void set_audio_out_stream_active(bool active, bool headset_plugged = false); + void set_headset_mic_plugged(bool plugged); + BtStateReport build_bt_mic_state_report(bool active, bool muted); + BtInitReport build_bt_mic_report(bool active); void set_companion_overrides(const DsCompanionOverrides &overrides); BtInitReport build_bt_init_report(); BtStateReport build_bt_state_report(); @@ -125,20 +162,29 @@ public: private: void recompute_effective_state(); + void apply_mic_select(); DsState state_{}; DsState effective_state_{}; DsCompanionOverrides companion_; std::array light_color_{}; + std::uint8_t headphones_volume_ = 0; + std::uint8_t speaker_volume_ = 0; + std::uint8_t mic_volume_ = 0; + std::uint8_t audio_control_ = 0; + std::uint8_t audio_output_path_ = 0; + std::uint8_t audio_control2_ = 0; + std::uint8_t power_save_control_ = 0; std::uint8_t light_brightness_ = 0; bool emulate_light_brightness_ = false; + bool headset_mic_plugged_ = false; std::uint8_t report_sequence_ = 0; + std::uint8_t mic_sequence_ = 0; }; class PcmAudioExtractor { public: - explicit PcmAudioExtractor( - std::size_t speaker_input_frames = kSpeakerInputFrames); + explicit PcmAudioExtractor(std::size_t pcm_window_frames = kPcmWindowFrames); ~PcmAudioExtractor(); PcmAudioExtractor(PcmAudioExtractor &&) noexcept; @@ -156,11 +202,11 @@ private: std::unique_ptr speaker_encoder_; std::array pending_frame_{}; - SpeakerInput speaker_input_{}; - SpeakerInput haptics_input_{}; - std::size_t speaker_input_frames_ = kSpeakerInputFrames; + PcmWindow speaker_pcm_{}; + PcmWindow haptics_pcm_{}; + std::size_t pcm_window_frames_ = kPcmWindowFrames; std::size_t pending_frame_pos_ = 0; - std::size_t speaker_frame_pos_ = 0; + std::size_t pcm_window_frame_pos_ = 0; bool chunk_has_signal_ = false; bool chunk_has_haptics_signal_ = false; }; diff --git a/vds/src/vdsctl_common.cc b/vds/src/vdsctl_common.cc index 56a044b..e69824a 100644 --- a/vds/src/vdsctl_common.cc +++ b/vds/src/vdsctl_common.cc @@ -68,7 +68,10 @@ std::string vdsctl_usage(std::string_view version, " vdsctl detach
\n" " vdsctl list\n" " vdsctl list-targets\n" - " vdsctl trace on|off [--scope input[,output...]]\n"; + " vdsctl trace on|off [--scope [,...]]\n" + "\n" + "trace scopes:\n" + " all, input, input-audio, input-control, output\n"; return text; } @@ -95,10 +98,14 @@ int run_vdsctl_app(int argc, char **argv, std::string_view version, std::string_view build_year, const VdsctlPlatform &platform) { try { - if (argc < 2 || std::string_view(argv[1]) == "-h" || + if (argc < 2) { + throw std::runtime_error("command is required"); + } + + if (std::string_view(argv[1]) == "-h" || std::string_view(argv[1]) == "--help") { std::cerr << vdsctl_usage(version, build_year); - return argc < 2 ? 1 : 0; + return 0; } switch (parse_vdsctl_command(argv[1])) { @@ -168,7 +175,7 @@ VdsctlTraceCommand parse_vdsctl_trace(int argc, char **argv) { const std::string_view mode = argv[2]; VdsctlTraceCommand command{ .enabled = false, - .scope = "input,output", + .scope = "all", }; if (argc == 5) { if (std::string_view(argv[3]) != "--scope") { diff --git a/vds/src/vdsd_common.cc b/vds/src/vdsd_common.cc index ef6d6ee..6a56897 100644 --- a/vds/src/vdsd_common.cc +++ b/vds/src/vdsd_common.cc @@ -90,13 +90,21 @@ std::uint32_t parse_trace_scope(std::string_view scope) { while (true) { const std::size_t separator = scope.find(','); const std::string_view item = scope.substr(0, separator); - if (item == "input") { + if (item == "input" || item == "all") { flags |= kTraceInput; + if (item == "all") { + flags |= kTraceOutput; + } + } else if (item == "input-audio") { + flags |= kTraceInputAudio; + } else if (item == "input-control") { + flags |= kTraceInputControl; } else if (item == "output") { flags |= kTraceOutput; } else { throw std::runtime_error( - "trace scope must be input, output, or comma-separated input/output"); + "trace scope must be all, input, input-audio, input-control, output, " + "or comma-separated scopes"); } if (separator == std::string_view::npos) { return flags; @@ -112,10 +120,32 @@ std::string trace_scope_name(std::uint32_t scope) { if (scope == kTraceInput) { return "input"; } + if (scope == kTraceInputAudio) { + return "input-audio"; + } + if (scope == kTraceInputControl) { + return "input-control"; + } if (scope == kTraceOutput) { return "output"; } - return "none"; + std::string name; + if (trace_enabled(scope, kTraceInputAudio)) { + name += "input-audio"; + } + if (trace_enabled(scope, kTraceInputControl)) { + if (!name.empty()) { + name += ','; + } + name += "input-control"; + } + if (trace_enabled(scope, kTraceOutput)) { + if (!name.empty()) { + name += ','; + } + name += "output"; + } + return name.empty() ? "none" : name; } std::string format_controller_config(const ControllerConfig &config) { @@ -438,8 +468,15 @@ std::string format_control_trace_reply(bool ok, std::string_view error, reply += jsonl_bool_field("trace", (trace_flags & kTraceAll) != 0); reply += ",\"scope\":["; bool wrote_scope = false; - if (trace_enabled(trace_flags, kTraceInput)) { - reply += jsonl_string_value("input"); + if (trace_enabled(trace_flags, kTraceInputAudio)) { + reply += jsonl_string_value("input-audio"); + wrote_scope = true; + } + if (trace_enabled(trace_flags, kTraceInputControl)) { + if (wrote_scope) { + reply += ','; + } + reply += jsonl_string_value("input-control"); wrote_scope = true; } if (trace_enabled(trace_flags, kTraceOutput)) { @@ -483,7 +520,7 @@ std::string handle_attach_control_request( if (!config.address.empty()) { const std::string error = "address is already registered"; - logger.log("control", LogLevel::Warn, + logger.log(vds::LogScope::Control, LogLevel::Warn, "attach rejected " + error + ": " + config.address); return format_control_attach_reply(false, error, config); } @@ -498,7 +535,8 @@ std::string handle_attach_control_request( const std::string error = "address is not paired, not supported, or not attachable; run " "vdsctl list-targets"; - logger.log("control", LogLevel::Warn, "attach rejected " + error); + logger.log(vds::LogScope::Control, LogLevel::Warn, + "attach rejected " + error); config = {}; return format_control_attach_reply(false, error, config); } @@ -517,7 +555,7 @@ std::string handle_attach_control_request( }); if (already_registered) { const std::string error = "address is already registered"; - logger.log("control", LogLevel::Warn, + logger.log(vds::LogScope::Control, LogLevel::Warn, "attach rejected " + error + ": " + config.address); return format_control_attach_reply(false, error, config); } @@ -526,7 +564,7 @@ std::string handle_attach_control_request( } reload_requested = true; - logger.log("control", LogLevel::Info, + logger.log(vds::LogScope::Control, LogLevel::Info, "attached controller config " + format_controller_config(config)); return format_control_attach_reply(true, "", config); } @@ -563,7 +601,7 @@ std::string handle_detach_control_request(std::span fields, return format_control_detach_reply(false, error.what(), address); } reload_requested = removed; - logger.log("control", LogLevel::Info, + logger.log(vds::LogScope::Control, LogLevel::Info, std::string(removed ? "detached " : "detach ignored ") + address); if (!removed) { @@ -624,7 +662,7 @@ std::string handle_trace_control_request(std::span fields, return format_control_trace_reply(false, error.what(), trace_flags); } - logger.log("control", LogLevel::Info, + logger.log(vds::LogScope::Control, LogLevel::Info, "trace " + trace_scope_name(scope) + " " + (enabled ? "enabled" : "disabled") + " active=" + active_trace_name(trace_flags)); @@ -665,7 +703,7 @@ std::string handle_vdsd_control_command( controllers, logger); } - logger.log("control", LogLevel::Warn, + logger.log(vds::LogScope::Control, LogLevel::Warn, "unknown command: " + std::string(command)); return format_control_error_reply("unknown command: " + command); } catch (const std::exception &error) { diff --git a/vds/src/vdsd_common.hh b/vds/src/vdsd_common.hh index 52d99cb..3af604b 100644 --- a/vds/src/vdsd_common.hh +++ b/vds/src/vdsd_common.hh @@ -18,8 +18,11 @@ namespace vds { class Logger; struct CompanionRuntime; -inline constexpr std::uint32_t kTraceInput = 1u << 0; -inline constexpr std::uint32_t kTraceOutput = 1u << 1; +inline constexpr std::uint32_t kTraceInputAudio = 1u << 0; +inline constexpr std::uint32_t kTraceInputControl = 1u << 1; +inline constexpr std::uint32_t kTraceInput = + kTraceInputAudio | kTraceInputControl; +inline constexpr std::uint32_t kTraceOutput = 1u << 2; inline constexpr std::uint32_t kTraceAll = kTraceInput | kTraceOutput; struct VdsdCommonOptions { diff --git a/vds/tests/companion_buffer_length_test.cc b/vds/tests/companion_buffer_length_test.cc new file mode 100644 index 0000000..4c37083 --- /dev/null +++ b/vds/tests/companion_buffer_length_test.cc @@ -0,0 +1,74 @@ +// Verifies SET_HAPTICS_BUFFER_LENGTH (0x0B) is stored in CompanionSettings +// through the control-request path (it was previously ack-and-ignore). +#include +#include +#include +#include + +#include "jsonl.hh" +#include "vds_companion.hh" +#include "vds_log.hh" + +namespace { + +std::string command_request_json(std::uint16_t value) { + std::vector report(vds::kCompanionReportLength, 0); + report[0] = 0x02; // command report id + report[1] = 'D'; + report[2] = 'S'; + report[3] = '5'; + report[4] = 'B'; + report[5] = vds::kCompanionProtocolMajor; + report[6] = vds::kCompanionProtocolMinor; + report[7] = 0x0B; // SET_HAPTICS_BUFFER_LENGTH + report[8] = 1; // sequence + report[9] = value & 0xff; + report[10] = (value >> 8) & 0xff; + std::string json = "{\"command\":\"companion\",\"op\":\"write\",\"report\":["; + for (std::size_t i = 0; i < report.size(); ++i) { + if (i != 0) { + json += ","; + } + json += std::to_string(report[i]); + } + json += "]}"; + return json; +} + +void send_command(vds::CompanionRuntime &runtime, std::uint16_t value, + vds::Logger &logger) { + const auto fields = + vds::parse_jsonl_object(command_request_json(value), "test"); + vds::handle_companion_control_request(fields, runtime, "/dev/null", {}, + logger); +} + +} // namespace + +int main() { + vds::Logger logger("/tmp/companion_buffer_length_test.log"); + vds::CompanionRuntime runtime; + + assert(runtime.settings.haptics_buffer_samples == 0); + + send_command(runtime, 115, logger); + assert(runtime.last_result_code == 0); // kAckOk + assert(runtime.settings.haptics_buffer_samples == 115); + + // Out-of-range values are rejected and do not clobber the stored value. + send_command(runtime, 8, logger); + assert(runtime.last_result_code != 0); + assert(runtime.settings.haptics_buffer_samples == 115); + + send_command(runtime, 300, logger); + assert(runtime.last_result_code != 0); + assert(runtime.settings.haptics_buffer_samples == 115); + + send_command(runtime, 16, logger); + assert(runtime.settings.haptics_buffer_samples == 16); + send_command(runtime, 240, logger); + assert(runtime.settings.haptics_buffer_samples == 240); + + std::puts("companion_buffer_length_test OK"); + return 0; +} diff --git a/vds/vdsd.service.in b/vds/vdsd.service.in index cc487bf..ff075a4 100644 --- a/vds/vdsd.service.in +++ b/vds/vdsd.service.in @@ -5,6 +5,7 @@ Wants=bluetooth.service [Service] Type=simple +ExecStartPre=modprobe vds_hcd ExecStart=@VDS_SYSTEMD_VDSD@ Restart=on-failure RestartSec=1s