mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(voice): scope disconnects, correct VAD and stream lifecycle (#2597)
This commit is contained in:
1 parent
667ac7da8e
commit
20a15ac11d
15 files changed
+1069
-36
No files matched your search
+183
@@ -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);
|
||||
});
|
||||
});
|
||||
+42
-3
@@ -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 {
|
||||
|
||||
+146
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user