mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): stop the Firefox microphone failure and self-mute storm (#1761)
This commit is contained in:
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 room = MediaEngine.room;
|
||||||
const connectedChannelId = MediaEngine.channelId;
|
const connectedChannelId = MediaEngine.channelId;
|
||||||
const currentMute = LocalVoiceState.getSelfMute();
|
const microphoneFailureLatched = MediaEngine.isMicrophoneFailureLatched();
|
||||||
|
const currentMute = LocalVoiceState.getSelfMute() || microphoneFailureLatched;
|
||||||
const currentDeaf = LocalVoiceState.getSelfDeaf();
|
const currentDeaf = LocalVoiceState.getSelfDeaf();
|
||||||
const willUndeafen = currentDeaf;
|
const willUndeafen = currentDeaf;
|
||||||
const willUnmute = currentMute;
|
const willUnmute = currentMute;
|
||||||
@@ -139,6 +140,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
|
|||||||
logger.info('toggleSelfMute', {
|
logger.info('toggleSelfMute', {
|
||||||
currentMute,
|
currentMute,
|
||||||
currentDeaf,
|
currentDeaf,
|
||||||
|
microphoneFailureLatched,
|
||||||
willUnmute,
|
willUnmute,
|
||||||
willUndeafen,
|
willUndeafen,
|
||||||
willMute,
|
willMute,
|
||||||
@@ -158,6 +160,7 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
|
|||||||
handleMicrophonePermissionBlocked();
|
handleMicrophonePermissionBlocked();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
MediaEngine.clearMicrophoneFailureLatch();
|
||||||
if (!MediaPermission.isMicrophoneGranted()) {
|
if (!MediaPermission.isMicrophoneGranted()) {
|
||||||
logger.info('Microphone permission not granted, requesting permission');
|
logger.info('Microphone permission not granted, requesting permission');
|
||||||
const permissionGranted = room?.localParticipant
|
const permissionGranted = room?.localParticipant
|
||||||
@@ -175,6 +178,11 @@ export async function toggleSelfMute(_guildId: string | null = null): Promise<vo
|
|||||||
return;
|
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();
|
LocalVoiceState.toggleSelfMute();
|
||||||
const newMute = LocalVoiceState.getSelfMute();
|
const newMute = LocalVoiceState.getSelfMute();
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ import {
|
|||||||
noteLocalVoiceActivity,
|
noteLocalVoiceActivity,
|
||||||
noteLocalVoiceActivityFromSnapshot,
|
noteLocalVoiceActivityFromSnapshot,
|
||||||
} from '@app/features/voice/engine/VoiceIdleActivityBridge';
|
} 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 {normalizeVoiceMediaGraphViewerStreamKeys} from '@app/features/voice/engine/VoiceMediaGraph';
|
||||||
import {startVoiceMediaGraphTimerScheduler} from '@app/features/voice/engine/VoiceMediaGraphTimerScheduler';
|
import {startVoiceMediaGraphTimerScheduler} from '@app/features/voice/engine/VoiceMediaGraphTimerScheduler';
|
||||||
import {bindOutputDeviceSync} from '@app/features/voice/engine/VoiceOutputDeviceSync';
|
import {bindOutputDeviceSync} from '@app/features/voice/engine/VoiceOutputDeviceSync';
|
||||||
@@ -748,6 +758,9 @@ class MediaEngineFacade extends Store {
|
|||||||
private videoCodecDecodeCapResyncDisposer: (() => void) | null = null;
|
private videoCodecDecodeCapResyncDisposer: (() => void) | null = null;
|
||||||
private videoCodecPublishOverrideSyncDisposer: (() => void) | null = null;
|
private videoCodecPublishOverrideSyncDisposer: (() => void) | null = null;
|
||||||
private localStreamCodecReconcileScheduled = false;
|
private localStreamCodecReconcileScheduled = false;
|
||||||
|
private localAudioReconcileCoalescer: VoiceLocalAudioReconcileCoalescerSnapshot =
|
||||||
|
createVoiceLocalAudioReconcileCoalescerSnapshot();
|
||||||
|
private applyingGatewayVoiceStateEcho = false;
|
||||||
private previousWatchedStreamCodecGossip = new Map<
|
private previousWatchedStreamCodecGossip = new Map<
|
||||||
string,
|
string,
|
||||||
{identity: string; source: VoiceEngineV2LocalStreamSource}
|
{identity: string; source: VoiceEngineV2LocalStreamSource}
|
||||||
@@ -1349,9 +1362,40 @@ class MediaEngineFacade extends Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private reconcileLocalAudioStateInBackground(reason: string): void {
|
private reconcileLocalAudioStateInBackground(reason: string): void {
|
||||||
void this.reconcileLocalAudioState(reason).catch((error) => {
|
const previous = this.localAudioReconcileCoalescer;
|
||||||
logger.warn('Local audio reconciliation failed', {reason, error});
|
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 {
|
private clearNativeVoiceTransportReconnect(): void {
|
||||||
@@ -1601,7 +1645,11 @@ class MediaEngineFacade extends Store {
|
|||||||
previousState,
|
previousState,
|
||||||
currentState,
|
currentState,
|
||||||
});
|
});
|
||||||
|
const unmutedLocally = previousState.selfMute && !currentState.selfMute && !this.applyingGatewayVoiceStateEcho;
|
||||||
previousState = currentState;
|
previousState = currentState;
|
||||||
|
if (unmutedLocally) {
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.noteUserMuteIntentChanged();
|
||||||
|
}
|
||||||
this.syncVoiceEngineV2AudioControlsFromAppState();
|
this.syncVoiceEngineV2AudioControlsFromAppState();
|
||||||
this.reconcileLocalAudioStateInBackground('local audio state change');
|
this.reconcileLocalAudioStateInBackground('local audio state change');
|
||||||
});
|
});
|
||||||
@@ -2110,6 +2158,7 @@ class MediaEngineFacade extends Store {
|
|||||||
SoundCommands.playSound(SoundType.VoiceDisconnect);
|
SoundCommands.playSound(SoundType.VoiceDisconnect);
|
||||||
VoiceCallLayout.reset();
|
VoiceCallLayout.reset();
|
||||||
voiceEngineV2AppMediaStateAdapter.resetLocalMediaState('disconnect');
|
voiceEngineV2AppMediaStateAdapter.resetLocalMediaState('disconnect');
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
this.resetLocalMediaAndScreenShareTracking();
|
this.resetLocalMediaAndScreenShareTracking();
|
||||||
this.voiceEngineV2Participants.clear();
|
this.voiceEngineV2Participants.clear();
|
||||||
if (connectionId) {
|
if (connectionId) {
|
||||||
@@ -2148,6 +2197,7 @@ class MediaEngineFacade extends Store {
|
|||||||
}
|
}
|
||||||
VoiceCallLayout.reset();
|
VoiceCallLayout.reset();
|
||||||
voiceEngineV2AppMediaStateAdapter.resetLocalMediaState('disconnect');
|
voiceEngineV2AppMediaStateAdapter.resetLocalMediaState('disconnect');
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
this.resetLocalMediaAndScreenShareTracking();
|
this.resetLocalMediaAndScreenShareTracking();
|
||||||
this.voiceEngineV2Participants.clear();
|
this.voiceEngineV2Participants.clear();
|
||||||
if (reason === 'user' && connectionId) {
|
if (reason === 'user' && connectionId) {
|
||||||
@@ -2208,7 +2258,10 @@ class MediaEngineFacade extends Store {
|
|||||||
private syncVoiceEngineV2AudioControlsFromAppState(): void {
|
private syncVoiceEngineV2AudioControlsFromAppState(): void {
|
||||||
this.voiceEngineV2Controller.setAudioControls({
|
this.voiceEngineV2Controller.setAudioControls({
|
||||||
mode: this.getVoiceEngineV2AudioMode(),
|
mode: this.getVoiceEngineV2AudioMode(),
|
||||||
locallyMuted: LocalVoiceState.getSelfMute(),
|
locallyMuted: selectVoiceLocalAudioEffectiveSelfMute({
|
||||||
|
localSelfMute: LocalVoiceState.getSelfMute(),
|
||||||
|
microphoneFailureLatched: voiceEngineV2AppMediaExecutionAdapter.isMicrophoneFailureLatched(),
|
||||||
|
}),
|
||||||
preferredLocallyMuted: LocalVoiceState.getSelfMute(),
|
preferredLocallyMuted: LocalVoiceState.getSelfMute(),
|
||||||
locallyDeafened: LocalVoiceState.getSelfDeaf(),
|
locallyDeafened: LocalVoiceState.getSelfDeaf(),
|
||||||
mutedByPermission: LocalVoiceState.getMutedByPermission(),
|
mutedByPermission: LocalVoiceState.getMutedByPermission(),
|
||||||
@@ -2341,6 +2394,7 @@ class MediaEngineFacade extends Store {
|
|||||||
}
|
}
|
||||||
void ScreenShareCodecNegotiation.publishLocalCapabilitiesNative('connected');
|
void ScreenShareCodecNegotiation.publishLocalCapabilitiesNative('connected');
|
||||||
this.restoreNativeLocalMediaStateInBackground('native voice engine connected');
|
this.restoreNativeLocalMediaStateInBackground('native voice engine connected');
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
this.reconcileLocalAudioStateInBackground('native voice engine connected');
|
this.reconcileLocalAudioStateInBackground('native voice engine connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3111,6 +3165,7 @@ class MediaEngineFacade extends Store {
|
|||||||
this.statsHostAdapter.startStatsTracking();
|
this.statsHostAdapter.startStatsTracking();
|
||||||
void ScreenShareCodecNegotiation.publishLocalCapabilitiesNative('reconnected');
|
void ScreenShareCodecNegotiation.publishLocalCapabilitiesNative('reconnected');
|
||||||
this.restoreNativeLocalMediaStateInBackground('native voice engine reconnected');
|
this.restoreNativeLocalMediaStateInBackground('native voice engine reconnected');
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
this.reconcileLocalAudioStateInBackground('native voice engine reconnected');
|
this.reconcileLocalAudioStateInBackground('native voice engine reconnected');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -3246,6 +3301,7 @@ class MediaEngineFacade extends Store {
|
|||||||
if (shouldApplyPendingSessionRestore) {
|
if (shouldApplyPendingSessionRestore) {
|
||||||
await this.restorePendingSessionMedia();
|
await this.restorePendingSessionMedia();
|
||||||
}
|
}
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
await this.reconcileLocalAudioState('voice room connected');
|
await this.reconcileLocalAudioState('voice room connected');
|
||||||
},
|
},
|
||||||
onDisconnected: () => {
|
onDisconnected: () => {
|
||||||
@@ -3266,6 +3322,7 @@ class MediaEngineFacade extends Store {
|
|||||||
this.statsHostAdapter.incrementReconnectionCount();
|
this.statsHostAdapter.incrementReconnectionCount();
|
||||||
this.statsHostAdapter.startLatencyTracking();
|
this.statsHostAdapter.startLatencyTracking();
|
||||||
this.statsHostAdapter.startStatsTracking();
|
this.statsHostAdapter.startStatsTracking();
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
this.reconcileLocalAudioStateInBackground('voice room reconnected');
|
this.reconcileLocalAudioStateInBackground('voice room reconnected');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3320,6 +3377,7 @@ class MediaEngineFacade extends Store {
|
|||||||
if (guildId && channelId) {
|
if (guildId && channelId) {
|
||||||
VoiceEngineV2AppPermissionAdapter.syncWithPermissionState(guildId, channelId, newRoom);
|
VoiceEngineV2AppPermissionAdapter.syncWithPermissionState(guildId, channelId, newRoom);
|
||||||
}
|
}
|
||||||
|
voiceEngineV2AppMediaExecutionAdapter.resetMicrophoneFailureLatch();
|
||||||
this.reconcileLocalAudioStateInBackground('region hot-swap complete');
|
this.reconcileLocalAudioStateInBackground('region hot-swap complete');
|
||||||
},
|
},
|
||||||
(_guildId, _channelId, _connectionId, _attemptId, error) => {
|
(_guildId, _channelId, _connectionId, _attemptId, error) => {
|
||||||
@@ -3471,13 +3529,18 @@ class MediaEngineFacade extends Store {
|
|||||||
const pttActive = this.isPushToTalkActive();
|
const pttActive = this.isPushToTalkActive();
|
||||||
const applyServerState = shouldApplyIncomingLocalState;
|
const applyServerState = shouldApplyIncomingLocalState;
|
||||||
shouldApplyCurrentServerState = applyServerState;
|
shouldApplyCurrentServerState = applyServerState;
|
||||||
LocalVoiceState.syncConnectionState(voiceState.connection_id, {
|
this.applyingGatewayVoiceStateEcho = true;
|
||||||
selfMute: ptmActive || pttActive || !applyServerState ? undefined : voiceState.self_mute,
|
try {
|
||||||
selfDeaf: applyServerState ? voiceState.self_deaf : undefined,
|
LocalVoiceState.syncConnectionState(voiceState.connection_id, {
|
||||||
selfVideo: applyServerState ? voiceState.self_video : undefined,
|
selfMute: ptmActive || pttActive || !applyServerState ? undefined : voiceState.self_mute,
|
||||||
selfStream: applyServerState ? voiceState.self_stream : undefined,
|
selfDeaf: applyServerState ? voiceState.self_deaf : undefined,
|
||||||
viewerStreamKeys: applyServerState ? incomingViewerStreamKeys : undefined,
|
selfVideo: applyServerState ? voiceState.self_video : undefined,
|
||||||
});
|
selfStream: applyServerState ? voiceState.self_stream : undefined,
|
||||||
|
viewerStreamKeys: applyServerState ? incomingViewerStreamKeys : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.applyingGatewayVoiceStateEcho = false;
|
||||||
|
}
|
||||||
if (applyServerState) {
|
if (applyServerState) {
|
||||||
replaceWatchedStreamKeys([...incomingViewerStreamKeys], {sync: false});
|
replaceWatchedStreamKeys([...incomingViewerStreamKeys], {sync: false});
|
||||||
}
|
}
|
||||||
@@ -3667,11 +3730,13 @@ class MediaEngineFacade extends Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncLocalVoiceStateWithServer(partial?: VoiceStateSyncPartial): void {
|
syncLocalVoiceStateWithServer(partial?: VoiceStateSyncPartial): void {
|
||||||
LocalVoiceState.ensurePermissionMute();
|
|
||||||
const {guildId, channelId, connectionId} = voiceEngineV2AppConnectionHostAdapter.connectionState;
|
|
||||||
if (!channelId || !connectionId) return;
|
|
||||||
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
const devicePermission = VoiceDevicePermissionState.getState().permissionStatus;
|
||||||
const micGranted = MediaPermission.isMicrophoneGranted() || devicePermission === 'granted';
|
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({
|
const selfMute = resolveVoiceStateSelfMute({
|
||||||
guildId,
|
guildId,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -4671,6 +4736,17 @@ class MediaEngineFacade extends Store {
|
|||||||
return voiceEngineV2AppMediaExecutionAdapter.getMuteReason(voiceState, this.guildId, this.channelId);
|
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> {
|
async toggleCameraFromKeybind(): Promise<void> {
|
||||||
const current = LocalVoiceState.getSelfVideo();
|
const current = LocalVoiceState.getSelfVideo();
|
||||||
await this.setCameraEnabled(!current, {deviceId: VoiceSettings.getVideoDeviceId()});
|
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;
|
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 {
|
function isSinkableMediaElement(value: unknown): value is SinkableMediaElement {
|
||||||
if (typeof HTMLMediaElement === 'undefined') return false;
|
if (typeof HTMLMediaElement === 'undefined') return false;
|
||||||
return value instanceof HTMLMediaElement;
|
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);
|
const resolvedDeviceId = await resolveAvailableOutputDeviceId(deviceId);
|
||||||
try {
|
if (canRoomSwitchAudioOutput(room)) {
|
||||||
await room.switchActiveDevice('audiooutput', resolvedDeviceId);
|
try {
|
||||||
} catch (error) {
|
await room.switchActiveDevice('audiooutput', resolveLiveKitSwitchDeviceId(room, resolvedDeviceId));
|
||||||
logger.warn('LiveKit failed to apply audio output device', {deviceId: resolvedDeviceId, error});
|
} 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 {
|
try {
|
||||||
await applyOutputDeviceToWebAudioMixer(room, resolvedDeviceId);
|
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;
|
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 {
|
function installResumeOnUserGesture(context: AudioContext): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
if (!window.document?.body) return;
|
if (!window.document?.body) return;
|
||||||
@@ -41,15 +52,8 @@ export function getSharedVoiceAudioContext(): AudioContext | null {
|
|||||||
if (sharedAudioContext && sharedAudioContext.state !== 'closed') {
|
if (sharedAudioContext && sharedAudioContext.state !== 'closed') {
|
||||||
return sharedAudioContext;
|
return sharedAudioContext;
|
||||||
}
|
}
|
||||||
const ctor = resolveAudioContextConstructor();
|
sharedAudioContext = createVoiceAudioContext({latencyHint: 'interactive'});
|
||||||
if (!ctor) return null;
|
if (!sharedAudioContext) return null;
|
||||||
try {
|
|
||||||
sharedAudioContext = new ctor({latencyHint: 'interactive'});
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Failed to construct shared voice AudioContext', {error});
|
|
||||||
sharedAudioContext = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
assert.notEqual(sharedAudioContext.state, 'closed', 'shared voice AudioContext must not start closed');
|
assert.notEqual(sharedAudioContext.state, 'closed', 'shared voice AudioContext must not start closed');
|
||||||
if (sharedAudioContext.state === 'suspended') {
|
if (sharedAudioContext.state === 'suspended') {
|
||||||
void sharedAudioContext.resume().catch((error) => {
|
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 VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
||||||
import type {EffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
|
import type {EffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
|
||||||
import {getEffectiveAudioState} 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 {resolveLocalSpeakingOverrideState} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
|
||||||
import {
|
import {
|
||||||
getRoomFromMediaEngine,
|
getRoomFromMediaEngine,
|
||||||
@@ -151,10 +159,36 @@ function getVoiceEngineV2AudioModeFromAppState(): VoiceEngineV2AudioMode {
|
|||||||
return 'voiceActivity';
|
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 {
|
function getVoiceEngineV2AudioControlsFromAppState(): VoiceEngineV2AudioControls {
|
||||||
return {
|
return {
|
||||||
mode: getVoiceEngineV2AudioModeFromAppState(),
|
mode: getVoiceEngineV2AudioModeFromAppState(),
|
||||||
locallyMuted: LocalVoiceState.getSelfMute(),
|
locallyMuted: getLatchedLocalSelfMute(),
|
||||||
preferredLocallyMuted: LocalVoiceState.getSelfMute(),
|
preferredLocallyMuted: LocalVoiceState.getSelfMute(),
|
||||||
locallyDeafened: LocalVoiceState.getSelfDeaf(),
|
locallyDeafened: LocalVoiceState.getSelfDeaf(),
|
||||||
mutedByPermission: LocalVoiceState.getMutedByPermission(),
|
mutedByPermission: LocalVoiceState.getMutedByPermission(),
|
||||||
@@ -250,6 +284,31 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
this.cameraLifecycleBinding = null;
|
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 {
|
private isSpeakPermissionDenied(channelId: string | null): boolean {
|
||||||
const connection = getVoiceConnectionContextFromMediaEngine();
|
const connection = getVoiceConnectionContextFromMediaEngine();
|
||||||
return isVoiceSpeakPermissionDenied(connection?.guildId ?? null, channelId);
|
return isVoiceSpeakPermissionDenied(connection?.guildId ?? null, channelId);
|
||||||
@@ -356,6 +415,7 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
async ensureMicrophone(room: Room, channelId: string): Promise<void> {
|
async ensureMicrophone(room: Room, channelId: string): Promise<void> {
|
||||||
assertObjectLike<Room>(room, 'ensureMicrophone.room');
|
assertObjectLike<Room>(room, 'ensureMicrophone.room');
|
||||||
assertNonEmptyString(channelId, 'ensureMicrophone.channelId');
|
assertNonEmptyString(channelId, 'ensureMicrophone.channelId');
|
||||||
|
this.observeMicrophoneFailureLatchScope(channelId);
|
||||||
if (this.isSpeakPermissionDenied(channelId)) {
|
if (this.isSpeakPermissionDenied(channelId)) {
|
||||||
logger.debug('Skipping microphone: speak permission denied');
|
logger.debug('Skipping microphone: speak permission denied');
|
||||||
await this.enforceSpeakPermissionMute(room);
|
await this.enforceSpeakPermissionMute(room);
|
||||||
@@ -425,10 +485,12 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
logger.debug('Skipping audio-state reconciliation: no local participant');
|
logger.debug('Skipping audio-state reconciliation: no local participant');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.observeMicrophoneFailureLatchScope(params.channelId);
|
||||||
const permissionMuted = this.isSpeakPermissionDenied(params.channelId);
|
const permissionMuted = this.isSpeakPermissionDenied(params.channelId);
|
||||||
const audioState = this.getEffectiveAudioState({
|
const audioState = this.getEffectiveAudioState({
|
||||||
serverMute: params.serverMute || permissionMuted,
|
serverMute: params.serverMute || permissionMuted,
|
||||||
serverDeaf: params.serverDeaf,
|
serverDeaf: params.serverDeaf,
|
||||||
|
selfMute: getLatchedLocalSelfMute(),
|
||||||
});
|
});
|
||||||
logger.info('Reconciling local media after voice state update', {
|
logger.info('Reconciling local media after voice state update', {
|
||||||
channelId: params.channelId,
|
channelId: params.channelId,
|
||||||
@@ -577,6 +639,7 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
async refreshMicrophone(room: Room | null, options: RefreshMicrophoneOptions = {}): Promise<void> {
|
async refreshMicrophone(room: Room | null, options: RefreshMicrophoneOptions = {}): Promise<void> {
|
||||||
assertNullableObjectLike<Room>(room, 'refreshMicrophone.room');
|
assertNullableObjectLike<Room>(room, 'refreshMicrophone.room');
|
||||||
assertObjectLike<RefreshMicrophoneOptions>(options, 'refreshMicrophone.options');
|
assertObjectLike<RefreshMicrophoneOptions>(options, 'refreshMicrophone.options');
|
||||||
|
this.observeMicrophoneFailureLatchScope(this.getActiveChannelId());
|
||||||
const refresh = async () => this.refreshMicrophoneNow(room, options);
|
const refresh = async () => this.refreshMicrophoneNow(room, options);
|
||||||
const pendingRefresh = this.microphoneRefreshQueue.then(refresh, refresh);
|
const pendingRefresh = this.microphoneRefreshQueue.then(refresh, refresh);
|
||||||
this.microphoneRefreshQueue = pendingRefresh.catch(() => {});
|
this.microphoneRefreshQueue = pendingRefresh.catch(() => {});
|
||||||
@@ -762,6 +825,7 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
if (this.hasMicrophonePublication(room)) {
|
if (this.hasMicrophonePublication(room)) {
|
||||||
if (this.hasLiveMicrophonePublication(room)) {
|
if (this.hasLiveMicrophonePublication(room)) {
|
||||||
logger.debug('Microphone track already published, skipping duplicate publish');
|
logger.debug('Microphone track already published, skipping duplicate publish');
|
||||||
|
this.transitionMicrophoneFailureLatch({type: 'microphone.enableSucceeded'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.warn('Existing microphone publication has an ended track; unpublishing before reacquire');
|
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);
|
this.attachLocalSpeakingDetectorForPublish(ctx, state);
|
||||||
MediaPermission.updateMicrophonePermissionGranted();
|
MediaPermission.updateMicrophonePermissionGranted();
|
||||||
this.transitionMediaState({type: 'microphone.enable.success'});
|
this.transitionMediaState({type: 'microphone.enable.success'});
|
||||||
|
this.transitionMicrophoneFailureLatch({type: 'microphone.enableSucceeded'});
|
||||||
logger.info('Successfully enabled microphone');
|
logger.info('Successfully enabled microphone');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
this.transitionMicrophoneFailureLatch({
|
||||||
|
type: 'microphone.enableFailed',
|
||||||
|
channelId: ctx.channelId ?? this.getActiveChannelId(),
|
||||||
|
inputDeviceId: VoiceSettings.getInputDeviceId(),
|
||||||
|
});
|
||||||
await this.rollbackMicrophoneEnable(ctx, state, e);
|
await this.rollbackMicrophoneEnable(ctx, state, e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -1411,7 +1481,11 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
return (micPublication?.track as LocalAudioTrack) ?? null;
|
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);
|
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 RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
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 VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||||
import {DeepFilterNoiseFilterProcessor} from 'deepfilternet3-noise-filter';
|
import {DeepFilterNoiseFilterProcessor} from 'deepfilternet3-noise-filter';
|
||||||
import type {LocalAudioTrack} from 'livekit-client';
|
import type {LocalAudioTrack} from 'livekit-client';
|
||||||
|
|
||||||
const logger = new Logger('DeepFilterNoiseProcessor');
|
const logger = new Logger('DeepFilterNoiseProcessor');
|
||||||
|
const DEEP_FILTER_MODEL_SAMPLE_RATE = 48000;
|
||||||
const DEFAULT_SUPPRESSION_LEVEL = 80;
|
const DEFAULT_SUPPRESSION_LEVEL = 80;
|
||||||
const HIGH_PASS_FREQUENCY_HZ = 60;
|
const HIGH_PASS_FREQUENCY_HZ = 60;
|
||||||
const HIGH_PASS_Q = Math.SQRT1_2;
|
const HIGH_PASS_Q = Math.SQRT1_2;
|
||||||
@@ -24,7 +26,7 @@ export function createDeepFilterProcessor(
|
|||||||
): DeepFilterNoiseFilterProcessor {
|
): DeepFilterNoiseFilterProcessor {
|
||||||
const clampedNoiseReductionLevel = Math.max(0, Math.min(100, noiseReductionLevel));
|
const clampedNoiseReductionLevel = Math.max(0, Math.min(100, noiseReductionLevel));
|
||||||
return new DeepFilterNoiseFilterProcessor({
|
return new DeepFilterNoiseFilterProcessor({
|
||||||
sampleRate: 48000,
|
sampleRate: DEEP_FILTER_MODEL_SAMPLE_RATE,
|
||||||
noiseReductionLevel: clampedNoiseReductionLevel,
|
noiseReductionLevel: clampedNoiseReductionLevel,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
assetConfig: {
|
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 {
|
export interface DeepFilterAudioChain {
|
||||||
processedTrack: MediaStreamTrack;
|
processedTrack: MediaStreamTrack;
|
||||||
inputDestination: MediaStreamAudioDestinationNode;
|
inputDestination: MediaStreamAudioDestinationNode;
|
||||||
@@ -85,6 +113,7 @@ export async function buildDeepFilterAudioChain(opts: {
|
|||||||
throw new Error('buildDeepFilterAudioChain: missing DeepFilter feed track');
|
throw new Error('buildDeepFilterAudioChain: missing DeepFilter feed track');
|
||||||
}
|
}
|
||||||
const processor = createDeepFilterProcessor(noiseReductionLevel);
|
const processor = createDeepFilterProcessor(noiseReductionLevel);
|
||||||
|
processor.audioContext = resolveDeepFilterAudioContext(audioContext, deepFilterFeedTrack);
|
||||||
const disposeDeepFilterInputGraph = () => {
|
const disposeDeepFilterInputGraph = () => {
|
||||||
safeDisconnect(inputDestination);
|
safeDisconnect(inputDestination);
|
||||||
safeDisconnect(hpfSource);
|
safeDisconnect(hpfSource);
|
||||||
|
|||||||
Reference in new issue
Block a user