diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES new file mode 100644 index 000000000..957e20ffc --- /dev/null +++ b/THIRD_PARTY_NOTICES @@ -0,0 +1,61 @@ +This file reproduces copyright notices and license terms for third-party +software components that are compiled into Element Call's distributed bundle +and carry obligations to reproduce their notices in binary distributions. + +------------------------------------------------------------------------------- + +rnnoise — Recurrent neural network for audio noise reduction +Compiled to WebAssembly and bundled via @jitsi/rnnoise-wasm +https://github.com/xiph/rnnoise + +Copyright (c) 2017, Mozilla +Copyright (c) 2007-2017, Jean-Marc Valin +Copyright (c) 2005-2017, Xiph.Org Foundation +Copyright (c) 2003-2004, Mark Borgerding + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of the Xiph.Org Foundation nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------- + +@jitsi/rnnoise-wasm — WebAssembly build and JS wrapper for rnnoise +https://github.com/jitsi/rnnoise-wasm + +Copyright (c) 2013 ESTOS GmbH +Copyright (c) 2013 BlueJimp SARL + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/locales/en/app.json b/locales/en/app.json index 9649b156f..e159136d7 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -203,7 +203,14 @@ "audio_processing_header": "Audio processing", "audio_tab": { "effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.", - "effect_volume_label": "Sound effect volume" + "effect_volume_label": "Sound effect volume", + "rnnoise_header": "Noise suppression", + "rnnoise_label": "Enable enhanced noise suppression (RNNoise)", + "rnnoise_not_supported": "(Enhanced noise suppression is not supported by this browser.)", + "rnnoise_preset_balanced": "Balanced", + "rnnoise_preset_conservative": "Conservative", + "rnnoise_preset_description": "Pick a suppression profile. Stronger modes remove more keyboard noise but can sound more processed.", + "rnnoise_preset_strong": "Strong" }, "auto_gain_control_label": "Automatic gain control", "background_blur_header": "Background", @@ -235,6 +242,7 @@ "feedback_tab_title": "Feedback", "framerate_label": "Framerate", "noise_suppression_label": "Noise suppression", + "noise_suppression_rnnoise_override": "Overridden by RNNoise.", "opt_in_description": "<0><1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.", "preferences_tab": { "developer_mode_label": "Developer mode", diff --git a/package.json b/package.json index cc8a36eb1..c7f13c47b 100644 --- a/package.json +++ b/package.json @@ -145,5 +145,8 @@ "qs": "^6.14.1", "js-yaml": "^4.1.1" }, - "packageManager": "yarn@4.7.0" + "packageManager": "yarn@4.7.0", + "dependencies": { + "@jitsi/rnnoise-wasm": "0.2.1" + } } diff --git a/playwright.config.ts b/playwright.config.ts index 4fb86b95b..794d82bd7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,32 @@ import { defineConfig, devices } from "@playwright/test"; const baseURL = process.env.USE_DOCKER ? "http://localhost:8080" : "https://localhost:3000"; +const fakeAudioCaptureFile = process.env.PLAYWRIGHT_FAKE_AUDIO_CAPTURE_FILE; +const fakeVideoCaptureFile = process.env.PLAYWRIGHT_FAKE_VIDEO_CAPTURE_FILE; +const disableChromiumSandbox = + process.env.PLAYWRIGHT_DISABLE_CHROMIUM_SANDBOX === "1"; + +function buildFakeMediaArgs(): string[] { + const args = [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--mute-audio", + ]; + + if (fakeAudioCaptureFile) { + args.push(`--use-file-for-fake-audio-capture=${fakeAudioCaptureFile}`); + } + if (fakeVideoCaptureFile) { + args.push(`--use-file-for-fake-video-capture=${fakeVideoCaptureFile}`); + } + + return args; +} + +const fakeMediaArgs = buildFakeMediaArgs(); +const chromiumLaunchArgs = disableChromiumSandbox + ? [...fakeMediaArgs, "--no-sandbox", "--disable-setuid-sandbox"] + : fakeMediaArgs; /** * See https://playwright.dev/docs/test-configuration. @@ -49,12 +75,9 @@ export default defineConfig({ "camera", ], ignoreHTTPSErrors: true, + chromiumSandbox: !disableChromiumSandbox, launchOptions: { - args: [ - "--use-fake-ui-for-media-stream", - "--use-fake-device-for-media-stream", - "--mute-audio", - ], + args: chromiumLaunchArgs, }, }, }, @@ -84,12 +107,9 @@ export default defineConfig({ "microphone", "camera", ], + chromiumSandbox: !disableChromiumSandbox, launchOptions: { - args: [ - "--use-fake-ui-for-media-stream", - "--use-fake-device-for-media-stream", - "--mute-audio", - ], + args: chromiumLaunchArgs, }, }, }, diff --git a/playwright/spa-call-sticky.spec.ts b/playwright/spa-call-sticky.spec.ts index 246b4a73a..d328770d9 100644 --- a/playwright/spa-call-sticky.spec.ts +++ b/playwright/spa-call-sticky.spec.ts @@ -7,18 +7,24 @@ Please see LICENSE in the repository root for full details. import { expect, + type Locator, type Page, test, type Request, type Browser, + type ConsoleMessage, } from "@playwright/test"; import { SpaHelpers } from "./spa-helpers"; +const RNNOISE_LABEL = "Enable enhanced noise suppression (RNNoise)"; +const RNNOISE_TOGGLE_SELECTOR = "#activateRNNoiseSuppression"; + async function setupTwoUserSpaCall( browser: Browser, page: Page, browserName: string, + callName = `HelloCall-${Date.now()}-${Math.floor(Math.random() * 10000)}`, ): Promise<{ guestPage: Page }> { test.skip( browserName === "firefox", @@ -39,7 +45,7 @@ async function setupTwoUserSpaCall( }, ); - await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0"); + await SpaHelpers.createCall(page, "Androl", callName, true, "2_0"); const inviteLink = await SpaHelpers.getCallInviteLink(page); @@ -70,8 +76,8 @@ async function setupTwoUserSpaCall( "2_0", ); // Assert both sides have sent sticky membership events - expect(androlHasSentStickyEvent).toEqual(true); - expect(pevaraHasSentStickyEvent).toEqual(true); + await expect.poll(() => androlHasSentStickyEvent).toBe(true); + await expect.poll(() => pevaraHasSentStickyEvent).toBe(true); return { guestPage }; } @@ -114,6 +120,142 @@ test("One to One rejoin after improper leave does not crash EC", async ({ await expect(guestPage.getByTestId("videoTile")).toHaveCount(2); }); +test.describe("RNNoise scenarios", () => { + test.describe.configure({ mode: "serial" }); + + test.skip( + ({ browserName }) => browserName !== "chromium", + "RNNoise scenarios are validated on Chromium fake-media infrastructure.", + ); + + test("One to One rejoin after improper leave stays stable with RNNoise enabled", async ({ + browser, + page, + browserName, + }) => { + const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName); + + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + const rnnoiseSupported = await enableRNNoiseInSettings(guestPage); + test.skip( + !rnnoiseSupported, + "RNNoise is not supported in this browser environment", + ); + + await expect + .poll(async () => + guestPage.evaluate(() => + localStorage.getItem("matrix-setting-rnnoise-noise-suppression"), + ), + ) + .toBe("true"); + + await guestPage.reload(); + await expect(guestPage.getByTestId("lobby_joinCall")).toBeVisible(); + await guestPage.getByTestId("lobby_joinCall").click(); + + // Rejoin after abrupt disconnect should remain stable with RNNoise enabled. + await expect(page.getByTestId("videoTile")).toHaveCount(3); + await expect(guestPage.getByTestId("videoTile")).toHaveCount(2); + await expect( + guestPage.getByRole("button", { name: "Mute microphone" }), + ).toBeVisible(); + + await expectRNNoiseEnabledInSettings(guestPage); + }); + + test("One to One call stays stable when switching devices with RNNoise enabled", async ({ + browser, + page, + browserName, + }) => { + const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName); + + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + + const rnnoiseSupported = await enableRNNoiseInSettings(guestPage); + test.skip( + !rnnoiseSupported, + "RNNoise is not supported in this browser environment", + ); + + const rnnoiseErrors: string[] = []; + const consoleHandler = (message: ConsoleMessage): void => { + if ( + message.type() === "error" && + /rnnoise|audio\s*worklet/i.test(message.text()) + ) { + rnnoiseErrors.push(message.text()); + } + }; + guestPage.on("console", consoleHandler); + + await openAudioSettings(guestPage); + const microphoneDeviceRadios = await getDeviceSelectionRadios( + guestPage, + "Microphone", + ); + + // Some Chromium fake-device environments expose only one audio-input device, + // so device switching cannot be forced there. Fall back to output switching. + if (microphoneDeviceRadios.count < 2) { + const speakerDeviceRadios = await getDeviceSelectionRadios( + guestPage, + "Speaker", + ); + expect(speakerDeviceRadios.count).toBeGreaterThan(0); + + if (speakerDeviceRadios.count > 1) { + const selectedSpeakerBefore = await guestPage.evaluate(() => + localStorage.getItem("matrix-setting-audio-output"), + ); + const targetSpeakerIndex = + speakerDeviceRadios.firstUncheckedIndex >= 0 + ? speakerDeviceRadios.firstUncheckedIndex + : 0; + await speakerDeviceRadios.radios.nth(targetSpeakerIndex).click(); + await expect + .poll(async () => + guestPage.evaluate(() => + localStorage.getItem("matrix-setting-audio-output"), + ), + ) + .not.toBe(selectedSpeakerBefore); + } + } else { + const selectedMicrophoneBefore = await guestPage.evaluate(() => + localStorage.getItem("matrix-setting-audio-input"), + ); + const targetMicrophoneIndex = + microphoneDeviceRadios.firstUncheckedIndex >= 0 + ? microphoneDeviceRadios.firstUncheckedIndex + : 1; + await microphoneDeviceRadios.radios.nth(targetMicrophoneIndex).click(); + await expect + .poll(async () => + guestPage.evaluate(() => + localStorage.getItem("matrix-setting-audio-input"), + ), + ) + .not.toBe(selectedMicrophoneBefore); + } + + await guestPage.getByTestId("modal_close").click(); + await SpaHelpers.expectVideoTilesCount(page, 2); + await SpaHelpers.expectVideoTilesCount(guestPage, 2); + await expect( + guestPage.getByRole("button", { name: "Mute microphone" }), + ).toBeVisible(); + await expectRNNoiseEnabledInSettings(guestPage); + expect(rnnoiseErrors).toEqual([]); + + guestPage.off("console", consoleHandler); + }); +}); + function isStickySend(url: string): boolean { return !!new URL(url).searchParams.get( "org.matrix.msc4354.sticky_duration_ms", @@ -133,3 +275,63 @@ async function interceptEventSend( }, ); } + +async function openAudioSettings(page: Page): Promise { + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("tab", { name: "Audio" }).click(); +} + +async function getDeviceSelectionRadios( + page: Page, + sectionHeading: string, +): Promise<{ + radios: Locator; + count: number; + firstUncheckedIndex: number; +}> { + const section = page + .locator("div") + .filter({ + has: page.getByRole("heading", { name: sectionHeading, exact: true }), + }) + .first(); + const radios = section.getByRole("radio"); + const count = await radios.count(); + const firstUncheckedIndex = await radios.evaluateAll((nodes) => + nodes.findIndex((node) => { + if (node instanceof HTMLInputElement) { + return !node.checked; + } + return node.getAttribute("aria-checked") !== "true"; + }), + ); + + return { radios, count, firstUncheckedIndex }; +} + +async function enableRNNoiseInSettings(page: Page): Promise { + await openAudioSettings(page); + + const rnnoiseLabel = page.locator("label", { hasText: RNNOISE_LABEL }); + await expect(rnnoiseLabel).toBeVisible(); + const rnnoiseToggle = page.locator(RNNOISE_TOGGLE_SELECTOR); + const supported = await rnnoiseToggle.isEnabled(); + if (supported && !(await rnnoiseToggle.isChecked())) { + await rnnoiseLabel.click(); + await expect(rnnoiseToggle).toBeChecked(); + } + + await page.getByTestId("modal_close").click(); + return supported; +} + +async function expectRNNoiseEnabledInSettings(page: Page): Promise { + await openAudioSettings(page); + + const rnnoiseLabel = page.locator("label", { hasText: RNNOISE_LABEL }); + await expect(rnnoiseLabel).toBeVisible(); + const rnnoiseToggle = page.locator(RNNOISE_TOGGLE_SELECTOR); + await expect(rnnoiseToggle).toBeChecked(); + + await page.getByTestId("modal_close").click(); +} diff --git a/playwright/spa-helpers.ts b/playwright/spa-helpers.ts index 648f6e6e4..7e91e2524 100644 --- a/playwright/spa-helpers.ts +++ b/playwright/spa-helpers.ts @@ -95,6 +95,7 @@ async function setRtcModeFromSettings( page: Page, mode: RtcMode, ): Promise { + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); await page.getByRole("button", { name: "Settings" }).click(); await page.getByRole("tab", { name: "Preferences" }).click(); await page.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked diff --git a/src/audio/RNNoiseProcessor.test.ts b/src/audio/RNNoiseProcessor.test.ts new file mode 100644 index 000000000..8908a2b96 --- /dev/null +++ b/src/audio/RNNoiseProcessor.test.ts @@ -0,0 +1,589 @@ +/* +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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Track } from "livekit-client"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { + createRNNoiseWorkletCodeForTesting, + RNNoiseProcessor, + supportsRNNoiseProcessor, +} from "./RNNoiseProcessor"; + +const RNNOISE_WORKLET_MODULE_URL = "/assets/RNNoiseWorkletModule.js"; + +vi.mock("./RNNoiseWorkletModule.ts?url", () => ({ + default: "/assets/RNNoiseWorkletModule.js", +})); + +type TestContext = { + addModule: ReturnType; + createSourceNode: ReturnType; + createDestinationNode: ReturnType; + sourceNode: MediaStreamAudioSourceNode; + destinationNode: MediaStreamAudioDestinationNode; + processedTrack: MediaStreamTrack & { stop: ReturnType }; + workletNode: AudioWorkletNode; + audioContext: AudioContext; + track: MediaStreamTrack; +}; + +function createTestContext(sampleRate = 48000): TestContext { + const processedTrack = { + id: "processed-track", + stop: vi.fn(), + } as unknown as MediaStreamTrack & { stop: ReturnType }; + const sourceNode = { + connect: vi.fn(), + disconnect: vi.fn(), + } as unknown as MediaStreamAudioSourceNode; + const destinationNode = { + stream: { + getAudioTracks: () => [processedTrack], + }, + disconnect: vi.fn(), + } as unknown as MediaStreamAudioDestinationNode; + const workletNode = { + connect: vi.fn(), + disconnect: vi.fn(), + port: { + postMessage: vi.fn(), + }, + } as unknown as AudioWorkletNode; + const addModule = vi.fn().mockResolvedValue(undefined); + const createSourceNode = vi.fn().mockReturnValue(sourceNode); + const createDestinationNode = vi.fn().mockReturnValue(destinationNode); + const audioContext = { + sampleRate, + audioWorklet: { addModule }, + createMediaStreamSource: createSourceNode, + createMediaStreamDestination: createDestinationNode, + } as unknown as AudioContext; + const track = { + id: "input-track", + kind: Track.Kind.Audio, + } as MediaStreamTrack; + + return { + addModule, + createSourceNode, + createDestinationNode, + sourceNode, + destinationNode, + processedTrack, + workletNode, + audioContext, + track, + }; +} + +function getGeneratedWorkletCode(): string { + return createRNNoiseWorkletCodeForTesting( + "function createRNNWasmModuleSync(){}; export default createRNNWasmModuleSync;", + ); +} + +type WorkletPresetConfig = { + maxAttenuationDb: number; + openThreshold: number; + closeThreshold: number; + holdFrames: number; + attenuateMs: number; + releaseMs: number; +}; + +function getPresetConfig( + workletCode: string, + preset: "conservative" | "balanced" | "strong", +): WorkletPresetConfig { + const presetMatch = workletCode.match( + new RegExp(`${preset}:\\s*\\{([\\s\\S]*?)\\n\\s*\\},`), + ); + if (!presetMatch) { + throw new Error(`Could not find ${preset} preset in worklet code.`); + } + const presetBlock = presetMatch[1]; + const readNumber = (key: keyof WorkletPresetConfig): number => { + const keyMatch = presetBlock.match(new RegExp(`${key}:\\s*([0-9.]+)`)); + if (!keyMatch) { + throw new Error(`Could not find ${key} in ${preset} preset.`); + } + return Number(keyMatch[1]); + }; + + return { + maxAttenuationDb: readNumber("maxAttenuationDb"), + openThreshold: readNumber("openThreshold"), + closeThreshold: readNumber("closeThreshold"), + holdFrames: readNumber("holdFrames"), + attenuateMs: readNumber("attenuateMs"), + releaseMs: readNumber("releaseMs"), + }; +} + +function expectedAttenuationDb( + config: WorkletPresetConfig, + vadProbability: number, +): number { + if (vadProbability >= config.openThreshold) { + return 0; + } + + const thresholdRange = config.openThreshold - config.closeThreshold; + const attenuationProgress = + thresholdRange > 0 + ? Math.max( + 0, + Math.min(1, (config.openThreshold - vadProbability) / thresholdRange), + ) + : 1; + return attenuationProgress * config.maxAttenuationDb; +} + +function instantiateWorkletProcessor(workletCode: string): { + process: ( + inputs: Float32Array[][], + outputs: Float32Array[][], + params?: Record, + ) => boolean; +} { + let ProcessorCtor: + | (new () => { + process: ( + inputs: Float32Array[][], + outputs: Float32Array[][], + params?: Record, + ) => boolean; + }) + | undefined; + + class TestAudioWorkletProcessor { + public readonly port = { + postMessage: vi.fn(), + onmessage: null as ((event: MessageEvent) => void) | null, + }; + } + + const registerProcessor = vi.fn( + ( + _name: string, + ctor: new () => { + process: ( + inputs: Float32Array[][], + outputs: Float32Array[][], + params?: Record, + ) => boolean; + }, + ) => { + ProcessorCtor = ctor; + }, + ); + + const runWorkletModule = new Function( + "AudioWorkletProcessor", + "registerProcessor", + workletCode, + ); + runWorkletModule(TestAudioWorkletProcessor, registerProcessor); + + if (!ProcessorCtor) { + throw new Error("Expected worklet processor to be registered."); + } + + return new ProcessorCtor(); +} + +describe("RNNoiseProcessor", () => { + beforeEach(() => { + vi.stubGlobal( + "MediaStream", + class MediaStream { + public constructor(_tracks?: MediaStreamTrack[]) {} + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("initializes audio graph and exposes processed track", async () => { + const t = createTestContext(); + vi.stubGlobal("AudioWorkletNode", vi.fn().mockReturnValue(t.workletNode)); + const processor = new RNNoiseProcessor("balanced"); + + await processor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + + expect(t.addModule).toHaveBeenCalledWith(RNNOISE_WORKLET_MODULE_URL); + expect(t.createSourceNode).toHaveBeenCalledOnce(); + expect(t.createDestinationNode).toHaveBeenCalledOnce(); + expect(t.workletNode.port.postMessage).toHaveBeenCalledWith({ + type: "preset", + preset: "balanced", + }); + expect(processor.processedTrack).toBe(t.processedTrack); + }); + + it("destroys processing graph and is idempotent", async () => { + const t = createTestContext(); + vi.stubGlobal("AudioWorkletNode", vi.fn().mockReturnValue(t.workletNode)); + const processor = new RNNoiseProcessor(); + + await processor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + await processor.destroy(); + await processor.destroy(); + + expect(t.sourceNode.disconnect).toHaveBeenCalledOnce(); + expect(t.workletNode.disconnect).toHaveBeenCalledOnce(); + expect(t.destinationNode.disconnect).toHaveBeenCalledOnce(); + expect(t.workletNode.port.postMessage).toHaveBeenCalledWith({ + type: "destroy", + }); + expect(t.processedTrack.stop).toHaveBeenCalledOnce(); + expect(processor.processedTrack).toBeUndefined(); + }); + + it("destroy does not throw when no processed track exists", async () => { + const processor = new RNNoiseProcessor(); + + await expect(processor.destroy()).resolves.toBeUndefined(); + }); + + it("restart re-initializes with a new processed track", async () => { + const first = createTestContext(); + const second = createTestContext(); + const workletCtor = vi + .fn() + .mockReturnValueOnce(first.workletNode) + .mockReturnValueOnce(second.workletNode); + vi.stubGlobal("AudioWorkletNode", workletCtor); + + const processor = new RNNoiseProcessor(); + await processor.init({ + kind: Track.Kind.Audio, + track: first.track, + audioContext: first.audioContext, + }); + const firstProcessedTrack = processor.processedTrack; + + await processor.restart({ + kind: Track.Kind.Audio, + track: second.track, + audioContext: second.audioContext, + }); + + expect(processor.processedTrack).toBe(second.processedTrack); + expect(processor.processedTrack).not.toBe(firstProcessedTrack); + }); + + it("loads the worklet module once per AudioContext", async () => { + const t = createTestContext(); + vi.stubGlobal("AudioWorkletNode", vi.fn().mockReturnValue(t.workletNode)); + const firstProcessor = new RNNoiseProcessor(); + const secondProcessor = new RNNoiseProcessor(); + + await firstProcessor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + await secondProcessor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + + expect(t.addModule).toHaveBeenCalledOnce(); + }); + + it("reports support based on AudioWorklet availability", () => { + expect(supportsRNNoiseProcessor()).toBe(false); + vi.stubGlobal("AudioWorkletNode", class AudioWorkletNode {}); + vi.stubGlobal( + "MediaStreamAudioDestinationNode", + class MediaStreamAudioDestinationNode {}, + ); + vi.stubGlobal( + "MediaStreamAudioSourceNode", + class MediaStreamAudioSourceNode {}, + ); + vi.stubGlobal("AudioWorklet", class AudioWorkletWithoutAddModule {}); + expect(supportsRNNoiseProcessor()).toBe(false); + vi.stubGlobal( + "AudioWorklet", + class AudioWorklet { + public async addModule(): Promise { + await Promise.resolve(); + } + }, + ); + expect(supportsRNNoiseProcessor()).toBe(true); + }); + + it("updates worklet preset at runtime", async () => { + const t = createTestContext(); + vi.stubGlobal("AudioWorkletNode", vi.fn().mockReturnValue(t.workletNode)); + const processor = new RNNoiseProcessor(); + + await processor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + + processor.setPreset("strong"); + + expect(t.workletNode.port.postMessage).toHaveBeenCalledWith({ + type: "preset", + preset: "strong", + }); + }); + + it("bypasses RNNoise for unsupported audio context sample rates", async () => { + const t = createTestContext(44100); + const workletCtor = vi.fn().mockReturnValue(t.workletNode); + const warningSpy = vi.spyOn(logger, "warn"); + vi.stubGlobal("AudioWorkletNode", workletCtor); + const processor = new RNNoiseProcessor(); + + await expect( + processor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }), + ).rejects.toThrow("48000Hz"); + + expect(warningSpy).toHaveBeenCalledOnce(); + expect(t.addModule).not.toHaveBeenCalled(); + expect(workletCtor).not.toHaveBeenCalled(); + expect(processor.processedTrack).toBeUndefined(); + }); + + it("propagates worklet registration failures", async () => { + const t = createTestContext(); + const workletCtor = vi.fn().mockReturnValue(t.workletNode); + const addModuleError = new Error("Failed to register worklet module"); + t.addModule.mockRejectedValueOnce(addModuleError); + vi.stubGlobal("AudioWorkletNode", workletCtor); + const processor = new RNNoiseProcessor(); + + await expect( + processor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }), + ).rejects.toThrow(addModuleError); + + expect(workletCtor).not.toHaveBeenCalled(); + }); + + it("restarts with the last known audio context when restart omits audioContext", async () => { + const t = createTestContext(); + const workletCtor = vi.fn().mockReturnValue(t.workletNode); + vi.stubGlobal("AudioWorkletNode", workletCtor); + const processor = new RNNoiseProcessor(); + + await processor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + + const restartedTrack = { + id: "restarted-input-track", + kind: Track.Kind.Audio, + } as MediaStreamTrack; + await processor.restart({ + kind: Track.Kind.Audio, + track: restartedTrack, + // LiveKit restart paths can omit audioContext. + audioContext: undefined as unknown as AudioContext, + }); + + expect(t.addModule).toHaveBeenCalledOnce(); + expect(t.createSourceNode).toHaveBeenCalledTimes(2); + expect(workletCtor).toHaveBeenCalledTimes(2); + }); + + it("deterministically downmixes stereo input to mono in the worklet passthrough path", () => { + const workletCode = getGeneratedWorkletCode(); + const worklet = instantiateWorkletProcessor(workletCode); + const left = new Float32Array([1, -1, 0.5, 0]); + const right = new Float32Array([0, 1, -0.5, 0.5]); + const output = new Float32Array(left.length); + + const keepProcessing = worklet.process([[left, right]], [[output]], {}); + + expect(keepProcessing).toBe(true); + expect(output).toEqual(new Float32Array([0.5, 0, 0, 0.25])); + expect(output).toHaveLength(left.length); + }); + + it("downmixes all input channels by averaging each sample", () => { + const workletCode = getGeneratedWorkletCode(); + const worklet = instantiateWorkletProcessor(workletCode); + const first = new Float32Array([0.6, -0.3, 0.9]); + const second = new Float32Array([0.3, 0.3, -0.3]); + const third = new Float32Array([0, 0.6, 0]); + const output = new Float32Array(first.length); + + worklet.process([[first, second, third]], [[output]], {}); + + expect(output[0]).toBeCloseTo(0.3, 6); + expect(output[1]).toBeCloseTo(0.2, 6); + expect(output[2]).toBeCloseTo(0.2, 6); + expect(output).toHaveLength(first.length); + }); + + it("keeps the balanced preset tuning unchanged", () => { + const balanced = getPresetConfig(getGeneratedWorkletCode(), "balanced"); + + expect(balanced).toEqual({ + maxAttenuationDb: 8, + openThreshold: 0.9, + closeThreshold: 0.55, + holdFrames: 10, + attenuateMs: 90, + releaseMs: 22, + }); + }); + + it("maps strong preset to a more aggressive profile than balanced", () => { + const workletCode = getGeneratedWorkletCode(); + const balanced = getPresetConfig(workletCode, "balanced"); + const strong = getPresetConfig(workletCode, "strong"); + + expect(strong.maxAttenuationDb).toBeGreaterThan(balanced.maxAttenuationDb); + expect(strong.openThreshold).toBeGreaterThanOrEqual(balanced.openThreshold); + expect(strong.closeThreshold).toBeGreaterThanOrEqual( + balanced.closeThreshold, + ); + expect(strong.holdFrames).toBeLessThan(balanced.holdFrames); + expect(strong.attenuateMs).toBeLessThan(balanced.attenuateMs); + }); + + it("applies lower expected noise-floor gain on strong than balanced", () => { + const workletCode = getGeneratedWorkletCode(); + const balanced = getPresetConfig(workletCode, "balanced"); + const strong = getPresetConfig(workletCode, "strong"); + const noiseLikeVadProbabilities = [0.2, 0.4, 0.6, 0.8]; + + for (const vad of noiseLikeVadProbabilities) { + expect(expectedAttenuationDb(strong, vad)).toBeGreaterThanOrEqual( + expectedAttenuationDb(balanced, vad), + ); + } + + const balancedSilenceGain = Math.pow(10, -balanced.maxAttenuationDb / 20); + const strongSilenceGain = Math.pow(10, -strong.maxAttenuationDb / 20); + expect(strongSilenceGain).toBeLessThan(balancedSilenceGain); + }); + + it("init() called twice without destroy() cleans up previous nodes", async () => { + const first = createTestContext(); + const second = createTestContext(); + const workletCtor = vi + .fn() + .mockReturnValueOnce(first.workletNode) + .mockReturnValueOnce(second.workletNode); + vi.stubGlobal("AudioWorkletNode", workletCtor); + + const processor = new RNNoiseProcessor(); + await processor.init({ + kind: Track.Kind.Audio, + track: first.track, + audioContext: first.audioContext, + }); + await processor.init({ + kind: Track.Kind.Audio, + track: second.track, + audioContext: second.audioContext, + }); + + // First nodes must have been torn down + expect(first.sourceNode.disconnect).toHaveBeenCalledOnce(); + expect(first.workletNode.disconnect).toHaveBeenCalledOnce(); + expect(first.destinationNode.disconnect).toHaveBeenCalledOnce(); + expect(first.processedTrack.stop).toHaveBeenCalledOnce(); + + // Second nodes should now be active + expect(processor.processedTrack).toBe(second.processedTrack); + }); + + it("concurrent ensureWorkletRegistered calls only invoke addModule once", async () => { + const t = createTestContext(); + // Replace the default resolved mock with a controllable promise so both + // init() calls are in-flight at the same time. + let resolveAddModule!: () => void; + t.addModule.mockReturnValueOnce( + new Promise((resolve) => { + resolveAddModule = resolve; + }), + ); + vi.stubGlobal("AudioWorkletNode", vi.fn().mockReturnValue(t.workletNode)); + + const processor1 = new RNNoiseProcessor(); + const processor2 = new RNNoiseProcessor(); + + // Start both inits before either addModule resolves + const init1 = processor1.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + const init2 = processor2.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + + resolveAddModule(); + await Promise.all([init1, init2]); + + expect(t.addModule).toHaveBeenCalledOnce(); + }); + + it("does not expose an active graph after concurrent init and destroy", async () => { + const t = createTestContext(); + let resolveAddModule!: () => void; + t.addModule.mockReturnValueOnce( + new Promise((resolve) => { + resolveAddModule = resolve; + }), + ); + vi.stubGlobal("AudioWorkletNode", vi.fn().mockReturnValue(t.workletNode)); + + const processor = new RNNoiseProcessor(); + const initPromise = processor.init({ + kind: Track.Kind.Audio, + track: t.track, + audioContext: t.audioContext, + }); + + // destroy() races with the in-flight init(); let destroy complete first + await processor.destroy(); + + // Now let the worklet registration resolve and init() resume + resolveAddModule(); + await initPromise; + + // init() must have aborted after seeing destroyed=true; no track exposed + expect(processor.processedTrack).toBeUndefined(); + }); +}); diff --git a/src/audio/RNNoiseProcessor.ts b/src/audio/RNNoiseProcessor.ts new file mode 100644 index 000000000..023758abd --- /dev/null +++ b/src/audio/RNNoiseProcessor.ts @@ -0,0 +1,473 @@ +/* +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 { logger } from "matrix-js-sdk/lib/logger"; + +import type { + AudioProcessorOptions, + Track, + TrackProcessor, +} from "livekit-client"; +import type { RNNoiseSuppressionPreset } from "./rnnoiseTypes"; +import rnnoiseWorkletModuleUrl from "./RNNoiseWorkletModule.ts?worker&url"; + +/** + * The number of samples per frame expected by RNNoise (at 48kHz = 10ms). + */ +const RNNOISE_SAMPLE_LENGTH = 480; +const RNNOISE_REQUIRED_SAMPLE_RATE = 48000; +const RNNOISE_WORKLET_NAME = "rnnoise-processor"; +const DEFAULT_RNNOISE_PRESET: RNNoiseSuppressionPreset = "conservative"; +// Stores the addModule() promise per AudioContext: pending while in-flight, +// settled (resolved) once complete, absent on failure (cleared for retry). +const workletRegistrations = new WeakMap>(); +const warnedUnsupportedSampleRates = new Set(); + +type RNNoiseSupportGlobal = typeof globalThis & { + AudioWorklet?: { + prototype?: { + addModule?: unknown; + }; + }; +}; + +function createUnsupportedSampleRateError(sampleRate: number): Error { + return new Error( + `RNNoise requires an AudioContext sample rate of ${RNNOISE_REQUIRED_SAMPLE_RATE}Hz (received ${sampleRate}Hz).`, + ); +} + +function warnUnsupportedSampleRate(sampleRate: number): void { + if (warnedUnsupportedSampleRates.has(sampleRate)) { + return; + } + + warnedUnsupportedSampleRates.add(sampleRate); + logger.warn( + `Skipping RNNoise because AudioContext sample rate is ${sampleRate}Hz (expected ${RNNOISE_REQUIRED_SAMPLE_RATE}Hz).`, + ); +} + +/** + * Whether the current runtime supports the required APIs for RNNoise. + */ +export function supportsRNNoiseProcessor(): boolean { + const workletPrototype = (globalThis as RNNoiseSupportGlobal).AudioWorklet + ?.prototype; + + return ( + typeof AudioWorkletNode !== "undefined" && + typeof MediaStreamAudioDestinationNode !== "undefined" && + typeof MediaStreamAudioSourceNode !== "undefined" && + typeof workletPrototype?.addModule === "function" + ); +} + +/** + * Generates the AudioWorklet processor code as a string, for use in tests. + * + * WARNING: This function is the **test harness** version of the worklet. + * The authoritative runtime implementation lives in `RNNoiseWorkletModule.ts`, + * which Vite compiles and loads as a separate script via the `?url` import. + * If the processor logic changes in `RNNoiseWorkletModule.ts` — frame size, + * ring buffer, preset constants, downmix algorithm, etc. — the generated + * code here **must be updated to match** or tests will diverge from runtime + * behaviour. + * + * The worklet loads the RNNoise WASM module synchronously (base64-inlined) + * and processes audio in 480-sample frames. A ring buffer bridges the + * 128-sample AudioWorklet blocks to the 480-sample RNNoise frames. + */ +function createWorkletCode(rnnoiseModuleCode: string): string { + // Patch the rnnoise-sync.js for AudioWorklet scope: + // 1. Replace import.meta.url — not available in classic worklet scripts + // 2. Remove the ES module export statement + const patched = rnnoiseModuleCode + .replace(/import\.meta\.url/g, '""') + .replace(/export\s+default\s+createRNNWasmModuleSync;?\s*$/m, ""); + + return ` +${patched} + +const FRAME_SIZE = ${RNNOISE_SAMPLE_LENGTH}; +const RING_SIZE = FRAME_SIZE * 3; // Enough headroom for buffering +const SAMPLE_RATE = ${RNNOISE_REQUIRED_SAMPLE_RATE}; +const DEFAULT_PRESET = "${DEFAULT_RNNOISE_PRESET}"; +const PRESETS = { + conservative: { + maxAttenuationDb: 4, + openThreshold: 0.92, + closeThreshold: 0.60, + holdFrames: 12, + attenuateMs: 120, + releaseMs: 25, + }, + balanced: { + maxAttenuationDb: 8, + openThreshold: 0.90, + closeThreshold: 0.55, + holdFrames: 10, + attenuateMs: 90, + releaseMs: 22, + }, + strong: { + maxAttenuationDb: 16, + openThreshold: 0.90, + closeThreshold: 0.55, + holdFrames: 8, + attenuateMs: 55, + releaseMs: 18, + }, +}; + +class RNNoiseWorkletProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._ready = false; + this._destroyed = false; + + // Ring buffers + this._inBuf = new Float32Array(RING_SIZE); + this._outBuf = new Float32Array(RING_SIZE); + this._inW = 0; // input write position + this._inR = 0; // input read position + this._outW = 0; // output write position + this._outR = 0; // output read position + this._currentGain = 1; + this._targetGain = 1; + this._holdFrames = 0; + + this._setPreset(DEFAULT_PRESET); + + this._initRNNoise(); + + this.port.onmessage = (event) => { + if (event.data.type === 'destroy') { + this._cleanup(); + } else if (event.data.type === 'preset') { + this._setPreset(event.data.preset); + } + }; + } + + _smoothingStepFromMs(ms) { + if (ms <= 0) return 1; + const tau = ms / 1000; + return 1 - Math.exp(-1 / (SAMPLE_RATE * tau)); + } + + _setPreset(preset) { + if (!(preset in PRESETS)) return; + this._preset = preset; + const config = PRESETS[preset]; + this._maxAttenuationDb = config.maxAttenuationDb; + this._openThreshold = config.openThreshold; + this._closeThreshold = config.closeThreshold; + this._holdFramesConfig = config.holdFrames; + this._attenuateStep = this._smoothingStepFromMs(config.attenuateMs); + this._releaseStep = this._smoothingStepFromMs(config.releaseMs); + } + + _updateTargetGain(vadProbability) { + if (vadProbability >= this._openThreshold) { + this._holdFrames = this._holdFramesConfig; + this._targetGain = 1; + return; + } + + if (this._holdFrames > 0) { + this._holdFrames -= 1; + this._targetGain = 1; + return; + } + + const thresholdRange = this._openThreshold - this._closeThreshold; + const attenuationProgress = thresholdRange > 0 + ? Math.max( + 0, + Math.min(1, (this._openThreshold - vadProbability) / thresholdRange), + ) + : 1; + + const attenuationDb = attenuationProgress * this._maxAttenuationDb; + this._targetGain = Math.pow(10, -attenuationDb / 20); + } + + _ringAvailable(w, r) { + let avail = w - r; + if (avail < 0) avail += RING_SIZE; + return avail; + } + + _initRNNoise() { + try { + const module = createRNNWasmModuleSync(); + + // Allocate a buffer in WASM memory for one frame of float32 samples + const pcmBuf = module._malloc(FRAME_SIZE * 4); + module._rnnoise_init(); + const state = module._rnnoise_create(); + + this._module = module; + this._pcmBuf = pcmBuf; + this._state = state; + this._heapF32 = module.HEAPF32; + + this._ready = true; + } catch (e) { + // If RNNoise fails to initialize, audio will pass through unprocessed + this.port.postMessage({ type: 'error', message: String(e) }); + } + } + + _cleanup() { + if (this._module && this._state) { + this._module._rnnoise_destroy(this._state); + this._module._free(this._pcmBuf); + this._state = null; + } + this._destroyed = true; + } + + _processRNNoiseFrame() { + const heapIdx = this._pcmBuf >> 2; // byte offset → float32 index + + // Copy from input ring buffer to WASM heap, scaling to int16 range + for (let i = 0; i < FRAME_SIZE; i++) { + this._heapF32[heapIdx + i] = + this._inBuf[(this._inR + i) % RING_SIZE] * 32768.0; + } + this._inR = (this._inR + FRAME_SIZE) % RING_SIZE; + + // Run RNNoise denoising (in-place) + const vadProbability = this._module._rnnoise_process_frame( + this._state, this._pcmBuf, this._pcmBuf + ); + this._updateTargetGain(vadProbability); + + // Copy from WASM heap to output ring buffer, scaling back to float range. + // Apply additional conservative attenuation between speech segments. + for (let i = 0; i < FRAME_SIZE; i++) { + const smoothingStep = this._targetGain < this._currentGain + ? this._attenuateStep + : this._releaseStep; + this._currentGain += + (this._targetGain - this._currentGain) * smoothingStep; + this._outBuf[(this._outW + i) % RING_SIZE] = + (this._heapF32[heapIdx + i] / 32768.0) * this._currentGain; + } + this._outW = (this._outW + FRAME_SIZE) % RING_SIZE; + } + + _mixInputChannels(inputChannels, sampleIndex, channelCount) { + // RNNoise is mono-only; average all channels for deterministic downmixing. + let mixed = 0; + for (let i = 0; i < channelCount; i++) { + const channel = inputChannels[i]; + mixed += channel ? (channel[sampleIndex] ?? 0) : 0; + } + return mixed / channelCount; + } + + process(inputs, outputs) { + if (this._destroyed) return false; + + const inputChannels = inputs[0]; + const output = outputs[0]?.[0]; + + if (!inputChannels?.length || !output) return true; + + const blockSize = output.length; + const channelCount = inputChannels.length; + + if (!this._ready) { + // Pass through until RNNoise is ready, with deterministic mono downmix. + for (let i = 0; i < blockSize; i++) { + output[i] = this._mixInputChannels(inputChannels, i, channelCount); + } + return true; + } + + // Write input samples to the input ring buffer + for (let i = 0; i < blockSize; i++) { + this._inBuf[this._inW] = this._mixInputChannels( + inputChannels, + i, + channelCount, + ); + this._inW = (this._inW + 1) % RING_SIZE; + } + + // Process complete frames + while (this._ringAvailable(this._inW, this._inR) >= FRAME_SIZE) { + this._processRNNoiseFrame(); + } + + // Read from output ring buffer + const outAvail = this._ringAvailable(this._outW, this._outR); + const toRead = Math.min(blockSize, outAvail); + + for (let i = 0; i < toRead; i++) { + output[i] = this._outBuf[this._outR]; + this._outR = (this._outR + 1) % RING_SIZE; + } + // Fill remaining with silence (only during initial buffering) + for (let i = toRead; i < blockSize; i++) { + output[i] = 0; + } + + return true; + } +} + +registerProcessor('${RNNOISE_WORKLET_NAME}', RNNoiseWorkletProcessor); +`; +} + +export function createRNNoiseWorkletCodeForTesting( + rnnoiseModuleCode: string, +): string { + return createWorkletCode(rnnoiseModuleCode); +} + +/** + * A LiveKit TrackProcessor that applies RNNoise-based noise suppression + * to a local audio track via an AudioWorklet. + * + * The RNNoise WASM binary is lazy-loaded only when the processor is + * initialized, keeping the main bundle small. + */ +export class RNNoiseProcessor implements TrackProcessor< + Track.Kind.Audio, + AudioProcessorOptions +> { + public name = "rnnoise-noise-suppression"; + public processedTrack?: MediaStreamTrack; + + private sourceNode?: MediaStreamAudioSourceNode; + private workletNode?: AudioWorkletNode; + private destinationNode?: MediaStreamAudioDestinationNode; + private destroyed = false; + private preset: RNNoiseSuppressionPreset; + private lastAudioContext?: AudioContext; + + public constructor( + preset: RNNoiseSuppressionPreset = DEFAULT_RNNOISE_PRESET, + ) { + this.preset = preset; + } + + private async ensureWorkletRegistered( + audioContext: AudioContext, + ): Promise { + const existing = workletRegistrations.get(audioContext); + if (existing) return existing; + + const pending = audioContext.audioWorklet.addModule( + rnnoiseWorkletModuleUrl, + ); + workletRegistrations.set(audioContext, pending); + // On failure, remove the entry so the next call can retry. + pending.catch(() => { + workletRegistrations.delete(audioContext); + }); + return pending; + } + + public async init(opts: AudioProcessorOptions): Promise { + // If already initialized, tear down previous nodes before re-initializing + // so callers don't need to explicitly call destroy() first. + if (this.workletNode !== undefined) { + await this.destroy(); + } + this.destroyed = false; + const { audioContext, track } = opts; + + if (audioContext.sampleRate !== RNNOISE_REQUIRED_SAMPLE_RATE) { + warnUnsupportedSampleRate(audioContext.sampleRate); + throw createUnsupportedSampleRateError(audioContext.sampleRate); + } + + await this.ensureWorkletRegistered(audioContext); + + // A concurrent destroy() may have run while we awaited worklet registration. + if (this.destroyed) return; + + // Build the audio processing graph: + // MediaStreamSource → AudioWorkletNode (RNNoise) → MediaStreamDestination + const sourceNode = audioContext.createMediaStreamSource( + new MediaStream([track]), + ); + const workletNode = new AudioWorkletNode( + audioContext, + RNNOISE_WORKLET_NAME, + { + channelCount: 1, + channelCountMode: "explicit", + }, + ); + const destinationNode = audioContext.createMediaStreamDestination(); + + sourceNode.connect(workletNode); + workletNode.connect(destinationNode); + + this.sourceNode = sourceNode; + this.workletNode = workletNode; + this.destinationNode = destinationNode; + this.workletNode.port.postMessage({ type: "preset", preset: this.preset }); + this.processedTrack = destinationNode.stream.getAudioTracks()[0]; + this.lastAudioContext = audioContext; + } + + public async restart(opts: AudioProcessorOptions): Promise { + const audioContext = opts.audioContext ?? this.lastAudioContext; + if (!audioContext) { + throw new Error( + "RNNoise restart requires an AudioContext when no previous context has been initialized.", + ); + } + + await this.destroy(); + await this.init({ ...opts, audioContext }); + } + + public async destroy(): Promise { + if (this.destroyed) { + await Promise.resolve(); + return; + } + this.destroyed = true; + + // Signal the worklet to clean up WASM resources + this.workletNode?.port.postMessage({ type: "destroy" }); + + // Disconnect the audio graph + this.sourceNode?.disconnect(); + this.workletNode?.disconnect(); + this.destinationNode?.disconnect(); + + try { + this.processedTrack?.stop(); + } catch (e) { + logger.warn("Failed to stop RNNoise processed track during destroy", e); + } + + this.sourceNode = undefined; + this.workletNode = undefined; + this.destinationNode = undefined; + this.processedTrack = undefined; + await Promise.resolve(); + } + + public setPreset(preset: RNNoiseSuppressionPreset): void { + this.preset = preset; + this.workletNode?.port.postMessage({ + type: "preset", + preset: this.preset, + }); + } +} diff --git a/src/audio/RNNoiseWorkletModule.ts b/src/audio/RNNoiseWorkletModule.ts new file mode 100644 index 000000000..976d804d3 --- /dev/null +++ b/src/audio/RNNoiseWorkletModule.ts @@ -0,0 +1,303 @@ +/* +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 createRNNWasmModuleSync from "@jitsi/rnnoise-wasm/dist/rnnoise-sync.js"; + +import type { RNNoiseSuppressionPreset } from "./rnnoiseTypes"; + +declare abstract class AudioWorkletProcessor { + protected constructor(options?: AudioWorkletNodeOptions); + public readonly port: MessagePort; +} + +declare function registerProcessor( + name: string, + processorCtor: new ( + options?: AudioWorkletNodeOptions, + ) => AudioWorkletProcessor, +): void; + +const FRAME_SIZE = 480; +const RING_SIZE = FRAME_SIZE * 3; +const SAMPLE_RATE = 48000; +const RNNOISE_WORKLET_NAME = "rnnoise-processor"; +const DEFAULT_PRESET: RNNoiseSuppressionPreset = "conservative"; + +type PresetConfig = { + maxAttenuationDb: number; + openThreshold: number; + closeThreshold: number; + holdFrames: number; + attenuateMs: number; + releaseMs: number; +}; + +type RNNoiseModule = { + HEAPF32: Float32Array; + [key: string]: unknown; +}; + +type WorkletMessage = + | { type: "destroy" } + | { type: "preset"; preset: RNNoiseSuppressionPreset }; + +const PRESETS: Record = { + conservative: { + maxAttenuationDb: 4, + openThreshold: 0.92, + closeThreshold: 0.6, + holdFrames: 12, + attenuateMs: 120, + releaseMs: 25, + }, + balanced: { + maxAttenuationDb: 8, + openThreshold: 0.9, + closeThreshold: 0.55, + holdFrames: 10, + attenuateMs: 90, + releaseMs: 22, + }, + strong: { + maxAttenuationDb: 16, + openThreshold: 0.9, + closeThreshold: 0.55, + holdFrames: 8, + attenuateMs: 55, + releaseMs: 18, + }, +}; + +function isPreset( + preset: unknown, +): preset is keyof typeof PRESETS & RNNoiseSuppressionPreset { + return typeof preset === "string" && preset in PRESETS; +} + +class RNNoiseWorkletProcessor extends AudioWorkletProcessor { + private ready = false; + private destroyed = false; + private readonly inBuf = new Float32Array(RING_SIZE); + private readonly outBuf = new Float32Array(RING_SIZE); + private inW = 0; + private inR = 0; + private outW = 0; + private outR = 0; + private currentGain = 1; + private targetGain = 1; + private holdFrames = 0; + private maxAttenuationDb = PRESETS[DEFAULT_PRESET].maxAttenuationDb; + private openThreshold = PRESETS[DEFAULT_PRESET].openThreshold; + private closeThreshold = PRESETS[DEFAULT_PRESET].closeThreshold; + private holdFramesConfig = PRESETS[DEFAULT_PRESET].holdFrames; + private attenuateStep = 1; + private releaseStep = 1; + private module?: RNNoiseModule; + private pcmBuf?: number; + private state: number | null = null; + private heapF32?: Float32Array; + + public constructor() { + super(); + + this.setPreset(DEFAULT_PRESET); + this.initRNNoise(); + + this.port.onmessage = (event: MessageEvent): void => { + if (event.data.type === "destroy") { + this.cleanup(); + } else if (event.data.type === "preset" && isPreset(event.data.preset)) { + this.setPreset(event.data.preset); + } + }; + } + + private smoothingStepFromMs(ms: number): number { + if (ms <= 0) return 1; + const tau = ms / 1000; + return 1 - Math.exp(-1 / (SAMPLE_RATE * tau)); + } + + private setPreset(preset: RNNoiseSuppressionPreset): void { + if (!isPreset(preset)) return; + + const config = PRESETS[preset]; + this.maxAttenuationDb = config.maxAttenuationDb; + this.openThreshold = config.openThreshold; + this.closeThreshold = config.closeThreshold; + this.holdFramesConfig = config.holdFrames; + this.attenuateStep = this.smoothingStepFromMs(config.attenuateMs); + this.releaseStep = this.smoothingStepFromMs(config.releaseMs); + } + + private updateTargetGain(vadProbability: number): void { + if (vadProbability >= this.openThreshold) { + this.holdFrames = this.holdFramesConfig; + this.targetGain = 1; + return; + } + + if (this.holdFrames > 0) { + this.holdFrames -= 1; + this.targetGain = 1; + return; + } + + const thresholdRange = this.openThreshold - this.closeThreshold; + const attenuationProgress = + thresholdRange > 0 + ? Math.max( + 0, + Math.min(1, (this.openThreshold - vadProbability) / thresholdRange), + ) + : 1; + + const attenuationDb = attenuationProgress * this.maxAttenuationDb; + this.targetGain = Math.pow(10, -attenuationDb / 20); + } + + private ringAvailable(w: number, r: number): number { + let available = w - r; + if (available < 0) available += RING_SIZE; + return available; + } + + private initRNNoise(): void { + try { + const module = createRNNWasmModuleSync() as unknown as RNNoiseModule; + const malloc = module["_malloc"] as (size: number) => number; + const rnnoiseInit = module["_rnnoise_init"] as () => void; + const rnnoiseCreate = module["_rnnoise_create"] as () => number; + const pcmBuf = malloc(FRAME_SIZE * 4); + rnnoiseInit(); + const state = rnnoiseCreate(); + + this.module = module; + this.pcmBuf = pcmBuf; + this.state = state; + this.heapF32 = module.HEAPF32; + this.ready = true; + } catch (error) { + this.port.postMessage({ type: "error", message: String(error) }); + } + } + + private cleanup(): void { + if (this.module && this.state !== null && this.pcmBuf !== undefined) { + const rnnoiseDestroy = this.module["_rnnoise_destroy"] as ( + state: number, + ) => void; + const free = this.module["_free"] as (ptr: number) => void; + rnnoiseDestroy(this.state); + free(this.pcmBuf); + this.state = null; + } + this.destroyed = true; + } + + private processRNNoiseFrame(): void { + if ( + !this.module || + this.state === null || + this.pcmBuf === undefined || + !this.heapF32 + ) { + return; + } + + const heapIdx = this.pcmBuf >> 2; + + for (let i = 0; i < FRAME_SIZE; i++) { + this.heapF32[heapIdx + i] = + this.inBuf[(this.inR + i) % RING_SIZE] * 32768; + } + this.inR = (this.inR + FRAME_SIZE) % RING_SIZE; + + const rnnoiseProcessFrame = this.module["_rnnoise_process_frame"] as ( + state: number, + input: number, + output: number, + ) => number; + const vadProbability = rnnoiseProcessFrame( + this.state, + this.pcmBuf, + this.pcmBuf, + ); + this.updateTargetGain(vadProbability); + + for (let i = 0; i < FRAME_SIZE; i++) { + const smoothingStep = + this.targetGain < this.currentGain + ? this.attenuateStep + : this.releaseStep; + this.currentGain += (this.targetGain - this.currentGain) * smoothingStep; + this.outBuf[(this.outW + i) % RING_SIZE] = + (this.heapF32[heapIdx + i] / 32768) * this.currentGain; + } + this.outW = (this.outW + FRAME_SIZE) % RING_SIZE; + } + + private mixInputChannels( + inputChannels: Float32Array[], + sampleIndex: number, + channelCount: number, + ): number { + let mixed = 0; + for (let i = 0; i < channelCount; i++) { + const channel = inputChannels[i]; + mixed += channel ? (channel[sampleIndex] ?? 0) : 0; + } + return mixed / channelCount; + } + + public process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean { + if (this.destroyed) return false; + + const inputChannels = inputs[0]; + const output = outputs[0]?.[0]; + + if (!inputChannels?.length || !output) return true; + + const blockSize = output.length; + const channelCount = inputChannels.length; + + if (!this.ready) { + for (let i = 0; i < blockSize; i++) { + output[i] = this.mixInputChannels(inputChannels, i, channelCount); + } + return true; + } + + for (let i = 0; i < blockSize; i++) { + this.inBuf[this.inW] = this.mixInputChannels( + inputChannels, + i, + channelCount, + ); + this.inW = (this.inW + 1) % RING_SIZE; + } + + while (this.ringAvailable(this.inW, this.inR) >= FRAME_SIZE) { + this.processRNNoiseFrame(); + } + + const outAvailable = this.ringAvailable(this.outW, this.outR); + const toRead = Math.min(blockSize, outAvailable); + + for (let i = 0; i < toRead; i++) { + output[i] = this.outBuf[this.outR]; + this.outR = (this.outR + 1) % RING_SIZE; + } + for (let i = toRead; i < blockSize; i++) { + output[i] = 0; + } + + return true; + } +} + +registerProcessor(RNNOISE_WORKLET_NAME, RNNoiseWorkletProcessor); diff --git a/src/audio/noiseSuppressionPolicy.test.ts b/src/audio/noiseSuppressionPolicy.test.ts new file mode 100644 index 000000000..5ad2deaf3 --- /dev/null +++ b/src/audio/noiseSuppressionPolicy.test.ts @@ -0,0 +1,49 @@ +/* +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 { describe, expect, it } from "vitest"; + +import { shouldEnableNativeNoiseSuppression } from "./noiseSuppressionPolicy"; + +describe("shouldEnableNativeNoiseSuppression", () => { + it("keeps native suppression enabled when RNNoise is enabled but unsupported", () => { + expect( + shouldEnableNativeNoiseSuppression({ + urlNoiseSuppression: true, + rnnoiseEnabled: true, + rnnoiseSupported: false, + }), + ).toBe(true); + }); + + it("disables native suppression only when RNNoise is enabled and supported", () => { + expect( + shouldEnableNativeNoiseSuppression({ + urlNoiseSuppression: true, + rnnoiseEnabled: true, + rnnoiseSupported: true, + }), + ).toBe(false); + }); + + it("keeps native suppression disabled when explicitly disabled by URL", () => { + expect( + shouldEnableNativeNoiseSuppression({ + urlNoiseSuppression: false, + rnnoiseEnabled: false, + rnnoiseSupported: false, + }), + ).toBe(false); + expect( + shouldEnableNativeNoiseSuppression({ + urlNoiseSuppression: false, + rnnoiseEnabled: true, + rnnoiseSupported: false, + }), + ).toBe(false); + }); +}); diff --git a/src/audio/noiseSuppressionPolicy.ts b/src/audio/noiseSuppressionPolicy.ts new file mode 100644 index 000000000..93ec150be --- /dev/null +++ b/src/audio/noiseSuppressionPolicy.ts @@ -0,0 +1,26 @@ +/* +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. +*/ + +interface NativeNoiseSuppressionPolicyOptions { + urlNoiseSuppression: boolean | undefined; + rnnoiseEnabled: boolean; + rnnoiseSupported: boolean; +} + +/** + * Resolves whether native browser noise suppression should be enabled. + * + * Native suppression should only be disabled when RNNoise is both enabled and + * actually supported at runtime. + */ +export function shouldEnableNativeNoiseSuppression({ + urlNoiseSuppression, + rnnoiseEnabled, + rnnoiseSupported, +}: NativeNoiseSuppressionPolicyOptions): boolean { + return (urlNoiseSuppression ?? true) && !(rnnoiseEnabled && rnnoiseSupported); +} diff --git a/src/audio/rnnoiseTypes.ts b/src/audio/rnnoiseTypes.ts new file mode 100644 index 000000000..6d6d964f0 --- /dev/null +++ b/src/audio/rnnoiseTypes.ts @@ -0,0 +1,15 @@ +/* +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. +*/ + +export const rnnoiseSuppressionPresets = [ + "conservative", + "balanced", + "strong", +] as const; + +export type RNNoiseSuppressionPreset = + (typeof rnnoiseSuppressionPresets)[number]; diff --git a/src/settings/SettingsModal.test.tsx b/src/settings/SettingsModal.test.tsx new file mode 100644 index 000000000..c4a93e437 --- /dev/null +++ b/src/settings/SettingsModal.test.tsx @@ -0,0 +1,189 @@ +/* +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 { afterEach, describe, beforeEach, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import type { MatrixClient } from "matrix-js-sdk"; +import type { ReactNode } from "react"; +import { SettingsModal } from "./SettingsModal"; +import { + rnnoiseNoiseSuppression, + rnnoiseNoiseSuppressionPreset, +} from "./settings"; +import { supportsRNNoiseProcessor } from "../audio/RNNoiseProcessor"; + +const { mockRequestDeviceNames } = vi.hoisted(() => ({ + mockRequestDeviceNames: vi.fn(), +})); + +vi.mock("../audio/RNNoiseProcessor", async () => { + const actual = await vi.importActual("../audio/RNNoiseProcessor"); + + return { + ...actual, + supportsRNNoiseProcessor: vi.fn(() => true), + }; +}); + +vi.mock("../Modal", () => ({ + Modal: ({ + open, + children, + }: { + open: boolean; + children: ReactNode; + }): ReactNode => (open ?
{children}
: null), +})); + +vi.mock("../tabs/Tabs", () => ({ + TabContainer: ({ + tab, + tabs, + }: { + tab: string; + tabs: { key: string; content: ReactNode }[]; + }): ReactNode => ( +
{tabs.find((candidate) => candidate.key === tab)?.content}
+ ), +})); + +vi.mock("../MediaDevicesContext", () => ({ + useMediaDevices: (): { + requestDeviceNames: typeof mockRequestDeviceNames; + audioInput: object; + audioOutput: object; + videoInput: object; + } => ({ + requestDeviceNames: mockRequestDeviceNames, + audioInput: {}, + audioOutput: {}, + videoInput: {}, + }), +})); + +vi.mock("./DeviceSelection", () => ({ + DeviceSelection: (): ReactNode =>
, +})); + +vi.mock("../livekit/TrackProcessorContext", () => ({ + useTrackProcessor: (): { supported: boolean; processor: undefined } => ({ + supported: true, + processor: undefined, + }), +})); + +vi.mock("./submit-rageshake", () => ({ + useSubmitRageshake: (): { + submitRageshake: ReturnType; + sending: boolean; + sent: boolean; + error: undefined; + available: boolean; + } => ({ + submitRageshake: vi.fn(), + sending: false, + sent: false, + error: undefined, + available: false, + }), +})); + +vi.mock("../UrlParams", async () => { + const actual = await vi.importActual("../UrlParams"); + return { + ...actual, + useUrlParams: (): { controlledAudioDevices: boolean } => ({ + controlledAudioDevices: false, + }), + }; +}); + +function renderSettingsModal(): void { + render( + + + , + ); +} + +describe("SettingsModal RNNoise controls", () => { + beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} + }, + ); + localStorage.clear(); + mockRequestDeviceNames.mockClear(); + rnnoiseNoiseSuppressionPreset.setValue("conservative"); + rnnoiseNoiseSuppression.setValue(false); + vi.mocked(supportsRNNoiseProcessor).mockReturnValue(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the RNNoise checkbox in the audio tab", () => { + renderSettingsModal(); + + expect( + screen.getByLabelText("Enable enhanced noise suppression (RNNoise)"), + ).toBeInTheDocument(); + expect(mockRequestDeviceNames).toHaveBeenCalledOnce(); + }); + + it("disables RNNoise when AudioWorklet support is unavailable", () => { + vi.mocked(supportsRNNoiseProcessor).mockReturnValue(false); + rnnoiseNoiseSuppression.setValue(true); + + renderSettingsModal(); + + const checkbox = screen.getByLabelText( + "Enable enhanced noise suppression (RNNoise)", + ); + expect(checkbox).toBeDisabled(); + expect(checkbox).not.toBeChecked(); + expect( + screen.getByText( + "(Enhanced noise suppression is not supported by this browser.)", + ), + ).toBeInTheDocument(); + expect( + screen.queryByText( + "Pick a suppression profile. Stronger modes remove more keyboard noise but can sound more processed.", + ), + ).not.toBeInTheDocument(); + }); + + it("persists RNNoise setting when toggled", async () => { + const user = userEvent.setup(); + renderSettingsModal(); + + const checkbox = screen.getByLabelText( + "Enable enhanced noise suppression (RNNoise)", + ); + await user.click(checkbox); + + expect(rnnoiseNoiseSuppression.getValue()).toBe(true); + expect( + localStorage.getItem("matrix-setting-rnnoise-noise-suppression"), + ).toBe("true"); + }); +}); diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 0daad7be9..7850f1670 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -5,10 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type ReactNode, useEffect, useState } from "react"; +import { + type ChangeEvent, + type FC, + type ReactNode, + useEffect, + useId, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { type MatrixClient } from "matrix-js-sdk"; -import { Button, Root as Form, Separator } from "@vector-im/compound-web"; +import { + Button, + InlineField, + Label, + RadioControl, + Root as Form, + Separator, +} from "@vector-im/compound-web"; import { type Room as LivekitRoom } from "livekit-client"; import { Modal } from "../Modal"; @@ -40,6 +54,8 @@ import { noiseSuppressionSetting, autoGainControlSetting, type VideoCodec, + rnnoiseNoiseSuppression as rnnoiseNoiseSuppressionSetting, + rnnoiseNoiseSuppressionPreset as rnnoiseNoiseSuppressionPresetSetting, } from "./settings"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; @@ -50,6 +66,11 @@ import { FieldRow, InputField } from "../input/Input"; import { useSubmitRageshake } from "./submit-rageshake"; import { useUrlParams } from "../UrlParams"; import { useBehavior } from "../useBehavior"; +import { supportsRNNoiseProcessor } from "../audio/RNNoiseProcessor"; +import { + type RNNoiseSuppressionPreset, + rnnoiseSuppressionPresets, +} from "../audio/rnnoiseTypes"; type SettingsTab = | "audio" @@ -237,6 +258,68 @@ export const SettingsModal: FC = ({ ); }; + const RNNoiseCheckbox: React.FC = (): ReactNode => { + const supported = supportsRNNoiseProcessor(); + const [rnnoiseEnabled, setRnnoiseEnabled] = useSetting( + rnnoiseNoiseSuppressionSetting, + ); + const [rnnoisePreset, setRnnoisePreset] = useSetting( + rnnoiseNoiseSuppressionPresetSetting, + ); + const rnnoisePresetGroup = useId(); + + const onPresetChange = (e: ChangeEvent): void => { + setRnnoisePreset(e.target.value as RNNoiseSuppressionPreset); + }; + + const presetLabelByPreset: Record = { + conservative: t("settings.audio_tab.rnnoise_preset_conservative"), + balanced: t("settings.audio_tab.rnnoise_preset_balanced"), + strong: t("settings.audio_tab.rnnoise_preset_strong"), + }; + const effectiveRnnoiseEnabled = supported && !!rnnoiseEnabled; + + return ( + <> +

{t("settings.audio_tab.rnnoise_header")}

+ + setRnnoiseEnabled(e.target.checked)} + disabled={!supported} + /> + + {effectiveRnnoiseEnabled && ( + <> +

{t("settings.audio_tab.rnnoise_preset_description")}

+ {rnnoiseSuppressionPresets.map((preset) => ( + + } + > + + + ))} + + )} + + ); + }; + const AudioProcessingSettings: React.FC = (): ReactNode => { const [echoCancellation, setEchoCancellation] = useSetting( echoCancellationSetting, @@ -247,6 +330,9 @@ export const SettingsModal: FC = ({ const [autoGainControl, setAutoGainControl] = useSetting( autoGainControlSetting, ); + const [rnnoiseEnabled] = useSetting(rnnoiseNoiseSuppressionSetting); + const rnnoiseSupported = supportsRNNoiseProcessor(); + const rnnoiseOverridesNative = rnnoiseEnabled && rnnoiseSupported; return ( <> @@ -270,9 +356,18 @@ export const SettingsModal: FC = ({ setNoiseSuppression(e.target.checked)} + disabled={rnnoiseOverridesNative} /> @@ -384,6 +479,8 @@ export const SettingsModal: FC = ({ step={0.01} />
+ + diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 01039437d..6ab3661c1 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -9,6 +9,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { BehaviorSubject } from "rxjs"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; +import type { RNNoiseSuppressionPreset } from "../audio/rnnoiseTypes"; import { type Behavior } from "../state/Behavior"; import { useBehavior } from "../useBehavior"; @@ -115,6 +116,15 @@ export const videoInput = new Setting( ); export const backgroundBlur = new Setting("background-blur", false); +export const rnnoiseNoiseSuppression = new Setting( + "rnnoise-noise-suppression", + false, +); +export const rnnoiseNoiseSuppressionPreset = + new Setting( + "rnnoise-noise-suppression-preset", + "conservative", + ); export const showHandRaisedTimer = new Setting( "hand-raised-show-timer", diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 5deb9f43e..620f3f3cd 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -59,10 +59,15 @@ import { } from "../../utils/observable"; import { duplicateTiles, + echoCancellationSetting, MatrixRTCMode, + noiseSuppressionSetting, playReactionsSound, + rnnoiseNoiseSuppression, showReactions, } from "../../settings/settings"; +import { shouldEnableNativeNoiseSuppression } from "../../audio/noiseSuppressionPolicy"; +import { supportsRNNoiseProcessor } from "../../audio/RNNoiseProcessor"; import { isFirefox, platform } from "../../Platform"; import { setPipEnabled$ } from "../../controls"; import { TileStore } from "../TileStore"; @@ -479,6 +484,12 @@ export function createCallViewModel$( livekitKeyProvider, getUrlParams().controlledAudioDevices, options.livekitRoomFactory, + echoCancellationSetting.getValue(), + shouldEnableNativeNoiseSuppression({ + urlNoiseSuppression: noiseSuppressionSetting.getValue(), + rnnoiseEnabled: rnnoiseNoiseSuppression.getValue(), + rnnoiseSupported: supportsRNNoiseProcessor(), + }), ); const connectionManager = createConnectionManager$({ diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index a0eaa2fd6..b69fdc003 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -27,6 +27,11 @@ import { import { Publisher } from "./Publisher"; import { type Connection } from "../remoteMembers/Connection"; import { type MuteStates } from "../../MuteStates"; +import { + rnnoiseNoiseSuppression, + rnnoiseNoiseSuppressionPreset, +} from "../../../settings/settings"; +import type { RNNoiseProcessor } from "../../../audio/RNNoiseProcessor"; let scope: ObservableScope; @@ -37,8 +42,12 @@ beforeEach(() => { afterEach(() => scope.end()); function createMockLocalTrack(source: Track.Source): LocalTrack { + let processor: { name: string } | undefined; + const kind = + source === Track.Source.Microphone ? Track.Kind.Audio : Track.Kind.Video; const track = { source, + kind, isMuted: false, isUpstreamPaused: false, } as Partial as LocalTrack; @@ -57,6 +66,16 @@ function createMockLocalTrack(source: Track.Source): LocalTrack { // @ts-expect-error - for that test we want to set isUpstreamPaused directly track.isUpstreamPaused = false; }); + vi.mocked(track).getProcessor = vi.fn().mockImplementation(() => processor); + vi.mocked(track).setProcessor = vi + .fn() + .mockImplementation((nextProcessor) => { + processor = nextProcessor as { name: string }; + }); + vi.mocked(track).stopProcessor = vi.fn().mockImplementation(() => { + processor = undefined; + }); + vi.mocked(track).restartTrack = vi.fn().mockResolvedValue(undefined); return track; } @@ -96,6 +115,8 @@ let trackPublications: LocalTrackPublication[]; let createTrackLock: Promise; beforeEach(() => { + rnnoiseNoiseSuppression.setValue(false); + rnnoiseNoiseSuppressionPreset.setValue("conservative"); trackPublications = []; audioEnabled$ = new BehaviorSubject(false); videoEnabled$ = new BehaviorSubject(false); @@ -339,6 +360,282 @@ describe("Publisher", () => { }); it("does mute unmute audio", async () => {}); + + describe("RNNoise", () => { + beforeEach(() => { + vi.stubGlobal("AudioWorkletNode", class AudioWorkletNode {}); + vi.stubGlobal( + "AudioWorklet", + class AudioWorklet { + public async addModule(): Promise { + await Promise.resolve(); + } + }, + ); + vi.stubGlobal( + "MediaStreamAudioDestinationNode", + class MediaStreamAudioDestinationNode {}, + ); + vi.stubGlobal( + "MediaStreamAudioSourceNode", + class MediaStreamAudioSourceNode {}, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + rnnoiseNoiseSuppression.setValue(false); + rnnoiseNoiseSuppressionPreset.setValue("conservative"); + }); + + it("enabling setting applies RNNoise processor on microphone track", async () => { + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { setProcessor: (...args: unknown[]) => void }; + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + rnnoiseNoiseSuppression.setValue(true); + await flushPromises(); + + expect(micTrack.setProcessor).toHaveBeenCalledOnce(); + }); + + it("disabling setting removes RNNoise processor on microphone track", async () => { + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { + setProcessor: (...args: unknown[]) => void; + stopProcessor: () => void; + }; + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + rnnoiseNoiseSuppression.setValue(true); + await flushPromises(); + rnnoiseNoiseSuppression.setValue(false); + await flushPromises(); + + expect(micTrack.setProcessor).toHaveBeenCalledOnce(); + expect(micTrack.stopProcessor).toHaveBeenCalledOnce(); + }); + + it("auto-disables RNNoise when processor setup fails and falls back to native noise suppression", async () => { + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { + setProcessor: (...args: unknown[]) => Promise; + restartTrack: (...args: unknown[]) => Promise; + }; + const processorError = new Error("RNNoise setup failed"); + vi.mocked(micTrack.setProcessor).mockRejectedValueOnce(processorError); + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + rnnoiseNoiseSuppression.setValue(true); + + // The operation queue serializes restart/sync jobs. Flush enough microtasks + // to allow the fallback toggle and restart to complete. + for (let i = 0; i < 5; i++) { + await flushPromises(); + } + + expect(micTrack.setProcessor).toHaveBeenCalledOnce(); + expect(rnnoiseNoiseSuppression.getValue()).toBe(false); + expect(micTrack.restartTrack).toHaveBeenCalled(); + const restartCalls = vi.mocked(micTrack.restartTrack).mock.calls; + const finalRestartConfig = restartCalls[restartCalls.length - 1]?.[0] as { + noiseSuppression?: boolean; + }; + expect(finalRestartConfig).toEqual( + expect.objectContaining({ noiseSuppression: true }), + ); + }); + + it("restarts microphone track with native noise suppression disabled when RNNoise is enabled", async () => { + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { restartTrack: (...args: unknown[]) => void }; + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + rnnoiseNoiseSuppression.setValue(true); + await flushPromises(); + + expect(micTrack.restartTrack).toHaveBeenCalledWith( + expect.objectContaining({ + noiseSuppression: false, + }), + ); + }); + + it("restarts a newly-created publisher microphone track with native suppression disabled when RNNoise is already enabled", async () => { + await publisher.destroy(); + rnnoiseNoiseSuppression.setValue(true); + + const freshPublisher = new Publisher( + connection, + mockMediaDevices({}), + muteStates, + constant({ supported: false, processor: undefined }), + logger, + ); + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { + restartTrack: (...args: unknown[]) => void; + setProcessor: (...args: unknown[]) => void; + }; + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + try { + await flushPromises(); + + expect(micTrack.restartTrack).toHaveBeenCalledWith( + expect.objectContaining({ + noiseSuppression: false, + }), + ); + expect(micTrack.setProcessor).toHaveBeenCalledOnce(); + expect( + vi.mocked(micTrack.restartTrack).mock.invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(micTrack.setProcessor).mock.invocationCallOrder[0], + ); + } finally { + await freshPublisher.destroy(); + } + }); + + it("keeps native noise suppression enabled and skips processor when RNNoise is unsupported", async () => { + vi.stubGlobal("AudioWorkletNode", undefined); + vi.stubGlobal("AudioWorklet", undefined); + vi.stubGlobal("MediaStreamAudioDestinationNode", undefined); + vi.stubGlobal("MediaStreamAudioSourceNode", undefined); + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { + restartTrack: (...args: unknown[]) => void; + setProcessor: (...args: unknown[]) => void; + }; + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + rnnoiseNoiseSuppression.setValue(true); + await flushPromises(); + + expect(micTrack.setProcessor).not.toHaveBeenCalled(); + expect(micTrack.restartTrack).toHaveBeenCalledWith( + expect.objectContaining({ + noiseSuppression: true, + }), + ); + }); + + it("stops RNNoise processor before restarting microphone track when disabling RNNoise", async () => { + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { + stopProcessor: () => void; + restartTrack: (...args: unknown[]) => void; + }; + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + rnnoiseNoiseSuppression.setValue(true); + await flushPromises(); + + vi.mocked(micTrack.stopProcessor).mockClear(); + vi.mocked(micTrack.restartTrack).mockClear(); + + rnnoiseNoiseSuppression.setValue(false); + await flushPromises(); + + expect(micTrack.stopProcessor).toHaveBeenCalledOnce(); + expect(micTrack.restartTrack).toHaveBeenCalledOnce(); + expect( + vi.mocked(micTrack.stopProcessor).mock.invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(micTrack.restartTrack).mock.invocationCallOrder[0], + ); + }); + + it("updates active RNNoise processor preset when preset setting changes", async () => { + const micTrack = createMockLocalTrack( + Track.Source.Microphone, + ) as LocalTrack & { getProcessor: () => unknown }; + trackPublications.push({ + source: Track.Source.Microphone, + track: micTrack, + audioTrack: micTrack, + } as unknown as LocalTrackPublication); + localParticipant.emit( + ParticipantEvent.LocalTrackPublished, + trackPublications[0], + ); + + rnnoiseNoiseSuppression.setValue(true); + await flushPromises(); + + const processor = micTrack.getProcessor() as RNNoiseProcessor; + const setPresetSpy = vi.spyOn(processor, "setPreset"); + + rnnoiseNoiseSuppressionPreset.setValue("strong"); + await flushPromises(); + + expect(setPresetSpy).toHaveBeenCalledWith("strong"); + }); + }); }); describe("Bug fix", () => { diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index b7841c498..412617f81 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. */ import { ConnectionState as LivekitConnectionState, + type LocalAudioTrack, type LocalTrackPublication, LocalVideoTrack, ParticipantEvent, @@ -14,9 +15,12 @@ import { Track, } from "livekit-client"; import { + combineLatest, + distinctUntilChanged, map, NEVER, type Observable, + skip, type Subscription, switchMap, } from "rxjs"; @@ -33,6 +37,18 @@ import { getUrlParams } from "../../../UrlParams.ts"; import { observeTrackReference$ } from "../../observeTrackReference"; import { type Connection } from "../remoteMembers/Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; +import { + RNNoiseProcessor, + supportsRNNoiseProcessor, +} from "../../../audio/RNNoiseProcessor.ts"; +import { shouldEnableNativeNoiseSuppression } from "../../../audio/noiseSuppressionPolicy.ts"; +import { + echoCancellationSetting, + noiseSuppressionSetting, + rnnoiseNoiseSuppression, + rnnoiseNoiseSuppressionPreset, +} from "../../../settings/settings.ts"; +import type { RNNoiseSuppressionPreset } from "../../../audio/rnnoiseTypes.ts"; /** * A wrapper for a Connection object. @@ -48,6 +64,8 @@ export class Publisher { public shouldPublish = false; private readonly scope = new ObservableScope(); + private rnnoiseOperationQueue: Promise = Promise.resolve(); + private rnnoisePolicySyncedTrack: LocalAudioTrack | null = null; /** * Creates a new Publisher. @@ -73,6 +91,8 @@ export class Publisher { // Setup track processor syncing (blur) this.observeTrackProcessors(this.scope, room, trackerProcessorState$); + this.observeRNNoiseProcessor(this.scope, room, devices); + this.observeRNNoiseSettingRestart(this.scope, room, devices); // Observe media device changes and update LiveKit active devices accordingly this.observeMediaDevices(this.scope, devices, controlledAudioDevices); @@ -416,4 +436,160 @@ export class Publisher { ); trackProcessorSync(scope, track$, trackerProcessorState$); } + + private observeRNNoiseProcessor( + scope: ObservableScope, + room: LivekitRoom, + devices: MediaDevices, + ): void { + const microphoneTrack$ = scope.behavior( + observeTrackReference$( + room.localParticipant, + Track.Source.Microphone, + ).pipe( + map((trackRef) => { + const track = trackRef?.publication.track; + return track?.kind === Track.Kind.Audio + ? (track as LocalAudioTrack) + : null; + }), + ), + null, + ); + + combineLatest([ + microphoneTrack$, + rnnoiseNoiseSuppression.value$, + rnnoiseNoiseSuppressionPreset.value$, + ]) + .pipe( + scope.bind(), + distinctUntilChanged( + ([aTrack, _aEnabled, aPreset], [bTrack, _bEnabled, bPreset]) => { + return aTrack === bTrack && aPreset === bPreset; + }, + ), + ) + .subscribe(([microphoneTrack, rnnoiseEnabled, rnnoisePreset]) => { + const rnnoiseSupported = supportsRNNoiseProcessor(); + if (!microphoneTrack || !rnnoiseSupported) { + this.rnnoisePolicySyncedTrack = microphoneTrack; + return; + } + + const isNewMicrophoneTrack = + microphoneTrack !== this.rnnoisePolicySyncedTrack; + this.rnnoisePolicySyncedTrack = microphoneTrack; + + this.enqueueRNNoiseOperation(async () => { + if (rnnoiseEnabled && isNewMicrophoneTrack) { + await this.restartMicrophoneTrackForNoiseSuppressionPolicy( + microphoneTrack, + devices, + rnnoiseEnabled, + ); + } + await this.syncRNNoiseProcessor( + microphoneTrack, + rnnoiseEnabled, + rnnoisePreset, + ); + }); + }); + } + + private observeRNNoiseSettingRestart( + scope: ObservableScope, + room: LivekitRoom, + devices: MediaDevices, + ): void { + rnnoiseNoiseSuppression.value$ + .pipe(scope.bind(), distinctUntilChanged(), skip(1)) + .subscribe((rnnoiseEnabled) => { + const audioTrack = room.localParticipant.getTrackPublication( + Track.Source.Microphone, + )?.audioTrack; + if (!audioTrack) return; + + const rnnoiseSupported = supportsRNNoiseProcessor(); + this.enqueueRNNoiseOperation(async () => { + await this.restartMicrophoneTrackForNoiseSuppressionPolicy( + audioTrack, + devices, + rnnoiseEnabled, + ); + await this.syncRNNoiseProcessor( + audioTrack, + rnnoiseEnabled && rnnoiseSupported, + rnnoiseNoiseSuppressionPreset.getValue(), + ); + }); + }); + } + + private enqueueRNNoiseOperation(operation: () => Promise): void { + this.rnnoiseOperationQueue = this.rnnoiseOperationQueue.then(async () => { + try { + await operation(); + } catch (e) { + this.logger.error("Failed to process RNNoise operation", e); + } + }); + } + + private async restartMicrophoneTrackForNoiseSuppressionPolicy( + audioTrack: LocalAudioTrack, + devices: MediaDevices, + rnnoiseEnabled: boolean, + ): Promise { + const activeProcessor = audioTrack.getProcessor(); + if (activeProcessor?.name === "rnnoise-noise-suppression") { + await audioTrack.stopProcessor(); + } + + await audioTrack.restartTrack({ + deviceId: devices.audioInput.selected$.value?.id, + echoCancellation: echoCancellationSetting.getValue(), + noiseSuppression: shouldEnableNativeNoiseSuppression({ + urlNoiseSuppression: noiseSuppressionSetting.getValue(), + rnnoiseEnabled, + rnnoiseSupported: supportsRNNoiseProcessor(), + }), + }); + } + + private async syncRNNoiseProcessor( + microphoneTrack: LocalAudioTrack, + rnnoiseEnabled: boolean, + rnnoisePreset: RNNoiseSuppressionPreset, + ): Promise { + try { + const processor = microphoneTrack.getProcessor(); + const rnnoiseActive = processor?.name === "rnnoise-noise-suppression"; + const rnnoiseProcessor = + processor instanceof RNNoiseProcessor ? processor : undefined; + + if (rnnoiseEnabled) { + if (rnnoiseProcessor) { + rnnoiseProcessor.setPreset(rnnoisePreset); + return; + } + + if (rnnoiseActive) { + await microphoneTrack.stopProcessor(); + } + await microphoneTrack.setProcessor(new RNNoiseProcessor(rnnoisePreset)); + } else if (rnnoiseActive) { + await microphoneTrack.stopProcessor(); + } + } catch (e) { + this.logger.error("Failed to apply RNNoise microphone processor", e); + if (rnnoiseEnabled && rnnoiseNoiseSuppression.getValue()) { + this.logger.warn( + "Disabling RNNoise setting after processor setup failure", + ); + rnnoiseNoiseSuppression.setValue(false); + } + } + } } diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 0a0bd7529..5f9e1ff8b 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -35,8 +35,6 @@ import { cameraBitrate, cameraCodec, parseResolution, - echoCancellationSetting, - noiseSuppressionSetting, autoGainControlSetting, } from "../../../settings/settings.ts"; @@ -73,6 +71,8 @@ export class ECConnectionFactory implements ConnectionFactory { livekitKeyProvider: BaseKeyProvider | undefined, private controlledAudioDevices: boolean, livekitRoomFactory?: () => LivekitRoom, + echoCancellation: boolean = true, + noiseSuppression: boolean = true, ) { const defaultFactory = (): LivekitRoom => new LivekitRoom( @@ -86,6 +86,8 @@ export class ECConnectionFactory implements ConnectionFactory { worker: new E2EEWorker(), }, controlledAudioDevices: this.controlledAudioDevices, + echoCancellation, + noiseSuppression, }), ); this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory; @@ -131,6 +133,8 @@ function generateRoomOption({ processorState, e2eeLivekitOptions, controlledAudioDevices, + echoCancellation, + noiseSuppression, }: { devices: MediaDevices; processorState: ProcessorState; @@ -139,6 +143,8 @@ function generateRoomOption({ | { e2eeManager: BaseE2EEManager } | undefined; controlledAudioDevices: boolean; + echoCancellation: boolean; + noiseSuppression: boolean; }): RoomOptions { const liveKitOptions = getLiveKitOptions(); @@ -174,8 +180,8 @@ function generateRoomOption({ audioCaptureDefaults: { ...liveKitOptions.audioCaptureDefaults, deviceId: devices.audioInput.selected$.value?.id, - echoCancellation: echoCancellationSetting.getValue(), - noiseSuppression: noiseSuppressionSetting.getValue(), + echoCancellation, + noiseSuppression, autoGainControl: autoGainControlSetting.getValue(), }, audioOutput: { diff --git a/src/types/jitsi-rnnoise-wasm.d.ts b/src/types/jitsi-rnnoise-wasm.d.ts new file mode 100644 index 000000000..c7ef1b9d9 --- /dev/null +++ b/src/types/jitsi-rnnoise-wasm.d.ts @@ -0,0 +1,12 @@ +/* +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. +*/ + +declare module "@jitsi/rnnoise-wasm/dist/rnnoise-sync.js" { + const createRNNWasmModuleSync: () => unknown; + + export default createRNNWasmModuleSync; +} diff --git a/yarn.lock b/yarn.lock index 12e1b8578..4880d72d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2901,13 +2901,20 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": +"@eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard +"@eslint-community/regexpp@npm:^4.6.1": + version: 4.11.1 + resolution: "@eslint-community/regexpp@npm:4.11.1" + checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8 + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -3171,6 +3178,13 @@ __metadata: languageName: node linkType: hard +"@jitsi/rnnoise-wasm@npm:0.2.1": + version: 0.2.1 + resolution: "@jitsi/rnnoise-wasm@npm:0.2.1" + checksum: 10c0/6e5b475b364660eb24c0fa9843a63040253c2ce4034de9313e811448f5c6dad2205a0f22d3a9ef15cbef3c808941b0681d238d53d5a853e194a4c88cdd5569b1 + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.12": version: 0.3.12 resolution: "@jridgewell/gen-mapping@npm:0.3.12" @@ -3537,6 +3551,20 @@ __metadata: languageName: node linkType: hard +"@oxc-project/runtime@npm:0.115.0": + version: 0.115.0 + resolution: "@oxc-project/runtime@npm:0.115.0" + checksum: 10c0/88905181724fcad06d2852969e706a25a7b6c4fadac22dd6aece24b882a947eda7487451e0824781c9dc87b40b2c6ee582790e47fec5a9ba5d27c6e8c6c35bc1 + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.115.0": + version: 0.115.0 + resolution: "@oxc-project/types@npm:0.115.0" + checksum: 10c0/47fc31eb3fb3fcf4119955339f92ba2003f9b445834c1a28ed945cd6b9cd833c7ba66fca88aa5277336c2c55df300a593bc67970e544691eceaa486ebe12cb58 + languageName: node + linkType: hard + "@oxc-resolver/binding-android-arm-eabi@npm:11.19.1": version: 11.19.1 resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.19.1" @@ -5022,6 +5050,113 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-android-arm64@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.9" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.9" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.9" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.9" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.9" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.9" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.9" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.9" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.9" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.9" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.9" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.9" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.9" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.1.1" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.9" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.9" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -5029,6 +5164,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.9" + checksum: 10c0/fca488fb96b134ccf95b42632b6112b4abb8b3a9688f166fbd627410def2538ee201953717d234ddecbff62dfe4edc4e72c657b01a9d0750134608d767eea5fd + languageName: node + linkType: hard + "@rollup/plugin-inject@npm:^5.0.3": version: 5.0.5 resolution: "@rollup/plugin-inject@npm:5.0.5" @@ -5071,177 +5213,149 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0" +"@rollup/rollup-android-arm-eabi@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-android-arm64@npm:4.59.0" +"@rollup/rollup-android-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-android-arm64@npm:4.50.1" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-darwin-arm64@npm:4.59.0" +"@rollup/rollup-darwin-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-darwin-arm64@npm:4.50.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-darwin-x64@npm:4.59.0" +"@rollup/rollup-darwin-x64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-darwin-x64@npm:4.50.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.59.0" +"@rollup/rollup-freebsd-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.1" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-freebsd-x64@npm:4.59.0" +"@rollup/rollup-freebsd-x64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-freebsd-x64@npm:4.50.1" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.59.0" +"@rollup/rollup-linux-arm-musleabihf@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.59.0" +"@rollup/rollup-linux-arm64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.1" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.59.0" +"@rollup/rollup-linux-arm64-musl@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.1" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-loong64-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0" +"@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1" conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-loong64-musl@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-loong64-musl@npm:4.59.0" - conditions: os=linux & cpu=loong64 & libc=musl - languageName: node - linkType: hard - -"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0" +"@rollup/rollup-linux-ppc64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-musl@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.59.0" - conditions: os=linux & cpu=ppc64 & libc=musl - languageName: node - linkType: hard - -"@rollup/rollup-linux-riscv64-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.59.0" +"@rollup/rollup-linux-riscv64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.59.0" +"@rollup/rollup-linux-riscv64-musl@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.59.0" +"@rollup/rollup-linux-s390x-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.1" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.59.0" +"@rollup/rollup-linux-x64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.1" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.59.0" +"@rollup/rollup-linux-x64-musl@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.1" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-openbsd-x64@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-openbsd-x64@npm:4.59.0" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - -"@rollup/rollup-openharmony-arm64@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-openharmony-arm64@npm:4.59.0" +"@rollup/rollup-openharmony-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.50.1" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.59.0" +"@rollup/rollup-win32-arm64-msvc@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.50.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.59.0" +"@rollup/rollup-win32-ia32-msvc@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.1" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rollup/rollup-win32-x64-gnu@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@rollup/rollup-win32-x64-msvc@npm:4.59.0": - version: 4.59.0 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.59.0" +"@rollup/rollup-win32-x64-msvc@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -5460,7 +5574,7 @@ __metadata: languageName: node linkType: hard -"@standard-schema/spec@npm:^1.0.0": +"@standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 @@ -6211,9 +6325,9 @@ __metadata: linkType: hard "@ungap/structured-clone@npm:^1.2.0": - version: 1.3.0 - resolution: "@ungap/structured-clone@npm:1.3.0" - checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a + version: 1.2.0 + resolution: "@ungap/structured-clone@npm:1.2.0" + checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d languageName: node linkType: hard @@ -6293,68 +6407,68 @@ __metadata: linkType: hard "@vitest/coverage-v8@npm:^4.0.18": - version: 4.0.18 - resolution: "@vitest/coverage-v8@npm:4.0.18" + version: 4.1.0 + resolution: "@vitest/coverage-v8@npm:4.1.0" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.0.18" - ast-v8-to-istanbul: "npm:^0.3.10" + "@vitest/utils": "npm:4.1.0" + ast-v8-to-istanbul: "npm:^1.0.0" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" istanbul-reports: "npm:^3.2.0" - magicast: "npm:^0.5.1" + magicast: "npm:^0.5.2" obug: "npm:^2.1.1" - std-env: "npm:^3.10.0" + std-env: "npm:^4.0.0-rc.1" tinyrainbow: "npm:^3.0.3" peerDependencies: - "@vitest/browser": 4.0.18 - vitest: 4.0.18 + "@vitest/browser": 4.1.0 + vitest: 4.1.0 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/e23e0da86f0b2a020c51562bc40ebdc7fc7553c24f8071dfb39a6df0161badbd5eaf2eebbf8ceaef18933a18c1934ff52d1c0c4bde77bb87e0c1feb0c8cbee4d + checksum: 10c0/0bcbc9d20dd4c998ff76b82a721d6000f1300346b93cfc441f9012797a34be65bb73dc99451275d7f7dcb06b98856b4e5dc30b2c483051ec2320e9a89af14179 languageName: node linkType: hard -"@vitest/expect@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/expect@npm:4.0.18" +"@vitest/expect@npm:4.1.0": + version: 4.1.0 + resolution: "@vitest/expect@npm:4.1.0" dependencies: - "@standard-schema/spec": "npm:^1.0.0" + "@standard-schema/spec": "npm:^1.1.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.18" - "@vitest/utils": "npm:4.0.18" - chai: "npm:^6.2.1" + "@vitest/spy": "npm:4.1.0" + "@vitest/utils": "npm:4.1.0" + chai: "npm:^6.2.2" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/123b0aa111682e82ec5289186df18037b1a1768700e468ee0f9879709aaa320cf790463c15c0d8ee10df92b402f4394baf5d27797e604d78e674766d87bcaadc + checksum: 10c0/91cd7bb036401df5dfd9204f3de9a0afdb21dea6ee154622e5ed849e87a0df68b74258d490559c7046d3c03bc7aa634e9b0c166942a21d5e475c86c971486091 languageName: node linkType: hard -"@vitest/mocker@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/mocker@npm:4.0.18" +"@vitest/mocker@npm:4.1.0": + version: 4.1.0 + resolution: "@vitest/mocker@npm:4.1.0" dependencies: - "@vitest/spy": "npm:4.0.18" + "@vitest/spy": "npm:4.1.0" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.21" peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - checksum: 10c0/fb0a257e7e167759d4ad228d53fa7bad2267586459c4a62188f2043dd7163b4b02e1e496dc3c227837f776e7d73d6c4343613e89e7da379d9d30de8260f1ee4b + checksum: 10c0/f61d3df6461008eb1e62ba465172207b29bd0d9866ff6bc88cd40fc99cd5d215ad89e2894ba6de87068e33f75de903b28a65ccc6074edf3de1fbead6a4a369cc languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/pretty-format@npm:4.0.18" +"@vitest/pretty-format@npm:4.1.0": + version: 4.1.0 + resolution: "@vitest/pretty-format@npm:4.1.0" dependencies: tinyrainbow: "npm:^3.0.3" - checksum: 10c0/0086b8c88eeca896d8e4b98fcdef452c8041a1b63eb9e85d3e0bcc96c8aa76d8e9e0b6990ebb0bb0a697c4ebab347e7735888b24f507dbff2742ddce7723fd94 + checksum: 10c0/638077f53b5f24ff2d4bc062e69931fa718141db28ddafe435de98a402586b82e8c3cadfc580c0ad233d7f0203aa22d866ac2adca98b83038dbd5423c3d7fe27 languageName: node linkType: hard @@ -6367,41 +6481,43 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/runner@npm:4.0.18" +"@vitest/runner@npm:4.1.0": + version: 4.1.0 + resolution: "@vitest/runner@npm:4.1.0" dependencies: - "@vitest/utils": "npm:4.0.18" + "@vitest/utils": "npm:4.1.0" pathe: "npm:^2.0.3" - checksum: 10c0/fdb4afa411475133c05ba266c8092eaf1e56cbd5fb601f92ec6ccb9bab7ca52e06733ee8626599355cba4ee71cb3a8f28c84d3b69dc972e41047edc50229bc01 + checksum: 10c0/9e09ca1b9070d3fe26c9bd48443d21b9fe2cb9abb2f694300bd9e5065f4e904f7322c07cd4bafadfed6fb11adfb50e4d1535f327ac6d24b6c373e92be90510bc languageName: node linkType: hard -"@vitest/snapshot@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/snapshot@npm:4.0.18" +"@vitest/snapshot@npm:4.1.0": + version: 4.1.0 + resolution: "@vitest/snapshot@npm:4.1.0" dependencies: - "@vitest/pretty-format": "npm:4.0.18" + "@vitest/pretty-format": "npm:4.1.0" + "@vitest/utils": "npm:4.1.0" magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/d3bfefa558db9a69a66886ace6575eb96903a5ba59f4d9a5d0fecb4acc2bb8dbb443ef409f5ac1475f2e1add30bd1d71280f98912da35e89c75829df9e84ea43 + checksum: 10c0/582c22988c47a99d93dd17ef660427fefe101f67ae4394b64fe58ec103ddc55fc5993626b4a2b556e0a38d40552abaca78196907455e794805ba197b3d56860f languageName: node linkType: hard -"@vitest/spy@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/spy@npm:4.0.18" - checksum: 10c0/6de537890b3994fcadb8e8d8ac05942320ae184f071ec395d978a5fba7fa928cbb0c5de85af86a1c165706c466e840de8779eaff8c93450c511c7abaeb9b8a4e +"@vitest/spy@npm:4.1.0": + version: 4.1.0 + resolution: "@vitest/spy@npm:4.1.0" + checksum: 10c0/363776bbffda45af76ff500deacb9b1a35ad8b889462c1be9ebe5f29578ce1dd2c4bd7858c8188614a7db9699a5c802b7beb72e5a18ab5130a70326817961446 languageName: node linkType: hard -"@vitest/utils@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/utils@npm:4.0.18" +"@vitest/utils@npm:4.1.0": + version: 4.1.0 + resolution: "@vitest/utils@npm:4.1.0" dependencies: - "@vitest/pretty-format": "npm:4.0.18" + "@vitest/pretty-format": "npm:4.1.0" + convert-source-map: "npm:^2.0.0" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/4a3c43c1421eb90f38576926496f6c80056167ba111e63f77cf118983902673737a1a38880b890d7c06ec0a12475024587344ee502b3c43093781533022f2aeb + checksum: 10c0/222afbdef4f680a554bb6c3d946a4a879a441ebfb8597295cb7554d295e0e2624f3d4c2920b5767bbb8961a9f8a16756270ffc84032f5ea432cdce080ccab050 languageName: node linkType: hard @@ -6430,7 +6546,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.16.0, acorn@npm:^8.9.0": +"acorn@npm:^8.16.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -6439,6 +6555,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.9.0": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" + bin: + acorn: bin/acorn + checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 + languageName: node + linkType: hard + "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6465,14 +6590,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.4": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" + version: 6.12.6 + resolution: "ajv@npm:6.12.6" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 + checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 languageName: node linkType: hard @@ -6749,14 +6874,14 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^0.3.10": - version: 0.3.12 - resolution: "ast-v8-to-istanbul@npm:0.3.12" +"ast-v8-to-istanbul@npm:^1.0.0": + version: 1.0.0 + resolution: "ast-v8-to-istanbul@npm:1.0.0" dependencies: "@jridgewell/trace-mapping": "npm:^0.3.31" estree-walker: "npm:^3.0.3" js-tokens: "npm:^10.0.0" - checksum: 10c0/bad6ba222b1073c165c8d65dbf366193d4a90536dabe37f93a3df162269b1c9473975756e4c048f708c235efccc26f8e5321c547b7e9563b64b21b2e0f27cbc9 + checksum: 10c0/35e57b754ba63287358094d4f7ae8de2de27286fb4e76a1fbf28b2e67e3b670b59c3f511882473d0fd2cdbaa260062e3cd4f216b724c70032e2b09e5cebbd618 languageName: node linkType: hard @@ -7319,7 +7444,7 @@ __metadata: languageName: node linkType: hard -"chai@npm:^6.2.1": +"chai@npm:^6.2.2": version: 6.2.2 resolution: "chai@npm:6.2.2" checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 @@ -7733,7 +7858,18 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -8071,6 +8207,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + "detect-node-es@npm:^1.1.0": version: 1.1.0 resolution: "detect-node-es@npm:1.1.0" @@ -8293,6 +8436,7 @@ __metadata: "@fontsource/inter": "npm:^5.1.0" "@formatjs/intl-durationformat": "npm:^0.10.0" "@formatjs/intl-segmenter": "npm:^11.7.3" + "@jitsi/rnnoise-wasm": "npm:0.2.1" "@livekit/components-core": "npm:^0.12.0" "@livekit/components-react": "npm:^2.0.0" "@livekit/protocol": "npm:^1.42.2" @@ -8644,10 +8788,10 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.7.0": - version: 1.7.0 - resolution: "es-module-lexer@npm:1.7.0" - checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b +"es-module-lexer@npm:^2.0.0": + version: 2.0.0 + resolution: "es-module-lexer@npm:2.0.0" + checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817 languageName: node linkType: hard @@ -9281,21 +9425,21 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2, esquery@npm:^1.7.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" +"esquery@npm:^1.4.2, esquery@npm:^1.6.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 languageName: node linkType: hard -"esquery@npm:^1.6.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 languageName: node linkType: hard @@ -9363,7 +9507,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.2": +"expect-type@npm:^1.3.0": version: 1.3.0 resolution: "expect-type@npm:1.3.0" checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd @@ -10318,23 +10462,13 @@ __metadata: linkType: hard "immutable@npm:^5.0.2": - version: 5.1.5 - resolution: "immutable@npm:5.1.5" - checksum: 10c0/8017ece1578e3c5939ba3305176aee059def1b8a90c7fa2a347ef583ebbd38cbe77ce1bbd786a5fab57e2da00bbcb0493b92e4332cdc4e1fe5cfb09a4688df31 - languageName: node - linkType: hard - -"import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + version: 5.0.3 + resolution: "immutable@npm:5.0.3" + checksum: 10c0/3269827789e1026cd25c2ea97f0b2c19be852ffd49eda1b674b20178f73d84fa8d945ad6f5ac5bc4545c2b4170af9f6e1f77129bc1cae7974a4bf9b04a9cdfb9 languageName: node linkType: hard -"import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -11082,8 +11216,8 @@ __metadata: linkType: hard "knip@npm:^5.86.0": - version: 5.86.0 - resolution: "knip@npm:5.86.0" + version: 5.87.0 + resolution: "knip@npm:5.87.0" dependencies: "@nodelib/fs.walk": "npm:^1.2.3" fast-glob: "npm:^3.3.3" @@ -11104,7 +11238,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10c0/6905c3c2bd21b1f5d51bf83568d1eff67d9d74dd9547c428f810b0dbc3624225a0c41b8e8caccbb111df2db175933aa853345798a05f91f9344ce3aca26898ff + checksum: 10c0/be3618f397c1904f824a092400f2ce0fd454ef2c523e81405c23c494fa16f8127ec854ecbea00f98204b992af3037e3de51b78d7734227ed2b5f23b44bd41127 languageName: node linkType: hard @@ -11141,6 +11275,126 @@ __metadata: languageName: node linkType: hard +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + "lilconfig@npm:^3.1.3": version: 3.1.3 resolution: "lilconfig@npm:3.1.3" @@ -11298,7 +11552,7 @@ __metadata: languageName: node linkType: hard -"magicast@npm:^0.5.1": +"magicast@npm:^0.5.2": version: 0.5.2 resolution: "magicast@npm:0.5.2" dependencies: @@ -12772,6 +13026,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.8": + version: 8.5.8 + resolution: "postcss@npm:8.5.8" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/dd918f7127ee7c60a0295bae2e72b3787892296e1d1c3c564d7a2a00c68d8df83cadc3178491259daa19ccc54804fb71ed8c937c6787e08d8bd4bedf8d17044c + languageName: node + linkType: hard + "posthog-js@npm:1.160.3": version: 1.160.3 resolution: "posthog-js@npm:1.160.3" @@ -13584,35 +13849,89 @@ __metadata: languageName: node linkType: hard +"rolldown@npm:1.0.0-rc.9": + version: 1.0.0-rc.9 + resolution: "rolldown@npm:1.0.0-rc.9" + dependencies: + "@oxc-project/types": "npm:=0.115.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-rc.9" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.9" + "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.9" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.9" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.9" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.9" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.9" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.9" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.9" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.9" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.9" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.9" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.9" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.9" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.9" + "@rolldown/pluginutils": "npm:1.0.0-rc.9" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: bin/cli.mjs + checksum: 10c0/d19af14dccf569dc25c0c3c2f1142b7a6f7cec291d55bba80cea71099f89c6d634145bb1b6487626ddd41d578f183f7065ed68067e49d2b964ad6242693b0f79 + languageName: node + linkType: hard + "rollup@npm:^4.43.0": - version: 4.59.0 - resolution: "rollup@npm:4.59.0" - dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.59.0" - "@rollup/rollup-android-arm64": "npm:4.59.0" - "@rollup/rollup-darwin-arm64": "npm:4.59.0" - "@rollup/rollup-darwin-x64": "npm:4.59.0" - "@rollup/rollup-freebsd-arm64": "npm:4.59.0" - "@rollup/rollup-freebsd-x64": "npm:4.59.0" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.59.0" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.59.0" - "@rollup/rollup-linux-arm64-gnu": "npm:4.59.0" - "@rollup/rollup-linux-arm64-musl": "npm:4.59.0" - "@rollup/rollup-linux-loong64-gnu": "npm:4.59.0" - "@rollup/rollup-linux-loong64-musl": "npm:4.59.0" - "@rollup/rollup-linux-ppc64-gnu": "npm:4.59.0" - "@rollup/rollup-linux-ppc64-musl": "npm:4.59.0" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.59.0" - "@rollup/rollup-linux-riscv64-musl": "npm:4.59.0" - "@rollup/rollup-linux-s390x-gnu": "npm:4.59.0" - "@rollup/rollup-linux-x64-gnu": "npm:4.59.0" - "@rollup/rollup-linux-x64-musl": "npm:4.59.0" - "@rollup/rollup-openbsd-x64": "npm:4.59.0" - "@rollup/rollup-openharmony-arm64": "npm:4.59.0" - "@rollup/rollup-win32-arm64-msvc": "npm:4.59.0" - "@rollup/rollup-win32-ia32-msvc": "npm:4.59.0" - "@rollup/rollup-win32-x64-gnu": "npm:4.59.0" - "@rollup/rollup-win32-x64-msvc": "npm:4.59.0" + version: 4.50.1 + resolution: "rollup@npm:4.50.1" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.50.1" + "@rollup/rollup-android-arm64": "npm:4.50.1" + "@rollup/rollup-darwin-arm64": "npm:4.50.1" + "@rollup/rollup-darwin-x64": "npm:4.50.1" + "@rollup/rollup-freebsd-arm64": "npm:4.50.1" + "@rollup/rollup-freebsd-x64": "npm:4.50.1" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.50.1" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.50.1" + "@rollup/rollup-linux-arm64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-arm64-musl": "npm:4.50.1" + "@rollup/rollup-linux-loongarch64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-riscv64-musl": "npm:4.50.1" + "@rollup/rollup-linux-s390x-gnu": "npm:4.50.1" + "@rollup/rollup-linux-x64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-x64-musl": "npm:4.50.1" + "@rollup/rollup-openharmony-arm64": "npm:4.50.1" + "@rollup/rollup-win32-arm64-msvc": "npm:4.50.1" + "@rollup/rollup-win32-ia32-msvc": "npm:4.50.1" + "@rollup/rollup-win32-x64-msvc": "npm:4.50.1" "@types/estree": "npm:1.0.8" fsevents: "npm:~2.3.2" dependenciesMeta: @@ -13636,14 +13955,10 @@ __metadata: optional: true "@rollup/rollup-linux-arm64-musl": optional: true - "@rollup/rollup-linux-loong64-gnu": - optional: true - "@rollup/rollup-linux-loong64-musl": + "@rollup/rollup-linux-loongarch64-gnu": optional: true "@rollup/rollup-linux-ppc64-gnu": optional: true - "@rollup/rollup-linux-ppc64-musl": - optional: true "@rollup/rollup-linux-riscv64-gnu": optional: true "@rollup/rollup-linux-riscv64-musl": @@ -13654,23 +13969,19 @@ __metadata: optional: true "@rollup/rollup-linux-x64-musl": optional: true - "@rollup/rollup-openbsd-x64": - optional: true "@rollup/rollup-openharmony-arm64": optional: true "@rollup/rollup-win32-arm64-msvc": optional: true "@rollup/rollup-win32-ia32-msvc": optional: true - "@rollup/rollup-win32-x64-gnu": - optional: true "@rollup/rollup-win32-x64-msvc": optional: true fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: 10c0/f38742da34cfee5e899302615fa157aa77cb6a2a1495e5e3ce4cc9c540d3262e235bbe60caa31562bbfe492b01fdb3e7a8c43c39d842d3293bcf843123b766fc + checksum: 10c0/2029282826d5fb4e308be261b2c28329a4d2bd34304cc3960da69fd21d5acccd0267d6770b1656ffc8f166203ef7e865b4583d5f842a519c8ef059ac71854205 languageName: node linkType: hard @@ -14196,10 +14507,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.10.0": - version: 3.10.0 - resolution: "std-env@npm:3.10.0" - checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f +"std-env@npm:^4.0.0-rc.1": + version: 4.0.0 + resolution: "std-env@npm:4.0.0" + checksum: 10c0/63b1716eae27947adde49e21b7225a0f75fb2c3d410273ae9de8333c07c7d5fc7a0628ae4c8af6b4b49b4274ed46c2bf118ed69b64f1261c9d8213d76ed1c16c languageName: node linkType: hard @@ -14558,9 +14869,9 @@ __metadata: linkType: hard "tinyexec@npm:^1.0.2": - version: 1.0.2 - resolution: "tinyexec@npm:1.0.2" - checksum: 10c0/1261a8e34c9b539a9aae3b7f0bb5372045ff28ee1eba035a2a059e532198fe1a182ec61ac60fa0b4a4129f0c4c4b1d2d57355b5cb9aa2d17ac9454ecace502ee + version: 1.0.4 + resolution: "tinyexec@npm:1.0.4" + checksum: 10c0/d4a5bbcf6bdb23527a4b74c4aa566f41432167112fe76f420ec7e3a90a3ecfd3a7d944383e2719fc3987b69400f7b928daf08700d145fb527c2e80ec01e198bd languageName: node linkType: hard @@ -15350,7 +15661,65 @@ __metadata: languageName: node linkType: hard -"vite@npm:^6.0.0 || ^7.0.0, vite@npm:^7.3.0": +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0-0": + version: 8.0.0 + resolution: "vite@npm:8.0.0" + dependencies: + "@oxc-project/runtime": "npm:0.115.0" + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.8" + rolldown: "npm:1.0.0-rc.9" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.0.0-alpha.31 + esbuild: ^0.27.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/2246d3d54788dcd53c39da82da3f934a760756642ba9a575c84c5ef9f310bc47697f7f9fde6721fa566675e93e408736b4ac068008d2ddbd75b0ed99c7fd4c67 + languageName: node + linkType: hard + +"vite@npm:^7.3.0": version: 7.3.1 resolution: "vite@npm:7.3.1" dependencies: @@ -15420,39 +15789,40 @@ __metadata: linkType: hard "vitest@npm:^4.0.18": - version: 4.0.18 - resolution: "vitest@npm:4.0.18" - dependencies: - "@vitest/expect": "npm:4.0.18" - "@vitest/mocker": "npm:4.0.18" - "@vitest/pretty-format": "npm:4.0.18" - "@vitest/runner": "npm:4.0.18" - "@vitest/snapshot": "npm:4.0.18" - "@vitest/spy": "npm:4.0.18" - "@vitest/utils": "npm:4.0.18" - es-module-lexer: "npm:^1.7.0" - expect-type: "npm:^1.2.2" + version: 4.1.0 + resolution: "vitest@npm:4.1.0" + dependencies: + "@vitest/expect": "npm:4.1.0" + "@vitest/mocker": "npm:4.1.0" + "@vitest/pretty-format": "npm:4.1.0" + "@vitest/runner": "npm:4.1.0" + "@vitest/snapshot": "npm:4.1.0" + "@vitest/spy": "npm:4.1.0" + "@vitest/utils": "npm:4.1.0" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" magic-string: "npm:^0.30.21" obug: "npm:^2.1.1" pathe: "npm:^2.0.3" picomatch: "npm:^4.0.3" - std-env: "npm:^3.10.0" + std-env: "npm:^4.0.0-rc.1" tinybench: "npm:^2.9.0" tinyexec: "npm:^1.0.2" tinyglobby: "npm:^0.2.15" tinyrainbow: "npm:^3.0.3" - vite: "npm:^6.0.0 || ^7.0.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0-0" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.0.18 - "@vitest/browser-preview": 4.0.18 - "@vitest/browser-webdriverio": 4.0.18 - "@vitest/ui": 4.0.18 + "@vitest/browser-playwright": 4.1.0 + "@vitest/browser-preview": 4.1.0 + "@vitest/browser-webdriverio": 4.1.0 + "@vitest/ui": 4.1.0 happy-dom: "*" jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: "@edge-runtime/vm": optional: true @@ -15472,9 +15842,11 @@ __metadata: optional: true jsdom: optional: true + vite: + optional: false bin: vitest: vitest.mjs - checksum: 10c0/b913cd32032c95f29ff08c931f4b4c6fd6d2da498908d6770952c561a1b8d75c62499a1f04cadf82fb89cc0f9a33f29fb5dfdb899f6dbb27686a9d91571be5fa + checksum: 10c0/48048e4391e4e8190aa12b1c868bef4ad8d346214631b4506e0dc1f3241ecb8bcb24f296c38a7d98eae712a042375ae209da4b35165db38f9a9bc79a3a9e2a04 languageName: node linkType: hard