fix(voice): scope disconnects, correct VAD and stream lifecycle (#2597)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-08 19:06:42 +02:00
1 parent 667ac7da8e
commit 20a15ac11d
15 files changed
+1069 -36

No files matched your search

@@ -0,0 +1,183 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
isScreenShareVideoSubscriptionRecoveryWanted,
ScreenShareVideoSubscriptionRecoveryCoordinator,
type ScreenShareVideoSubscriptionRecoveryGraph,
type ScreenShareVideoSubscriptionRecoveryPublication,
type ScreenShareVideoSubscriptionRecoveryScheduler,
} from '@app/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery';
import {
createVoiceMediaGraphSnapshot,
transitionVoiceMediaGraph,
VOICE_MEDIA_GRAPH_SCREEN_SHARE_SOURCE,
type VoiceMediaGraphFailure,
type VoiceMediaGraphSnapshot,
} from '@app/features/voice/engine/VoiceMediaGraph';
import {ScreenShareWatchErrorCode} from '@app/features/voice/state/ScreenShareWatchFailures';
import {describe, expect, it, vi} from 'vitest';
const STREAM_KEY = 'dm:channel-a:connection-a';
const PARTICIPANT_IDENTITY = 'user_1_connection-a';
const TRACK_SID = 'TR_screen_share';
const FIRST_FRAME_TIMEOUT_FAILURE: Partial<VoiceMediaGraphFailure> = {
code: ScreenShareWatchErrorCode.FirstFrameTimeout,
reason: 'first-frame-timeout',
};
const REMOTE_SUBSCRIPTION_FAILURE: Partial<VoiceMediaGraphFailure> = {
code: ScreenShareWatchErrorCode.RemoteTrackSubscriptionFailed,
reason: 'remote-track-subscription-failed',
};
function createScheduler() {
let nextId = 1;
const callbacks = new Map<number, () => void>();
const scheduledDelays: Array<number> = [];
const scheduler: ScreenShareVideoSubscriptionRecoveryScheduler = {
setTimeout: (callback, delayMs) => {
const timeoutId = nextId;
nextId += 1;
callbacks.set(timeoutId, callback);
scheduledDelays.push(delayMs);
return timeoutId;
},
clearTimeout: (timeoutId) => {
callbacks.delete(timeoutId);
},
};
return {
scheduler,
pendingCount: () => callbacks.size,
scheduledDelays: () => [...scheduledDelays],
fire: () => {
const next = callbacks.entries().next();
if (next.done) return;
callbacks.delete(next.value[0]);
next.value[1]();
},
};
}
function createWatchedSnapshot(failure: Partial<VoiceMediaGraphFailure> | null): VoiceMediaGraphSnapshot {
let snapshot: VoiceMediaGraphSnapshot = createVoiceMediaGraphSnapshot();
snapshot = transitionVoiceMediaGraph(snapshot, {type: 'watchIntent.add', key: STREAM_KEY});
snapshot = transitionVoiceMediaGraph(snapshot, {type: 'watch.started', streamKey: STREAM_KEY});
if (!failure) return snapshot;
return transitionVoiceMediaGraph(snapshot, {
type: 'failure.reported',
failure: {
code: -2202,
reason: 'remote-track-subscription-failed',
reportedAt: 1000,
source: VOICE_MEDIA_GRAPH_SCREEN_SHARE_SOURCE,
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
...failure,
},
});
}
function createGraphPort(snapshot: VoiceMediaGraphSnapshot): ScreenShareVideoSubscriptionRecoveryGraph {
return {
getGraphSnapshot: () => snapshot,
nowMs: () => 1000,
transition: () => undefined,
};
}
function createReceivablePublication(): ScreenShareVideoSubscriptionRecoveryPublication {
return {
trackSid: TRACK_SID,
isSubscribed: true,
track: {mediaStreamTrack: {readyState: 'live', muted: false}},
};
}
function acquireWatchingTile(failure: Partial<VoiceMediaGraphFailure> | null) {
const snapshot = createWatchedSnapshot(failure);
const {scheduler, fire, pendingCount, scheduledDelays} = createScheduler();
const coordinator = new ScreenShareVideoSubscriptionRecoveryCoordinator(scheduler, createGraphPort(snapshot));
const recover = vi.fn();
coordinator.acquire({
key: TRACK_SID,
publication: createReceivablePublication(),
streamKey: STREAM_KEY,
participantIdentity: PARTICIPANT_IDENTITY,
isStillWanted: () => isScreenShareVideoSubscriptionRecoveryWanted(snapshot, STREAM_KEY),
recover,
});
return {coordinator, fire, pendingCount, scheduledDelays, recover};
}
describe('isScreenShareVideoSubscriptionRecoveryWanted', () => {
it('keeps the lease armed after a first-frame timeout so recovery can still run', () => {
expect(
isScreenShareVideoSubscriptionRecoveryWanted(createWatchedSnapshot(FIRST_FRAME_TIMEOUT_FAILURE), STREAM_KEY),
).toBe(true);
});
it('drops the lease for failures other than a first-frame timeout', () => {
expect(
isScreenShareVideoSubscriptionRecoveryWanted(createWatchedSnapshot(REMOTE_SUBSCRIPTION_FAILURE), STREAM_KEY),
).toBe(false);
});
it('drops the lease once the stream is no longer watched', () => {
expect(isScreenShareVideoSubscriptionRecoveryWanted(createVoiceMediaGraphSnapshot(), STREAM_KEY)).toBe(false);
});
});
describe('ScreenShareVideoSubscriptionRecoveryCoordinator first-frame recovery', () => {
it('keeps resubscribing while a first-frame timeout stands, up to the recovery limit', () => {
const {coordinator, fire, pendingCount, recover} = acquireWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE);
fire();
expect(recover).toHaveBeenCalledTimes(1);
expect(recover).toHaveBeenCalledWith('resubscribe');
expect(pendingCount()).toBe(1);
fire();
fire();
expect(recover).toHaveBeenCalledTimes(3);
expect(recover).toHaveBeenNthCalledWith(3, 'resubscribe');
expect(pendingCount()).toBe(1);
fire();
expect(recover).toHaveBeenCalledTimes(3);
expect(coordinator.getActiveSessionCount()).toBe(0);
});
it('gives every rebuilt subscription at least the first-frame budget before resubscribing again', () => {
const {fire, scheduledDelays} = acquireWatchingTile(FIRST_FRAME_TIMEOUT_FAILURE);
fire();
fire();
expect(scheduledDelays().slice(1)).toEqual([20_000, 20_000]);
});
it('keeps polling without recovering while a receivable track has no reported failure', () => {
const {coordinator, fire, recover} = acquireWatchingTile(null);
fire();
fire();
fire();
expect(recover).not.toHaveBeenCalled();
expect(coordinator.getActiveSessionCount()).toBe(1);
});
it('closes the session without recovering for a failure other than a first-frame timeout', () => {
const {coordinator, fire, recover} = acquireWatchingTile(REMOTE_SUBSCRIPTION_FAILURE);
fire();
expect(recover).not.toHaveBeenCalled();
expect(coordinator.getActiveSessionCount()).toBe(0);
});
});
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
selectVoiceMediaGraphFailure,
selectVoiceMediaGraphViewerStreamKeys,
selectVoiceMediaGraphWatchGeneration,
type VoiceMediaGraphEvent,
@@ -8,7 +9,10 @@ import {
} from '@app/features/voice/engine/VoiceMediaGraph';
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import {getScreenShareWatchFailureForPublicationOperation} from '@app/features/voice/state/ScreenShareWatchFailures';
import {
getScreenShareWatchFailureForPublicationOperation,
ScreenShareWatchErrorCode,
} from '@app/features/voice/state/ScreenShareWatchFailures';
import {
refreshScreenSharePublicationSubscription,
resubscribeScreenSharePublication,
@@ -58,6 +62,7 @@ interface ScreenShareVideoSubscriptionRecoverySession {
key: string;
leaseCount: number;
attempt: number;
firstFrameRecoveries: number;
timeoutId: number | null;
graphGeneration: number | null;
options: ScreenShareVideoSubscriptionRecoveryLeaseOptions;
@@ -67,6 +72,8 @@ const SCREEN_SHARE_VIDEO_SUBSCRIPTION_RETRY_INITIAL_DELAY_MS = 2500;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_RETRY_MAX_DELAY_MS = 30_000;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS = 2500;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_REFRESH_ATTEMPTS = 0;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS = 3;
const SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS = 20_000;
const defaultScheduler: ScreenShareVideoSubscriptionRecoveryScheduler = {
setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
@@ -106,6 +113,16 @@ function hasReceivableTrack(publication: ScreenShareVideoSubscriptionRecoveryPub
return isTrackReceivable(track);
}
export function isScreenShareVideoSubscriptionRecoveryWanted(
snapshot: VoiceMediaGraphSnapshot,
streamKey: string | null | undefined,
): boolean {
if (!streamKey) return false;
if (!selectVoiceMediaGraphViewerStreamKeys(snapshot).includes(streamKey)) return false;
const failure = selectVoiceMediaGraphFailure(snapshot, {streamKey});
return failure == null || failure.code === ScreenShareWatchErrorCode.FirstFrameTimeout;
}
export function getScreenShareVideoSubscriptionRecoveryKey({
trackSid,
streamKey,
@@ -152,6 +169,7 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
key: options.key,
leaseCount: 0,
attempt: 0,
firstFrameRecoveries: 0,
timeoutId: null,
graphGeneration: null,
options,
@@ -225,6 +243,13 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
return selectVoiceMediaGraphWatchGeneration(snapshot, streamKey) === session.graphGeneration;
}
private hasFirstFrameTimeoutFailure(session: ScreenShareVideoSubscriptionRecoverySession): boolean {
const streamKey = session.options.streamKey ?? null;
if (!streamKey) return false;
const failure = selectVoiceMediaGraphFailure(this.graph.getGraphSnapshot(), {streamKey});
return failure?.code === ScreenShareWatchErrorCode.FirstFrameTimeout;
}
private run(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
@@ -234,11 +259,20 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
this.closeSession(session);
return;
}
if (hasReceivableTrack(options.publication)) {
const firstFrameStalled = this.hasFirstFrameTimeoutFailure(session);
if (
firstFrameStalled &&
session.firstFrameRecoveries >= SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_ATTEMPTS
) {
this.closeSession(session);
return;
}
if (!firstFrameStalled && hasReceivableTrack(options.publication)) {
session.attempt = 0;
this.schedule(session, SCREEN_SHARE_VIDEO_SUBSCRIPTION_HEALTH_CHECK_DELAY_MS);
return;
}
if (firstFrameStalled) session.firstFrameRecoveries += 1;
session.attempt += 1;
const mode = selectScreenShareVideoSubscriptionRecoveryMode(session.attempt);
const delayMs = getScreenShareVideoSubscriptionRetryDelayMs(session.attempt);
@@ -250,7 +284,12 @@ export class ScreenShareVideoSubscriptionRecoveryCoordinator {
mode,
});
this.recoverPublication(session, mode);
this.schedule(session, getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1));
this.schedule(
session,
firstFrameStalled
? SCREEN_SHARE_VIDEO_SUBSCRIPTION_FIRST_FRAME_RECOVERY_DELAY_MS
: getScreenShareVideoSubscriptionRetryDelayMs(session.attempt + 1),
);
}
private reportCommandFailed(
@@ -13,6 +13,7 @@ import {
} from '@app/features/voice/components/voice_participant_tile/previewEncoding';
import {
getScreenShareVideoSubscriptionRecoveryKey,
isScreenShareVideoSubscriptionRecoveryWanted,
screenShareVideoSubscriptionRecoveryCoordinator,
} from '@app/features/voice/components/voice_participant_tile/ScreenShareVideoSubscriptionRecovery';
import {
@@ -27,7 +28,6 @@ import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import ScreenSharePublicationMigration from '@app/features/voice/engine/ScreenSharePublicationMigration';
import {useStoreVersion} from '@app/features/voice/engine/Store';
import {
selectVoiceMediaGraphHasFailureForStreamKey,
selectVoiceMediaGraphViewerStreamKeys,
type VoiceMediaGraphSnapshot,
} from '@app/features/voice/engine/VoiceMediaGraph';
@@ -538,14 +538,8 @@ export function useScreenshareWatchSubscription(opts: {
publication: pub,
streamKey,
participantIdentity,
isStillWanted: () => {
const currentStreamKey = streamKeyRef.current;
if (currentStreamKey == null) return false;
const graph = getGraphSnapshotRef.current();
if (!selectVoiceMediaGraphViewerStreamKeys(graph).includes(currentStreamKey)) return false;
if (selectVoiceMediaGraphHasFailureForStreamKey(graph, currentStreamKey)) return false;
return true;
},
isStillWanted: () =>
isScreenShareVideoSubscriptionRecoveryWanted(getGraphSnapshotRef.current(), streamKeyRef.current),
onRetry: (retry) => {
logger.warn('Retrying stalled screen share video subscription', retry);
},
@@ -121,7 +121,7 @@ describe('MediaEngineFacadeStateMachine', () => {
expect(snapshot.context.pendingServerDisconnectConnectionId).toBeNull();
});
it('disconnects immediately when the server removes the current local voice state', () => {
it('disconnects immediately when the server removes the current local voice state without a live transport', () => {
expect(
shouldImmediatelyDisconnectMediaEngineForServerVoiceStateRemoval({
voiceStateConnectionId: 'connection-1',
@@ -131,6 +131,28 @@ describe('MediaEngineFacadeStateMachine', () => {
connected: true,
connecting: false,
}),
).toBe(false);
expect(
shouldImmediatelyDisconnectMediaEngineForServerVoiceStateRemoval({
voiceStateConnectionId: 'connection-1',
voiceStateChannelId: null,
currentConnectionId: 'connection-1',
currentChannelId: 'channel-1',
connected: false,
connecting: true,
}),
).toBe(true);
expect(
shouldImmediatelyDisconnectMediaEngineForServerVoiceStateRemoval({
voiceStateConnectionId: 'connection-1',
voiceStateChannelId: null,
currentConnectionId: 'connection-1',
currentChannelId: 'channel-1',
connected: false,
connecting: false,
}),
).toBe(true);
expect(
@@ -156,6 +178,50 @@ describe('MediaEngineFacadeStateMachine', () => {
).toBe(false);
});
it('keeps the transport during a server move until the replacement connection arrives', () => {
let snapshot = createMediaEngineFacadeSnapshot();
expect(
shouldImmediatelyDisconnectMediaEngineForServerVoiceStateRemoval({
voiceStateConnectionId: 'connection-1',
voiceStateChannelId: null,
currentConnectionId: 'connection-1',
currentChannelId: 'channel-1',
connected: true,
connecting: false,
}),
).toBe(false);
expect(
shouldCancelMediaEngineReconnectForServerVoiceStateRemoval({
voiceStateConnectionId: 'connection-1',
voiceStateChannelId: null,
currentConnectionId: 'connection-1',
currentChannelId: 'channel-1',
connected: true,
connecting: false,
}),
).toBe(false);
snapshot = transitionMediaEngineFacadeSnapshot(snapshot, {
type: 'serverDisconnect.schedule',
connectionId: 'connection-1',
});
expect(
shouldRunMediaEngineDeferredDisconnect(snapshot, {
connectionId: 'connection-1',
currentConnectionId: 'connection-2',
connected: true,
currentVoiceStateChannelId: null,
}),
).toBe(false);
expect(
shouldRunMediaEngineDeferredDisconnect(snapshot, {
connectionId: 'connection-1',
currentConnectionId: 'connection-1',
connected: true,
currentVoiceStateChannelId: null,
}),
).toBe(true);
});
it('cancels reconnect when local voice-state removal arrives after transport disconnect', () => {
expect(
shouldCancelMediaEngineReconnectForServerVoiceStateRemoval({
@@ -527,7 +527,8 @@ export function shouldImmediatelyDisconnectMediaEngineForServerVoiceStateRemoval
input: MediaEngineFacadeServerVoiceStateRemovalInput,
): boolean {
if (!isCurrentServerVoiceStateRemoval(input)) return false;
return input.connected || input.connecting || input.currentChannelId != null;
if (input.connected) return false;
return input.connecting || input.currentChannelId != null;
}
export function shouldCancelMediaEngineReconnectForServerVoiceStateRemoval(
@@ -1,7 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getLocalSpeakingThresholdRms,
LOCAL_MAX_RMS,
LOCAL_MIN_RMS,
SPEAKING_LOCAL_RELEASE_MS,
} from '@app/features/voice/engine/VoiceSpeakingThreshold';
import {describe, expect, it} from 'vitest';
import {type LocalSpeakingOverrideInput, resolveLocalSpeakingOverrideState} from './VoiceLocalSpeakingGate';
import {
type LocalSpeakingOverrideInput,
resolveLocalSpeakingOverrideState,
resolveVoiceActivityGateState,
resolveVoiceActivityThresholdRms,
updateVoiceActivityNoiseFloorRms,
type VoiceActivityGateInput,
type VoiceActivityGateState,
} from './VoiceLocalSpeakingGate';
const UNMUTED_OPEN_MIC: LocalSpeakingOverrideInput = {
pushToTalkActive: false,
@@ -70,3 +84,131 @@ describe('resolveLocalSpeakingOverrideState', () => {
).toBe(false);
});
});
const GATE_TICK_MS = 50;
const KEYBOARD_NOISE_RMS = 0.012;
const SPEECH_RMS = 0.06;
function runGate(samples: ReadonlyArray<number>, thresholdRms: number): Array<boolean> {
let open = false;
let silenceStartedAtMs: number | null = null;
return samples.map((rms, index) => {
const input: VoiceActivityGateInput = {
rms,
thresholdRms,
nowMs: index * GATE_TICK_MS,
silenceStartedAtMs,
gateOpen: open,
releaseDelayMs: SPEAKING_LOCAL_RELEASE_MS,
};
const next = resolveVoiceActivityGateState(input);
open = next.open;
silenceStartedAtMs = next.silenceStartedAtMs;
return open;
});
}
describe('resolveVoiceActivityGateState', () => {
it('keeps keyboard noise off the wire when the activity threshold is raised', () => {
const opened = runGate(new Array(20).fill(KEYBOARD_NOISE_RMS), getLocalSpeakingThresholdRms(100));
expect(opened.some((open) => open)).toBe(false);
});
it('still transmits the same noise when the activity threshold is lowered', () => {
const opened = runGate(new Array(20).fill(KEYBOARD_NOISE_RMS), getLocalSpeakingThresholdRms(0));
expect(opened.every((open) => open)).toBe(true);
});
it('opens on the first sample at or above the threshold and clears the silence clock', () => {
const expected: VoiceActivityGateState = {open: true, silenceStartedAtMs: null};
expect(
resolveVoiceActivityGateState({
rms: SPEECH_RMS,
thresholdRms: getLocalSpeakingThresholdRms(100),
nowMs: 1_000,
silenceStartedAtMs: 800,
gateOpen: false,
releaseDelayMs: SPEAKING_LOCAL_RELEASE_MS,
}),
).toEqual(expected);
});
it('holds the gate open for the release window before closing', () => {
const threshold = getLocalSpeakingThresholdRms(100);
expect(
resolveVoiceActivityGateState({
rms: KEYBOARD_NOISE_RMS,
thresholdRms: threshold,
nowMs: 1_000,
silenceStartedAtMs: null,
gateOpen: true,
releaseDelayMs: SPEAKING_LOCAL_RELEASE_MS,
}),
).toEqual({open: true, silenceStartedAtMs: 1_000});
expect(
resolveVoiceActivityGateState({
rms: KEYBOARD_NOISE_RMS,
thresholdRms: threshold,
nowMs: 1_000 + SPEAKING_LOCAL_RELEASE_MS - 1,
silenceStartedAtMs: 1_000,
gateOpen: true,
releaseDelayMs: SPEAKING_LOCAL_RELEASE_MS,
}).open,
).toBe(true);
expect(
resolveVoiceActivityGateState({
rms: KEYBOARD_NOISE_RMS,
thresholdRms: threshold,
nowMs: 1_000 + SPEAKING_LOCAL_RELEASE_MS,
silenceStartedAtMs: 1_000,
gateOpen: true,
releaseDelayMs: SPEAKING_LOCAL_RELEASE_MS,
}).open,
).toBe(false);
});
it('closes after speech stops and reopens when speech returns', () => {
const threshold = getLocalSpeakingThresholdRms(100);
const opened = runGate(
[SPEECH_RMS, SPEECH_RMS, ...new Array(6).fill(KEYBOARD_NOISE_RMS), SPEECH_RMS, KEYBOARD_NOISE_RMS],
threshold,
);
expect(opened).toEqual([true, true, true, true, true, true, false, false, true, true]);
});
});
describe('resolveVoiceActivityThresholdRms', () => {
it('follows the activity threshold slider when auto sensitivity is off', () => {
expect(resolveVoiceActivityThresholdRms({autoSensitivity: false, vadThreshold: 100, noiseFloorRms: 0})).toBe(
getLocalSpeakingThresholdRms(100),
);
expect(resolveVoiceActivityThresholdRms({autoSensitivity: false, vadThreshold: 0, noiseFloorRms: 0})).toBe(
getLocalSpeakingThresholdRms(0),
);
});
it('ignores the slider and tracks the measured noise floor when auto sensitivity is on', () => {
expect(
resolveVoiceActivityThresholdRms({autoSensitivity: true, vadThreshold: 0, noiseFloorRms: 0.005}),
).toBeCloseTo(0.015, 6);
});
it('clamps the auto threshold to the local speaking range', () => {
expect(resolveVoiceActivityThresholdRms({autoSensitivity: true, vadThreshold: 50, noiseFloorRms: 0})).toBe(
LOCAL_MIN_RMS,
);
expect(resolveVoiceActivityThresholdRms({autoSensitivity: true, vadThreshold: 50, noiseFloorRms: 1})).toBe(
LOCAL_MAX_RMS,
);
});
});
describe('updateVoiceActivityNoiseFloorRms', () => {
it('converges on the level measured while the gate is closed', () => {
let noiseFloorRms = 0;
for (let i = 0; i < 200; i++) {
noiseFloorRms = updateVoiceActivityNoiseFloorRms(noiseFloorRms, KEYBOARD_NOISE_RMS);
}
expect(noiseFloorRms).toBeCloseTo(KEYBOARD_NOISE_RMS, 4);
});
});
@@ -1,5 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getLocalSpeakingThresholdRms,
LOCAL_MAX_RMS,
LOCAL_MIN_RMS,
} from '@app/features/voice/engine/VoiceSpeakingThreshold';
const VOICE_ACTIVITY_NOISE_FLOOR_HEADROOM = 3;
const VOICE_ACTIVITY_NOISE_FLOOR_SMOOTHING = 0.05;
export interface LocalSpeakingOverrideInput {
pushToTalkActive: boolean;
pushToMuteActive: boolean;
@@ -18,3 +27,45 @@ export function resolveLocalSpeakingOverrideState(input: LocalSpeakingOverrideIn
if (effectivelyMuted) return false;
return null;
}
export interface VoiceActivityGateInput {
rms: number;
thresholdRms: number;
nowMs: number;
silenceStartedAtMs: number | null;
gateOpen: boolean;
releaseDelayMs: number;
}
export interface VoiceActivityGateState {
open: boolean;
silenceStartedAtMs: number | null;
}
export function resolveVoiceActivityGateState(input: VoiceActivityGateInput): VoiceActivityGateState {
if (input.rms >= input.thresholdRms) {
return {open: true, silenceStartedAtMs: null};
}
const silenceStartedAtMs = input.silenceStartedAtMs ?? input.nowMs;
return {
open: input.gateOpen && input.nowMs - silenceStartedAtMs < input.releaseDelayMs,
silenceStartedAtMs,
};
}
export function resolveVoiceActivityThresholdRms(input: {
autoSensitivity: boolean;
vadThreshold: number;
noiseFloorRms: number;
}): number {
if (!input.autoSensitivity) {
return getLocalSpeakingThresholdRms(input.vadThreshold);
}
const tracked = input.noiseFloorRms * VOICE_ACTIVITY_NOISE_FLOOR_HEADROOM;
if (!Number.isFinite(tracked)) return LOCAL_MIN_RMS;
return Math.min(LOCAL_MAX_RMS, Math.max(LOCAL_MIN_RMS, tracked));
}
export function updateVoiceActivityNoiseFloorRms(noiseFloorRms: number, rms: number): number {
return noiseFloorRms * (1 - VOICE_ACTIVITY_NOISE_FLOOR_SMOOTHING) + rms * VOICE_ACTIVITY_NOISE_FLOOR_SMOOTHING;
}
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const LOCAL_MIN_RMS = 0.003;
export const LOCAL_MIN_RMS = 0.003;
const LOCAL_DEFAULT_RMS = 0.008;
const LOCAL_MAX_RMS = 0.04;
export const LOCAL_MAX_RMS = 0.04;
const REMOTE_MIN_RMS = 0.002;
const REMOTE_DEFAULT_RMS = 0.006;
const REMOTE_MAX_RMS = 0.035;
@@ -3,6 +3,7 @@
import assert from 'node:assert/strict';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {isVoiceActivityGateEnabled} from '@app/features/voice/utils/VoiceInputProcessor';
import {
getActiveInputDeviceLabel,
resolveVoiceProcessingFromStateForDeviceLabel,
@@ -13,6 +14,8 @@ export interface VoiceEngineV2AppAudioSettingsSnapshot {
readonly inputVolume: number;
readonly outputVolume: number;
readonly vadThreshold: number;
readonly vadAutoSensitivity: boolean;
readonly voiceActivityGate: boolean;
readonly requestedInputDeviceId: string;
readonly effectiveInputDeviceId: string;
readonly activeInputDeviceLabel: string | null;
@@ -39,6 +42,8 @@ function assertAudioSettingsSnapshot(snapshot: VoiceEngineV2AppAudioSettingsSnap
assert.equal(typeof snapshot.inputVolume, 'number', `${name}.inputVolume must be a number`);
assert.equal(typeof snapshot.outputVolume, 'number', `${name}.outputVolume must be a number`);
assert.equal(typeof snapshot.vadThreshold, 'number', `${name}.vadThreshold must be a number`);
assert.equal(typeof snapshot.vadAutoSensitivity, 'boolean', `${name}.vadAutoSensitivity must be a boolean`);
assert.equal(typeof snapshot.voiceActivityGate, 'boolean', `${name}.voiceActivityGate must be a boolean`);
assert.equal(typeof snapshot.requestedInputDeviceId, 'string', `${name}.requestedInputDeviceId must be a string`);
assert.equal(typeof snapshot.effectiveInputDeviceId, 'string', `${name}.effectiveInputDeviceId must be a string`);
assert.equal(typeof snapshot.echoCancellation, 'boolean', `${name}.echoCancellation must be a boolean`);
@@ -54,6 +59,8 @@ export function createVoiceEngineV2AppAudioSettingsSnapshot(): VoiceEngineV2AppA
inputVolume: VoiceSettings.getInputVolume(),
outputVolume: VoiceSettings.getOutputVolume(),
vadThreshold: VoiceSettings.getVadThreshold(),
vadAutoSensitivity: VoiceSettings.getVadAutoSensitivity(),
voiceActivityGate: isVoiceActivityGateEnabled(),
requestedInputDeviceId: VoiceSettings.getInputDeviceId(),
effectiveInputDeviceId: resolveEffectiveInputDeviceId(),
activeInputDeviceLabel,
@@ -90,5 +97,7 @@ export function hasVoiceEngineV2InputProcessorSettingsChanged(
assertAudioSettingsSnapshot(current, 'current');
if (previous.deepFilter !== current.deepFilter) return true;
if (previous.deepFilterNoiseReductionLevel !== current.deepFilterNoiseReductionLevel) return true;
if (previous.voiceActivityGate !== current.voiceActivityGate) return true;
if (previous.vadAutoSensitivity !== current.vadAutoSensitivity) return true;
return false;
}
@@ -806,7 +806,12 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
assertObjectLike<VoiceEngineV2MicrophoneOptions>(options, 'enableMicrophone.options');
if (this.microphoneEnablePromise) {
await this.microphoneEnablePromise;
return;
if (this.hasLiveMicrophonePublication(room) || this.microphoneEnablePromise !== null) {
return;
}
logger.warn('Coalesced microphone enable left no live publication for this room; enabling again', {
channelId,
});
}
this.microphoneEnablePromise = this.enableMicrophoneNow(room, channelId, options);
try {
@@ -1524,6 +1529,9 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
this.syncVoiceState({self_mute: targetMute});
this.updateMediaAudioControls();
this.syncLocalSpeakingOverride(room);
void this.refreshLocalVoiceInputProcessor(room).catch((error) => {
logger.warn('Failed to refresh voice input processor after transmit mode change', {error});
});
}
getMuteReason(voiceState: VoiceState | null, guildId?: string | null, channelId?: string | null): VoiceMuteReason {
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type {Room} from 'livekit-client';
import {expect, test, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: () => null,
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
}));
installVoiceMenuTestBootstrap();
await import('@app/features/voice/engine/MediaEngineFacade');
const {VoiceEngineV2AppMediaExecutionAdapter} = await import(
'@app/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter'
);
interface FakeRoom {
audioTrackPublications: Map<string, unknown>;
localParticipant: {audioTrackPublications: Map<string, unknown>};
}
function createFakeRoom(): FakeRoom {
const audioTrackPublications = new Map<string, unknown>();
return {audioTrackPublications, localParticipant: {audioTrackPublications}};
}
function publishMicrophone(room: FakeRoom): void {
room.audioTrackPublications.set('mic', {
source: 'microphone',
track: {mediaStreamTrack: {readyState: 'live'}},
});
}
function unpublishMicrophone(room: FakeRoom): void {
room.audioTrackPublications.delete('mic');
}
function asRoom(room: FakeRoom): Room {
return room as unknown as Room;
}
function createDeferred(): {promise: Promise<void>; resolve: () => void} {
let resolve = (): void => undefined;
const promise = new Promise<void>((res) => {
resolve = () => res();
});
return {promise, resolve};
}
function createAdapter(onEnable: (room: FakeRoom, channelId: string | null) => Promise<void>): {
adapter: InstanceType<typeof VoiceEngineV2AppMediaExecutionAdapter>;
calls: Array<{room: FakeRoom; channelId: string | null}>;
} {
const adapter = new VoiceEngineV2AppMediaExecutionAdapter();
const calls: Array<{room: FakeRoom; channelId: string | null}> = [];
(
adapter as unknown as {enableMicrophoneNow: (room: Room, channelId: string | null) => Promise<void>}
).enableMicrophoneNow = async (room: Room, channelId: string | null): Promise<void> => {
const fakeRoom = room as unknown as FakeRoom;
calls.push({room: fakeRoom, channelId});
await onEnable(fakeRoom, channelId);
};
return {adapter, calls};
}
test('an enable for a second room is not satisfied by an enable in flight for the first room', async () => {
const roomA = createFakeRoom();
const roomB = createFakeRoom();
const gate = createDeferred();
const {adapter, calls} = createAdapter(async (room) => {
await gate.promise;
publishMicrophone(room);
});
const first = adapter.enableMicrophone(asRoom(roomA), 'channel-a');
await Promise.resolve();
const second = adapter.enableMicrophone(asRoom(roomB), 'channel-b');
gate.resolve();
await Promise.all([first, second]);
expect(calls.map((call) => call.channelId)).toEqual(['channel-a', 'channel-b']);
expect(roomA.audioTrackPublications.size).toBe(1);
expect(roomB.audioTrackPublications.size).toBe(1);
});
test('an enable is not satisfied by an in-flight enable whose publication was already unpublished', async () => {
const room = createFakeRoom();
const gate = createDeferred();
const {adapter, calls} = createAdapter(async (target) => {
publishMicrophone(target);
await gate.promise;
});
const first = adapter.enableMicrophone(asRoom(room), 'channel-a');
await Promise.resolve();
unpublishMicrophone(room);
const second = adapter.enableMicrophone(asRoom(room), 'channel-a');
gate.resolve();
await Promise.all([first, second]);
expect(calls).toHaveLength(2);
expect(room.audioTrackPublications.size).toBe(1);
});
test('a coalesced enable that already has a live publication does not enable again', async () => {
const room = createFakeRoom();
const gate = createDeferred();
const {adapter, calls} = createAdapter(async (target) => {
publishMicrophone(target);
await gate.promise;
});
const first = adapter.enableMicrophone(asRoom(room), 'channel-a');
await Promise.resolve();
const second = adapter.enableMicrophone(asRoom(room), 'channel-a');
gate.resolve();
await Promise.all([first, second]);
expect(calls).toHaveLength(1);
expect(room.audioTrackPublications.size).toBe(1);
});
test('a rejection from the in-flight enable still propagates to the coalesced caller', async () => {
const room = createFakeRoom();
const gate = createDeferred();
const {adapter, calls} = createAdapter(async () => {
await gate.promise;
throw new Error('enable failed');
});
const first = adapter.enableMicrophone(asRoom(room), 'channel-a');
await Promise.resolve();
const second = adapter.enableMicrophone(asRoom(room), 'channel-a');
gate.resolve();
await expect(first).rejects.toThrow('enable failed');
await expect(second).rejects.toThrow('enable failed');
expect(calls).toHaveLength(1);
});
@@ -0,0 +1,208 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PiPContent} from '@app/features/ui/state/PiP';
import type {VoiceMediaGraphSnapshot} from '@app/features/voice/engine/VoiceMediaGraph';
import {type Room, Track} from 'livekit-client';
import {beforeEach, describe, expect, test, vi} from 'vitest';
const viewerStreamKeys = {current: [] as Array<string>};
const pipContent = {current: null as PiPContent | null};
const selfStream = {current: false};
const mediaEngine = {
room: null as Room | null,
participants: {} as Record<string, {identity: string; isScreenShareEnabled: boolean}>,
connectionVoiceStates: {} as Record<string, {channel_id: string}>,
channelId: null as string | null,
guildId: null as string | null,
connectionId: null as string | null,
subscribe: vi.fn(),
};
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/utils/DeferUntilModulesLoaded', () => ({
deferUntilModulesLoaded: vi.fn(),
}));
vi.mock('@app/features/navigation/state/SelectedChannel', () => ({
default: {currentChannelId: null},
}));
vi.mock('@app/features/ui/commands/PiPCommands', () => ({
openPiP: vi.fn(),
closePiP: vi.fn(),
}));
vi.mock('@app/features/ui/state/MobileLayout', () => ({
default: {isMobileLayout: () => false},
}));
vi.mock('@app/features/ui/state/PiP', () => ({
default: {
getContent: () => pipContent.current,
getSessionDisable: () => false,
},
}));
vi.mock('@app/features/user/state/Users', () => ({
default: {currentUser: {id: '1'}},
}));
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({
default: mediaEngine,
}));
vi.mock('@app/features/voice/engine/VoiceMediaGraphStore', () => ({
voiceMediaGraphStore: {
getGraphSnapshot: () =>
({watchIntent: {viewerStreamKeys: viewerStreamKeys.current}}) as unknown as VoiceMediaGraphSnapshot,
subscribe: vi.fn(),
},
}));
vi.mock('@app/features/voice/state/LocalVoiceState', () => ({
default: {
getSelfStream: () => selfStream.current,
subscribe: vi.fn(),
},
}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {disablePictureInPicturePopoutScreenShare: false},
}));
const {detectScreenSharePiPContent} = await import('@app/features/voice/state/ScreenSharePiPController');
function makeParticipant(identity: string, sharing: boolean) {
const publications = new Map<string, {source: Track.Source; isMuted: boolean; trackSid: string}>();
publications.set('camera', {source: Track.Source.Camera, isMuted: false, trackSid: `${identity}-camera`});
if (sharing) {
publications.set('screen', {source: Track.Source.ScreenShare, isMuted: false, trackSid: `${identity}-screen`});
}
return {identity, trackPublications: publications};
}
function setRoom(remoteIdentities: Array<string>) {
mediaEngine.room = {
localParticipant: makeParticipant('user_1_connection-self', false),
remoteParticipants: new Map(remoteIdentities.map((identity) => [identity, makeParticipant(identity, true)])),
} as unknown as Room;
}
beforeEach(() => {
viewerStreamKeys.current = [];
pipContent.current = null;
selfStream.current = false;
mediaEngine.room = null;
mediaEngine.participants = {};
mediaEngine.connectionVoiceStates = {};
mediaEngine.channelId = 'channel-1';
mediaEngine.guildId = 'guild-1';
mediaEngine.connectionId = 'connection-self';
});
describe('detectScreenSharePiPContent', () => {
test('ignores a remote screen share the user is not watching', () => {
setRoom(['user_2_connection-2']);
expect(detectScreenSharePiPContent('channel-1', 'guild-1')).toBeNull();
});
test('returns a remote screen share the user is watching', () => {
setRoom(['user_2_connection-2']);
viewerStreamKeys.current = ['guild-1:channel-1:connection-2'];
expect(detectScreenSharePiPContent('channel-1', 'guild-1')).toEqual({
participantIdentity: 'user_2_connection-2',
userId: '2',
connectionId: 'connection-2',
source: 'livekit',
});
});
test('returns the watched share when several participants are sharing', () => {
setRoom(['user_2_connection-2', 'user_3_connection-3']);
viewerStreamKeys.current = ['guild-1:channel-1:connection-3'];
expect(detectScreenSharePiPContent('channel-1', 'guild-1')).toEqual({
participantIdentity: 'user_3_connection-3',
userId: '3',
connectionId: 'connection-3',
source: 'livekit',
});
});
test('ignores an unwatched participant snapshot screen share', () => {
mediaEngine.participants = {
'user_2_connection-2': {identity: 'user_2_connection-2', isScreenShareEnabled: true},
};
mediaEngine.connectionVoiceStates = {'connection-2': {channel_id: 'channel-1'}};
expect(detectScreenSharePiPContent('channel-1', 'guild-1')).toBeNull();
});
test('returns a watched participant snapshot screen share', () => {
mediaEngine.participants = {
'user_2_connection-2': {identity: 'user_2_connection-2', isScreenShareEnabled: true},
};
mediaEngine.connectionVoiceStates = {'connection-2': {channel_id: 'channel-1'}};
viewerStreamKeys.current = ['guild-1:channel-1:connection-2'];
expect(detectScreenSharePiPContent('channel-1', 'guild-1')).toEqual({
participantIdentity: 'user_2_connection-2',
userId: '2',
connectionId: 'connection-2',
source: 'participant-snapshot',
});
});
test('ignores an unwatched remote screen share in a DM call', () => {
setRoom(['user_2_connection-2']);
mediaEngine.guildId = null;
expect(detectScreenSharePiPContent('channel-1', null)).toBeNull();
});
test('returns a watched remote screen share in a DM call', () => {
setRoom(['user_2_connection-2']);
mediaEngine.guildId = null;
viewerStreamKeys.current = ['dm:channel-1:connection-2'];
expect(detectScreenSharePiPContent('channel-1', null)).toEqual({
participantIdentity: 'user_2_connection-2',
userId: '2',
connectionId: 'connection-2',
source: 'livekit',
});
});
test('keeps returning the local share while watch intent is empty', () => {
selfStream.current = true;
setRoom([]);
expect(detectScreenSharePiPContent('channel-1', 'guild-1')).toEqual({
participantIdentity: 'user_1_connection-self',
userId: '1',
connectionId: 'connection-self',
source: 'local-self-state',
});
});
test('keeps an open PiP alive for a watched stream that stopped publishing', () => {
pipContent.current = {
type: 'stream',
participantIdentity: 'user_2_connection-2',
channelId: 'channel-1',
guildId: 'guild-1',
connectionId: 'connection-2',
userId: '2',
};
viewerStreamKeys.current = ['guild-1:channel-1:connection-2'];
expect(detectScreenSharePiPContent('channel-1', 'guild-1')).toEqual({
participantIdentity: 'user_2_connection-2',
userId: '2',
connectionId: 'connection-2',
source: 'watched-stream',
});
});
});
@@ -54,7 +54,16 @@ function buildScreenShare(
return {...screenShare, source};
}
function pickLiveKitScreenShare(room: Room, channelId: string): ScreenSharePiPScreenShare | null {
function isWatchedScreenShare(guildId: string | null, channelId: string, connectionId: string): boolean {
const streamKey = getStreamKey(guildId, channelId, connectionId);
return selectVoiceMediaGraphViewerStreamKeys(voiceMediaGraphStore.getGraphSnapshot()).includes(streamKey);
}
function pickLiveKitScreenShare(
room: Room,
channelId: string,
guildId: string | null,
): ScreenSharePiPScreenShare | null {
const participants = [room.localParticipant, ...Array.from(room.remoteParticipants.values())];
for (const participant of participants) {
for (const publication of participant.trackPublications.values()) {
@@ -62,6 +71,7 @@ function pickLiveKitScreenShare(room: Room, channelId: string): ScreenSharePiPSc
if (publication.isMuted) continue;
const parsed = parseVoiceParticipantIdentity(participant.identity);
if (!parsed.userId || !parsed.connectionId) continue;
if (!isWatchedScreenShare(guildId, channelId, parsed.connectionId)) continue;
return buildScreenShare(
{
participantIdentity: participant.identity,
@@ -72,11 +82,13 @@ function pickLiveKitScreenShare(room: Room, channelId: string): ScreenSharePiPSc
);
}
}
void channelId;
return null;
}
function pickRemoteScreenShareFromSnapshots(channelId: string): ScreenSharePiPScreenShare | null {
function pickRemoteScreenShareFromSnapshots(
channelId: string,
guildId: string | null,
): ScreenSharePiPScreenShare | null {
const snapshots = MediaEngine.participants;
const connectionVoiceStates = MediaEngine.connectionVoiceStates;
for (const participantIdentity in snapshots) {
@@ -87,6 +99,7 @@ function pickRemoteScreenShareFromSnapshots(channelId: string): ScreenSharePiPSc
if (!parsed.userId || !parsed.connectionId) continue;
const voiceState = connectionVoiceStates[parsed.connectionId];
if (voiceState && voiceState.channel_id !== channelId) continue;
if (!isWatchedScreenShare(guildId, channelId, parsed.connectionId)) continue;
return buildScreenShare(
{
participantIdentity: snapshot.identity,
@@ -116,16 +129,16 @@ function pickLocalSelfShare(channelId: string): ScreenSharePiPScreenShare | null
);
}
function detectActiveScreenShare(channelId: string | null): ScreenSharePiPScreenShare | null {
function detectActiveScreenShare(channelId: string | null, guildId: string | null): ScreenSharePiPScreenShare | null {
if (!channelId) return null;
const local = pickLocalSelfShare(channelId);
if (local) return local;
const room = MediaEngine.room;
if (room) {
const fromRoom = pickLiveKitScreenShare(room, channelId);
const fromRoom = pickLiveKitScreenShare(room, channelId, guildId);
if (fromRoom) return fromRoom;
}
const fromSnapshots = pickRemoteScreenShareFromSnapshots(channelId);
const fromSnapshots = pickRemoteScreenShareFromSnapshots(channelId, guildId);
if (fromSnapshots) return fromSnapshots;
return null;
}
@@ -150,16 +163,16 @@ function pickWatchedScreenShareFromOpenPiP(
);
}
function detectScreenSharePiPContent(
export function detectScreenSharePiPContent(
channelId: string | null,
guildId: string | null,
): ScreenSharePiPScreenShare | null {
return detectActiveScreenShare(channelId) ?? pickWatchedScreenShareFromOpenPiP(channelId, guildId);
return detectActiveScreenShare(channelId, guildId) ?? pickWatchedScreenShareFromOpenPiP(channelId, guildId);
}
function getClosedReason(conditions: ScreenSharePiPConditions): string | null {
if (!conditions.connectedChannelId) return 'not-connected-to-voice';
if (!conditions.screenShare) return 'no-active-screen-share';
if (!conditions.screenShare) return 'no-watched-screen-share';
if (conditions.isMobile) return 'mobile-layout';
if (conditions.disabledBySetting) return 'disabled-by-setting';
if (conditions.disabledBySession) return 'disabled-for-session';
@@ -272,6 +285,7 @@ class ScreenSharePiPController {
);
MediaEngine.subscribe(() => this.recompute('media-engine-store'));
LocalVoiceState.subscribe(() => this.recompute('local-voice-state'));
voiceMediaGraphStore.subscribe(() => this.recompute('voice-media-graph'));
}
private readReactiveInputs(): ReactiveInputs {
@@ -1,6 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Keybind from '@app/features/input/state/InputKeybind';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {
resolveVoiceActivityGateState,
resolveVoiceActivityThresholdRms,
updateVoiceActivityNoiseFloorRms,
} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
import {SPEAKING_LOCAL_RELEASE_MS} from '@app/features/voice/engine/VoiceSpeakingThreshold';
import {computeSpeakingDetectorRms} from '@app/features/voice/engine/v2/VoiceEngineV2AppMicrophoneTransaction';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import {buildDeepFilterAudioChain, type DeepFilterAudioChain} from '@app/features/voice/utils/DeepFilterNoiseProcessor';
import {
@@ -12,6 +20,10 @@ import {inputVoiceVolumePercentToGain} from '@app/features/voice/utils/VoiceVolu
import type {AudioProcessorOptions, LocalAudioTrack, Track, TrackProcessor} from 'livekit-client';
const logger = new Logger('VoiceInputProcessor');
const GATE_ANALYSER_FFT_SIZE = 256;
const GATE_TICK_INTERVAL_MS = 50;
const GATE_ATTACK_TIME_CONSTANT = 0.005;
const GATE_RELEASE_TIME_CONSTANT = 0.02;
class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio, AudioProcessorOptions> {
name = 'fluxer-voice-input-processor';
@@ -20,17 +32,26 @@ class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio, Audio
private gainNode: GainNode | null = null;
private passthroughDestination: MediaStreamAudioDestinationNode | null = null;
private deepFilterChain: DeepFilterAudioChain | null = null;
private gateNode: GainNode | null = null;
private gateAnalyserNode: AnalyserNode | null = null;
private gateSamples: Uint8Array<ArrayBuffer> | null = null;
private gateTimerId: number | null = null;
private gateOpen = false;
private gateSilenceStartedAtMs: number | null = null;
private gateNoiseFloorRms = 0;
constructor(
private inputVolumePercent: number,
private deepFilterEnabled: boolean,
private deepFilterNoiseReductionLevel: number,
private gateEnabled: boolean,
) {}
matchesMode(deepFilterEnabled: boolean, deepFilterNoiseReductionLevel: number): boolean {
matchesMode(deepFilterEnabled: boolean, deepFilterNoiseReductionLevel: number, gateEnabled: boolean): boolean {
return (
this.deepFilterEnabled === deepFilterEnabled &&
this.deepFilterNoiseReductionLevel === deepFilterNoiseReductionLevel
this.deepFilterNoiseReductionLevel === deepFilterNoiseReductionLevel &&
this.gateEnabled === gateEnabled
);
}
@@ -60,18 +81,19 @@ class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio, Audio
this.gainNode = opts.audioContext.createGain();
this.gainNode.gain.value = inputVoiceVolumePercentToGain(this.inputVolumePercent);
this.sourceNode.connect(this.gainNode);
const chainTail = this.gateEnabled ? this.startVoiceActivityGate(opts.audioContext) : this.gainNode;
if (this.deepFilterEnabled) {
const chain = await buildDeepFilterAudioChain({
audioContext: opts.audioContext,
noiseReductionLevel: this.deepFilterNoiseReductionLevel,
});
this.deepFilterChain = chain;
this.gainNode.connect(chain.inputDestination);
chainTail.connect(chain.inputDestination);
this.processedTrack = chain.processedTrack;
return;
}
this.passthroughDestination = opts.audioContext.createMediaStreamDestination();
this.gainNode.connect(this.passthroughDestination);
chainTail.connect(this.passthroughDestination);
const passthroughTrack = this.passthroughDestination.stream.getAudioTracks()[0];
if (!passthroughTrack) {
throw new Error('Voice input processor produced no passthrough output track');
@@ -83,9 +105,70 @@ class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio, Audio
}
}
private startVoiceActivityGate(audioContext: BaseAudioContext): AudioNode {
const gainNode = this.gainNode;
if (!gainNode || !this.sourceNode) {
throw new Error('Voice input processor gate requires a built input chain');
}
const gateNode = audioContext.createGain();
gateNode.gain.value = 0;
gainNode.connect(gateNode);
const analyserNode = audioContext.createAnalyser();
analyserNode.fftSize = GATE_ANALYSER_FFT_SIZE;
this.sourceNode.connect(analyserNode);
this.gateNode = gateNode;
this.gateAnalyserNode = analyserNode;
this.gateSamples = new Uint8Array(analyserNode.fftSize);
this.gateOpen = false;
this.gateSilenceStartedAtMs = null;
this.gateNoiseFloorRms = 0;
this.gateTimerId = window.setInterval(() => {
this.tickVoiceActivityGate(audioContext);
}, GATE_TICK_INTERVAL_MS);
return gateNode;
}
private tickVoiceActivityGate(audioContext: BaseAudioContext): void {
const analyserNode = this.gateAnalyserNode;
const samples = this.gateSamples;
const gateNode = this.gateNode;
if (!analyserNode || !samples || !gateNode) return;
analyserNode.getByteTimeDomainData(samples);
const rms = computeSpeakingDetectorRms(samples);
const next = resolveVoiceActivityGateState({
rms,
thresholdRms: resolveVoiceActivityThresholdRms({
autoSensitivity: VoiceSettings.getVadAutoSensitivity(),
vadThreshold: VoiceSettings.getVadThreshold(),
noiseFloorRms: this.gateNoiseFloorRms,
}),
nowMs: performance.now(),
silenceStartedAtMs: this.gateSilenceStartedAtMs,
gateOpen: this.gateOpen,
releaseDelayMs: SPEAKING_LOCAL_RELEASE_MS,
});
this.gateSilenceStartedAtMs = next.silenceStartedAtMs;
if (!next.open) {
this.gateNoiseFloorRms = updateVoiceActivityNoiseFloorRms(this.gateNoiseFloorRms, rms);
}
if (next.open === this.gateOpen) return;
this.gateOpen = next.open;
gateNode.gain.setTargetAtTime(
next.open ? 1 : 0,
audioContext.currentTime,
next.open ? GATE_ATTACK_TIME_CONSTANT : GATE_RELEASE_TIME_CONSTANT,
);
}
private async teardown(): Promise<void> {
if (this.gateTimerId !== null) {
window.clearInterval(this.gateTimerId);
this.gateTimerId = null;
}
this.sourceNode?.disconnect();
this.gainNode?.disconnect();
this.gateNode?.disconnect();
this.gateAnalyserNode?.disconnect();
this.passthroughDestination?.disconnect();
if (this.deepFilterChain) {
try {
@@ -103,6 +186,12 @@ class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio, Audio
}
this.sourceNode = null;
this.gainNode = null;
this.gateNode = null;
this.gateAnalyserNode = null;
this.gateSamples = null;
this.gateOpen = false;
this.gateSilenceStartedAtMs = null;
this.gateNoiseFloorRms = 0;
this.passthroughDestination = null;
this.deepFilterChain = null;
this.processedTrack = undefined;
@@ -117,8 +206,16 @@ function resolveActiveVoiceProcessing(): ResolvedVoiceProcessing {
return resolveVoiceProcessingFromStateForDeviceLabel(VoiceSettings, label);
}
export function isVoiceActivityGateEnabled(): boolean {
if (Keybind.transmitMode !== 'voice_activity') return false;
if (Keybind.isPushToMuteEffective()) return false;
return resolveActiveVoiceProcessing().mode !== 'studio';
}
function shouldUseVoiceInputProcessor(): boolean {
return resolveActiveVoiceProcessing().deepFilter || VoiceSettings.getInputVolume() !== 100;
return (
resolveActiveVoiceProcessing().deepFilter || VoiceSettings.getInputVolume() !== 100 || isVoiceActivityGateEnabled()
);
}
export async function syncVoiceInputProcessor(track: LocalAudioTrack | null): Promise<void> {
@@ -130,16 +227,25 @@ export async function syncVoiceInputProcessor(track: LocalAudioTrack | null): Pr
const deepFilterEnabled = profile.deepFilter;
const deepFilterNoiseReductionLevel = profile.deepFilterNoiseReductionLevel;
const inputVolumePercent = VoiceSettings.getInputVolume();
const gateEnabled = isVoiceActivityGateEnabled();
if (!shouldUseVoiceInputProcessor()) {
await removeVoiceInputProcessor(track);
return;
}
if (activeTrack === track && activeProcessor?.matchesMode(deepFilterEnabled, deepFilterNoiseReductionLevel)) {
if (
activeTrack === track &&
activeProcessor?.matchesMode(deepFilterEnabled, deepFilterNoiseReductionLevel, gateEnabled)
) {
activeProcessor.updateInputVolumePercent(inputVolumePercent);
return;
}
await removeVoiceInputProcessor();
const processor = new VoiceInputTrackProcessor(inputVolumePercent, deepFilterEnabled, deepFilterNoiseReductionLevel);
const processor = new VoiceInputTrackProcessor(
inputVolumePercent,
deepFilterEnabled,
deepFilterNoiseReductionLevel,
gateEnabled,
);
try {
await track.setProcessor(processor);
} catch (error) {
@@ -157,7 +263,7 @@ export async function syncVoiceInputProcessor(track: LocalAudioTrack | null): Pr
}
activeTrack = track;
activeProcessor = processor;
logger.debug('Applied voice input processor', {inputVolumePercent, deepFilterEnabled});
logger.debug('Applied voice input processor', {inputVolumePercent, deepFilterEnabled, gateEnabled});
}
export function updateVoiceInputGain(track: LocalAudioTrack | null): void {