Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions locales/de/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@
"developer_mode_label": "Entwickler-Modus",
"developer_mode_label_description": "Aktivieren Sie den Entwicklermodus und zeigen Sie die Registerkarte mit den Entwicklereinstellungen an.",
"introduction": "Hier können zusätzliche Optionen für individuelle Anforderungen eingestellt werden.",
"experimental_microphone_denoise_description": "Reduziert Hintergrundgeräusche vom Mikrofon.",
"experimental_microphone_denoise_label": "Geräuschunterdrückung",
"reactions_play_sound_description": "Spielen Sie einen Soundeffekt ab, wenn jemand eine Reaktion auf einen Anruf sendet.",
"reactions_play_sound_label": "Reaktionstöne abspielen",
"reactions_show_description": "Zeige eine Animation, wenn jemand eine Reaktion sendet.",
Expand Down
2 changes: 2 additions & 0 deletions locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@
"developer_mode_label": "Developer mode",
"developer_mode_label_description": "Enable developer mode and show developer settings tab.",
"introduction": "Here you can configure extra options for an improved experience.",
"experimental_microphone_denoise_description": "Reduce background noise from your microphone.",
"experimental_microphone_denoise_label": "Background noise reduction",
"reactions_play_sound_description": "Play a sound effect when anyone sends a reaction into a call.",
"reactions_play_sound_label": "Play reaction sounds",
"reactions_show_description": "Show an animation when anyone sends a reaction.",
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,8 @@
"qs": "^6.14.1",
"js-yaml": "^4.1.1"
},
"packageManager": "yarn@4.7.0"
"packageManager": "yarn@4.7.0",
"dependencies": {
"@sapphi-red/web-noise-suppressor": "^0.3.5"
}
}
208 changes: 208 additions & 0 deletions src/livekit/MicrophoneDenoiseController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import {
loadRnnoise,
RnnoiseWorkletNode,
} from "@sapphi-red/web-noise-suppressor";
import rnnoiseWorkletPath from "@sapphi-red/web-noise-suppressor/rnnoiseWorklet.js?url";
import rnnoiseWasmPath from "@sapphi-red/web-noise-suppressor/rnnoise.wasm?url";
import rnnoiseSimdWasmPath from "@sapphi-red/web-noise-suppressor/rnnoise_simd.wasm?url";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";

const logger = rootLogger.getChild("[MicrophoneDenoiseController]");

export interface MicrophoneTrackProcessor {
connect(
input: MediaStreamAudioSourceNode,
destination: MediaStreamAudioDestinationNode,
): void;
disconnect(): void;
destroy?(): void;
}

export interface MicrophoneTrackProcessorFactory {
create(context: AudioContext): Promise<MicrophoneTrackProcessor>;
}

let rnnoiseWasmBinaryPromise: Promise<ArrayBuffer> | undefined;
const rnnoiseWorkletModules = new WeakMap<AudioContext, Promise<void>>();

async function loadRnnoiseBinary(): Promise<ArrayBuffer> {
rnnoiseWasmBinaryPromise ??= loadRnnoise({
url: rnnoiseWasmPath,
simdUrl: rnnoiseSimdWasmPath,
});
return rnnoiseWasmBinaryPromise;
}

async function ensureRnnoiseWorklet(context: AudioContext): Promise<void> {
let modulePromise = rnnoiseWorkletModules.get(context);
if (modulePromise === undefined) {
modulePromise = context.audioWorklet.addModule(rnnoiseWorkletPath);
rnnoiseWorkletModules.set(context, modulePromise);
}
return modulePromise;
}

class RnnoiseMicrophoneTrackProcessor implements MicrophoneTrackProcessor {
private readonly preFilter: BiquadFilterNode;

public constructor(
context: AudioContext,
private readonly rnnoiseNode: RnnoiseWorkletNode,
) {
// Trim subsonic rumble before RNNoise sees the signal.
this.preFilter = context.createBiquadFilter();
this.preFilter.type = "highpass";
this.preFilter.frequency.value = 80;
this.preFilter.Q.value = 0.7;
}

public connect(
input: MediaStreamAudioSourceNode,
destination: MediaStreamAudioDestinationNode,
): void {
input.connect(this.preFilter);
this.preFilter.connect(this.rnnoiseNode);
this.rnnoiseNode.connect(destination);
}

public disconnect(): void {
this.preFilter.disconnect();
this.rnnoiseNode.disconnect();
}

public destroy(): void {
this.rnnoiseNode.destroy();
}
}

export class RnnoiseMicrophoneTrackProcessorFactory implements MicrophoneTrackProcessorFactory {
public async create(
context: AudioContext,
): Promise<MicrophoneTrackProcessor> {
if (
context.audioWorklet === undefined ||
typeof AudioWorkletNode === "undefined"
) {
throw new Error("AudioWorklet is not available");
}

const wasmBinary = await loadRnnoiseBinary();
await ensureRnnoiseWorklet(context);

const rnnoiseNode = new RnnoiseWorkletNode(context, {
wasmBinary,
maxChannels: 1,
});

return new RnnoiseMicrophoneTrackProcessor(context, rnnoiseNode);
}
}

