fix(app): stop the Firefox microphone failure and self-mute storm (#1761)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-19 19:32:41 +02:00
1 parent 97bd022bee
commit b7de83f7fc
11 files changed
+1404 -32

No files matched your search

@@ -125,7 +125,8 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
}
const room = MediaEngine.room;
const connectedChannelId = MediaEngine.channelId;
const currentMute = LocalVoiceState.getSelfMute();
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched;
const currentDeaf = LocalVoiceState.getSelfDeaf();
const willUndeafen = currentDeaf;
const willUnmute = currentMute;
@@ -139,6 +140,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
logger.info('toggleSelfMute', {
currentMute,
currentDeaf,
microphoneFailureLatched,
willUnmute,
willUndeafen,
willMute,
@@ -158,6 +160,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
handleMicrophonePermissionBlocked();
return;
}
MediaEngine.clearMicrophoneFailureLatch();
if (!MediaPermission.isMicrophoneGranted()) {
logger.info('Microphone permission not granted, requesting permission');
const permissionGranted = room?.localParticipant
@@ -175,6 +178,11 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
return;
}
}
if (!LocalVoiceState.getSelfMute() && !LocalVoiceState.getSelfDeaf()) {
logger.debug('Unmute intent only had to clear the microphone failure latch');
SoundCommands.playSound(SoundType.Unmute);
return;
}
}
LocalVoiceState.toggleSelfMute();
const newMute = LocalVoiceState.getSelfMute();
@@ -130,6 +130,16 @@ import {
noteLocalVoiceActivity,
noteLocalVoiceActivityFromSnapshot,
} from '@app/features/voice/engine/VoiceIdleActivityBridge';
import {
createVoiceLocalAudioReconcileCoalescerSnapshot,
selectVoiceLocalAudioEffectiveSelfMute,
selectVoiceLocalAudioReconcileCoalescedCount,
selectVoiceLocalAudioReconcileRunReasons,
shouldStartVoiceLocalAudioReconcileRun,
shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns,
transitionVoiceLocalAudioReconcileCoalescerSnapshot,
type VoiceLocalAudioReconcileCoalescerSnapshot,
} from '@app/features/voice/engine/VoiceLocalAudioReconcilePolicy';
import {normalizeVoiceMediaGraphViewerStreamKeys} from '@app/features/voice/engine/VoiceMediaGraph';
import {startVoiceMediaGraphTimerScheduler} from '@app/features/voice/engine/VoiceMediaGraphTimerScheduler';
import {bindOutputDeviceSync} from '@app/features/voice/engine/VoiceOutputDeviceSync';
@@ -748,6 +758,9 @@ class MediaEngineFacade extends Store {
private videoCodecDecodeCapResyncDisposer: (() => void) | null = null;
private videoCodecPublishOverrideSyncDisposer: (() => void) | null = null;
private localStreamCodecReconcileScheduled = false;
private localAudioReconcileCoalescer: VoiceLocalAudioReconcileCoalescerSnapshot =
createVoiceLocalAudioReconcileCoalescerSnapshot();
private applyingGatewayVoiceStateEcho = false;
private previousWatchedStreamCodecGossip = new Map<
string,
{identity: string; source: VoiceEngineV2LocalStreamSource}
@@ -1349,9 +1362,40 @@ class MediaEngineFacade extends Store {
}
private reconcileLocalAudioStateInBackground(reason: string): void {
void this.reconcileLocalAudioState(reason).catch((error) => {
logger.warn('Local audio reconciliation failed', {reason, error});
});
const previous = this.localAudioReconcileCoalescer;
const next = transitionVoiceLocalAudioReconcileCoalescerSnapshot(previous, {type: 'run.requested', reason});
this.localAudioReconcileCoalescer = next;
if (!shouldStartVoiceLocalAudioReconcileRun(previous, next)) return;
void this.drainLocalAudioReconcile();
}
private async drainLocalAudioReconcile(): Promise<void> {
try {
for (;;) {
const reasons = selectVoiceLocalAudioReconcileRunReasons(this.localAudioReconcileCoalescer);
try {
await this.reconcileLocalAudioState(reasons.join(' + '));
} catch (error) {
logger.warn('Local audio reconciliation failed', {reasons, error});
}
const previous = this.localAudioReconcileCoalescer;
const next = transitionVoiceLocalAudioReconcileCoalescerSnapshot(previous, {type: 'run.settled'});
this.localAudioReconcileCoalescer = next;
if (!shouldStartVoiceLocalAudioReconcileRun(previous, next)) return;
if (
shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(next) &&
!shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(previous)
) {
logger.warn('Local audio reconciliation keeps re-queueing itself', {
reasons: selectVoiceLocalAudioReconcileRunReasons(next),
coalescedCount: selectVoiceLocalAudioReconcileCoalescedCount(previous),
});
}
}
} catch (error) {
logger.error('Local audio reconciliation loop aborted; resetting the coalescer', {error});
this.localAudioReconcileCoalescer = createVoiceLocalAudioReconcileCoalescerSnapshot();
}
}
private clearNativeVoiceTransportReconnect(): void {
@@ -1601,7 +1645,11 @@ class MediaEngineFacade extends Store {
previousState,
currentState,
});
const unmutedLocally = previousState.selfMute && !currentState.selfMute && !this.applyingGatewayVoiceStateEcho;
previousState = currentState;
if (unmutedLocally) {
voiceEngineV2AppMediaExecutionAdapter.noteUserMuteIntentChanged();
}
this.syncVoiceEngineV2AudioControlsFromAppState();
this.reconcileLocalAudioStateInBackground('local audio state change');
});
@@ -2110,6 +2158,7 @@ class MediaEngineFacade extends Store {
SoundCommands.playSound(SoundType.VoiceDisconnect);
VoiceCallLayout.reset();
voiceEngineV2AppMediaStateAdapter.resetLocalMediaState('disconnect');
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
this.resetLocalMediaAndScreenShareTracking();
this.voiceEngineV2Participants.clear();
if (connectionId) {
@@ -2148,6 +2197,7 @@ class MediaEngineFacade extends Store {
}
VoiceCallLayout.reset();
voiceEngineV2AppMediaStateAdapter.resetLocalMediaState('disconnect');
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
this.resetLocalMediaAndScreenShareTracking();
this.voiceEngineV2Participants.clear();
if (reason === 'user' && connectionId) {
@@ -2208,7 +2258,10 @@ class MediaEngineFacade extends Store {
private syncVoiceEngineV2AudioControlsFromAppState(): void {
this.voiceEngineV2Controller.setAudioControls({
mode: this.getVoiceEngineV2AudioMode(),
locallyMuted: LocalVoiceState.getSelfMute(),
locallyMuted: selectVoiceLocalAudioEffectiveSelfMute({
localSelfMute: LocalVoiceState.getSelfMute(),
microphoneFailureLatched: voiceEngineV2AppMediaExecutionAdapter.isMicrophoneFailureLatched(),
}),
preferredLocallyMuted: LocalVoiceState.getSelfMute(),
locallyDeafened: LocalVoiceState.getSelfDeaf(),
mutedByPermission: LocalVoiceState.getMutedByPermission(),
@@ -2341,6 +2394,7 @@ class MediaEngineFacade extends Store {
}
void ScreenShareCodecNegotiation.publishLocalCapabilitiesNative('connected');
this.restoreNativeLocalMediaStateInBackground('native voice engine connected');
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
this.reconcileLocalAudioStateInBackground('native voice engine connected');
}
@@ -3111,6 +3165,7 @@ class MediaEngineFacade extends Store {
this.statsHostAdapter.startStatsTracking();
void ScreenShareCodecNegotiation.publishLocalCapabilitiesNative('reconnected');
this.restoreNativeLocalMediaStateInBackground('native voice engine reconnected');
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
this.reconcileLocalAudioStateInBackground('native voice engine reconnected');
break;
default:
@@ -3246,6 +3301,7 @@ class MediaEngineFacade extends Store {
if (shouldApplyPendingSessionRestore) {
await this.restorePendingSessionMedia();
}
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
await this.reconcileLocalAudioState('voice room connected');
},
onDisconnected: () => {
@@ -3266,6 +3322,7 @@ class MediaEngineFacade extends Store {
this.statsHostAdapter.incrementReconnectionCount();
this.statsHostAdapter.startLatencyTracking();
this.statsHostAdapter.startStatsTracking();
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
this.reconcileLocalAudioStateInBackground('voice room reconnected');
},
},
@@ -3320,6 +3377,7 @@ class MediaEngineFacade extends Store {
if (guildId && channelId) {
VoiceEngineV2AppPermissionAdapter.syncWithPermissionState(guildId, channelId, newRoom);
}
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
this.reconcileLocalAudioStateInBackground('region hot-swap complete');
},
(_guildId, _channelId, _connectionId, _attemptId, error) => {
@@ -3471,13 +3529,18 @@ class MediaEngineFacade extends Store {
const pttActive = this.isPushToTalkActive();
const applyServerState = shouldApplyIncomingLocalState;
shouldApplyCurrentServerState = applyServerState;
LocalVoiceState.syncConnectionState(voiceState.connection_id, {
selfMute: ptmActive || pttActive || !applyServerState ? undefined : voiceState.self_mute,
selfDeaf: applyServerState ? voiceState.self_deaf : undefined,
selfVideo: applyServerState ? voiceState.self_video : undefined,
selfStream: applyServerState ? voiceState.self_stream : undefined,
viewerStreamKeys: applyServerState ? incomingViewerStreamKeys : undefined,
});
this.applyingGatewayVoiceStateEcho = true;
try {
LocalVoiceState.syncConnectionState(voiceState.connection_id, {
selfMute: ptmActive || pttActive || !applyServerState ? undefined : voiceState.self_mute,
selfDeaf: applyServerState ? voiceState.self_deaf : undefined,
selfVideo: applyServerState ? voiceState.self_video : undefined,
selfStream: applyServerState ? voiceState.self_stream : undefined,
viewerStreamKeys: applyServerState ? incomingViewerStreamKeys : undefined,
});
} finally {
this.applyingGatewayVoiceStateEcho = false;
}
if (applyServerState) {
replaceWatchedStreamKeys([...incomingViewerStreamKeys], {sync: false});
}
@@ -3667,11 +3730,13 @@ class MediaEngineFacade extends Store {
}
syncLocalVoiceStateWithServer(partial?: VoiceStateSyncPartial): void {
LocalVoiceState.ensurePermissionMute();
const {guildId, channelId, connectionId} = voiceEngineV2AppConnectionHostAdapter.connectionState;
if (!channelId || !connectionId) return;
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
const micGranted = MediaPermission.isMicrophoneGranted() || devicePermission === 'granted';
if (!micGranted || LocalVoiceState.getMutedByPermission()) {
LocalVoiceState.ensurePermissionMute();
}
const {guildId, channelId, connectionId} = voiceEngineV2AppConnectionHostAdapter.connectionState;
if (!channelId || !connectionId) return;
const selfMute = resolveVoiceStateSelfMute({
guildId,
channelId,
@@ -4671,6 +4736,17 @@ class MediaEngineFacade extends Store {
return voiceEngineV2AppMediaExecutionAdapter.getMuteReason(voiceState, this.guildId, this.channelId);
}
isMicrophoneFailureLatched(): boolean {
return voiceEngineV2AppMediaExecutionAdapter.isMicrophoneFailureLatched();
}
clearMicrophoneFailureLatch(): void {
if (!voiceEngineV2AppMediaExecutionAdapter.isMicrophoneFailureLatched()) return;
voiceEngineV2AppMediaExecutionAdapter.noteUserMuteIntentChanged();
this.syncVoiceEngineV2AudioControlsFromAppState();
this.reconcileLocalAudioStateInBackground('microphone failure latch cleared');
}
async toggleCameraFromKeybind(): Promise<void> {
const current = LocalVoiceState.getSelfVideo();
await this.setCameraEnabled(!current, {deviceId: VoiceSettings.getVideoDeviceId()});
@@ -0,0 +1,407 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {
createVoiceLocalAudioReconcileCoalescerSnapshot,
createVoiceMicrophoneFailureLatchSnapshot,
isVoiceMicrophoneFailureLatchActive,
selectVoiceLocalAudioEffectiveSelfMute,
selectVoiceLocalAudioReconcileCoalescedCount,
selectVoiceLocalAudioReconcileRunReasons,
selectVoiceLocalAudioReconcileStartedRuns,
selectVoiceMicrophoneFailureCount,
shouldStartVoiceLocalAudioReconcileRun,
shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns,
transitionVoiceLocalAudioReconcileCoalescerSnapshot,
transitionVoiceMicrophoneFailureLatchSnapshot,
VOICE_LOCAL_AUDIO_RECONCILE_FOLLOW_UP_WARN_LIMIT,
VOICE_LOCAL_AUDIO_RECONCILE_REASON_LIMIT,
type VoiceLocalAudioReconcileCoalescerEvent,
type VoiceLocalAudioReconcileCoalescerSnapshot,
type VoiceMicrophoneFailureLatchEvent,
type VoiceMicrophoneFailureLatchSnapshot,
} from './VoiceLocalAudioReconcilePolicy';
const RUNAWAY_RUN_LIMIT = 512;
function sendCoalescer(
snapshot: VoiceLocalAudioReconcileCoalescerSnapshot,
event: VoiceLocalAudioReconcileCoalescerEvent,
): VoiceLocalAudioReconcileCoalescerSnapshot {
return transitionVoiceLocalAudioReconcileCoalescerSnapshot(snapshot, event);
}
function sendLatch(
snapshot: VoiceMicrophoneFailureLatchSnapshot,
event: VoiceMicrophoneFailureLatchEvent,
): VoiceMicrophoneFailureLatchSnapshot {
return transitionVoiceMicrophoneFailureLatchSnapshot(snapshot, event);
}
class ReconcileHarness {
snapshot = createVoiceLocalAudioReconcileCoalescerSnapshot();
startedRuns: Array<ReadonlyArray<string>> = [];
private runningDepth = 0;
constructor(private readonly body: (harness: ReconcileHarness) => void) {}
request(reason: string): void {
const previous = this.snapshot;
this.snapshot = sendCoalescer(previous, {type: 'run.requested', reason});
if (shouldStartVoiceLocalAudioReconcileRun(previous, this.snapshot)) {
this.drain();
}
}
private drain(): void {
expect(this.runningDepth).toBe(0);
this.runningDepth += 1;
for (;;) {
if (this.startedRuns.length >= RUNAWAY_RUN_LIMIT) {
throw new Error('runaway local audio reconciliation');
}
this.startedRuns.push(selectVoiceLocalAudioReconcileRunReasons(this.snapshot));
this.body(this);
const previous = this.snapshot;
this.snapshot = sendCoalescer(previous, {type: 'run.settled'});
if (!shouldStartVoiceLocalAudioReconcileRun(previous, this.snapshot)) break;
}
this.runningDepth -= 1;
}
}
describe('voice local audio reconcile coalescer', () => {
it('starts a run when a request arrives while idle', () => {
const initial = createVoiceLocalAudioReconcileCoalescerSnapshot();
const next = sendCoalescer(initial, {type: 'run.requested', reason: 'voice room connected'});
expect(shouldStartVoiceLocalAudioReconcileRun(initial, next)).toBe(true);
expect(next.value).toBe('running');
expect(selectVoiceLocalAudioReconcileStartedRuns(next)).toBe(1);
expect(selectVoiceLocalAudioReconcileRunReasons(next)).toEqual(['voice room connected']);
});
it('absorbs a request that arrives while a run is in flight instead of starting a second run', () => {
const running = sendCoalescer(createVoiceLocalAudioReconcileCoalescerSnapshot(), {
type: 'run.requested',
reason: 'voice room connected',
});
const dirty = sendCoalescer(running, {type: 'run.requested', reason: 'local audio state change'});
expect(shouldStartVoiceLocalAudioReconcileRun(running, dirty)).toBe(false);
expect(dirty.value).toBe('runningDirty');
expect(selectVoiceLocalAudioReconcileStartedRuns(dirty)).toBe(1);
expect(selectVoiceLocalAudioReconcileCoalescedCount(dirty)).toBe(1);
});
it('collapses K triggers arriving during one run into exactly one follow-up run', () => {
const triggerCount = 10;
let pendingTriggers = triggerCount;
const harness = new ReconcileHarness((self) => {
for (let index = 0; index < pendingTriggers; index++) {
self.request(`trigger ${index}`);
}
pendingTriggers = 0;
});
harness.request('voice room connected');
expect(harness.startedRuns).toHaveLength(2);
expect(selectVoiceLocalAudioReconcileStartedRuns(harness.snapshot)).toBe(2);
expect(harness.snapshot.value).toBe('idle');
});
it('records how many triggers were coalesced into the follow-up run', () => {
let snapshot = sendCoalescer(createVoiceLocalAudioReconcileCoalescerSnapshot(), {
type: 'run.requested',
reason: 'voice room connected',
});
for (let index = 0; index < 10; index++) {
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: `trigger ${index}`});
}
expect(selectVoiceLocalAudioReconcileCoalescedCount(snapshot)).toBe(10);
expect(selectVoiceLocalAudioReconcileStartedRuns(snapshot)).toBe(1);
const followUp = sendCoalescer(snapshot, {type: 'run.settled'});
expect(followUp.value).toBe('running');
expect(selectVoiceLocalAudioReconcileStartedRuns(followUp)).toBe(2);
expect(selectVoiceLocalAudioReconcileCoalescedCount(followUp)).toBe(0);
});
it('returns to idle when a clean run settles and does not start another run', () => {
const running = sendCoalescer(createVoiceLocalAudioReconcileCoalescerSnapshot(), {
type: 'run.requested',
reason: 'voice state update',
});
const settled = sendCoalescer(running, {type: 'run.settled'});
expect(settled.value).toBe('idle');
expect(shouldStartVoiceLocalAudioReconcileRun(running, settled)).toBe(false);
expect(selectVoiceLocalAudioReconcileRunReasons(settled)).toEqual([]);
});
it('promotes the pending reasons into the follow-up run exactly once', () => {
let snapshot = sendCoalescer(createVoiceLocalAudioReconcileCoalescerSnapshot(), {
type: 'run.requested',
reason: 'voice room connected',
});
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: 'local audio state change'});
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: 'voice state update'});
snapshot = sendCoalescer(snapshot, {type: 'run.settled'});
expect(selectVoiceLocalAudioReconcileRunReasons(snapshot)).toEqual([
'local audio state change',
'voice state update',
]);
expect(selectVoiceLocalAudioReconcileStartedRuns(snapshot)).toBe(2);
const idle = sendCoalescer(snapshot, {type: 'run.settled'});
expect(idle.value).toBe('idle');
expect(selectVoiceLocalAudioReconcileStartedRuns(idle)).toBe(2);
});
it('ignores a settle that arrives while idle', () => {
const initial = createVoiceLocalAudioReconcileCoalescerSnapshot();
const settled = sendCoalescer(initial, {type: 'run.settled'});
expect(settled.value).toBe('idle');
expect(selectVoiceLocalAudioReconcileStartedRuns(settled)).toBe(0);
});
it('keeps the reconcile-storm ordering linear instead of doubling', () => {
const failingRuns = 8;
let remainingFailures = failingRuns;
const harness = new ReconcileHarness((self) => {
if (remainingFailures === 0) return;
remainingFailures -= 1;
self.request('local audio state change');
self.request('voice state update');
});
harness.request('voice room connected');
expect(harness.startedRuns).toHaveLength(failingRuns + 1);
expect(harness.startedRuns.length).toBeLessThan(2 ** failingRuns);
expect(harness.snapshot.value).toBe('idle');
});
it('settles after two runs once the failure latches self-mute and stops emitting triggers', () => {
let latched = false;
const harness = new ReconcileHarness((self) => {
if (latched) return;
latched = true;
self.request('local audio state change');
self.request('voice state update');
});
harness.request('voice room connected');
expect(harness.startedRuns).toEqual([['voice room connected'], ['local audio state change', 'voice state update']]);
expect(harness.snapshot.value).toBe('idle');
});
it('never drops a trigger: a run always starts after the last request in a burst', () => {
const observedAtRunStart: Array<number> = [];
let requestCounter = 0;
let burst = 3;
const harness = new ReconcileHarness((self) => {
observedAtRunStart.push(requestCounter);
for (let index = 0; index < burst; index++) {
requestCounter += 1;
self.request(`burst ${requestCounter}`);
}
burst = 0;
});
requestCounter += 1;
harness.request(`burst ${requestCounter}`);
expect(observedAtRunStart).toEqual([1, 4]);
expect(observedAtRunStart[observedAtRunStart.length - 1]).toBe(requestCounter);
});
it('caps the reason list while still counting every coalesced trigger', () => {
let snapshot = sendCoalescer(createVoiceLocalAudioReconcileCoalescerSnapshot(), {
type: 'run.requested',
reason: 'voice room connected',
});
const triggerCount = VOICE_LOCAL_AUDIO_RECONCILE_REASON_LIMIT + 5;
for (let index = 0; index < triggerCount; index++) {
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: `trigger ${index}`});
}
expect(selectVoiceLocalAudioReconcileCoalescedCount(snapshot)).toBe(triggerCount);
const followUp = sendCoalescer(snapshot, {type: 'run.settled'});
expect(selectVoiceLocalAudioReconcileRunReasons(followUp)).toHaveLength(VOICE_LOCAL_AUDIO_RECONCILE_REASON_LIMIT);
});
it('deduplicates repeated reasons so a storm stays readable', () => {
let snapshot = sendCoalescer(createVoiceLocalAudioReconcileCoalescerSnapshot(), {
type: 'run.requested',
reason: 'voice room connected',
});
for (let index = 0; index < 50; index++) {
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: 'local audio state change'});
}
const followUp = sendCoalescer(snapshot, {type: 'run.settled'});
expect(selectVoiceLocalAudioReconcileRunReasons(followUp)).toEqual(['local audio state change']);
expect(selectVoiceLocalAudioReconcileCoalescedCount(snapshot)).toBe(50);
});
it('flags a livelock once the follow-up runs never stop chaining', () => {
let snapshot = createVoiceLocalAudioReconcileCoalescerSnapshot();
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: 'voice room connected'});
for (let index = 0; index < VOICE_LOCAL_AUDIO_RECONCILE_FOLLOW_UP_WARN_LIMIT; index++) {
expect(shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(snapshot)).toBe(false);
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: 'local audio state change'});
snapshot = sendCoalescer(snapshot, {type: 'run.settled'});
}
expect(shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(snapshot)).toBe(true);
const drained = sendCoalescer(snapshot, {type: 'run.settled'});
expect(drained.value).toBe('idle');
expect(shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(drained)).toBe(false);
});
it('crosses the livelock threshold once so the drain can warn a single time', () => {
let snapshot = createVoiceLocalAudioReconcileCoalescerSnapshot();
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: 'voice room connected'});
let crossings = 0;
for (let index = 0; index < VOICE_LOCAL_AUDIO_RECONCILE_FOLLOW_UP_WARN_LIMIT * 4; index++) {
const before = snapshot;
snapshot = sendCoalescer(snapshot, {type: 'run.requested', reason: 'local audio state change'});
snapshot = sendCoalescer(snapshot, {type: 'run.settled'});
if (
shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(snapshot) &&
!shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(before)
) {
crossings += 1;
}
}
expect(crossings).toBe(1);
});
it('is level-triggered: the reason never selects control flow', () => {
const script: Array<VoiceLocalAudioReconcileCoalescerEvent> = [
{type: 'run.requested', reason: 'first'},
{type: 'run.requested', reason: 'second'},
{type: 'run.settled'},
{type: 'run.settled'},
];
const replay = (reasonFor: (index: number) => string) => {
let snapshot = createVoiceLocalAudioReconcileCoalescerSnapshot();
const trace: Array<string> = [];
script.forEach((event, index) => {
snapshot = sendCoalescer(
snapshot,
event.type === 'run.requested' ? {type: 'run.requested', reason: reasonFor(index)} : event,
);
trace.push(`${String(snapshot.value)}:${selectVoiceLocalAudioReconcileStartedRuns(snapshot)}`);
});
return trace;
};
expect(replay((index) => `reason-${index}`)).toEqual(replay(() => 'voice state update'));
});
});
describe('voice microphone failure latch', () => {
const scope = {channelId: 'channel-1', inputDeviceId: 'mic-1'};
function latchedSnapshot(): VoiceMicrophoneFailureLatchSnapshot {
return sendLatch(createVoiceMicrophoneFailureLatchSnapshot(), {type: 'microphone.enableFailed', ...scope});
}
it('starts clear', () => {
const initial = createVoiceMicrophoneFailureLatchSnapshot();
expect(isVoiceMicrophoneFailureLatchActive(initial)).toBe(false);
expect(selectVoiceMicrophoneFailureCount(initial)).toBe(0);
});
it('latches when enabling the microphone fails', () => {
const latched = latchedSnapshot();
expect(isVoiceMicrophoneFailureLatchActive(latched)).toBe(true);
expect(selectVoiceMicrophoneFailureCount(latched)).toBe(1);
});
it('keeps forcing self-mute when the server echoes a stale self_mute:false', () => {
const latched = latchedSnapshot();
expect(selectVoiceLocalAudioEffectiveSelfMute({localSelfMute: true, microphoneFailureLatched: true})).toBe(true);
const afterStaleEcho = sendLatch(latched, {type: 'scope.observed', ...scope});
expect(isVoiceMicrophoneFailureLatchActive(afterStaleEcho)).toBe(true);
expect(
selectVoiceLocalAudioEffectiveSelfMute({
localSelfMute: false,
microphoneFailureLatched: isVoiceMicrophoneFailureLatchActive(afterStaleEcho),
}),
).toBe(true);
});
it('survives repeated recomputes of the audio-controls snapshot', () => {
let snapshot = latchedSnapshot();
for (let index = 0; index < 20; index++) {
snapshot = sendLatch(snapshot, {type: 'scope.observed', ...scope});
expect(
selectVoiceLocalAudioEffectiveSelfMute({
localSelfMute: false,
microphoneFailureLatched: isVoiceMicrophoneFailureLatchActive(snapshot),
}),
).toBe(true);
}
expect(selectVoiceMicrophoneFailureCount(snapshot)).toBe(1);
});
it('clears when the microphone is enabled successfully', () => {
const cleared = sendLatch(latchedSnapshot(), {type: 'microphone.enableSucceeded'});
expect(isVoiceMicrophoneFailureLatchActive(cleared)).toBe(false);
expect(selectVoiceMicrophoneFailureCount(cleared)).toBe(0);
});
it('clears when the user changes their own mute intent', () => {
const cleared = sendLatch(latchedSnapshot(), {type: 'mute.userIntentChanged'});
expect(isVoiceMicrophoneFailureLatchActive(cleared)).toBe(false);
});
it('clears when the voice connection is torn down or re-established', () => {
const cleared = sendLatch(latchedSnapshot(), {type: 'latch.reset'});
expect(isVoiceMicrophoneFailureLatchActive(cleared)).toBe(false);
});
it('clears when the active channel changes', () => {
const cleared = sendLatch(latchedSnapshot(), {
type: 'scope.observed',
channelId: 'channel-2',
inputDeviceId: scope.inputDeviceId,
});
expect(isVoiceMicrophoneFailureLatchActive(cleared)).toBe(false);
});
it('clears when the input device changes', () => {
const cleared = sendLatch(latchedSnapshot(), {
type: 'scope.observed',
channelId: scope.channelId,
inputDeviceId: 'mic-2',
});
expect(isVoiceMicrophoneFailureLatchActive(cleared)).toBe(false);
});
it('re-latches after a cleared latch fails again and counts the failures', () => {
let snapshot = latchedSnapshot();
snapshot = sendLatch(snapshot, {type: 'mute.userIntentChanged'});
snapshot = sendLatch(snapshot, {type: 'microphone.enableFailed', ...scope});
expect(isVoiceMicrophoneFailureLatchActive(snapshot)).toBe(true);
expect(selectVoiceMicrophoneFailureCount(snapshot)).toBe(1);
snapshot = sendLatch(snapshot, {type: 'microphone.enableFailed', ...scope});
expect(selectVoiceMicrophoneFailureCount(snapshot)).toBe(2);
});
it('does not latch on recovery events observed while already clear', () => {
let snapshot = createVoiceMicrophoneFailureLatchSnapshot();
for (const event of [
{type: 'microphone.enableSucceeded'},
{type: 'mute.userIntentChanged'},
{type: 'latch.reset'},
{type: 'scope.observed', ...scope},
] as Array<VoiceMicrophoneFailureLatchEvent>) {
snapshot = sendLatch(snapshot, event);
expect(isVoiceMicrophoneFailureLatchActive(snapshot)).toBe(false);
}
});
it('leaves self-mute untouched when nothing has failed', () => {
expect(selectVoiceLocalAudioEffectiveSelfMute({localSelfMute: false, microphoneFailureLatched: false})).toBe(false);
expect(selectVoiceLocalAudioEffectiveSelfMute({localSelfMute: true, microphoneFailureLatched: false})).toBe(true);
});
});
@@ -0,0 +1,235 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
export const VOICE_LOCAL_AUDIO_RECONCILE_REASON_LIMIT = 8;
export const VOICE_LOCAL_AUDIO_RECONCILE_FOLLOW_UP_WARN_LIMIT = 16;
export type VoiceLocalAudioReconcileCoalescerEvent = {type: 'run.requested'; reason: string} | {type: 'run.settled'};
interface VoiceLocalAudioReconcileCoalescerContext {
generation: number;
runningReasons: Array<string>;
pendingReasons: Array<string>;
coalescedCount: number;
consecutiveFollowUpRuns: number;
}
function createCoalescerContext(): VoiceLocalAudioReconcileCoalescerContext {
return {
generation: 0,
runningReasons: [],
pendingReasons: [],
coalescedCount: 0,
consecutiveFollowUpRuns: 0,
};
}
function appendReason(reasons: Array<string>, reason: string): Array<string> {
if (reasons.includes(reason)) return reasons;
if (reasons.length >= VOICE_LOCAL_AUDIO_RECONCILE_REASON_LIMIT) return reasons;
return [...reasons, reason];
}
const voiceLocalAudioReconcileCoalescerMachine = setup({
types: {} as {
context: VoiceLocalAudioReconcileCoalescerContext;
events: VoiceLocalAudioReconcileCoalescerEvent;
},
actions: {
beginRun: assign(({context, event}) =>
event.type === 'run.requested'
? {
generation: context.generation + 1,
runningReasons: appendReason([], event.reason),
pendingReasons: [],
coalescedCount: 0,
consecutiveFollowUpRuns: 0,
}
: context,
),
absorbRequest: assign(({context, event}) =>
event.type === 'run.requested'
? {
pendingReasons: appendReason(context.pendingReasons, event.reason),
coalescedCount: context.coalescedCount + 1,
}
: context,
),
finishRun: assign(() => ({
runningReasons: [],
pendingReasons: [],
coalescedCount: 0,
consecutiveFollowUpRuns: 0,
})),
beginFollowUpRun: assign(({context}) => ({
generation: context.generation + 1,
runningReasons: context.pendingReasons,
pendingReasons: [],
coalescedCount: 0,
consecutiveFollowUpRuns: context.consecutiveFollowUpRuns + 1,
})),
},
}).createMachine({
id: 'voiceLocalAudioReconcileCoalescer',
context: () => createCoalescerContext(),
initial: 'idle',
states: {
idle: {
on: {
'run.requested': {target: 'running', actions: 'beginRun'},
},
},
running: {
on: {
'run.requested': {target: 'runningDirty', actions: 'absorbRequest'},
'run.settled': {target: 'idle', actions: 'finishRun'},
},
},
runningDirty: {
on: {
'run.requested': {actions: 'absorbRequest'},
'run.settled': {target: 'running', actions: 'beginFollowUpRun'},
},
},
},
});
export type VoiceLocalAudioReconcileCoalescerSnapshot = SnapshotFrom<typeof voiceLocalAudioReconcileCoalescerMachine>;
export function createVoiceLocalAudioReconcileCoalescerSnapshot(): VoiceLocalAudioReconcileCoalescerSnapshot {
return getInitialSnapshot(voiceLocalAudioReconcileCoalescerMachine);
}
export function transitionVoiceLocalAudioReconcileCoalescerSnapshot(
snapshot: VoiceLocalAudioReconcileCoalescerSnapshot,
event: VoiceLocalAudioReconcileCoalescerEvent,
): VoiceLocalAudioReconcileCoalescerSnapshot {
return transition(
voiceLocalAudioReconcileCoalescerMachine,
snapshot,
event,
)[0] as VoiceLocalAudioReconcileCoalescerSnapshot;
}
export function shouldStartVoiceLocalAudioReconcileRun(
previous: VoiceLocalAudioReconcileCoalescerSnapshot,
next: VoiceLocalAudioReconcileCoalescerSnapshot,
): boolean {
return next.context.generation !== previous.context.generation;
}
export function selectVoiceLocalAudioReconcileRunReasons(
snapshot: VoiceLocalAudioReconcileCoalescerSnapshot,
): ReadonlyArray<string> {
return snapshot.context.runningReasons;
}
export function selectVoiceLocalAudioReconcileCoalescedCount(
snapshot: VoiceLocalAudioReconcileCoalescerSnapshot,
): number {
return snapshot.context.coalescedCount;
}
export function selectVoiceLocalAudioReconcileStartedRuns(snapshot: VoiceLocalAudioReconcileCoalescerSnapshot): number {
return snapshot.context.generation;
}
export function shouldWarnAboutVoiceLocalAudioReconcileFollowUpRuns(
snapshot: VoiceLocalAudioReconcileCoalescerSnapshot,
): boolean {
return snapshot.context.consecutiveFollowUpRuns >= VOICE_LOCAL_AUDIO_RECONCILE_FOLLOW_UP_WARN_LIMIT;
}
export type VoiceMicrophoneFailureLatchEvent =
| {type: 'microphone.enableFailed'; channelId: string | null; inputDeviceId: string | null}
| {type: 'microphone.enableSucceeded'}
| {type: 'mute.userIntentChanged'}
| {type: 'scope.observed'; channelId: string | null; inputDeviceId: string | null}
| {type: 'latch.reset'};
interface VoiceMicrophoneFailureLatchContext {
channelId: string | null;
inputDeviceId: string | null;
failureCount: number;
}
function createLatchContext(): VoiceMicrophoneFailureLatchContext {
return {
channelId: null,
inputDeviceId: null,
failureCount: 0,
};
}
const voiceMicrophoneFailureLatchMachine = setup({
types: {} as {
context: VoiceMicrophoneFailureLatchContext;
events: VoiceMicrophoneFailureLatchEvent;
},
actions: {
recordFailure: assign(({context, event}) =>
event.type === 'microphone.enableFailed'
? {
channelId: event.channelId,
inputDeviceId: event.inputDeviceId,
failureCount: context.failureCount + 1,
}
: context,
),
clearLatch: assign(() => createLatchContext()),
},
guards: {
isOutOfScope: ({context, event}) =>
event.type === 'scope.observed' &&
(event.channelId !== context.channelId || event.inputDeviceId !== context.inputDeviceId),
},
}).createMachine({
id: 'voiceMicrophoneFailureLatch',
context: () => createLatchContext(),
initial: 'clear',
states: {
clear: {
on: {
'microphone.enableFailed': {target: 'latched', actions: 'recordFailure'},
},
},
latched: {
on: {
'microphone.enableFailed': {actions: 'recordFailure'},
'microphone.enableSucceeded': {target: 'clear', actions: 'clearLatch'},
'mute.userIntentChanged': {target: 'clear', actions: 'clearLatch'},
'latch.reset': {target: 'clear', actions: 'clearLatch'},
'scope.observed': {guard: 'isOutOfScope', target: 'clear', actions: 'clearLatch'},
},
},
},
});
export type VoiceMicrophoneFailureLatchSnapshot = SnapshotFrom<typeof voiceMicrophoneFailureLatchMachine>;
export function createVoiceMicrophoneFailureLatchSnapshot(): VoiceMicrophoneFailureLatchSnapshot {
return getInitialSnapshot(voiceMicrophoneFailureLatchMachine);
}
export function transitionVoiceMicrophoneFailureLatchSnapshot(
snapshot: VoiceMicrophoneFailureLatchSnapshot,
event: VoiceMicrophoneFailureLatchEvent,
): VoiceMicrophoneFailureLatchSnapshot {
return transition(voiceMicrophoneFailureLatchMachine, snapshot, event)[0] as VoiceMicrophoneFailureLatchSnapshot;
}
export function isVoiceMicrophoneFailureLatchActive(snapshot: VoiceMicrophoneFailureLatchSnapshot): boolean {
return snapshot.value === 'latched';
}
export function selectVoiceMicrophoneFailureCount(snapshot: VoiceMicrophoneFailureLatchSnapshot): number {
return snapshot.context.failureCount;
}
export function selectVoiceLocalAudioEffectiveSelfMute(input: {
localSelfMute: boolean;
microphoneFailureLatched: boolean;
}): boolean {
return input.localSelfMute || input.microphoneFailureLatched;
}
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Room} from 'livekit-client';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const updateSettings = vi.fn();
let outputDevices: Array<{deviceId: string}> = [];
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {
getOutputDeviceId: () => 'default',
updateSettings: (update: unknown) => updateSettings(update),
subscribe: () => () => undefined,
},
}));
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({
voiceDeviceManager: {
ensureDevices: async () => ({
inputDevices: [],
outputDevices,
videoDevices: [],
permissionStatus: 'granted',
}),
},
}));
const {applyOutputDeviceToRoom} = await import('./VoiceOutputDeviceSync');
class FakeMediaElement {
setSinkId = vi.fn(async () => undefined);
}
Object.defineProperty(FakeMediaElement.prototype, 'setSinkId', {
configurable: true,
writable: true,
value: async (): Promise<void> => undefined,
});
vi.stubGlobal('HTMLMediaElement', FakeMediaElement);
interface FakeRoomOptions {
webAudioMix?: boolean;
audioContextSupportsSetSinkId?: boolean;
switchActiveDevice?: () => Promise<boolean>;
}
function createRoom(options: FakeRoomOptions = {}) {
const {webAudioMix = true, audioContextSupportsSetSinkId = true} = options;
const switchActiveDevice = vi.fn(options.switchActiveDevice ?? (async () => true));
const mixerSetSinkId = vi.fn(async () => undefined);
const trackSetSinkId = vi.fn(async () => undefined);
const element = new FakeMediaElement();
const audioContext = audioContextSupportsSetSinkId ? {setSinkId: mixerSetSinkId} : {};
const room = {
options: {webAudioMix},
audioContext,
switchActiveDevice,
remoteParticipants: new Map([
[
'participant-with-track-sink',
{
audioTrackPublications: new Map([
['pub-track-sink', {kind: 'audio', track: {kind: 'audio', setSinkId: trackSetSinkId}}],
]),
},
],
[
'participant-with-elements',
{
audioTrackPublications: new Map([
['pub-elements', {kind: 'audio', track: {kind: 'audio', attachedElements: [element]}}],
]),
},
],
]),
};
return {room: room as unknown as Room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element};
}
describe('applyOutputDeviceToRoom', () => {
beforeEach(() => {
updateSettings.mockClear();
outputDevices = [{deviceId: 'speaker-1'}];
});
it('hands the Web Audio mixer room the literal default id so LiveKit resolves it to a real device', async () => {
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
expect(switchActiveDevice).not.toHaveBeenCalledWith('audiooutput', '');
});
it('passes a concrete device id through verbatim', async () => {
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'speaker-1');
});
it('applies the normalized sink id to the Web Audio mixer and the attached elements', async () => {
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
await applyOutputDeviceToRoom(room, 'default');
expect(mixerSetSinkId).toHaveBeenCalledWith('');
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('applies a concrete sink id to the Web Audio mixer and the attached elements', async () => {
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(mixerSetSinkId).toHaveBeenCalledWith('speaker-1');
expect(trackSetSinkId).toHaveBeenCalledWith('speaker-1');
expect(element.setSinkId).toHaveBeenCalledWith('speaker-1');
});
it('still runs the mixer and element paths when LiveKit rejects with NotFoundError', async () => {
const notFound = new Error('Requested device not found');
notFound.name = 'NotFoundError';
const {room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element} = createRoom({
switchActiveDevice: async () => {
throw notFound;
},
});
await expect(applyOutputDeviceToRoom(room, 'default')).resolves.toBeUndefined();
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
expect(mixerSetSinkId).toHaveBeenCalledWith('');
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('skips the LiveKit switch when the Web Audio mixer context cannot switch sinks', async () => {
const {room, switchActiveDevice, trackSetSinkId, element} = createRoom({
audioContextSupportsSetSinkId: false,
});
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).not.toHaveBeenCalled();
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('normalizes the sink id only when LiveKit forwards it straight to the browser', async () => {
const {room, switchActiveDevice} = createRoom({
webAudioMix: false,
audioContextSupportsSetSinkId: false,
});
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', '');
});
it('falls back to the default device when the stored output device disappeared', async () => {
outputDevices = [{deviceId: 'speaker-2'}];
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(updateSettings).toHaveBeenCalledWith({outputDeviceId: 'default'});
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
});
});
@@ -43,6 +43,29 @@ function getRoomAudioContext(room: Room): SinkableAudioContext | null {
return isSinkableAudioContext(audioContext) ? audioContext : null;
}
function isRoomWebAudioMixEnabled(room: Room): boolean {
const options: unknown = Reflect.get(room, 'options');
if (typeof options !== 'object' || options === null) return false;
return Boolean((options as {webAudioMix?: unknown}).webAudioMix);
}
function supportsMediaElementSetSinkId(): boolean {
if (typeof HTMLMediaElement === 'undefined') return false;
return 'setSinkId' in HTMLMediaElement.prototype;
}
function canRoomSwitchAudioOutput(room: Room): boolean {
if (!isRoomWebAudioMixEnabled(room)) return supportsMediaElementSetSinkId();
const audioContext = getRoomAudioContext(room);
if (!audioContext) return true;
return typeof audioContext.setSinkId === 'function';
}
function resolveLiveKitSwitchDeviceId(room: Room, deviceId: string): string {
if (isRoomWebAudioMixEnabled(room)) return deviceId;
return normalizeSinkIdForBrowserApi(deviceId);
}
function isSinkableMediaElement(value: unknown): value is SinkableMediaElement {
if (typeof HTMLMediaElement === 'undefined') return false;
return value instanceof HTMLMediaElement;
@@ -125,12 +148,19 @@ async function applyOutputDeviceToAttachedElements(room: Room, deviceId: string)
}
}
async function applyOutputDeviceToRoom(room: Room, deviceId: string): Promise<void> {
export async function applyOutputDeviceToRoom(room: Room, deviceId: string): Promise<void> {
const resolvedDeviceId = await resolveAvailableOutputDeviceId(deviceId);
try {
await room.switchActiveDevice('audiooutput', resolvedDeviceId);
} catch (error) {
logger.warn('LiveKit failed to apply audio output device', {deviceId: resolvedDeviceId, error});
if (canRoomSwitchAudioOutput(room)) {
try {
await room.switchActiveDevice('audiooutput', resolveLiveKitSwitchDeviceId(room, resolvedDeviceId));
} catch (error) {
logger.warn('LiveKit failed to apply audio output device', {deviceId: resolvedDeviceId, error});
}
} else {
logger.warn('Audio output selection is unavailable: this browser cannot move the voice playback sink', {
deviceId: resolvedDeviceId,
webAudioMix: isRoomWebAudioMixEnabled(room),
});
}
try {
await applyOutputDeviceToWebAudioMixer(room, resolvedDeviceId);
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, describe, expect, it, vi} from 'vitest';
interface FakeAudioContext {
state: AudioContextState;
sampleRate: number;
resumeCount: number;
}
interface FakeWindowHarness {
requestedOptions: Array<AudioContextOptions | undefined>;
contexts: Array<FakeAudioContext>;
bodyListeners: Array<string>;
}
const HARDWARE_SAMPLE_RATE = 44100;
function installFakeWindow(
config: {
throwOn?: (options: AudioContextOptions | undefined) => boolean;
initialState?: AudioContextState;
honourSampleRate?: boolean;
} = {},
): FakeWindowHarness {
const harness: FakeWindowHarness = {requestedOptions: [], contexts: [], bodyListeners: []};
const throwOn = config.throwOn ?? ((): boolean => false);
const honourSampleRate = config.honourSampleRate ?? true;
class FakeAudioContextImpl {
state: AudioContextState = config.initialState ?? 'running';
sampleRate: number;
resumeCount = 0;
constructor(options?: AudioContextOptions) {
harness.requestedOptions.push(options);
if (throwOn(options)) {
throw new DOMException('sample rate not supported', 'NotSupportedError');
}
this.sampleRate = (honourSampleRate ? options?.sampleRate : undefined) ?? HARDWARE_SAMPLE_RATE;
harness.contexts.push(this as unknown as FakeAudioContext);
}
async resume(): Promise<void> {
this.resumeCount += 1;
this.state = 'running';
}
addEventListener(): void {}
removeEventListener(): void {}
}
vi.stubGlobal('window', {
AudioContext: FakeAudioContextImpl,
document: {
body: {
addEventListener: (type: string): void => {
harness.bodyListeners.push(type);
},
removeEventListener: (): void => {},
},
},
});
return harness;
}
async function loadModule() {
vi.resetModules();
return await import('@app/features/voice/engine/VoiceSharedAudioContext');
}
describe('VoiceSharedAudioContext', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('leaves the shared context on the hardware rate so it shares a graph with capture and playback', async () => {
const harness = installFakeWindow();
const {getSharedVoiceAudioContext} = await loadModule();
const context = getSharedVoiceAudioContext();
expect(harness.requestedOptions).toEqual([{latencyHint: 'interactive'}]);
expect(context).toBe(harness.contexts[0] as unknown as AudioContext);
expect(context?.sampleRate).toBe(HARDWARE_SAMPLE_RATE);
});
it('returns null without throwing when the construction fails', async () => {
const harness = installFakeWindow({throwOn: () => true});
const {getSharedVoiceAudioContext} = await loadModule();
let context: AudioContext | null | undefined;
expect(() => {
context = getSharedVoiceAudioContext();
}).not.toThrow();
expect(context).toBeNull();
expect(harness.contexts).toHaveLength(0);
expect(harness.requestedOptions).toHaveLength(1);
});
it('caches the context across calls and rebuilds it once it has closed', async () => {
const harness = installFakeWindow();
const {getSharedVoiceAudioContext} = await loadModule();
const first = getSharedVoiceAudioContext();
expect(getSharedVoiceAudioContext()).toBe(first);
expect(harness.requestedOptions).toHaveLength(1);
harness.contexts[0].state = 'closed';
const second = getSharedVoiceAudioContext();
expect(second).not.toBe(first);
expect(harness.contexts).toHaveLength(2);
expect(harness.requestedOptions).toEqual([{latencyHint: 'interactive'}, {latencyHint: 'interactive'}]);
});
it('resumes a suspended context and arms the user-gesture resume listener', async () => {
const harness = installFakeWindow({initialState: 'suspended'});
const {getSharedVoiceAudioContext} = await loadModule();
const context = getSharedVoiceAudioContext();
expect(context).not.toBeNull();
expect(harness.contexts[0].resumeCount).toBe(1);
expect(harness.bodyListeners).toEqual(['click']);
});
it('createVoiceAudioContext reports the rate the browser actually resolved, not the requested one', async () => {
installFakeWindow({honourSampleRate: false});
const {createVoiceAudioContext} = await loadModule();
const context = createVoiceAudioContext({latencyHint: 'interactive', sampleRate: 48000});
expect(context?.sampleRate).toBe(HARDWARE_SAMPLE_RATE);
});
it('createVoiceAudioContext returns null instead of throwing when the rate is rejected', async () => {
installFakeWindow({throwOn: (options) => options?.sampleRate !== undefined});
const {createVoiceAudioContext} = await loadModule();
expect(createVoiceAudioContext({latencyHint: 'interactive', sampleRate: 48000})).toBeNull();
});
});
@@ -17,6 +17,17 @@ function resolveAudioContextConstructor(): AudioContextConstructor | null {
return ctor ?? null;
}
export function createVoiceAudioContext(options: AudioContextOptions): AudioContext | null {
const ctor = resolveAudioContextConstructor();
if (!ctor) return null;
try {
return new ctor(options);
} catch (error) {
logger.warn('Failed to construct voice AudioContext', {options, error});
return null;
}
}
function installResumeOnUserGesture(context: AudioContext): void {
if (typeof window === 'undefined') return;
if (!window.document?.body) return;
@@ -41,15 +52,8 @@ export function getSharedVoiceAudioContext(): AudioContext | null {
if (sharedAudioContext && sharedAudioContext.state !== 'closed') {
return sharedAudioContext;
}
const ctor = resolveAudioContextConstructor();
if (!ctor) return null;
try {
sharedAudioContext = new ctor({latencyHint: 'interactive'});
} catch (error) {
logger.warn('Failed to construct shared voice AudioContext', {error});
sharedAudioContext = null;
return null;
}
sharedAudioContext = createVoiceAudioContext({latencyHint: 'interactive'});
if (!sharedAudioContext) return null;
assert.notEqual(sharedAudioContext.state, 'closed', 'shared voice AudioContext must not start closed');
if (sharedAudioContext.state === 'suspended') {
void sharedAudioContext.resume().catch((error) => {
@@ -18,6 +18,14 @@ import {Store} from '@app/features/voice/engine/Store';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import type {EffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
import {getEffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
import {
createVoiceMicrophoneFailureLatchSnapshot,
isVoiceMicrophoneFailureLatchActive,
selectVoiceLocalAudioEffectiveSelfMute,
selectVoiceMicrophoneFailureCount,
transitionVoiceMicrophoneFailureLatchSnapshot,
type VoiceMicrophoneFailureLatchEvent,
} from '@app/features/voice/engine/VoiceLocalAudioReconcilePolicy';
import {resolveLocalSpeakingOverrideState} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
import {
getRoomFromMediaEngine,
@@ -151,10 +159,36 @@ function getVoiceEngineV2AudioModeFromAppState(): VoiceEngineV2AudioMode {
return 'voiceActivity';
}
let microphoneFailureLatchSnapshot = createVoiceMicrophoneFailureLatchSnapshot();
function applyMicrophoneFailureLatchEvent(event: VoiceMicrophoneFailureLatchEvent): boolean {
const wasLatched = isVoiceMicrophoneFailureLatchActive(microphoneFailureLatchSnapshot);
microphoneFailureLatchSnapshot = transitionVoiceMicrophoneFailureLatchSnapshot(microphoneFailureLatchSnapshot, event);
const isLatched = isVoiceMicrophoneFailureLatchActive(microphoneFailureLatchSnapshot);
if (wasLatched === isLatched) return false;
logger.info('Microphone failure self-mute latch changed', {
event: event.type,
latched: isLatched,
failureCount: selectVoiceMicrophoneFailureCount(microphoneFailureLatchSnapshot),
});
return true;
}
export function isMicrophoneEnableFailureLatched(): boolean {
return isVoiceMicrophoneFailureLatchActive(microphoneFailureLatchSnapshot);
}
function getLatchedLocalSelfMute(): boolean {
return selectVoiceLocalAudioEffectiveSelfMute({
localSelfMute: LocalVoiceState.getSelfMute(),
microphoneFailureLatched: isMicrophoneEnableFailureLatched(),
});
}
function getVoiceEngineV2AudioControlsFromAppState(): VoiceEngineV2AudioControls {
return {
mode: getVoiceEngineV2AudioModeFromAppState(),
locallyMuted: LocalVoiceState.getSelfMute(),
locallyMuted: getLatchedLocalSelfMute(),
preferredLocallyMuted: LocalVoiceState.getSelfMute(),
locallyDeafened: LocalVoiceState.getSelfDeaf(),
mutedByPermission: LocalVoiceState.getMutedByPermission(),
@@ -250,6 +284,31 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
this.cameraLifecycleBinding = null;
}
isMicrophoneFailureLatched(): boolean {
return isMicrophoneEnableFailureLatched();
}
resetMicrophoneFailureLatch(): void {
this.transitionMicrophoneFailureLatch({type: 'latch.reset'});
}
noteUserMuteIntentChanged(): void {
this.transitionMicrophoneFailureLatch({type: 'mute.userIntentChanged'});
}
private transitionMicrophoneFailureLatch(event: VoiceMicrophoneFailureLatchEvent): void {
if (!applyMicrophoneFailureLatchEvent(event)) return;
this.emitChange();
}
private observeMicrophoneFailureLatchScope(channelId: string | null): void {
this.transitionMicrophoneFailureLatch({
type: 'scope.observed',
channelId,
inputDeviceId: VoiceSettings.getInputDeviceId(),
});
}
private isSpeakPermissionDenied(channelId: string | null): boolean {
const connection = getVoiceConnectionContextFromMediaEngine();
return isVoiceSpeakPermissionDenied(connection?.guildId ?? null, channelId);
@@ -356,6 +415,7 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
async ensureMicrophone(room: Room, channelId: string): Promise<void> {
assertObjectLike<Room>(room, 'ensureMicrophone.room');
assertNonEmptyString(channelId, 'ensureMicrophone.channelId');
this.observeMicrophoneFailureLatchScope(channelId);
if (this.isSpeakPermissionDenied(channelId)) {
logger.debug('Skipping microphone: speak permission denied');
await this.enforceSpeakPermissionMute(room);
@@ -425,10 +485,12 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
logger.debug('Skipping audio-state reconciliation: no local participant');
return;
}
this.observeMicrophoneFailureLatchScope(params.channelId);
const permissionMuted = this.isSpeakPermissionDenied(params.channelId);
const audioState = this.getEffectiveAudioState({
serverMute: params.serverMute || permissionMuted,
serverDeaf: params.serverDeaf,
selfMute: getLatchedLocalSelfMute(),
});
logger.info('Reconciling local media after voice state update', {
channelId: params.channelId,
@@ -577,6 +639,7 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
async refreshMicrophone(room: Room | null, options: RefreshMicrophoneOptions = {}): Promise<void> {
assertNullableObjectLike<Room>(room, 'refreshMicrophone.room');
assertObjectLike<RefreshMicrophoneOptions>(options, 'refreshMicrophone.options');
this.observeMicrophoneFailureLatchScope(this.getActiveChannelId());
const refresh = async () => this.refreshMicrophoneNow(room, options);
const pendingRefresh = this.microphoneRefreshQueue.then(refresh, refresh);
this.microphoneRefreshQueue = pendingRefresh.catch(() => {});
@@ -762,6 +825,7 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
if (this.hasMicrophonePublication(room)) {
if (this.hasLiveMicrophonePublication(room)) {
logger.debug('Microphone track already published, skipping duplicate publish');
this.transitionMicrophoneFailureLatch({type: 'microphone.enableSucceeded'});
return;
}
logger.warn('Existing microphone publication has an ended track; unpublishing before reacquire');
@@ -781,8 +845,14 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
this.attachLocalSpeakingDetectorForPublish(ctx, state);
MediaPermission.updateMicrophonePermissionGranted();
this.transitionMediaState({type: 'microphone.enable.success'});
this.transitionMicrophoneFailureLatch({type: 'microphone.enableSucceeded'});
logger.info('Successfully enabled microphone');
} catch (e: unknown) {
this.transitionMicrophoneFailureLatch({
type: 'microphone.enableFailed',
channelId: ctx.channelId ?? this.getActiveChannelId(),
inputDeviceId: VoiceSettings.getInputDeviceId(),
});
await this.rollbackMicrophoneEnable(ctx, state, e);
throw e;
}
@@ -1411,7 +1481,11 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
return (micPublication?.track as LocalAudioTrack) ?? null;
}
private getEffectiveAudioState(override?: {serverMute?: boolean; serverDeaf?: boolean}): EffectiveAudioState {
private getEffectiveAudioState(override?: {
serverMute?: boolean;
serverDeaf?: boolean;
selfMute?: boolean;
}): EffectiveAudioState {
return getEffectiveAudioState(override);
}
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/app/state/RuntimeConfig', () => ({
default: {staticCdnEndpoint: 'https://cdn.test'},
}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {
getDeepFilterNoiseSuppression: () => true,
getDeepFilterNoiseSuppressionLevel: () => 80,
},
}));
const deepFilterHarness = vi.hoisted(() => ({
instances: [] as Array<{
audioContext: AudioContext | null;
processedTrack: MediaStreamTrack | null;
sampleRateAtInit: number | null;
}>,
}));
vi.mock('deepfilternet3-noise-filter', () => ({
DeepFilterNoiseFilterProcessor: class {
audioContext: AudioContext | null = null;
processedTrack: MediaStreamTrack | null = null;
sampleRateAtInit: number | null = null;
constructor() {
deepFilterHarness.instances.push(this);
}
async init(): Promise<void> {
this.sampleRateAtInit = this.audioContext?.sampleRate ?? null;
this.processedTrack = {} as MediaStreamTrack;
}
async destroy(): Promise<void> {}
},
}));
const MODEL_SAMPLE_RATE = 48000;
interface ContextHarness {
requestedRates: Array<number | undefined>;
closedRates: Array<number>;
}
function installAudioGraph(config: {hardwareSampleRate: number; canBridgeSampleRates: boolean}): ContextHarness {
const harness: ContextHarness = {requestedRates: [], closedRates: []};
class FakeAudioContext {
state: AudioContextState = 'running';
sampleRate: number;
constructor(options?: AudioContextOptions) {
harness.requestedRates.push(options?.sampleRate);
this.sampleRate = options?.sampleRate ?? config.hardwareSampleRate;
}
createMediaStreamSource(): {disconnect: () => void} {
if (!config.canBridgeSampleRates && this.sampleRate !== config.hardwareSampleRate) {
throw new DOMException(
'Connecting AudioNodes from AudioContexts with different sample-rate is currently not supported.',
'NotSupportedError',
);
}
return {disconnect: (): void => undefined};
}
async close(): Promise<void> {
harness.closedRates.push(this.sampleRate);
this.state = 'closed';
}
}
vi.stubGlobal('window', {AudioContext: FakeAudioContext});
vi.stubGlobal('MediaStream', class {});
return harness;
}
async function loadModule() {
vi.resetModules();
return await import('@app/features/voice/utils/DeepFilterNoiseProcessor');
}
function createSourceContext(sampleRate: number): AudioContext {
return {sampleRate} as unknown as AudioContext;
}
const feedTrack = {} as MediaStreamTrack;
describe('resolveDeepFilterAudioContext', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('runs the worklet at the model rate when the capture graph already runs there', async () => {
const harness = installAudioGraph({hardwareSampleRate: MODEL_SAMPLE_RATE, canBridgeSampleRates: false});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(MODEL_SAMPLE_RATE), feedTrack);
expect(context?.sampleRate).toBe(MODEL_SAMPLE_RATE);
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE]);
expect(harness.closedRates).toEqual([]);
});
it('keeps the model rate on browsers that resample across graphs', async () => {
const harness = installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: true});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(44100), feedTrack);
expect(context?.sampleRate).toBe(MODEL_SAMPLE_RATE);
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE]);
expect(harness.closedRates).toEqual([]);
});
it('falls back to the capture rate when the browser refuses to bridge sample rates', async () => {
const harness = installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: false});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(44100), feedTrack);
expect(context?.sampleRate).toBe(44100);
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE, 44100]);
expect(harness.closedRates).toEqual([MODEL_SAMPLE_RATE]);
});
it('falls back to the capture rate for a bluetooth headset running well below the model rate', async () => {
installAudioGraph({hardwareSampleRate: 16000, canBridgeSampleRates: false});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(16000), feedTrack);
expect(context?.sampleRate).toBe(16000);
});
});
function createCaptureContext(sampleRate: number): AudioContext {
const track = {stop: (): void => undefined} as unknown as MediaStreamTrack;
const node = {
connect: (next: unknown): unknown => next,
disconnect: (): void => undefined,
stream: {getAudioTracks: (): Array<MediaStreamTrack> => [track]},
type: '',
frequency: {value: 0},
Q: {value: 0},
threshold: {value: 0},
knee: {value: 0},
ratio: {value: 0},
attack: {value: 0},
release: {value: 0},
};
return {
sampleRate,
createMediaStreamDestination: () => node,
createMediaStreamSource: () => node,
createBiquadFilter: () => node,
createDynamicsCompressor: () => node,
} as unknown as AudioContext;
}
describe('buildDeepFilterAudioChain', () => {
beforeEach(() => {
deepFilterHarness.instances.length = 0;
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('gives the processor its audio context before the processor builds its own graph', async () => {
installAudioGraph({hardwareSampleRate: MODEL_SAMPLE_RATE, canBridgeSampleRates: false});
const {buildDeepFilterAudioChain} = await loadModule();
await buildDeepFilterAudioChain({audioContext: createCaptureContext(MODEL_SAMPLE_RATE)});
expect(deepFilterHarness.instances[0]?.sampleRateAtInit).toBe(MODEL_SAMPLE_RATE);
});
it('gives the processor a capture-rate context when the browser refuses to bridge sample rates', async () => {
installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: false});
const {buildDeepFilterAudioChain} = await loadModule();
await buildDeepFilterAudioChain({audioContext: createCaptureContext(44100)});
expect(deepFilterHarness.instances[0]?.sampleRateAtInit).toBe(44100);
});
});
@@ -2,11 +2,13 @@
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {createVoiceAudioContext} from '@app/features/voice/engine/VoiceSharedAudioContext';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {DeepFilterNoiseFilterProcessor} from 'deepfilternet3-noise-filter';
import type {LocalAudioTrack} from 'livekit-client';
const logger = new Logger('DeepFilterNoiseProcessor');
const DEEP_FILTER_MODEL_SAMPLE_RATE = 48000;
const DEFAULT_SUPPRESSION_LEVEL = 80;
const HIGH_PASS_FREQUENCY_HZ = 60;
const HIGH_PASS_Q = Math.SQRT1_2;
@@ -24,7 +26,7 @@ export function createDeepFilterProcessor(
): DeepFilterNoiseFilterProcessor {
const clampedNoiseReductionLevel = Math.max(0, Math.min(100, noiseReductionLevel));
return new DeepFilterNoiseFilterProcessor({
sampleRate: 48000,
sampleRate: DEEP_FILTER_MODEL_SAMPLE_RATE,
noiseReductionLevel: clampedNoiseReductionLevel,
enabled: true,
assetConfig: {
@@ -33,6 +35,32 @@ export function createDeepFilterProcessor(
});
}
export function resolveDeepFilterAudioContext(
sourceContext: AudioContext,
feedTrack: MediaStreamTrack,
): AudioContext | null {
const modelRateContext = createVoiceAudioContext({
latencyHint: 'interactive',
sampleRate: DEEP_FILTER_MODEL_SAMPLE_RATE,
});
if (!modelRateContext) return null;
if (modelRateContext.sampleRate === sourceContext.sampleRate) return modelRateContext;
try {
modelRateContext.createMediaStreamSource(new MediaStream([feedTrack])).disconnect();
return modelRateContext;
} catch (error) {
logger.info('DeepFilter cannot read the capture graph at the model rate; running it at the capture rate', {
modelSampleRate: modelRateContext.sampleRate,
captureSampleRate: sourceContext.sampleRate,
error,
});
}
void modelRateContext.close().catch((error) => {
logger.debug('Failed to close the rejected DeepFilter AudioContext', error);
});
return createVoiceAudioContext({latencyHint: 'interactive', sampleRate: sourceContext.sampleRate});
}
export interface DeepFilterAudioChain {
processedTrack: MediaStreamTrack;
inputDestination: MediaStreamAudioDestinationNode;
@@ -85,6 +113,7 @@ export async function buildDeepFilterAudioChain(opts: {
throw new Error('buildDeepFilterAudioChain: missing DeepFilter feed track');
}
const processor = createDeepFilterProcessor(noiseReductionLevel);
processor.audioContext = resolveDeepFilterAudioContext(audioContext, deepFilterFeedTrack);
const disposeDeepFilterInputGraph = () => {
safeDisconnect(inputDestination);
safeDisconnect(hpfSource);