mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(voice): judge local speaking from the sent audio (#3503)
This commit is contained in:
1 parent
b9ea837c75
commit
3224f2113a
13 files changed
+581
-215
No files matched your search
@@ -1,16 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type LocalSpeakingOverrideInput,
|
isLocalSpeakingSuppressed,
|
||||||
resolveLocalSpeakingOverrideState,
|
type LocalSpeakingSuppressionInput,
|
||||||
} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
|
} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
|
||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
function input(overrides: Partial<LocalSpeakingOverrideInput>): LocalSpeakingOverrideInput {
|
function input(overrides: Partial<LocalSpeakingSuppressionInput>): LocalSpeakingSuppressionInput {
|
||||||
return {
|
return {
|
||||||
pushToTalkActive: true,
|
|
||||||
pushToMuteActive: false,
|
|
||||||
pushToMuteHeld: false,
|
|
||||||
selfDeaf: false,
|
selfDeaf: false,
|
||||||
effectiveSelfMute: false,
|
effectiveSelfMute: false,
|
||||||
hasMicrophonePublication: true,
|
hasMicrophonePublication: true,
|
||||||
@@ -19,15 +16,15 @@ function input(overrides: Partial<LocalSpeakingOverrideInput>): LocalSpeakingOve
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('resolveLocalSpeakingOverrideState in push to talk', () => {
|
describe('isLocalSpeakingSuppressed', () => {
|
||||||
it('never lights the speaking indicator for a self-muted user holding the key', () => {
|
it('lets the measured outgoing audio decide for a live, unmuted microphone', () => {
|
||||||
expect(resolveLocalSpeakingOverrideState(input({effectiveSelfMute: true}))).toBe(false);
|
expect(isLocalSpeakingSuppressed(input({}))).toBe(false);
|
||||||
expect(resolveLocalSpeakingOverrideState(input({effectiveSelfMute: true, microphonePublicationMuted: true}))).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lights the speaking indicator for an unmuted user holding the key', () => {
|
it('suppresses the indicator whenever nothing is sent', () => {
|
||||||
expect(resolveLocalSpeakingOverrideState(input({}))).toBe(true);
|
expect(isLocalSpeakingSuppressed(input({effectiveSelfMute: true}))).toBe(true);
|
||||||
|
expect(isLocalSpeakingSuppressed(input({selfDeaf: true}))).toBe(true);
|
||||||
|
expect(isLocalSpeakingSuppressed(input({hasMicrophonePublication: false}))).toBe(true);
|
||||||
|
expect(isLocalSpeakingSuppressed(input({microphonePublicationMuted: true}))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
export interface LocalSpeakingOverrideInput {
|
export interface LocalSpeakingSuppressionInput {
|
||||||
pushToTalkActive: boolean;
|
|
||||||
pushToMuteActive: boolean;
|
|
||||||
pushToMuteHeld: boolean;
|
|
||||||
selfDeaf: boolean;
|
selfDeaf: boolean;
|
||||||
effectiveSelfMute: boolean;
|
effectiveSelfMute: boolean;
|
||||||
hasMicrophonePublication: boolean;
|
hasMicrophonePublication: boolean;
|
||||||
microphonePublicationMuted: boolean;
|
microphonePublicationMuted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveLocalSpeakingOverrideState(input: LocalSpeakingOverrideInput): boolean | null {
|
export function isLocalSpeakingSuppressed(input: LocalSpeakingSuppressionInput): boolean {
|
||||||
const effectivelyMuted =
|
return (
|
||||||
input.selfDeaf || input.effectiveSelfMute || !input.hasMicrophonePublication || input.microphonePublicationMuted;
|
input.selfDeaf || input.effectiveSelfMute || !input.hasMicrophonePublication || input.microphonePublicationMuted
|
||||||
if (input.pushToTalkActive) return !effectivelyMuted;
|
);
|
||||||
if (input.pushToMuteActive && input.pushToMuteHeld) return false;
|
|
||||||
if (effectivelyMuted) return false;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {SPEAKING_REMOTE_ATTACK_MS, SPEAKING_REMOTE_RELEASE_MS} from '@app/features/voice/engine/VoiceSpeakingThreshold';
|
import {advanceSpeakingHysteresis, type SpeakingHysteresis} from '@app/features/voice/engine/VoiceSpeakingThreshold';
|
||||||
import type {VoiceConnectionQuality} from '@app/features/voice/engine/VoiceTrackSource';
|
import type {VoiceConnectionQuality} from '@app/features/voice/engine/VoiceTrackSource';
|
||||||
import {assign, initialTransition, type SnapshotFrom, setup, transition} from 'xstate';
|
import {assign, initialTransition, type SnapshotFrom, setup, transition} from 'xstate';
|
||||||
|
|
||||||
@@ -25,12 +25,9 @@ export type LivekitParticipantSnapshot = Readonly<{
|
|||||||
lastSpokeAt: number | null;
|
lastSpokeAt: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
interface RemoteSpeakingAnalyserState {
|
interface RemoteSpeakingAnalyserState extends SpeakingHysteresis {
|
||||||
identity: string;
|
identity: string;
|
||||||
track: unknown;
|
track: unknown;
|
||||||
speaking: boolean;
|
|
||||||
belowSinceMs: number | null;
|
|
||||||
aboveSinceMs: number | null;
|
|
||||||
playbackBoost: number;
|
playbackBoost: number;
|
||||||
appliedBoost: number;
|
appliedBoost: number;
|
||||||
}
|
}
|
||||||
@@ -172,29 +169,12 @@ function tickRemoteAnalyser(
|
|||||||
if (event.trackEnded) return detachRemoteAnalyser(context, event.identity);
|
if (event.trackEnded) return detachRemoteAnalyser(context, event.identity);
|
||||||
|
|
||||||
const commands: Array<VoiceRemoteSpeakingCommand> = [];
|
const commands: Array<VoiceRemoteSpeakingCommand> = [];
|
||||||
let entry = existing;
|
let entry: RemoteSpeakingAnalyserState = {
|
||||||
if (event.rms >= event.threshold) {
|
...existing,
|
||||||
const aboveSinceMs = entry.aboveSinceMs ?? event.nowMs;
|
...advanceSpeakingHysteresis(existing, event.rms, event.threshold, event.nowMs),
|
||||||
entry = {
|
};
|
||||||
...entry,
|
if (entry.speaking !== existing.speaking) {
|
||||||
belowSinceMs: null,
|
commands.push({type: 'setAudioLevelSpeaking', identity: event.identity, speaking: entry.speaking});
|
||||||
aboveSinceMs,
|
|
||||||
};
|
|
||||||
if (!entry.speaking && event.nowMs - aboveSinceMs >= SPEAKING_REMOTE_ATTACK_MS) {
|
|
||||||
entry = {...entry, speaking: true};
|
|
||||||
commands.push({type: 'setAudioLevelSpeaking', identity: event.identity, speaking: true});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const belowSinceMs = entry.belowSinceMs ?? event.nowMs;
|
|
||||||
entry = {
|
|
||||||
...entry,
|
|
||||||
aboveSinceMs: null,
|
|
||||||
belowSinceMs,
|
|
||||||
};
|
|
||||||
if (entry.speaking && event.nowMs - belowSinceMs >= SPEAKING_REMOTE_RELEASE_MS) {
|
|
||||||
entry = {...entry, speaking: false};
|
|
||||||
commands.push({type: 'setAudioLevelSpeaking', identity: event.identity, speaking: false});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const boostUpdate = updateRemotePlaybackBoost(entry, event.rms, event.threshold, event.nowMs);
|
const boostUpdate = updateRemotePlaybackBoost(entry, event.rms, event.threshold, event.nowMs);
|
||||||
entry = boostUpdate.entry;
|
entry = boostUpdate.entry;
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import gateWorkletSource from '@app/features/voice/utils/voiceGateProcessor.worklet.js?raw';
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 48000;
|
||||||
|
const QUANTUM = 128;
|
||||||
|
const TICK_SAMPLES = SAMPLE_RATE / 20;
|
||||||
|
const ANALYSER_WINDOW = 512;
|
||||||
|
|
||||||
|
const audio = vi.hoisted(() => ({window: new Float32Array(512), state: 'running'}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/state/VoiceSettings', () => ({default: {getVadThreshold: () => 50}}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/engine/VoiceSharedAudioContext', () => ({
|
||||||
|
getSharedVoiceAudioContext: () => ({
|
||||||
|
get state() {
|
||||||
|
return audio.state;
|
||||||
|
},
|
||||||
|
createMediaStreamSource: () => ({connect: () => undefined, disconnect: () => undefined}),
|
||||||
|
createAnalyser: () => ({
|
||||||
|
fftSize: 0,
|
||||||
|
smoothingTimeConstant: 0,
|
||||||
|
disconnect: () => undefined,
|
||||||
|
getFloatTimeDomainData: (target: Float32Array) => target.set(audio.window),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {LOCAL_AUTO_MIN_RMS, LOCAL_MAX_RMS, SPEAKING_LOCAL_RELEASE_MS, getLocalSpeakingThresholdRms} = await import(
|
||||||
|
'@app/features/voice/engine/VoiceSpeakingThreshold'
|
||||||
|
);
|
||||||
|
const {computeTimeDomainRms, readSpeakingThresholdRms, VoiceSpeakingMeter} = await import(
|
||||||
|
'@app/features/voice/engine/VoiceSpeakingDetection'
|
||||||
|
);
|
||||||
|
const {createVoiceRemoteSpeakingSnapshot, transitionVoiceRemoteSpeakingSnapshot, clearVoiceRemoteSpeakingCommands} =
|
||||||
|
await import('@app/features/voice/engine/VoiceParticipantStateMachine');
|
||||||
|
|
||||||
|
interface GateProcessor {
|
||||||
|
speaking: boolean;
|
||||||
|
process(inputs: Array<Array<Float32Array>>, outputs: Array<Array<Float32Array>>): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GateConstructor = new (options: {processorOptions: Record<string, unknown>}) => GateProcessor;
|
||||||
|
|
||||||
|
interface GateMode {
|
||||||
|
apply: boolean;
|
||||||
|
auto: boolean;
|
||||||
|
slider?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGate(mode: GateMode): GateProcessor {
|
||||||
|
let Processor: GateConstructor | null = null;
|
||||||
|
const sandbox = vm.createContext({
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
currentTime: 0,
|
||||||
|
AudioWorkletProcessor: class {
|
||||||
|
port = {postMessage: () => undefined, onmessage: null};
|
||||||
|
},
|
||||||
|
registerProcessor: (_name: string, processor: GateConstructor) => {
|
||||||
|
Processor = processor;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(gateWorkletSource, sandbox);
|
||||||
|
if (!Processor) throw new Error('voice gate worklet did not register');
|
||||||
|
return new (Processor as GateConstructor)({
|
||||||
|
processorOptions: {
|
||||||
|
apply: mode.apply,
|
||||||
|
auto: mode.auto,
|
||||||
|
manualThresholdRms: getLocalSpeakingThresholdRms(mode.slider ?? 50),
|
||||||
|
channelCount: 1,
|
||||||
|
minRms: LOCAL_AUTO_MIN_RMS,
|
||||||
|
maxRms: LOCAL_MAX_RMS,
|
||||||
|
releaseMs: SPEAKING_LOCAL_RELEASE_MS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRandom(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state = (state + 0x6d2b79f5) >>> 0;
|
||||||
|
let value = state;
|
||||||
|
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||||
|
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||||
|
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIFORM_RMS_TO_PEAK = Math.sqrt(3);
|
||||||
|
|
||||||
|
function noise(random: () => number, rms: number): number {
|
||||||
|
return (random() * 2 - 1) * rms * UNIFORM_RMS_TO_PEAK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syllable(index: number, periodSamples: number, onSamples: number): boolean {
|
||||||
|
return index % periodSamples < onSamples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tone(index: number, rms: number): number {
|
||||||
|
return Math.sin((2 * Math.PI * 180 * index) / SAMPLE_RATE) * rms * Math.SQRT2;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Timeline {
|
||||||
|
gateSpeaking: boolean;
|
||||||
|
localSpeaking: Array<boolean>;
|
||||||
|
remoteSpeaking: Array<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPipeline(mode: GateMode, seconds: number, signal: (index: number) => number, transmit = true): Timeline {
|
||||||
|
const gate = loadGate(mode);
|
||||||
|
const track = {readyState: 'live'} as MediaStreamTrack;
|
||||||
|
const meter = new VoiceSpeakingMeter(
|
||||||
|
() => track,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
let remote = transitionVoiceRemoteSpeakingSnapshot(createVoiceRemoteSpeakingSnapshot(), {
|
||||||
|
type: 'remote.attach',
|
||||||
|
identity: 'peer',
|
||||||
|
track: 'track',
|
||||||
|
});
|
||||||
|
const history = new Float32Array(ANALYSER_WINDOW);
|
||||||
|
const timeline: Timeline = {gateSpeaking: false, localSpeaking: [], remoteSpeaking: []};
|
||||||
|
const seedEnd = 0.3 * SAMPLE_RATE;
|
||||||
|
const total = Math.floor(seconds * SAMPLE_RATE);
|
||||||
|
let nextTick = TICK_SAMPLES;
|
||||||
|
for (let start = 0; start < total; start += QUANTUM) {
|
||||||
|
const input = new Float32Array(QUANTUM);
|
||||||
|
for (let offset = 0; offset < QUANTUM; offset++) input[offset] = signal(start + offset);
|
||||||
|
const output = new Float32Array(QUANTUM);
|
||||||
|
gate.process([[input]], [[output]]);
|
||||||
|
if (start >= seedEnd && gate.speaking) timeline.gateSpeaking = true;
|
||||||
|
history.copyWithin(0, QUANTUM);
|
||||||
|
history.set(transmit ? output : new Float32Array(QUANTUM), ANALYSER_WINDOW - QUANTUM);
|
||||||
|
const end = start + QUANTUM;
|
||||||
|
if (end < nextTick) continue;
|
||||||
|
nextTick += TICK_SAMPLES;
|
||||||
|
const nowMs = (end / SAMPLE_RATE) * 1000;
|
||||||
|
audio.window.set(history);
|
||||||
|
meter.tick(nowMs);
|
||||||
|
remote = transitionVoiceRemoteSpeakingSnapshot(remote, {
|
||||||
|
type: 'remote.tick',
|
||||||
|
identity: 'peer',
|
||||||
|
rms: computeTimeDomainRms(history),
|
||||||
|
threshold: readSpeakingThresholdRms(),
|
||||||
|
nowMs,
|
||||||
|
});
|
||||||
|
remote = clearVoiceRemoteSpeakingCommands(remote);
|
||||||
|
timeline.localSpeaking.push(meter.speaking);
|
||||||
|
timeline.remoteSpeaking.push(remote.context.analysers.get('peer')?.speaking ?? false);
|
||||||
|
}
|
||||||
|
meter.dispose();
|
||||||
|
return timeline;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VAD_AUTO: GateMode = {apply: true, auto: true};
|
||||||
|
const VAD_MANUAL: GateMode = {apply: true, auto: false, slider: 50};
|
||||||
|
const VAD_MANUAL_SENSITIVE: GateMode = {apply: true, auto: false, slider: 10};
|
||||||
|
const PUSH_TO_TALK: GateMode = {apply: false, auto: true};
|
||||||
|
const MODES: Array<[string, GateMode]> = [
|
||||||
|
['voice activity with auto sensitivity', VAD_AUTO],
|
||||||
|
['voice activity with a manual threshold', VAD_MANUAL],
|
||||||
|
['voice activity with a sensitive manual threshold', VAD_MANUAL_SENSITIVE],
|
||||||
|
['push to talk', PUSH_TO_TALK],
|
||||||
|
];
|
||||||
|
|
||||||
|
function suppressionResidue(seed: number): (index: number) => number {
|
||||||
|
const random = createRandom(seed);
|
||||||
|
return (index) => {
|
||||||
|
const click = syllable(index, 0.3 * SAMPLE_RATE, 0.02 * SAMPLE_RATE);
|
||||||
|
return noise(random, click ? 0.001 : 0.00003);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hardwareMutedWhilePeersTalk(seed: number): (index: number) => number {
|
||||||
|
const random = createRandom(seed);
|
||||||
|
return (index) => {
|
||||||
|
if (index < SAMPLE_RATE) return 0;
|
||||||
|
const peerTalking = syllable(index, 0.25 * SAMPLE_RATE, 0.15 * SAMPLE_RATE);
|
||||||
|
return peerTalking ? tone(index, 0.00004) + noise(random, 0.00001) : 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function speechInRoomNoise(seed: number): (index: number) => number {
|
||||||
|
const random = createRandom(seed);
|
||||||
|
return (index) => {
|
||||||
|
const room = noise(random, 0.0003);
|
||||||
|
if (index < SAMPLE_RATE) return room;
|
||||||
|
return syllable(index, 0.2 * SAMPLE_RATE, 0.15 * SAMPLE_RATE) ? room + tone(index, 0.05) : room;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('local speaking indicator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('MediaStream', class {});
|
||||||
|
audio.window.fill(0);
|
||||||
|
audio.state = 'running';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(MODES)('stays dark on noise left behind by noise suppression in %s', (_name, mode) => {
|
||||||
|
const timeline = runPipeline(mode, 3, suppressionResidue(1));
|
||||||
|
expect(timeline.localSpeaking).not.toContain(true);
|
||||||
|
expect(timeline.remoteSpeaking).not.toContain(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(MODES)('stays dark when a hardware-muted mic only carries peer bleed in %s', (_name, mode) => {
|
||||||
|
const timeline = runPipeline(mode, 4, hardwareMutedWhilePeersTalk(2));
|
||||||
|
expect(timeline.localSpeaking).not.toContain(true);
|
||||||
|
expect(timeline.remoteSpeaking).not.toContain(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(MODES)('lights for real speech in %s', (_name, mode) => {
|
||||||
|
const timeline = runPipeline(mode, 3, speechInRoomNoise(3));
|
||||||
|
expect(timeline.localSpeaking).toContain(true);
|
||||||
|
expect(timeline.localSpeaking.slice(0, 18)).not.toContain(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(MODES)('agrees tick for tick with how peers judge the same audio in %s', (_name, mode) => {
|
||||||
|
for (const signal of [suppressionResidue(4), hardwareMutedWhilePeersTalk(5), speechInRoomNoise(6)]) {
|
||||||
|
const timeline = runPipeline(mode, 3, signal);
|
||||||
|
expect(timeline.localSpeaking).toEqual(timeline.remoteSpeaking);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lights while the outgoing track is muted, even for loud speech', () => {
|
||||||
|
for (const [, mode] of MODES) {
|
||||||
|
const timeline = runPipeline(mode, 3, speechInRoomNoise(7), false);
|
||||||
|
expect(timeline.localSpeaking).not.toContain(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the transmit gate opening on those inaudible inputs, so the gate cannot drive the indicator', () => {
|
||||||
|
expect(runPipeline(VAD_AUTO, 3, suppressionResidue(1)).gateSpeaking).toBe(true);
|
||||||
|
expect(runPipeline(VAD_AUTO, 4, hardwareMutedWhilePeersTalk(2)).gateSpeaking).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not hold a stale reading while the analysing context is not running', () => {
|
||||||
|
const track = {readyState: 'live'} as MediaStreamTrack;
|
||||||
|
const meter = new VoiceSpeakingMeter(
|
||||||
|
() => track,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
audio.window.fill(0.1);
|
||||||
|
meter.tick(0);
|
||||||
|
meter.tick(50);
|
||||||
|
expect(meter.speaking).toBe(true);
|
||||||
|
audio.state = 'suspended';
|
||||||
|
for (let nowMs = 100; nowMs <= 400; nowMs += 50) meter.tick(nowMs);
|
||||||
|
expect(meter.speaking).toBe(false);
|
||||||
|
meter.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
|
import {getSharedVoiceAudioContext} from '@app/features/voice/engine/VoiceSharedAudioContext';
|
||||||
|
import {
|
||||||
|
advanceSpeakingHysteresis,
|
||||||
|
getRemoteSpeakingThresholdRms,
|
||||||
|
SILENT_SPEAKING_HYSTERESIS,
|
||||||
|
type SpeakingHysteresis,
|
||||||
|
} from '@app/features/voice/engine/VoiceSpeakingThreshold';
|
||||||
|
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||||
|
|
||||||
|
const logger = new Logger('VoiceSpeakingDetection');
|
||||||
|
export const SPEAKING_ANALYSER_INTERVAL_MS = 50;
|
||||||
|
const SPEAKING_ANALYSER_FFT_SIZE = 512;
|
||||||
|
const SPEAKING_ANALYSER_SMOOTHING = 0.2;
|
||||||
|
|
||||||
|
export function computeTimeDomainRms(samples: Float32Array): number {
|
||||||
|
if (samples.length === 0) return 0;
|
||||||
|
let sumSquares = 0;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const sample = samples[i]!;
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
}
|
||||||
|
return Math.sqrt(sumSquares / samples.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSpeakingThresholdRms(): number {
|
||||||
|
return getRemoteSpeakingThresholdRms(VoiceSettings.getVadThreshold());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpeakingAnalyser(context: BaseAudioContext): AnalyserNode {
|
||||||
|
const analyser = context.createAnalyser();
|
||||||
|
analyser.fftSize = SPEAKING_ANALYSER_FFT_SIZE;
|
||||||
|
analyser.smoothingTimeConstant = SPEAKING_ANALYSER_SMOOTHING;
|
||||||
|
return analyser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpeakingSamples(): Float32Array<ArrayBuffer> {
|
||||||
|
return new Float32Array(new ArrayBuffer(SPEAKING_ANALYSER_FFT_SIZE * Float32Array.BYTES_PER_ELEMENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeakingMeterTap {
|
||||||
|
context: BaseAudioContext;
|
||||||
|
source: MediaStreamAudioSourceNode;
|
||||||
|
analyser: AnalyserNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoiceSpeakingMeter {
|
||||||
|
private track: MediaStreamTrack | null = null;
|
||||||
|
private tap: SpeakingMeterTap | null = null;
|
||||||
|
private hysteresis = SILENT_SPEAKING_HYSTERESIS;
|
||||||
|
private readonly samples = createSpeakingSamples();
|
||||||
|
private readonly timer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly readTrack: () => MediaStreamTrack | null,
|
||||||
|
private readonly onChange: () => void,
|
||||||
|
) {
|
||||||
|
this.timer = setInterval(() => this.tick(performance.now()), SPEAKING_ANALYSER_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
get speaking(): boolean {
|
||||||
|
return this.hysteresis.speaking;
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(nowMs: number): void {
|
||||||
|
const tap = this.syncTap();
|
||||||
|
if (!tap) {
|
||||||
|
this.update(SILENT_SPEAKING_HYSTERESIS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const running = tap.context.state === 'running';
|
||||||
|
if (running) tap.analyser.getFloatTimeDomainData(this.samples);
|
||||||
|
const rms = running ? computeTimeDomainRms(this.samples) : 0;
|
||||||
|
this.update(advanceSpeakingHysteresis(this.hysteresis, rms, readSpeakingThresholdRms(), nowMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.closeTap();
|
||||||
|
this.hysteresis = SILENT_SPEAKING_HYSTERESIS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncTap(): SpeakingMeterTap | null {
|
||||||
|
const candidate = this.readTrack();
|
||||||
|
const track = candidate?.readyState === 'live' ? candidate : null;
|
||||||
|
if (track === this.track) return this.tap;
|
||||||
|
this.closeTap();
|
||||||
|
this.track = track;
|
||||||
|
this.hysteresis = {...SILENT_SPEAKING_HYSTERESIS, speaking: this.hysteresis.speaking};
|
||||||
|
const context = track ? getSharedVoiceAudioContext() : null;
|
||||||
|
if (!track || !context) return null;
|
||||||
|
try {
|
||||||
|
const source = context.createMediaStreamSource(new MediaStream([track]));
|
||||||
|
const analyser = createSpeakingAnalyser(context);
|
||||||
|
source.connect(analyser);
|
||||||
|
this.tap = {context, source, analyser};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to attach the local speaking analyser', {error});
|
||||||
|
}
|
||||||
|
return this.tap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeTap(): void {
|
||||||
|
const tap = this.tap;
|
||||||
|
this.track = null;
|
||||||
|
this.tap = null;
|
||||||
|
if (!tap) return;
|
||||||
|
tap.source.disconnect();
|
||||||
|
tap.analyser.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(next: SpeakingHysteresis): void {
|
||||||
|
const changed = next.speaking !== this.hysteresis.speaking;
|
||||||
|
this.hysteresis = next;
|
||||||
|
if (changed) this.onChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,18 @@ const REMOTE_MIN_RMS = 0.002;
|
|||||||
const REMOTE_DEFAULT_RMS = 0.006;
|
const REMOTE_DEFAULT_RMS = 0.006;
|
||||||
const REMOTE_MAX_RMS = 0.035;
|
const REMOTE_MAX_RMS = 0.035;
|
||||||
export const SPEAKING_LOCAL_RELEASE_MS = 180;
|
export const SPEAKING_LOCAL_RELEASE_MS = 180;
|
||||||
export const SPEAKING_REMOTE_RELEASE_MS = 220;
|
const SPEAKING_REMOTE_RELEASE_MS = 220;
|
||||||
export const SPEAKING_REMOTE_ATTACK_MS = 30;
|
const SPEAKING_REMOTE_ATTACK_MS = 30;
|
||||||
const SLIDER_DEFAULT = 50;
|
const SLIDER_DEFAULT = 50;
|
||||||
|
|
||||||
|
export interface SpeakingHysteresis {
|
||||||
|
speaking: boolean;
|
||||||
|
aboveSinceMs: number | null;
|
||||||
|
belowSinceMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SILENT_SPEAKING_HYSTERESIS: SpeakingHysteresis = {speaking: false, aboveSinceMs: null, belowSinceMs: null};
|
||||||
|
|
||||||
function clampSlider(slider: number): number {
|
function clampSlider(slider: number): number {
|
||||||
if (!Number.isFinite(slider)) return SLIDER_DEFAULT;
|
if (!Number.isFinite(slider)) return SLIDER_DEFAULT;
|
||||||
if (slider < 0) return 0;
|
if (slider < 0) return 0;
|
||||||
@@ -40,3 +48,25 @@ export function getLocalSpeakingThresholdRms(slider: number): number {
|
|||||||
export function getRemoteSpeakingThresholdRms(slider: number): number {
|
export function getRemoteSpeakingThresholdRms(slider: number): number {
|
||||||
return interpolateThreshold(slider, REMOTE_MIN_RMS, REMOTE_DEFAULT_RMS, REMOTE_MAX_RMS);
|
return interpolateThreshold(slider, REMOTE_MIN_RMS, REMOTE_DEFAULT_RMS, REMOTE_MAX_RMS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function advanceSpeakingHysteresis(
|
||||||
|
state: SpeakingHysteresis,
|
||||||
|
rms: number,
|
||||||
|
thresholdRms: number,
|
||||||
|
nowMs: number,
|
||||||
|
): SpeakingHysteresis {
|
||||||
|
if (rms >= thresholdRms) {
|
||||||
|
const aboveSinceMs = state.aboveSinceMs ?? nowMs;
|
||||||
|
return {
|
||||||
|
speaking: state.speaking || nowMs - aboveSinceMs >= SPEAKING_REMOTE_ATTACK_MS,
|
||||||
|
aboveSinceMs,
|
||||||
|
belowSinceMs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const belowSinceMs = state.belowSinceMs ?? nowMs;
|
||||||
|
return {
|
||||||
|
speaking: state.speaking && nowMs - belowSinceMs < SPEAKING_REMOTE_RELEASE_MS,
|
||||||
|
aboveSinceMs: null,
|
||||||
|
belowSinceMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,14 +29,7 @@ import {
|
|||||||
} from '@app/features/voice/engine/mesh/VoiceMeshSignal';
|
} from '@app/features/voice/engine/mesh/VoiceMeshSignal';
|
||||||
import {VoiceMeshSignalClient} from '@app/features/voice/engine/mesh/VoiceMeshSignalClient';
|
import {VoiceMeshSignalClient} from '@app/features/voice/engine/mesh/VoiceMeshSignalClient';
|
||||||
import type {NormalizedVoiceState} from '@app/features/voice/engine/VoiceGatewayStateMachine';
|
import type {NormalizedVoiceState} from '@app/features/voice/engine/VoiceGatewayStateMachine';
|
||||||
import {getSharedVoiceAudioContext} from '@app/features/voice/engine/VoiceSharedAudioContext';
|
|
||||||
import {
|
|
||||||
getRemoteSpeakingThresholdRms,
|
|
||||||
SPEAKING_REMOTE_RELEASE_MS,
|
|
||||||
} from '@app/features/voice/engine/VoiceSpeakingThreshold';
|
|
||||||
import {computeTimeDomainRms} from '@app/features/voice/engine/v2/VoiceEngineV2AppRemoteSpeakingAdapter';
|
|
||||||
import voiceEngineV2AppVoiceStateAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter';
|
import voiceEngineV2AppVoiceStateAdapter from '@app/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter';
|
||||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
|
||||||
import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
|
import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
|
||||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +49,6 @@ import {
|
|||||||
type Room,
|
type Room,
|
||||||
RTCEngine,
|
RTCEngine,
|
||||||
type SignalOptions,
|
type SignalOptions,
|
||||||
SpeakerInfo,
|
|
||||||
Track,
|
Track,
|
||||||
TrackInfo,
|
TrackInfo,
|
||||||
TrackInvalidError,
|
TrackInvalidError,
|
||||||
@@ -68,7 +60,6 @@ const logger = new Logger('VoiceMeshEngine');
|
|||||||
|
|
||||||
const INBOX_LIMIT = 64;
|
const INBOX_LIMIT = 64;
|
||||||
const CONVERSION_GRACE_MS = 10_000;
|
const CONVERSION_GRACE_MS = 10_000;
|
||||||
const SPEAKING_TICK_MS = 50;
|
|
||||||
const SCREEN_SHARE_BUDGET_BPS = 12_000_000;
|
const SCREEN_SHARE_BUDGET_BPS = 12_000_000;
|
||||||
const CAMERA_BUDGET_BPS = 4_000_000;
|
const CAMERA_BUDGET_BPS = 4_000_000;
|
||||||
const MESH_VIDEO_CODEC_MIME_TYPES = ['video/H264', 'video/VP8'];
|
const MESH_VIDEO_CODEC_MIME_TYPES = ['video/H264', 'video/VP8'];
|
||||||
@@ -81,16 +72,6 @@ export interface VoiceMeshTarget {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpeakingAnalyser {
|
|
||||||
track: MediaStreamTrack;
|
|
||||||
source: MediaStreamAudioSourceNode;
|
|
||||||
analyser: AnalyserNode;
|
|
||||||
samples: Float32Array<ArrayBuffer>;
|
|
||||||
timer: ReturnType<typeof setInterval>;
|
|
||||||
lastLoudAt: number | null;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeEngine: VoiceMeshEngine | null = null;
|
let activeEngine: VoiceMeshEngine | null = null;
|
||||||
|
|
||||||
function mergeStatsReports(reports: ReadonlyArray<readonly [string, RTCStatsReport]>): RTCStatsReport {
|
function mergeStatsReports(reports: ReadonlyArray<readonly [string, RTCStatsReport]>): RTCStatsReport {
|
||||||
@@ -156,7 +137,6 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
private tracks: {v: number; tracks: Array<VoiceMeshTrackInfo>} = {v: this.version, tracks: []};
|
private tracks: {v: number; tracks: Array<VoiceMeshTrackInfo>} = {v: this.version, tracks: []};
|
||||||
private conversionTimer: ReturnType<typeof setTimeout> | null = null;
|
private conversionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private conversionHeld = false;
|
private conversionHeld = false;
|
||||||
private speaking: SpeakingAnalyser | null = null;
|
|
||||||
|
|
||||||
constructor(options: InternalRoomOptions, target: VoiceMeshTarget) {
|
constructor(options: InternalRoomOptions, target: VoiceMeshTarget) {
|
||||||
super(options);
|
super(options);
|
||||||
@@ -237,7 +217,6 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
this.syncRoster();
|
this.syncRoster();
|
||||||
this.syncSpeaking();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async createSender(
|
override async createSender(
|
||||||
@@ -296,7 +275,6 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
|
|
||||||
syncSender(src: VoiceMeshSource): Promise<void> {
|
syncSender(src: VoiceMeshSource): Promise<void> {
|
||||||
this.refreshTracks();
|
this.refreshTracks();
|
||||||
if (src === 'microphone') this.syncSpeaking();
|
|
||||||
return Promise.all(Array.from(this.peers.values(), (peer) => peer.requestSenderSync(src))).then(() => undefined);
|
return Promise.all(Array.from(this.peers.values(), (peer) => peer.requestSenderSync(src))).then(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,55 +562,6 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
this.conversionTimer = null;
|
this.conversionTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncSpeaking(): void {
|
|
||||||
const microphone = this.senders.microphone;
|
|
||||||
const track = this.room && microphone.sid !== null ? microphone.track : null;
|
|
||||||
if ((this.speaking?.track ?? null) === track) return;
|
|
||||||
this.stopSpeaking();
|
|
||||||
if (!track) return;
|
|
||||||
const context = getSharedVoiceAudioContext();
|
|
||||||
if (!context) return;
|
|
||||||
const source = context.createMediaStreamSource(new MediaStream([track]));
|
|
||||||
const analyser = context.createAnalyser();
|
|
||||||
analyser.fftSize = 512;
|
|
||||||
source.connect(analyser);
|
|
||||||
const speaking: SpeakingAnalyser = {
|
|
||||||
track,
|
|
||||||
source,
|
|
||||||
analyser,
|
|
||||||
samples: new Float32Array(analyser.fftSize),
|
|
||||||
timer: setInterval(() => this.tickSpeaking(speaking), SPEAKING_TICK_MS),
|
|
||||||
lastLoudAt: null,
|
|
||||||
active: false,
|
|
||||||
};
|
|
||||||
this.speaking = speaking;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tickSpeaking(speaking: SpeakingAnalyser): void {
|
|
||||||
speaking.analyser.getFloatTimeDomainData(speaking.samples);
|
|
||||||
const level = computeTimeDomainRms(speaking.samples);
|
|
||||||
const now = performance.now();
|
|
||||||
if (level >= getRemoteSpeakingThresholdRms(VoiceSettings.getVadThreshold())) speaking.lastLoudAt = now;
|
|
||||||
const active = speaking.lastLoudAt !== null && now - speaking.lastLoudAt < SPEAKING_REMOTE_RELEASE_MS;
|
|
||||||
if (active === speaking.active) return;
|
|
||||||
speaking.active = active;
|
|
||||||
this.emitLocalSpeaking(level, active);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopSpeaking(): void {
|
|
||||||
const speaking = this.speaking;
|
|
||||||
if (!speaking) return;
|
|
||||||
this.speaking = null;
|
|
||||||
clearInterval(speaking.timer);
|
|
||||||
speaking.source.disconnect();
|
|
||||||
speaking.analyser.disconnect();
|
|
||||||
if (speaking.active) this.emitLocalSpeaking(0, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitLocalSpeaking(level: number, active: boolean): void {
|
|
||||||
this.emit(EngineEvent.SpeakersChanged, [new SpeakerInfo({sid: this.localParticipantSid, level, active})]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopMesh(): void {
|
private stopMesh(): void {
|
||||||
if (!this.room) return;
|
if (!this.room) return;
|
||||||
this.room = null;
|
this.room = null;
|
||||||
@@ -646,7 +575,6 @@ export class VoiceMeshEngine extends RTCEngine {
|
|||||||
}
|
}
|
||||||
this.peers.clear();
|
this.peers.clear();
|
||||||
this.inbox.clear();
|
this.inbox.clear();
|
||||||
this.stopSpeaking();
|
|
||||||
flushVoiceMeshConnectionReports();
|
flushVoiceMeshConnectionReports();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {Store} from '@app/features/voice/engine/Store';
|
|||||||
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
|
||||||
import type {EffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
|
import type {EffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
|
||||||
import {getEffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
|
import {getEffectiveAudioState} from '@app/features/voice/engine/VoiceEffectiveAudioState';
|
||||||
|
import {noteLocalVoiceActivity} from '@app/features/voice/engine/VoiceIdleActivityBridge';
|
||||||
import {acquireVoiceInputContext, type VoiceInputContextLease} from '@app/features/voice/engine/VoiceInputAudioContext';
|
import {acquireVoiceInputContext, type VoiceInputContextLease} from '@app/features/voice/engine/VoiceInputAudioContext';
|
||||||
import {
|
import {
|
||||||
createVoiceMicrophoneFailureLatchSnapshot,
|
createVoiceMicrophoneFailureLatchSnapshot,
|
||||||
@@ -28,7 +29,7 @@ import {
|
|||||||
transitionVoiceMicrophoneFailureLatchSnapshot,
|
transitionVoiceMicrophoneFailureLatchSnapshot,
|
||||||
type VoiceMicrophoneFailureLatchEvent,
|
type VoiceMicrophoneFailureLatchEvent,
|
||||||
} from '@app/features/voice/engine/VoiceLocalAudioReconcilePolicy';
|
} from '@app/features/voice/engine/VoiceLocalAudioReconcilePolicy';
|
||||||
import {resolveLocalSpeakingOverrideState} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
|
import {isLocalSpeakingSuppressed} from '@app/features/voice/engine/VoiceLocalSpeakingGate';
|
||||||
import {
|
import {
|
||||||
getRoomFromMediaEngine,
|
getRoomFromMediaEngine,
|
||||||
getVoiceConnectionContextFromMediaEngine,
|
getVoiceConnectionContextFromMediaEngine,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
type VoiceMediaEvent,
|
type VoiceMediaEvent,
|
||||||
type VoiceMediaSnapshot,
|
type VoiceMediaSnapshot,
|
||||||
} from '@app/features/voice/engine/VoiceMediaStateMachine';
|
} from '@app/features/voice/engine/VoiceMediaStateMachine';
|
||||||
|
import {VoiceSpeakingMeter} from '@app/features/voice/engine/VoiceSpeakingDetection';
|
||||||
import type {VoiceStateSyncPartial} from '@app/features/voice/engine/VoiceStateSyncTypes';
|
import type {VoiceStateSyncPartial} from '@app/features/voice/engine/VoiceStateSyncTypes';
|
||||||
import {
|
import {
|
||||||
enforceLocalMediaPublicationCap,
|
enforceLocalMediaPublicationCap,
|
||||||
@@ -231,6 +233,7 @@ function getEffectiveSelfMuteForVoiceStatePayloadFromV2AudioControls(): boolean
|
|||||||
|
|
||||||
class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
||||||
private speakingDetectorCleanup: (() => void) | null = null;
|
private speakingDetectorCleanup: (() => void) | null = null;
|
||||||
|
private localSpeakingMeter: VoiceSpeakingMeter | null = null;
|
||||||
private mediaStateSnapshot: VoiceMediaSnapshot = createVoiceMediaSnapshot();
|
private mediaStateSnapshot: VoiceMediaSnapshot = createVoiceMediaSnapshot();
|
||||||
private microphoneEnablePromise: Promise<void> | null = null;
|
private microphoneEnablePromise: Promise<void> | null = null;
|
||||||
private microphoneRefreshQueue: Promise<void> = Promise.resolve();
|
private microphoneRefreshQueue: Promise<void> = Promise.resolve();
|
||||||
@@ -373,7 +376,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
private async enforceSpeakPermissionMute(room: Room): Promise<void> {
|
private async enforceSpeakPermissionMute(room: Room): Promise<void> {
|
||||||
await this.disableMicrophone(room);
|
await this.disableMicrophone(room);
|
||||||
this.syncVoiceState({self_mute: true});
|
this.syncVoiceState({self_mute: true});
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeaking(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareMicrophonePermissionForConnect(): Promise<boolean> {
|
async prepareMicrophonePermissionForConnect(): Promise<boolean> {
|
||||||
@@ -485,7 +488,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
} else {
|
} else {
|
||||||
this.syncVoiceState({self_mute: audioState.selfMute});
|
this.syncVoiceState({self_mute: audioState.selfMute});
|
||||||
}
|
}
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeaking(room);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (isPermissionDeniedError(e)) {
|
if (isPermissionDeniedError(e)) {
|
||||||
this.handleMicrophonePermissionDenied();
|
this.handleMicrophonePermissionDenied();
|
||||||
@@ -542,7 +545,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
}
|
}
|
||||||
if (audioState.effectiveDeaf || audioState.serverMute || audioState.selfMute) {
|
if (audioState.effectiveDeaf || audioState.serverMute || audioState.selfMute) {
|
||||||
this.setAudioPublicationsMuted(room, true, 'voice state update');
|
this.setAudioPublicationsMuted(room, true, 'voice state update');
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeaking(room);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.hasLiveMicrophonePublication(room)) {
|
if (!this.hasLiveMicrophonePublication(room)) {
|
||||||
@@ -562,7 +565,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.setAudioPublicationsMuted(room, this.shouldMuteMicrophonePublication(), 'voice state update');
|
this.setAudioPublicationsMuted(room, this.shouldMuteMicrophonePublication(), 'voice state update');
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeaking(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasMicrophonePublication(room: Room): boolean {
|
private hasMicrophonePublication(room: Room): boolean {
|
||||||
@@ -1506,7 +1509,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
}
|
}
|
||||||
this.syncVoiceState({self_mute: targetMute});
|
this.syncVoiceState({self_mute: targetMute});
|
||||||
this.updateMediaAudioControls();
|
this.updateMediaAudioControls();
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeaking(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPushToMuteHold(held: boolean, room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void {
|
applyPushToMuteHold(held: boolean, room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void {
|
||||||
@@ -1534,7 +1537,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
}
|
}
|
||||||
this.syncVoiceState({self_mute: targetMute});
|
this.syncVoiceState({self_mute: targetMute});
|
||||||
this.updateMediaAudioControls();
|
this.updateMediaAudioControls();
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeaking(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePushToTalkModeChange(room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void {
|
handlePushToTalkModeChange(room: Room | null, getCurrentUserVoiceState: () => VoiceState | null): void {
|
||||||
@@ -1560,7 +1563,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
}
|
}
|
||||||
this.syncVoiceState({self_mute: targetMute});
|
this.syncVoiceState({self_mute: targetMute});
|
||||||
this.updateMediaAudioControls();
|
this.updateMediaAudioControls();
|
||||||
this.syncLocalSpeakingOverride(room);
|
this.syncLocalSpeaking(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMuteReason(voiceState: VoiceState | null, guildId?: string | null, channelId?: string | null): VoiceMuteReason {
|
getMuteReason(voiceState: VoiceState | null, guildId?: string | null, channelId?: string | null): VoiceMuteReason {
|
||||||
@@ -1671,13 +1674,9 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLocalSpeakingOverrideState(room: Room): boolean | null {
|
private shouldSuppressLocalSpeaking(room: Room): boolean {
|
||||||
if (!room.localParticipant) return false;
|
|
||||||
const publication = this.getMicrophonePublication(room);
|
const publication = this.getMicrophonePublication(room);
|
||||||
return resolveLocalSpeakingOverrideState({
|
return isLocalSpeakingSuppressed({
|
||||||
pushToTalkActive: isPushToTalkActiveFromAppState(),
|
|
||||||
pushToMuteActive: isPushToMuteActiveFromAppState(),
|
|
||||||
pushToMuteHeld: Keybind.pushToMuteHeld,
|
|
||||||
selfDeaf: LocalVoiceState.getSelfDeaf(),
|
selfDeaf: LocalVoiceState.getSelfDeaf(),
|
||||||
effectiveSelfMute: getEffectiveSelfMuteForVoiceStatePayloadFromV2AudioControls(),
|
effectiveSelfMute: getEffectiveSelfMuteForVoiceStatePayloadFromV2AudioControls(),
|
||||||
hasMicrophonePublication: Boolean(publication),
|
hasMicrophonePublication: Boolean(publication),
|
||||||
@@ -1703,10 +1702,9 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
return selectVoiceEngineV2AppParticipant(snapshot, identity)?.isAudioLevelSpeaking ?? null;
|
return selectVoiceEngineV2AppParticipant(snapshot, identity)?.isAudioLevelSpeaking ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncLocalSpeakingOverride(room: Room | null): void {
|
private syncLocalSpeaking(room: Room | null): void {
|
||||||
if (!room?.localParticipant) return;
|
if (!room?.localParticipant) return;
|
||||||
const speaking = this.getLocalSpeakingOverrideState(room);
|
const speaking = !this.shouldSuppressLocalSpeaking(room) && (this.localSpeakingMeter?.speaking ?? false);
|
||||||
if (speaking === null) return;
|
|
||||||
this.setLocalParticipantAudioLevelSpeaking(room, speaking);
|
this.setLocalParticipantAudioLevelSpeaking(room, speaking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1714,28 +1712,27 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
this.stopLocalSpeakingDetector(room);
|
this.stopLocalSpeakingDetector(room);
|
||||||
const track = this.getLocalAudioTrack(room);
|
const track = this.getLocalAudioTrack(room);
|
||||||
if (!track || !room.localParticipant) return;
|
if (!track || !room.localParticipant) return;
|
||||||
let stopLevel: (() => void) | null = null;
|
const meter = new VoiceSpeakingMeter(
|
||||||
const bind = (): void => {
|
() => track.mediaStreamTrack,
|
||||||
stopLevel?.();
|
() => {
|
||||||
stopLevel = null;
|
this.syncLocalSpeaking(room);
|
||||||
this.setLocalParticipantAudioLevelSpeaking(room, this.getLocalSpeakingOverrideState(room) ?? false);
|
if (meter.speaking) noteLocalVoiceActivity(room.localParticipant, {force: true, speaking: true});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const reportRuntime = (): void => {
|
||||||
const processor = readVoiceInputProcessor(track);
|
const processor = readVoiceInputProcessor(track);
|
||||||
if (!processor) {
|
if (processor) processor.reportRuntime();
|
||||||
this.reportRawMicrophoneRuntime(track);
|
else this.reportRawMicrophoneRuntime(track);
|
||||||
return;
|
|
||||||
}
|
|
||||||
processor.reportRuntime();
|
|
||||||
stopLevel = processor.onLevel((level) => {
|
|
||||||
this.setLocalParticipantAudioLevelSpeaking(room, this.getLocalSpeakingOverrideState(room) ?? level.speaking);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
track.on(TrackEvent.TrackProcessorUpdate, bind);
|
track.on(TrackEvent.TrackProcessorUpdate, reportRuntime);
|
||||||
|
this.localSpeakingMeter = meter;
|
||||||
this.speakingDetectorCleanup = () => {
|
this.speakingDetectorCleanup = () => {
|
||||||
track.off(TrackEvent.TrackProcessorUpdate, bind);
|
track.off(TrackEvent.TrackProcessorUpdate, reportRuntime);
|
||||||
stopLevel?.();
|
meter.dispose();
|
||||||
NoiseSuppressionAvailability.clearVoiceInputRuntime(track);
|
NoiseSuppressionAvailability.clearVoiceInputRuntime(track);
|
||||||
};
|
};
|
||||||
bind();
|
reportRuntime();
|
||||||
|
meter.tick(performance.now());
|
||||||
this.transitionMediaState({type: 'speakingDetector.attach'});
|
this.transitionMediaState({type: 'speakingDetector.attach'});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1743,6 +1740,7 @@ class VoiceEngineV2AppMediaExecutionAdapter extends Store {
|
|||||||
this.transitionMediaState({type: 'speakingDetector.detach'});
|
this.transitionMediaState({type: 'speakingDetector.detach'});
|
||||||
this.speakingDetectorCleanup?.();
|
this.speakingDetectorCleanup?.();
|
||||||
this.speakingDetectorCleanup = null;
|
this.speakingDetectorCleanup = null;
|
||||||
|
this.localSpeakingMeter = null;
|
||||||
if (room) this.setLocalParticipantAudioLevelSpeaking(room, false);
|
if (room) this.setLocalParticipantAudioLevelSpeaking(room, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
|
|
||||||
import type {Room} from 'livekit-client';
|
import type {Room} from 'livekit-client';
|
||||||
import {Track} from 'livekit-client';
|
import {Track} from 'livekit-client';
|
||||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
const syncLocalVoiceStateWithServer = vi.fn();
|
const syncLocalVoiceStateWithServer = vi.fn();
|
||||||
const setVoiceEngineV2ParticipantAudioLevelSpeaking = vi.fn(() => true);
|
const setVoiceEngineV2ParticipantAudioLevelSpeaking = vi.fn(() => true);
|
||||||
|
const outgoingAudio = vi.hoisted(() => ({rms: 0}));
|
||||||
|
|
||||||
const keybind = {
|
const keybind = {
|
||||||
pushToTalkEnabled: true,
|
pushToTalkEnabled: true,
|
||||||
@@ -63,6 +64,18 @@ vi.mock('@app/features/voice/state/LocalVoiceState', () => ({default: localVoice
|
|||||||
|
|
||||||
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: {}, useMediaEngineVersion: () => 0}));
|
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({default: {}, useMediaEngineVersion: () => 0}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/engine/VoiceSharedAudioContext', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('@app/features/voice/engine/VoiceSharedAudioContext')>()),
|
||||||
|
getSharedVoiceAudioContext: () => ({
|
||||||
|
state: 'running',
|
||||||
|
createMediaStreamSource: () => ({connect: () => undefined, disconnect: () => undefined}),
|
||||||
|
createAnalyser: () => ({
|
||||||
|
disconnect: () => undefined,
|
||||||
|
getFloatTimeDomainData: (target: Float32Array) => target.fill(outgoingAudio.rms),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@app/features/voice/engine/VoiceMediaEngineBridge', () => ({
|
vi.mock('@app/features/voice/engine/VoiceMediaEngineBridge', () => ({
|
||||||
getRoomFromMediaEngine: () => null,
|
getRoomFromMediaEngine: () => null,
|
||||||
getVoiceConnectionContextFromMediaEngine: () => null,
|
getVoiceConnectionContextFromMediaEngine: () => null,
|
||||||
@@ -82,7 +95,12 @@ const {default: adapter} = await import('@app/features/voice/engine/v2/VoiceEngi
|
|||||||
interface FakeMicrophonePublication {
|
interface FakeMicrophonePublication {
|
||||||
source: Track.Source;
|
source: Track.Source;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
track: {mediaStreamTrack: {readyState: string}};
|
track: {
|
||||||
|
mediaStreamTrack: {readyState: string; getSettings(): MediaTrackSettings; getConstraints(): MediaTrackConstraints};
|
||||||
|
getProcessor(): undefined;
|
||||||
|
on(): void;
|
||||||
|
off(): void;
|
||||||
|
};
|
||||||
mute: ReturnType<typeof vi.fn>;
|
mute: ReturnType<typeof vi.fn>;
|
||||||
unmute: ReturnType<typeof vi.fn>;
|
unmute: ReturnType<typeof vi.fn>;
|
||||||
}
|
}
|
||||||
@@ -91,7 +109,12 @@ function createMicrophonePublication(): FakeMicrophonePublication {
|
|||||||
const publication: FakeMicrophonePublication = {
|
const publication: FakeMicrophonePublication = {
|
||||||
source: Track.Source.Microphone,
|
source: Track.Source.Microphone,
|
||||||
isMuted: true,
|
isMuted: true,
|
||||||
track: {mediaStreamTrack: {readyState: 'live'}},
|
track: {
|
||||||
|
mediaStreamTrack: {readyState: 'live', getSettings: () => ({}), getConstraints: () => ({})},
|
||||||
|
getProcessor: () => undefined,
|
||||||
|
on: () => undefined,
|
||||||
|
off: () => undefined,
|
||||||
|
},
|
||||||
mute: vi.fn(async () => {
|
mute: vi.fn(async () => {
|
||||||
publication.isMuted = true;
|
publication.isMuted = true;
|
||||||
}),
|
}),
|
||||||
@@ -120,7 +143,7 @@ function lastSyncedSelfMute(): unknown {
|
|||||||
return (call?.[0] as {self_mute?: boolean} | undefined)?.self_mute;
|
return (call?.[0] as {self_mute?: boolean} | undefined)?.self_mute;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastSpeakingOverride(): unknown {
|
function lastSpeaking(): unknown {
|
||||||
return setVoiceEngineV2ParticipantAudioLevelSpeaking.mock.calls.at(-1)?.[1 as never];
|
return setVoiceEngineV2ParticipantAudioLevelSpeaking.mock.calls.at(-1)?.[1 as never];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +170,7 @@ describe('push to talk with self-mute', () => {
|
|||||||
expect(publication.mute).toHaveBeenCalled();
|
expect(publication.mute).toHaveBeenCalled();
|
||||||
expect(publication.isMuted).toBe(true);
|
expect(publication.isMuted).toBe(true);
|
||||||
expect(lastSyncedSelfMute()).toBe(true);
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
expect(lastSpeakingOverride()).toBe(false);
|
expect(lastSpeaking()).toBe(false);
|
||||||
adapter.applyPushToTalkHold(false, room, noServerVoiceState);
|
adapter.applyPushToTalkHold(false, room, noServerVoiceState);
|
||||||
expect(publication.unmute).not.toHaveBeenCalled();
|
expect(publication.unmute).not.toHaveBeenCalled();
|
||||||
expect(lastSyncedSelfMute()).toBe(true);
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
@@ -160,11 +183,11 @@ describe('push to talk with self-mute', () => {
|
|||||||
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
expect(publication.unmute).toHaveBeenCalledTimes(1);
|
expect(publication.unmute).toHaveBeenCalledTimes(1);
|
||||||
expect(lastSyncedSelfMute()).toBe(false);
|
expect(lastSyncedSelfMute()).toBe(false);
|
||||||
expect(lastSpeakingOverride()).toBe(true);
|
expect(lastSpeaking()).toBe(false);
|
||||||
adapter.applyPushToTalkHold(false, room, noServerVoiceState);
|
adapter.applyPushToTalkHold(false, room, noServerVoiceState);
|
||||||
expect(publication.isMuted).toBe(true);
|
expect(publication.isMuted).toBe(true);
|
||||||
expect(lastSyncedSelfMute()).toBe(true);
|
expect(lastSyncedSelfMute()).toBe(true);
|
||||||
expect(lastSpeakingOverride()).toBe(false);
|
expect(lastSpeaking()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('transmits when the user unmutes while the key is still held', () => {
|
it('transmits when the user unmutes while the key is still held', () => {
|
||||||
@@ -228,3 +251,71 @@ describe('push to talk with self-mute', () => {
|
|||||||
enableMicrophone.mockRestore();
|
enableMicrophone.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface LocalSpeakingMeterProbe {
|
||||||
|
startLocalSpeakingDetector(room: Room): void;
|
||||||
|
stopLocalSpeakingDetector(room?: Room | null): void;
|
||||||
|
localSpeakingMeter: {tick(nowMs: number): void} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('local speaking indicator in push to talk', () => {
|
||||||
|
const probe = adapter as unknown as LocalSpeakingMeterProbe;
|
||||||
|
let nowMs = 0;
|
||||||
|
|
||||||
|
function tick(rms: number, ticks = 1): void {
|
||||||
|
outgoingAudio.rms = rms;
|
||||||
|
for (let index = 0; index < ticks; index++) {
|
||||||
|
nowMs += 50;
|
||||||
|
probe.localSpeakingMeter?.tick(nowMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('MediaStream', class {});
|
||||||
|
keybind.pushToTalkEnabled = true;
|
||||||
|
keybind.pushToTalkHeld = false;
|
||||||
|
localVoiceState.selfMute = false;
|
||||||
|
localVoiceState.selfDeaf = false;
|
||||||
|
outgoingAudio.rms = 0;
|
||||||
|
setVoiceEngineV2ParticipantAudioLevelSpeaking.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
probe.stopLocalSpeakingDetector();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function startWithMicrophone(): {room: Room; publication: FakeMicrophonePublication} {
|
||||||
|
const {room, publications} = createRoom();
|
||||||
|
const publication = createMicrophonePublication();
|
||||||
|
publications.set('mic', publication);
|
||||||
|
probe.startLocalSpeakingDetector(room);
|
||||||
|
return {room, publication};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stays dark while the key is held and nothing audible is sent', () => {
|
||||||
|
const {room} = startWithMicrophone();
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
tick(0.001, 10);
|
||||||
|
expect(setVoiceEngineV2ParticipantAudioLevelSpeaking).not.toHaveBeenCalledWith(expect.anything(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights for speech sent while the key is held and goes dark the moment it is released', () => {
|
||||||
|
const {room} = startWithMicrophone();
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
tick(0.05, 2);
|
||||||
|
expect(lastSpeaking()).toBe(true);
|
||||||
|
adapter.applyPushToTalkHold(false, room, noServerVoiceState);
|
||||||
|
expect(lastSpeaking()).toBe(false);
|
||||||
|
tick(0.05, 4);
|
||||||
|
expect(setVoiceEngineV2ParticipantAudioLevelSpeaking).toHaveBeenLastCalledWith(expect.anything(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lights for a self-muted user, however loud the microphone is', () => {
|
||||||
|
const {room} = startWithMicrophone();
|
||||||
|
localVoiceState.selfMute = true;
|
||||||
|
adapter.applyPushToTalkHold(true, room, noServerVoiceState);
|
||||||
|
tick(0.2, 10);
|
||||||
|
expect(setVoiceEngineV2ParticipantAudioLevelSpeaking).not.toHaveBeenCalledWith(expect.anything(), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,13 @@ import {
|
|||||||
getSharedVoiceAudioContext,
|
getSharedVoiceAudioContext,
|
||||||
resolveAudioContextConstructor,
|
resolveAudioContextConstructor,
|
||||||
} from '@app/features/voice/engine/VoiceSharedAudioContext';
|
} from '@app/features/voice/engine/VoiceSharedAudioContext';
|
||||||
import {getRemoteSpeakingThresholdRms} from '@app/features/voice/engine/VoiceSpeakingThreshold';
|
import {
|
||||||
|
computeTimeDomainRms,
|
||||||
|
createSpeakingAnalyser,
|
||||||
|
createSpeakingSamples,
|
||||||
|
readSpeakingThresholdRms,
|
||||||
|
SPEAKING_ANALYSER_INTERVAL_MS,
|
||||||
|
} from '@app/features/voice/engine/VoiceSpeakingDetection';
|
||||||
import {VoiceTrackKind, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
import {VoiceTrackKind, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||||
import {
|
import {
|
||||||
assertNonEmptyString,
|
assertNonEmptyString,
|
||||||
@@ -30,23 +36,11 @@ import {
|
|||||||
clearRemoteVoicePlaybackBoost,
|
clearRemoteVoicePlaybackBoost,
|
||||||
setRemoteVoicePlaybackBoost,
|
setRemoteVoicePlaybackBoost,
|
||||||
} from '@app/features/voice/state/RemoteVoicePlaybackBoost';
|
} from '@app/features/voice/state/RemoteVoicePlaybackBoost';
|
||||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
|
||||||
import type {Participant, RemoteAudioTrack, RemoteTrack, RemoteTrackPublication, Room} from 'livekit-client';
|
import type {Participant, RemoteAudioTrack, RemoteTrack, RemoteTrackPublication, Room} from 'livekit-client';
|
||||||
|
|
||||||
const logger = new Logger('VoiceEngineV2AppRemoteSpeakingAdapter');
|
const logger = new Logger('VoiceEngineV2AppRemoteSpeakingAdapter');
|
||||||
const REMOTE_SPEAKING_ANALYSER_INTERVAL_MS = 50;
|
|
||||||
const REMOTE_SPEAKING_ANALYSER_HANDLES_CAP = 256;
|
const REMOTE_SPEAKING_ANALYSER_HANDLES_CAP = 256;
|
||||||
|
|
||||||
export function computeTimeDomainRms(samples: Float32Array): number {
|
|
||||||
if (samples.length === 0) return 0;
|
|
||||||
let sumSquares = 0;
|
|
||||||
for (let i = 0; i < samples.length; i++) {
|
|
||||||
const sample = samples[i]!;
|
|
||||||
sumSquares += sample * sample;
|
|
||||||
}
|
|
||||||
return Math.sqrt(sumSquares / samples.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnalyserHandle {
|
interface AnalyserHandle {
|
||||||
identity: string;
|
identity: string;
|
||||||
track: MediaStreamTrack;
|
track: MediaStreamTrack;
|
||||||
@@ -171,17 +165,15 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const source = ctx.createMediaStreamSource(new MediaStream([mediaStreamTrack]));
|
const source = ctx.createMediaStreamSource(new MediaStream([mediaStreamTrack]));
|
||||||
const analyser = ctx.createAnalyser();
|
const analyser = createSpeakingAnalyser(ctx);
|
||||||
analyser.fftSize = 512;
|
|
||||||
analyser.smoothingTimeConstant = 0.2;
|
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
const handle: AnalyserHandle = {
|
const handle: AnalyserHandle = {
|
||||||
identity,
|
identity,
|
||||||
track: mediaStreamTrack,
|
track: mediaStreamTrack,
|
||||||
source,
|
source,
|
||||||
analyser,
|
analyser,
|
||||||
samples: new Float32Array(new ArrayBuffer(analyser.fftSize * Float32Array.BYTES_PER_ELEMENT)),
|
samples: createSpeakingSamples(),
|
||||||
nextTickAtMs: this.nowMs() + REMOTE_SPEAKING_ANALYSER_INTERVAL_MS,
|
nextTickAtMs: this.nowMs() + SPEAKING_ANALYSER_INTERVAL_MS,
|
||||||
};
|
};
|
||||||
this.analysers.set(identity, handle);
|
this.analysers.set(identity, handle);
|
||||||
this.applyCommands(this.transition({type: 'remote.attach', identity, track: mediaStreamTrack}));
|
this.applyCommands(this.transition({type: 'remote.attach', identity, track: mediaStreamTrack}));
|
||||||
@@ -301,9 +293,9 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
|
|||||||
}
|
}
|
||||||
handle.analyser.getFloatTimeDomainData(handle.samples);
|
handle.analyser.getFloatTimeDomainData(handle.samples);
|
||||||
const rms = computeTimeDomainRms(handle.samples);
|
const rms = computeTimeDomainRms(handle.samples);
|
||||||
const threshold = getRemoteSpeakingThresholdRms(VoiceSettings.getVadThreshold());
|
const threshold = readSpeakingThresholdRms();
|
||||||
this.applyCommands(this.transition({type: 'remote.tick', identity: handle.identity, rms, threshold, nowMs}));
|
this.applyCommands(this.transition({type: 'remote.tick', identity: handle.identity, rms, threshold, nowMs}));
|
||||||
handle.nextTickAtMs = nowMs + REMOTE_SPEAKING_ANALYSER_INTERVAL_MS;
|
handle.nextTickAtMs = nowMs + SPEAKING_ANALYSER_INTERVAL_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private disposeHandle(handle: AnalyserHandle): void {
|
private disposeHandle(handle: AnalyserHandle): void {
|
||||||
@@ -332,7 +324,7 @@ class VoiceEngineV2AppRemoteSpeakingAdapter {
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
this.autoScheduleTimerId = window.setInterval(() => {
|
this.autoScheduleTimerId = window.setInterval(() => {
|
||||||
this.tickHost(this.nowMs());
|
this.tickHost(this.nowMs());
|
||||||
}, REMOTE_SPEAKING_ANALYSER_INTERVAL_MS);
|
}, SPEAKING_ANALYSER_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopAutoSchedule(): void {
|
private stopAutoSchedule(): void {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export interface VoiceInputLevel {
|
|||||||
rms: number;
|
rms: number;
|
||||||
floorRms: number;
|
floorRms: number;
|
||||||
thresholdRms: number;
|
thresholdRms: number;
|
||||||
speaking: boolean;
|
|
||||||
nonFinite: boolean;
|
nonFinite: boolean;
|
||||||
frame: number;
|
frame: number;
|
||||||
contextTime: number;
|
contextTime: number;
|
||||||
|
|||||||
@@ -461,8 +461,6 @@ export class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio
|
|||||||
readonly name = 'fluxer-voice-input-processor';
|
readonly name = 'fluxer-voice-input-processor';
|
||||||
processedTrack?: MediaStreamTrack;
|
processedTrack?: MediaStreamTrack;
|
||||||
private state: 'idle' | 'live' | 'destroyed' = 'idle';
|
private state: 'idle' | 'live' | 'destroyed' = 'idle';
|
||||||
private readonly levelListeners = new Set<(level: VoiceInputLevel) => void>();
|
|
||||||
private latestLevel: VoiceInputLevel | null = null;
|
|
||||||
private hasRunningLevel = false;
|
private hasRunningLevel = false;
|
||||||
private lease: VoiceInputContextLease | null = null;
|
private lease: VoiceInputContextLease | null = null;
|
||||||
private graph: VoiceInputGraph | null = null;
|
private graph: VoiceInputGraph | null = null;
|
||||||
@@ -485,7 +483,7 @@ export class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio
|
|||||||
context,
|
context,
|
||||||
this.channelCount,
|
this.channelCount,
|
||||||
resolveVoiceInputConfig,
|
resolveVoiceInputConfig,
|
||||||
(level) => this.emitLevel(level, true),
|
() => this.noteLevel(),
|
||||||
() => this.events.onGraphFailed(this),
|
() => this.events.onGraphFailed(this),
|
||||||
() => this.reportRuntime(),
|
() => this.reportRuntime(),
|
||||||
);
|
);
|
||||||
@@ -528,22 +526,10 @@ export class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio
|
|||||||
if (this.state === 'live') this.graph?.reportRuntime(this);
|
if (this.state === 'live') this.graph?.reportRuntime(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
onLevel(listener: (level: VoiceInputLevel) => void): () => void {
|
private noteLevel(): void {
|
||||||
this.levelListeners.add(listener);
|
if (this.state !== 'live' || this.hasRunningLevel || this.lease?.context.state !== 'running') return;
|
||||||
if (this.latestLevel) listener(this.latestLevel);
|
this.hasRunningLevel = true;
|
||||||
return () => {
|
NoiseSuppressionAvailability.setVoiceInputGraphUnavailable(false);
|
||||||
this.levelListeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitLevel(level: VoiceInputLevel, fromWorklet = false): void {
|
|
||||||
if (this.state !== 'live') return;
|
|
||||||
if (fromWorklet && !this.hasRunningLevel && this.lease?.context.state === 'running') {
|
|
||||||
this.hasRunningLevel = true;
|
|
||||||
NoiseSuppressionAvailability.setVoiceInputGraphUnavailable(false);
|
|
||||||
}
|
|
||||||
this.latestLevel = level;
|
|
||||||
for (const listener of this.levelListeners) listener(level);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private watchContextState(): void {
|
private watchContextState(): void {
|
||||||
@@ -553,7 +539,6 @@ export class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio
|
|||||||
this.clearStallTimer();
|
this.clearStallTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.latestLevel) this.emitLevel({...this.latestLevel, rms: 0, speaking: false});
|
|
||||||
this.stallTimer ??= setTimeout(() => {
|
this.stallTimer ??= setTimeout(() => {
|
||||||
this.stallTimer = null;
|
this.stallTimer = null;
|
||||||
if (this.state === 'live' && context.state !== 'running') this.events.onGraphStalled(this);
|
if (this.state === 'live' && context.state !== 'running') this.events.onGraphStalled(this);
|
||||||
@@ -568,8 +553,6 @@ export class VoiceInputTrackProcessor implements TrackProcessor<Track.Kind.Audio
|
|||||||
|
|
||||||
async destroy(): Promise<void> {
|
async destroy(): Promise<void> {
|
||||||
if (this.state === 'destroyed') return;
|
if (this.state === 'destroyed') return;
|
||||||
if (this.latestLevel) this.emitLevel({...this.latestLevel, rms: 0, speaking: false});
|
|
||||||
this.levelListeners.clear();
|
|
||||||
this.state = 'destroyed';
|
this.state = 'destroyed';
|
||||||
NoiseSuppressionAvailability.clearVoiceInputRuntime(this);
|
NoiseSuppressionAvailability.clearVoiceInputRuntime(this);
|
||||||
this.clearStallTimer();
|
this.clearStallTimer();
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ class VoiceGateProcessor extends AudioWorkletProcessor {
|
|||||||
const thresholdRms = this.auto
|
const thresholdRms = this.auto
|
||||||
? Math.min(this.maxRms, Math.max(this.minRms, floorRms * 3))
|
? Math.min(this.maxRms, Math.max(this.minRms, floorRms * 3))
|
||||||
: this.manualThresholdRms;
|
: this.manualThresholdRms;
|
||||||
const wasSpeaking = this.speaking;
|
|
||||||
if (this.auto && this.frame < this.seedUntilFrame) {
|
if (this.auto && this.frame < this.seedUntilFrame) {
|
||||||
this.speaking = false;
|
this.speaking = false;
|
||||||
this.silenceFrames = 0;
|
this.silenceFrames = 0;
|
||||||
@@ -129,13 +128,12 @@ class VoiceGateProcessor extends AudioWorkletProcessor {
|
|||||||
if (this.delayFrames > 0) this.delayIndex = (this.delayIndex + 1) % this.delayFrames;
|
if (this.delayFrames > 0) this.delayIndex = (this.delayIndex + 1) % this.delayFrames;
|
||||||
}
|
}
|
||||||
this.frame += length;
|
this.frame += length;
|
||||||
if (wasSpeaking !== this.speaking || this.frame >= this.reportFrame) {
|
if (this.frame >= this.reportFrame) {
|
||||||
this.port.postMessage({
|
this.port.postMessage({
|
||||||
type: 'level',
|
type: 'level',
|
||||||
rms,
|
rms,
|
||||||
floorRms,
|
floorRms,
|
||||||
thresholdRms,
|
thresholdRms,
|
||||||
speaking: this.speaking,
|
|
||||||
nonFinite: this.nonFinite,
|
nonFinite: this.nonFinite,
|
||||||
frame: this.frame,
|
frame: this.frame,
|
||||||
contextTime: currentTime,
|
contextTime: currentTime,
|
||||||
|
|||||||
Reference in new issue
Block a user