interface ActivePipeline {
readonly sourceTrack: MediaStreamTrack;
readonly processedTrack: MediaStreamTrack;
readonly context: AudioContext;
readonly sourceNode: MediaStreamAudioSourceNode;
readonly destinationNode: MediaStreamAudioDestinationNode;
readonly processor: MicrophoneTrackProcessor;
}

/**
* Experimental client-side microphone processing hook.
*
* When enabled, we wrap the captured mic track in a Web Audio
* graph and hand LiveKit a processed output track instead of the raw one.
*/
export class MicrophoneDenoiseController {
private activePipeline?: ActivePipeline;

public constructor(
private readonly processorFactory: MicrophoneTrackProcessorFactory = new RnnoiseMicrophoneTrackProcessorFactory(),
) {}

public get sourceTrack(): MediaStreamTrack | undefined {
return this.activePipeline?.sourceTrack;
}

public get processedTrack(): MediaStreamTrack | undefined {
return this.activePipeline?.processedTrack;
}

public async rebuild(
sourceTrack: MediaStreamTrack,
): Promise<MediaStreamTrack> {
this.destroy();

if (typeof AudioContext === "undefined") {
throw new Error("AudioContext is not available");
}

const context = new AudioContext({
latencyHint: "interactive",
sampleRate: 48000,
});

try {
const stream = new MediaStream([sourceTrack]);
const sourceNode = context.createMediaStreamSource(stream);
const destinationNode = context.createMediaStreamDestination();
const processor = await this.processorFactory.create(context);

processor.connect(sourceNode, destinationNode);

const processedTrack = destinationNode.stream.getAudioTracks()[0];
if (processedTrack === undefined) {
throw new Error(
"Processed microphone stream did not expose an audio track",
);
}

if (context.state === "suspended") {
await context.resume();
}

this.activePipeline = {
sourceTrack,
processedTrack,
context,
sourceNode,
destinationNode,
processor,
};

return processedTrack;
} catch (error) {
try {
await context.close();
} catch (closeError) {
logger.warn(
"Failed to close microphone denoise audio context",
closeError,
);
}
throw error;
}
}

public destroy(): void {
const pipeline = this.activePipeline;
if (pipeline === undefined) return;

this.activePipeline = undefined;
pipeline.processor.disconnect();
pipeline.processor.destroy?.();
pipeline.sourceNode.disconnect();
pipeline.destinationNode.disconnect();
pipeline.processedTrack.stop();
void pipeline.context.close().catch((error) => {
logger.warn("Failed to close microphone denoise audio context", error);
});
}
}
17 changes: 17 additions & 0 deletions src/settings/PreferencesSettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Text } from "@vector-im/compound-web";

import { FieldRow, InputField } from "../input/Input";
import {
experimentalMicrophoneDenoise as experimentalMicrophoneDenoiseSetting,
showHandRaisedTimer as showHandRaisedTimerSetting,
showReactions as showReactionsSetting,
playReactionsSound as playReactionsSoundSetting,
Expand All @@ -29,6 +30,8 @@ export const PreferencesSettingsTab: FC = () => {
const [playReactionsSound, setPlayReactionSound] = useSetting(
playReactionsSoundSetting,
);
const [experimentalMicrophoneDenoise, setExperimentalMicrophoneDenoise] =
useSetting(experimentalMicrophoneDenoiseSetting);

const onChangeSetting = (
e: ChangeEvent<HTMLInputElement>,
Expand Down Expand Up @@ -76,6 +79,20 @@ export const PreferencesSettingsTab: FC = () => {
onChange={(e) => onChangeSetting(e, setPlayReactionSound)}
/>
</FieldRow>
<FieldRow>
<InputField
id="experimentalMicrophoneDenoise"
label={t(
"settings.preferences_tab.experimental_microphone_denoise_label",
)}
description={t(
"settings.preferences_tab.experimental_microphone_denoise_description",
)}
type="checkbox"
checked={experimentalMicrophoneDenoise}
onChange={(e) => onChangeSetting(e, setExperimentalMicrophoneDenoise)}
/>
</FieldRow>
<FieldRow>
<InputField
id="developerSettingsTab"
Expand Down
5 changes: 5 additions & 0 deletions src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ export const videoInput = new Setting<string | undefined>(

export const backgroundBlur = new Setting<boolean>("background-blur", false);

export const experimentalMicrophoneDenoise = new Setting<boolean>(
"experimental-microphone-denoise",
false,
);

export const showHandRaisedTimer = new Setting<boolean>(
"hand-raised-show-timer",
false,
Expand Down
2 changes: 2 additions & 0 deletions src/state/CallViewModel/CallViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from "../../utils/observable";
import {
duplicateTiles,
experimentalMicrophoneDenoise,
MatrixRTCMode,
playReactionsSound,
showReactions,
Expand Down Expand Up @@ -552,6 +553,7 @@ export function createCallViewModel$(
mediaDevices,
muteStates,
trackProcessorState$,
experimentalMicrophoneDenoise.value$,
logger.getChild(
"[Publisher " + connection.transport.livekit_service_url + "]",
),
Expand Down
Loading
Loading