diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index a925822..6d47040 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -120,13 +120,14 @@ class HapticsProcessor { this.lowpassRight = biquadLowpass(cutoff); } - // stereo f32 in -> 4ch f32 out (speaker channels silent, haptics on 3-4) + // 4ch f32 in (front channels used, rears ignored) -> 4ch f32 out + // (speaker channels silent, haptics on 3-4) process(input) { - const frames = input.length / 2; + const frames = input.length / 4; 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 left = biquadStep(this.lowpassLeft, input[frame * 4]); + const right = biquadStep(this.lowpassRight, input[frame * 4 + 1]); 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; @@ -157,10 +158,22 @@ async function runRenderLoopbackHaptics(args) { const target = nodeProps(sink)['node.name']; const processor = new HapticsProcessor(readHapticsConfig(args)); + // Optional capture pin: monitor a specific output device instead of + // following the system default sink. + const captureDevice = argValue(args, '--haptics-output-device'); const record = spawn('pw-record', [ '--raw', '-P', '{ stream.capture.sink = true }', - '--format', 'f32', '--rate', `${SAMPLE_RATE}`, '--channels', '2', + ...(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'] }); @@ -182,7 +195,7 @@ async function runRenderLoopbackHaptics(args) { let carry = Buffer.alloc(0); record.stdout.on('data', (chunk) => { let data = carry.length ? Buffer.concat([carry, chunk]) : chunk; - const frameBytes = 2 * 4; + const frameBytes = 4 * 4; // 4 channels x f32 const usable = data.length - (data.length % frameBytes); carry = data.subarray(usable); if (usable === 0) { @@ -303,6 +316,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 || ''; @@ -400,6 +434,8 @@ 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')) { diff --git a/ds5-bridge/companion/src/main/audio-helper.ts b/ds5-bridge/companion/src/main/audio-helper.ts index d2390c0..d427f6c 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, @@ -207,6 +208,10 @@ export class SystemAudioHapticsEngine extends EventEmitter { '--haptics-release', config.release ]; + 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) { @@ -517,6 +522,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 +556,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 +790,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' diff --git a/ds5-bridge/companion/src/main/bridge-service.test.ts b/ds5-bridge/companion/src/main/bridge-service.test.ts index 9b18237..7697f76 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 () => { @@ -2214,6 +2214,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..c33b14d 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,7 @@ import { AudioHapticsSessionMonitor, MicKeepaliveEngine, SystemAudioHapticsEngine, + listAudioOutputDevices, playBridgeHapticsTestPattern, playBridgeSpeakerTestTone, getDefaultRenderEndpointStatus, @@ -97,6 +99,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 +509,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 +550,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()}`; } @@ -1396,6 +1411,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; @@ -1550,6 +1569,10 @@ export class BridgeService extends EventEmitter { await this.micKeepaliveEngine.stop(); } + async listAudioOutputDevices(): Promise { + return listAudioOutputDevices(); + } + async listAudioHapticsSessions(): Promise { if (!this.controllerAudioReady()) { await this.stopAudioHapticsSessionPolling(); @@ -2292,7 +2315,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(); } @@ -2433,9 +2456,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 })); @@ -2500,6 +2527,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 +2541,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 +2872,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; @@ -3620,8 +3661,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'; 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/main.ts b/ds5-bridge/companion/src/main/main.ts index 1237ef5..62cd4f8 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()) )); diff --git a/ds5-bridge/companion/src/main/settings-store.test.ts b/ds5-bridge/companion/src/main/settings-store.test.ts index 7cf49d5..dfec56b 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', () => { diff --git a/ds5-bridge/companion/src/main/settings-store.ts b/ds5-bridge/companion/src/main/settings-store.ts index 51a7877..c646ab4 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, @@ -245,6 +245,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 +909,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 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..4f0ab0b 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) diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 1c5b300..1ef7efb 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); @@ -3164,8 +3184,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 +3587,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 +3975,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 +4004,8 @@ export function App() { }, [ audioHapticsSessionByKey, audioHapticsSessions, + audioOutputDeviceByKey, + audioOutputDevices, audioReactiveHapticsSource, audioReactiveHapticsSourceKey ]); @@ -4795,6 +4840,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; @@ -5721,6 +5773,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; }); } diff --git a/ds5-bridge/companion/src/renderer/app-behavior.test.ts b/ds5-bridge/companion/src/renderer/app-behavior.test.ts index d63e2f0..e815868 100644 --- a/ds5-bridge/companion/src/renderer/app-behavior.test.ts +++ b/ds5-bridge/companion/src/renderer/app-behavior.test.ts @@ -202,7 +202,7 @@ describe('renderer behavior guards', () => { 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..2faf1de 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'; 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