diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index 75bf9bfb..4ca3ef7d 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -391,6 +391,21 @@ describe("UrlParams", () => { }); }); + describe("autoGainControl", () => { + it("defaults to true", () => { + expect(computeUrlParams().autoGainControl).toBe(true); + }); + + it("is parsed", () => { + expect(computeUrlParams("?autoGainControl=true").autoGainControl).toBe( + true, + ); + expect(computeUrlParams("?autoGainControl=false").autoGainControl).toBe( + false, + ); + }); + }); + describe("header", () => { it("uses header if provided", () => { expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe( diff --git a/src/UrlParams.ts b/src/UrlParams.ts index f4ea840d..ec3d5553 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -239,6 +239,12 @@ export interface UrlConfiguration { * Defaults to true. */ noiseSuppression?: boolean; + /** + * Whether to enable auto gain control for audio capture. + * Defaults to true. Lotus turns this OFF for the in-source ML denoise tier so + * the browser's dynamic gain doesn't fight the ML model (pumping artifacts). + */ + autoGainControl?: boolean; callIntent?: RTCCallIntent; } @@ -485,6 +491,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), noiseSuppression: parser.getFlagParam("noiseSuppression", true), echoCancellation: parser.getFlagParam("echoCancellation", true), + autoGainControl: parser.getFlagParam("autoGainControl", true), }; // Log the final configuration for debugging purposes. diff --git a/src/lotus/lotusDenoiseProcessor.ts b/src/lotus/lotusDenoiseProcessor.ts index 314f3446..71f09275 100644 --- a/src/lotus/lotusDenoiseProcessor.ts +++ b/src/lotus/lotusDenoiseProcessor.ts @@ -158,9 +158,17 @@ export class LotusDenoiseProcessor public constructor(private readonly config: LotusDenoiseConfig) {} public async init(_opts: AudioProcessorOptions): Promise { - await this.ensureContext(); - this.graph = await this.buildGraph(_opts.track); - this.processedTrack = this.graph.track; + try { + await this.ensureContext(); + this.graph = await this.buildGraph(_opts.track); + this.processedTrack = this.graph.track; + } catch (e) { + // Don't orphan the owned context if graph construction fails (browsers + // cap live AudioContexts, so repeated failed inits could exhaust them). + // The caller degrades to the raw mic; we just release our resources. + await this.closeContext(); + throw e; + } } public async restart(opts: AudioProcessorOptions): Promise { @@ -249,63 +257,78 @@ export class LotusDenoiseProcessor const nodes: AudioNode[] = []; const disposes: (() => void)[] = []; - // Wet (denoised) path: source → ml → [gate] → wetGain. - const ml = await this.buildMlNode(ctx); - source.connect(ml.node); - nodes.push(ml.node); - if (ml.dispose) disposes.push(ml.dispose); - let wetHead: AudioNode = ml.node; + try { + // Wet (denoised) path: source → ml → [gate] → wetGain. + const ml = await this.buildMlNode(ctx); + source.connect(ml.node); + nodes.push(ml.node); + if (ml.dispose) disposes.push(ml.dispose); + let wetHead: AudioNode = ml.node; - // Gate AFTER the ML model, not before: gating the raw noisy signal fed hard - // zeroed frames into the model (discontinuities it must fight) and made the - // threshold operate on pre-denoise levels. Gate the residual instead. - if (this.config.gate) { - const gate = new AudioWorkletNode(ctx, GATE.name, { - processorOptions: { - openThreshold: this.config.gateThreshold, - closeThreshold: this.config.gateThreshold - 5, - holdMs: 150, - maxChannels: 1, - }, + // Gate AFTER the ML model, not before: gating the raw noisy signal fed + // hard-zeroed frames into the model (discontinuities it must fight) and + // made the threshold operate on pre-denoise levels. Gate the residual. + if (this.config.gate) { + const gate = new AudioWorkletNode(ctx, GATE.name, { + processorOptions: { + openThreshold: this.config.gateThreshold, + closeThreshold: this.config.gateThreshold - 5, + holdMs: 150, + maxChannels: 1, + }, + }); + wetHead.connect(gate); + wetHead = gate; + nodes.push(gate); + } + + // Only mix a dry floor for the LOW-LATENCY flat models (RNNoise/Speex). + // DTLN/DeepFilterNet add tens of ms of algorithmic latency, so summing an + // undelayed dry copy would comb-filter the voice — for those we rely on + // the model's own level (e.g. DFN noiseReductionLevel) instead. RNNoise is + // also where the "robotic/underwater" reports come from, so this targets it. + const lowLatency = + this.config.model === "rnnoise" || this.config.model === "speex"; + const floor = lowLatency + ? Math.min(0.5, Math.max(0, this.config.floor)) + : 0; + if (floor > 0) { + // Dry/wet mix: blend a small amount of the ORIGINAL mic under the + // denoised signal so suppression can't fully collapse the noise floor + // (kills the "underwater"/pumping artifact). During speech (denoised ≈ + // original) the two sum back to ~unity; in noise-only gaps the output + // floors at `floor` × original instead of digital silence. + const wetGain = ctx.createGain(); + wetGain.gain.value = 1 - floor; + wetHead.connect(wetGain); + wetGain.connect(dest); + nodes.push(wetGain); + + const dryGain = ctx.createGain(); + dryGain.gain.value = floor; + source.connect(dryGain); + dryGain.connect(dest); + nodes.push(dryGain); + } else { + wetHead.connect(dest); + } + + logger.info( + `[lotus] denoise processor active (${this.config.model}, floor=${floor})`, + ); + return { source, nodes, disposes, track: dest.stream.getAudioTracks()[0] }; + } catch (e) { + // A node constructor / model load can throw mid-build; clean up the + // partially-built graph so it doesn't leak (init/restart still fall back + // to the raw mic on the rejection). + this.disposeGraph({ + source, + nodes, + disposes, + track: dest.stream.getAudioTracks()[0], }); - wetHead.connect(gate); - wetHead = gate; - nodes.push(gate); + throw e; } - - // Only mix a dry floor for the LOW-LATENCY flat models (RNNoise/Speex). - // DTLN/DeepFilterNet add tens of ms of algorithmic latency, so summing an - // undelayed dry copy would comb-filter the voice — for those we rely on the - // model's own level (e.g. DFN noiseReductionLevel) instead. RNNoise is also - // where the "robotic/underwater" reports come from, so this targets it. - const lowLatency = - this.config.model === "rnnoise" || this.config.model === "speex"; - const floor = lowLatency ? Math.min(0.5, Math.max(0, this.config.floor)) : 0; - if (floor > 0) { - // Dry/wet mix: blend a small amount of the ORIGINAL mic under the - // denoised signal so suppression can't fully collapse the noise floor - // (kills the "underwater"/pumping artifact). During speech (denoised ≈ - // original) the two sum back to ~unity; in noise-only gaps the output - // floors at `floor` × original instead of digital silence. - const wetGain = ctx.createGain(); - wetGain.gain.value = 1 - floor; - wetHead.connect(wetGain); - wetGain.connect(dest); - nodes.push(wetGain); - - const dryGain = ctx.createGain(); - dryGain.gain.value = floor; - source.connect(dryGain); - dryGain.connect(dest); - nodes.push(dryGain); - } else { - wetHead.connect(dest); - } - - logger.info( - `[lotus] denoise processor active (${this.config.model}, floor=${floor})`, - ); - return { source, nodes, disposes, track: dest.stream.getAudioTracks()[0] }; } private async buildMlNode(ctx: AudioContext): Promise { diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index a1290061..b56ef1f5 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -504,6 +504,7 @@ export function createCallViewModel$( options.livekitRoomFactory, getUrlParams().echoCancellation, getUrlParams().noiseSuppression, + getUrlParams().autoGainControl, ); const connectionManager = createConnectionManager$({ diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 38a09898..4690ee7c 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -55,6 +55,7 @@ export class ECConnectionFactory implements ConnectionFactory { * @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used. * @param echoCancellation - Whether to enable echo cancellation for audio capture. * @param noiseSuppression - Whether to enable noise suppression for audio capture. + * @param autoGainControl - Whether to enable auto gain control for audio capture. */ public constructor( private client: OpenIDClientParts, @@ -66,6 +67,7 @@ export class ECConnectionFactory implements ConnectionFactory { livekitRoomFactory?: () => LivekitRoom, echoCancellation: boolean = true, noiseSuppression: boolean = true, + autoGainControl: boolean = true, ) { const defaultFactory = (): LivekitRoom => new LivekitRoom( @@ -81,6 +83,7 @@ export class ECConnectionFactory implements ConnectionFactory { controlledAudioDevices: this.controlledAudioDevices, echoCancellation, noiseSuppression, + autoGainControl, }), ); this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory; @@ -127,6 +130,7 @@ function generateRoomOption({ controlledAudioDevices, echoCancellation, noiseSuppression, + autoGainControl, }: { devices: MediaDevices; processorState: ProcessorState; @@ -137,6 +141,7 @@ function generateRoomOption({ controlledAudioDevices: boolean; echoCancellation: boolean; noiseSuppression: boolean; + autoGainControl: boolean; }): RoomOptions { return { ...defaultLiveKitOptions, @@ -150,6 +155,7 @@ function generateRoomOption({ deviceId: devices.audioInput.selected$.value?.id, echoCancellation, noiseSuppression, + autoGainControl, }, audioOutput: { // When using controlled audio devices, we don't want to set the diff --git a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts index a66763d7..dd60d5f8 100644 --- a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts +++ b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts @@ -53,14 +53,13 @@ beforeEach(() => { describe("ECConnectionFactory - Audio inputs options", () => { test.each([ - { echo: true, noise: true }, - { echo: true, noise: false }, - { echo: false, noise: true }, - { echo: false, noise: false }, + { echo: true, noise: true, agc: true }, + { echo: true, noise: false, agc: false }, + { echo: false, noise: true, agc: false }, + { echo: false, noise: false, agc: true }, ])( - "it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters", - ({ echo, noise }) => { - // test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => { + "it sets echoCancellation=$echo, noiseSuppression=$noise, autoGainControl=$agc based on constructor parameters", + ({ echo, noise, agc }) => { const RoomConstructor = vi.mocked(LivekitRoom); const ecConnectionFactory = new ECConnectionFactory( @@ -76,6 +75,7 @@ describe("ECConnectionFactory - Audio inputs options", () => { undefined, echo, noise, + agc, ); ecConnectionFactory.createConnection( testScope, @@ -90,6 +90,7 @@ describe("ECConnectionFactory - Audio inputs options", () => { audioCaptureDefaults: expect.objectContaining({ echoCancellation: echo, noiseSuppression: noise, + autoGainControl: agc, }), }), );