fix(voice): apply noise suppression in the mic test (#2714)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-12 01:14:36 +02:00
1 parent 3affd295e8
commit d0c84b3d9b
39 files changed
+454 -1276

No files matched your search

@@ -46,7 +46,7 @@ import {
setNoiseSuppressionChoice,
} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionChoices';
import {
getNoiseSuppressionChoiceLabelDescriptor,
getNoiseSuppressionChoiceLabel,
STEREO_MICROPHONE_DESCRIPTION_DESCRIPTOR,
STEREO_MICROPHONE_DESCRIPTOR,
} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionLabels';
@@ -284,7 +284,7 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
const noiseSuppressionOptions: Array<ComboboxOption<VoiceNoiseSuppressionBackend>> =
getNoiseSuppressionChoiceValues().map((backend) => ({
value: backend,
label: i18n._(getNoiseSuppressionChoiceLabelDescriptor(backend)),
label: getNoiseSuppressionChoiceLabel(i18n, backend),
}));
const stereoMicrophoneAvailable = isStereoMicrophoneChoiceAvailable();
const setPushToTalkEnabled = (enabled: boolean) => {
@@ -3,6 +3,11 @@
import {Logger} from '@app/features/platform/utils/AppLogger';
import {createVoiceSoftClipNode} from '@app/features/voice/engine/VoiceSharedAudioContext';
import {buildDeepFilterAudioChain, type DeepFilterAudioChain} from '@app/features/voice/utils/DeepFilterNoiseProcessor';
import {
buildNoiseSuppressionWorkletChain,
type NoiseSuppressionWorkletChain,
} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionChain';
import type {NoiseSuppressionWorkletBackend} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionWorkletTypes';
const logger = new Logger('MicTestAudioGraph');
@@ -27,6 +32,8 @@ interface CreateMicTestAudioGraphOptions {
playbackDelaySeconds: number;
deepFilter: boolean;
deepFilterNoiseReductionLevel: number;
workletBackend: NoiseSuppressionWorkletBackend | null;
suppressionStrength: number;
}
export async function createMicTestAudioGraph({
@@ -38,6 +45,8 @@ export async function createMicTestAudioGraph({
playbackDelaySeconds,
deepFilter,
deepFilterNoiseReductionLevel,
workletBackend,
suppressionStrength,
}: CreateMicTestAudioGraphOptions): Promise<MicTestAudioGraph> {
const source = audioContext.createMediaStreamSource(new MediaStream([sourceTrack]));
const inputGainNode = audioContext.createGain();
@@ -45,8 +54,9 @@ export async function createMicTestAudioGraph({
source.connect(inputGainNode);
let deepFilterChain: DeepFilterAudioChain | null = null;
let passthroughDestination: MediaStreamAudioDestinationNode | null = null;
let processedTrack: MediaStreamTrack | null = null;
let workletChain: NoiseSuppressionWorkletChain | null = null;
let suppressedSource: MediaStreamAudioSourceNode | null = null;
let monitorHead: AudioNode = inputGainNode;
try {
if (deepFilter) {
deepFilterChain = await buildDeepFilterAudioChain({
@@ -54,25 +64,32 @@ export async function createMicTestAudioGraph({
noiseReductionLevel: deepFilterNoiseReductionLevel,
});
inputGainNode.connect(deepFilterChain.inputDestination);
processedTrack = deepFilterChain.processedTrack;
} else {
passthroughDestination = audioContext.createMediaStreamDestination();
inputGainNode.connect(passthroughDestination);
processedTrack = passthroughDestination.stream.getAudioTracks()[0] ?? null;
}
if (!processedTrack) {
throw new Error('Mic test graph produced no processed audio track');
suppressedSource = audioContext.createMediaStreamSource(new MediaStream([deepFilterChain.processedTrack]));
monitorHead = suppressedSource;
} else if (workletBackend) {
workletChain = await buildNoiseSuppressionWorkletChain({
audioContext,
backend: workletBackend,
suppressionStrength,
});
inputGainNode.connect(workletChain.inputDestination);
suppressedSource = audioContext.createMediaStreamSource(new MediaStream([workletChain.processedTrack]));
monitorHead = suppressedSource;
}
} catch (error) {
inputGainNode.disconnect();
source.disconnect();
passthroughDestination?.disconnect();
passthroughDestination?.stream.getTracks().forEach((track) => track.stop());
suppressedSource?.disconnect();
if (deepFilterChain) {
await deepFilterChain.dispose().catch((disposeError) => {
logger.debug('Failed to dispose DeepFilter chain after mic test graph init failure', disposeError);
});
}
if (workletChain) {
await workletChain.dispose().catch((disposeError) => {
logger.debug('Failed to dispose noise suppression chain after mic test graph init failure', disposeError);
});
}
throw error;
}
@@ -86,7 +103,7 @@ export async function createMicTestAudioGraph({
const softClip = createVoiceSoftClipNode(audioContext);
const softClipInput = softClip?.input ?? outputGainNode;
const softClipOutput: AudioNode = softClip?.output ?? outputGainNode;
inputGainNode.connect(analyser);
monitorHead.connect(analyser);
analyser.connect(delay);
delay.connect(outputGainNode);
if (softClip) outputGainNode.connect(softClip.input);
@@ -100,13 +117,17 @@ export async function createMicTestAudioGraph({
outputGainNode.disconnect();
softClipInput.disconnect();
softClipOutput.disconnect();
passthroughDestination?.disconnect();
passthroughDestination?.stream.getTracks().forEach((track) => track.stop());
suppressedSource?.disconnect();
if (deepFilterChain) {
await deepFilterChain.dispose().catch((error) => {
logger.warn('Failed to dispose DeepFilter chain for mic test graph', error);
});
}
if (workletChain) {
await workletChain.dispose().catch((error) => {
logger.warn('Failed to dispose noise suppression chain for mic test graph', error);
});
}
};
return {
@@ -8,6 +8,13 @@ import {
createMicTestAudioGraph,
type MicTestAudioGraph,
} from '@app/features/user/components/modals/tabs/hooks/MicTestAudioGraph';
import {
getNoiseSuppressionBackendDescriptor,
type VoiceNoiseSuppressionBackend,
} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionBackends';
import {readEffectiveNoiseSuppression} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionRuntime';
import {applyNoiseSuppressionOverride} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionSelection';
import type {NoiseSuppressionWorkletBackend} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionWorkletTypes';
import {
applyContentHintToTrack,
resolveVoiceProcessing,
@@ -41,6 +48,14 @@ export interface MicTestSettings {
voiceProcessingMode: VoiceProcessingMode;
}
const MIC_TEST_PROBE_SAMPLE_RATE = 48000;
function resolveMicTestWorkletBackend(backend: VoiceNoiseSuppressionBackend): NoiseSuppressionWorkletBackend | null {
return getNoiseSuppressionBackendDescriptor(backend).engine === 'worklet'
? (backend as NoiseSuppressionWorkletBackend)
: null;
}
function normalizeOutputDeviceId(deviceId: string): string {
return deviceId === 'default' ? '' : deviceId;
}
@@ -62,6 +77,7 @@ export const useMicTest = (settings: MicTestSettings) => {
const restartPendingRef = useRef(false);
const activeCaptureSignatureRef = useRef<string | null>(null);
const micExplicitlyDenied = MediaPermission.microphoneExplicitlyDenied;
const activeNoiseSuppression = readEffectiveNoiseSuppression(MIC_TEST_PROBE_SAMPLE_RATE);
const captureSignature = useMemo(
() =>
JSON.stringify({
@@ -73,6 +89,8 @@ export const useMicTest = (settings: MicTestSettings) => {
deepFilterNoiseSuppression: settings.deepFilterNoiseSuppression,
deepFilterNoiseSuppressionLevel: settings.deepFilterNoiseSuppressionLevel,
voiceProcessingMode: settings.voiceProcessingMode,
noiseSuppressionBackend: activeNoiseSuppression.backend,
noiseSuppressionStrength: activeNoiseSuppression.suppressionStrength,
}),
[
settings.autoGainControl,
@@ -83,6 +101,8 @@ export const useMicTest = (settings: MicTestSettings) => {
settings.noiseSuppression,
settings.outputDeviceId,
settings.voiceProcessingMode,
activeNoiseSuppression.backend,
activeNoiseSuppression.suppressionStrength,
],
);
const updateLevel = useCallback(() => {
@@ -171,7 +191,9 @@ export const useMicTest = (settings: MicTestSettings) => {
return exhaustive;
}
}
const profile = resolveVoiceProcessing(settings);
const effectiveNoiseSuppression = readEffectiveNoiseSuppression(MIC_TEST_PROBE_SAMPLE_RATE);
const profile = applyNoiseSuppressionOverride(resolveVoiceProcessing(settings), effectiveNoiseSuppression);
const workletBackend = resolveMicTestWorkletBackend(profile.noiseSuppressionBackend);
const baseAudioConstraints: MediaTrackConstraints & {voiceIsolation?: boolean} = {
echoCancellation: profile.echoCancellation,
noiseSuppression: profile.browserNoiseSuppression,
@@ -219,6 +241,8 @@ export const useMicTest = (settings: MicTestSettings) => {
playbackDelaySeconds: MIC_TEST_MONITOR_DELAY_SECONDS,
deepFilter: profile.deepFilter,
deepFilterNoiseReductionLevel: profile.deepFilterNoiseReductionLevel,
workletBackend,
suppressionStrength: effectiveNoiseSuppression.suppressionStrength,
});
timeDomainDataRef.current = new Float32Array(graphRef.current.analyser.fftSize);
const audioElement = new Audio();