Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d3de27c
Strip game bindings from imported profiles
LordVicky Jul 16, 2026
15554e8
Revert "Fix vdsd speaker audio pacing drift causing crackle"
LordVicky Jul 17, 2026
59329e5
Sync vds with upstream 0.3.0-rc7 (Linux-relevant changes)
LordVicky Jul 17, 2026
9036c42
Actuate SET_HAPTICS_BUFFER_LENGTH in the Linux daemon
LordVicky Jul 17, 2026
67dc181
Extend audio buffer slider range to 240 samples (80 ms)
LordVicky Jul 17, 2026
05096fb
Align audio buffer zone thresholds with the Linux chunk queue
LordVicky Jul 17, 2026
6ec50bc
Sync mic mute with the controller button; default buffer 120; mic gra…
LordVicky Jul 17, 2026
515dc35
Don't treat a pinned trigger profile as the active game
LordVicky Jul 17, 2026
5454376
Only commit app-initiated mic mute after the BT report sends
LordVicky Jul 17, 2026
41d8173
Merge sync/vds-0.3.0-rc7: upstream vds rc7 sync, working mic/headset …
LordVicky Jul 17, 2026
4bd5381
Light the DualSense mute LED when the mic is muted
LordVicky Jul 17, 2026
a96f26e
Hold off micMuted status reconcile right after an app mute command
LordVicky Jul 17, 2026
3523bc2
Keep queued BT state reports fresh across mic-state changes
LordVicky Jul 17, 2026
9eb5e93
Ignore host mute-LED/mic-mute output-report bits; daemon owns mic mute
LordVicky Jul 17, 2026
588e883
Toast on every mic mute/unmute
LordVicky Jul 17, 2026
62a799e
Fix audio-haptics feedback loop with headset; add capture-device picker
LordVicky Jul 17, 2026
02792b1
Capture 4 discrete channels in the haptics helper to defeat rear fold…
LordVicky Jul 17, 2026
ad70d31
Pin the vds card to the pro-audio profile in the shipped wireplumber …
LordVicky Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions ds5-bridge/companion/native/audio-helper-linux.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'] });
Expand All @@ -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) {
Expand Down Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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')) {
Expand Down
74 changes: 74 additions & 0 deletions ds5-bridge/companion/src/main/audio-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<AudioOutputDevice[]> {
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<AudioOutputDevice[]>((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'
Expand Down
26 changes: 24 additions & 2 deletions ds5-bridge/companion/src/main/bridge-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 44 additions & 1 deletion ds5-bridge/companion/src/main/bridge-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type {
AudioReactiveHapticsBassFocus,
AudioReactiveHapticsConfig,
AudioReactiveHapticsMode,
AudioOutputDevice,
AudioReactiveHapticsRelease,
AudioReactiveHapticsResponse,
AudioReactiveHapticsSource,
Expand Down Expand Up @@ -81,6 +82,7 @@ import {
AudioHapticsSessionMonitor,
MicKeepaliveEngine,
SystemAudioHapticsEngine,
listAudioOutputDevices,
playBridgeHapticsTestPattern,
playBridgeSpeakerTestTone,
getDefaultRenderEndpointStatus,
Expand All @@ -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;
Expand Down Expand Up @@ -506,6 +509,15 @@ function normalizeAudioReactiveHapticsSource(source: unknown): AudioReactiveHapt
if (!source || typeof source !== 'object') {
return 'system-audio';
}
const deviceCandidate = source as Partial<Extract<AudioReactiveHapticsSource, { kind: 'output-device' }>>;
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<Extract<AudioReactiveHapticsSource, { kind: 'app-session' }>>;
if (candidate.kind !== 'app-session') {
return 'system-audio';
Expand Down Expand Up @@ -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()}`;
}
Expand Down Expand Up @@ -1396,6 +1411,10 @@ export class BridgeService extends EventEmitter {
private systemAudioHapticsPassthroughActive = false;
private commandQueue: Promise<unknown> = 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;
Expand Down Expand Up @@ -1550,6 +1569,10 @@ export class BridgeService extends EventEmitter {
await this.micKeepaliveEngine.stop();
}

async listAudioOutputDevices(): Promise<AudioOutputDevice[]> {
return listAudioOutputDevices();
}

async listAudioHapticsSessions(): Promise<AudioHapticsSession[]> {
if (!this.controllerAudioReady()) {
await this.stopAudioHapticsSessionPolling();
Expand Down Expand Up @@ -2292,7 +2315,7 @@ export class BridgeService extends EventEmitter {
}

async setHapticsBufferLength(length: number): Promise<BridgeSnapshot> {
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();
}
Expand Down Expand Up @@ -2433,9 +2456,13 @@ export class BridgeService extends EventEmitter {
}

async setMicMute(enabled: boolean): Promise<BridgeSnapshot> {
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
}));
Expand Down Expand Up @@ -2500,6 +2527,7 @@ export class BridgeService extends EventEmitter {

async setDuplexMicEnabled(enabled: boolean): Promise<BridgeSnapshot> {
const nextEnabled = enabled;
this.lastMicMuteCommandAt = Date.now();
if (!nextEnabled) {
await this.sendCommand(COMMAND_ID.SET_MIC_MUTE, 1, {
expectSettingsRevisionChange: true
Expand All @@ -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
Expand Down Expand Up @@ -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<void> {
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;
Expand Down Expand Up @@ -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';

Expand Down
Loading
Loading