Files
element-call/src/lotus/lotusAudioConstraints.test.ts
T
Lotus CIandClaude Opus 5.5 949103d649
CI / Build embedded bundle (push) Skipped
CI / Build embedded bundle (pull_request) Successful in 1m47s
CI / Publish to Gitea npm registry (pull_request) Skipped
feat(denoise): switch noise suppression mid-call (io.lotus.set_denoise)
The host can now change noise suppression on the live mic without a
rejoin: off / browser-native / ML, the ML model, the gate and stacking
browser NS in front of the model.

- Model swaps preload the new model while the old one keeps running,
  then swap the processor (no mic re-capture).
- Browser NS on/off re-captures the mic via restartTrack with the new
  constraint (passing the deviceId, without which LiveKit drops every
  constraint). The choice is kept in lotusCaptureOverride, which
  ConnectionFactory reads, so a later SFU reconnect captures the same way.
- Switches are serialized; a burst collapses into the last one. Each one
  reports io.lotus.denoise_state (now with `mode`).

Fixes found while testing in Chromium:
- The mic level meter (and muted-speech nudge) kept a clone of the
  ORIGINAL mic track and never noticed LiveKit's restartTrack. Chrome
  makes all open tracks of a device share audio processing, so every
  re-capture silently kept the old noiseSuppression (asked false, got
  true). The meter now drops its clone around our re-capture and
  re-taps on TrackEvent.Restarted, which also fixes it listening to the
  old device after a mic switch.
- LiveKit defaults voiceIsolation ON, a further voice filter in "off"
  and ML modes where the platform implements it. It now follows browser
  noise suppression.

Verified in a two-browser call: joined as browser and as ML(dtln), then
switched dtln -> deepfilternet -> rnnoise -> browser -> off; each step
applied within ~0.5 s and every re-capture got the constraints it asked
for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-30 21:13:40 -04:00

156 lines
5.0 KiB
TypeScript

/*
Copyright 2026 Lotus Guild
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Room as LivekitRoom } from "livekit-client";
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import EventEmitter from "events";
import { ObservableScope } from "../state/ObservableScope.ts";
import { ECConnectionFactory } from "../state/CallViewModel/remoteMembers/ConnectionFactory.ts";
import type { OpenIDClientParts } from "../livekit/openIDSFU.ts";
import {
exampleTransport,
mockMediaDevices,
ownMemberMock,
} from "../utils/test.ts";
import type { ProcessorState } from "../livekit/TrackProcessorContext.tsx";
import {
autoGainControlSetting,
echoCancellationSetting,
noiseSuppressionSetting,
} from "../settings/settings.ts";
import { setLotusNoiseSuppression } from "./lotusCaptureOverride.ts";
// [lotus] Upstream v0.25.0 moved the audio-capture constraints to Settings.
// The Lotus host still drives them per-call via URL params (it turns browser
// noiseSuppression/AGC OFF for the in-source ML denoise tier), so
// ConnectionFactory ANDs the Setting with the URL param. These tests pin that
// contract.
const { getUrlParams } = vi.hoisted(() => ({ getUrlParams: vi.fn() }));
vi.mock("../UrlParams", () => ({ getUrlParams }));
vi.mock("livekit-client", async (importOriginal) => ({
...(await importOriginal()),
Room: vi.fn().mockImplementation(function (this: LivekitRoom) {
const emitter = new EventEmitter();
return {
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
disconnect: vi.fn(),
remoteParticipants: new Map(),
} as unknown as LivekitRoom;
}),
}));
let testScope: ObservableScope;
const mockClient: OpenIDClientParts = {
getOpenIdToken: vi.fn().mockReturnValue(""),
getDeviceId: vi.fn().mockReturnValue("DEV000"),
};
function createRoom(): void {
new ECConnectionFactory(
mockClient,
"!roomid:example.org",
mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
false,
).createConnection(testScope, exampleTransport, ownMemberMock, logger);
}
function capturedAudioDefaults(): Record<string, unknown> {
const RoomConstructor = vi.mocked(LivekitRoom);
const options = RoomConstructor.mock.calls.at(-1)?.[0];
return (options?.audioCaptureDefaults ?? {}) as Record<string, unknown>;
}
beforeEach(() => {
testScope = new ObservableScope();
echoCancellationSetting.setValue(true);
noiseSuppressionSetting.setValue(true);
autoGainControlSetting.setValue(true);
});
afterEach(() => {
setLotusNoiseSuppression(null);
testScope.end();
vi.mocked(LivekitRoom).mockClear();
});
describe("[lotus] audio-capture URL param overrides", () => {
test("mic capture is always requested mono (stereo interfaces on Firefox published L-only)", () => {
getUrlParams.mockReturnValue({
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
});
createRoom();
expect(capturedAudioDefaults()).toMatchObject({ channelCount: 1 });
});
test("with params defaulted to true, the Settings govern (upstream behaviour)", () => {
getUrlParams.mockReturnValue({
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
});
autoGainControlSetting.setValue(false);
createRoom();
expect(capturedAudioDefaults()).toMatchObject({
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false,
});
});
test("host-passed noiseSuppression=false / autoGainControl=false force OFF regardless of Settings", () => {
getUrlParams.mockReturnValue({
echoCancellation: true,
noiseSuppression: false,
autoGainControl: false,
});
createRoom();
expect(capturedAudioDefaults()).toMatchObject({
echoCancellation: true,
noiseSuppression: false,
voiceIsolation: false,
autoGainControl: false,
});
});
test("a URL param cannot force a constraint ON when the Setting is off", () => {
getUrlParams.mockReturnValue({
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
});
echoCancellationSetting.setValue(false);
createRoom();
expect(capturedAudioDefaults()).toMatchObject({ echoCancellation: false });
});
test("a mid-call set_denoise choice beats the noiseSuppression the call joined with", () => {
getUrlParams.mockReturnValue({ noiseSuppression: false });
setLotusNoiseSuppression(true);
createRoom();
expect(capturedAudioDefaults()).toMatchObject({ noiseSuppression: true });
getUrlParams.mockReturnValue({ noiseSuppression: true });
setLotusNoiseSuppression(false);
createRoom();
expect(capturedAudioDefaults()).toMatchObject({ noiseSuppression: false });
});
});