From df4e5eeb85e66d7450fb67b3e4a21fbe4d7ef72e Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Sun, 16 Aug 2026 12:00:00 +0000 Subject: [PATCH] feat(audio): attach the AI filter per-track after publication (way B, #0054) Instead of webAudioMix on the room - which would also rewire playback (sink selection through the AudioContext, LiveKit's Chrome echo workaround) - only the local microphone track gets an AudioContext, via setAudioContext() right before setProcessor(). The attach happens in onLocalTrackPublished, so a failing filter can no longer prevent unmuting: the track is already published by then. audioCaptureDefaults now never carry a processor key in any state; the conditional spread only toggles noiseSuppression. A regression test covers the active case too. The gate stays closed. A single test client opts in via two localStorage keys (ai-noise-suppression-dev plus the regular setting); the regular setting alone stays inert. Four unit tests pin the attach order - setAudioContext before setProcessor is exactly what v0.5.0 lacked - and the containment of a failing attach. 77 tests green across the touched suites. Co-Authored-By: Claude Opus 4.8 --- docs/axion1337-fork.md | 25 ++++- embedded/web/package.json | 2 +- src/livekit/aiNoiseSuppression.test.ts | 101 ++++++++++++++++++ src/livekit/aiNoiseSuppression.ts | 71 +++++++++++- src/settings/settings.ts | 8 ++ .../CallViewModel/localMember/Publisher.ts | 13 +++ .../remoteMembers/ConnectionFactory.ts | 30 +++--- .../remoteMembers/ECConnectionFactory.test.ts | 20 ++++ 8 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 src/livekit/aiNoiseSuppression.test.ts diff --git a/docs/axion1337-fork.md b/docs/axion1337-fork.md index e7b7324e..2d2576a4 100644 --- a/docs/axion1337-fork.md +++ b/docs/axion1337-fork.md @@ -146,11 +146,34 @@ Daraus zwei **stehende Regeln** fuer diesen Fork: (`ConnectionFactory.ts`), abgesichert durch Regressionstests in `ECConnectionFactory.test.ts` (die auf dem alten Stand nachweislich rot waren). 2. **Feature-Tor:** `AI_NOISE_SUPPRESSION_AVAILABLE` in `src/livekit/aiNoiseSuppression.ts` - steht auf `false`, bis `webAudioMix` entschieden und im Call zu zweit getestet ist. + steht auf `false`, bis die Track-Anbindung im Call zu zweit abgenommen ist. Das Tor neutralisiert auch Clients, die die Einstellung bereits aktiviert hatten (localStorage) - deren Zustand ueberlebt jedes Deployment. Die Bedienelemente in `SettingsModal.tsx` sind hinter demselben Tor versteckt. +### Weg B (Entscheidung sorb 2026-08-16): AudioContext nur auf dem Mikrofon-Track + +Statt `webAudioMix` am Raum (wuerde auch die Wiedergabe umbauen: Ausgabegeraete-Wahl +ueber den AudioContext statt `setSinkId`, eigener Chrome-Echo-Workaround in LiveKit) +bekommt **nur der lokale Mikrofon-Track** einen AudioContext - via +`LocalAudioTrack.setAudioContext()`, unmittelbar vor `setProcessor()`. Umgesetzt in +`applyAiNoiseSuppression()` (`aiNoiseSuppression.ts`), aufgerufen aus +`Publisher.onLocalTrackPublished` - also erst **nach** der Publikation: ein +scheiternder Filter kann das Entmuten dann nicht mehr verhindern, der Fehlerfall ist +per Konstruktion auf den Filter selbst begrenzt. + +Die `audioCaptureDefaults` tragen damit in **keinem** Zustand mehr einen +`processor`-Schluessel (Regressionstest deckt auch den Aktiv-Fall ab); dort wird nur +noch `noiseSuppression` umgeschaltet, damit Browser- und KI-Filter nicht +gegeneinander arbeiten. + +**Testausrollung:** Das Tor bleibt zu. Ein einzelner Test-Client aktiviert den Filter +ueber ZWEI localStorage-Schluessel: `matrix-setting-ai-noise-suppression-dev = true` +(Entwickler-Schalter, bewusst ohne UI) **und** `matrix-setting-ai-noise-suppression = +true`. Die normale Einstellung allein bleibt wirkungslos. Erst nach bestandener +Abnahme im Call zu zweit wird das Tor geoeffnet und die UI wieder sichtbar. +Einstellungs-Aenderungen wirken ab dem naechsten Beitritt, nicht live im Call. + ## 6. Produktname im Widget (`.env.production`) `VITE_PRODUCT_NAME=aXion1337.Chat` in `.env.production` — eine Zeile, keine diff --git a/embedded/web/package.json b/embedded/web/package.json index 0e07b66b..416b0691 100644 --- a/embedded/web/package.json +++ b/embedded/web/package.json @@ -1,6 +1,6 @@ { "name": "@sorb/threadnet-call-embedded", - "version": "0.19.2-threadnet.9", + "version": "0.19.2-threadnet.10", "files": [ "README.md", "LICENSE-AGPL-3.0", diff --git a/src/livekit/aiNoiseSuppression.test.ts b/src/livekit/aiNoiseSuppression.test.ts new file mode 100644 index 00000000..e89e770e --- /dev/null +++ b/src/livekit/aiNoiseSuppression.test.ts @@ -0,0 +1,101 @@ +/* +Copyright 2026 aXion1337.chat + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. + +ThreadNet-Fork-Anpassung (ADR-0018) - nicht Upstream. Siehe docs/axion1337-fork.md. +*/ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { type LocalAudioTrack } from "livekit-client"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { applyAiNoiseSuppression } from "./aiNoiseSuppression"; +import { + aiNoiseSuppressionDevSetting, + aiNoiseSuppressionSetting, +} from "../settings/settings"; + +// jsdom hat keinen AudioContext - wir stellen einen minimalen bereit, der +// sich merkt, dass er existiert. Der echte Kontextaufbau ist Browserarbeit +// und wird im Call zu zweit abgenommen; hier geht es um die REIHENFOLGE. +class FakeAudioContext { + public state = "running"; + public resume = vi.fn().mockResolvedValue(undefined); +} + +const makeTrack = (): { + track: LocalAudioTrack; + calls: string[]; + setProcessor: ReturnType; +} => { + const calls: string[] = []; + const setAudioContext = vi.fn(() => { + calls.push("setAudioContext"); + }); + const setProcessor = vi.fn(async (): Promise => { + calls.push("setProcessor"); + await Promise.resolve(); + }); + return { + track: { setAudioContext, setProcessor } as unknown as LocalAudioTrack, + calls, + setProcessor, + }; +}; + +beforeEach(() => { + vi.stubGlobal("AudioContext", FakeAudioContext); +}); + +afterEach(() => { + aiNoiseSuppressionDevSetting.setValue(false); + aiNoiseSuppressionSetting.setValue(false); + vi.unstubAllGlobals(); +}); + +describe("applyAiNoiseSuppression (Weg B, #0054)", () => { + test("does nothing at all when the filter is not enabled", async () => { + aiNoiseSuppressionSetting.setValue(false); + const { track, calls } = makeTrack(); + + await applyAiNoiseSuppression(track, logger); + + // Der Normalfall aller Nutzer: der Track wird nicht einmal beruehrt. + expect(calls).toEqual([]); + }); + + test("a stale enabled setting without the dev override touches nothing", async () => { + // sorbs Zustand nach dem Diagnosefenster: Einstellung an, Tor zu. + aiNoiseSuppressionSetting.setValue(true); + const { track, calls } = makeTrack(); + + await applyAiNoiseSuppression(track, logger); + + expect(calls).toEqual([]); + }); + + test("sets the audio context BEFORE attaching the processor", async () => { + // Genau die Reihenfolge, an der v0.5.0 gescheitert ist: setProcessor + // ohne AudioContext auf dem Track wirft in LiveKit. + aiNoiseSuppressionDevSetting.setValue(true); + aiNoiseSuppressionSetting.setValue(true); + const { track, calls } = makeTrack(); + + await applyAiNoiseSuppression(track, logger); + + expect(calls).toEqual(["setAudioContext", "setProcessor"]); + }); + + test("a failing processor attach is contained and does not throw", async () => { + // Der Track ist zu diesem Zeitpunkt publiziert - ein Filterfehler darf + // den Call nicht mehr erreichen. + aiNoiseSuppressionDevSetting.setValue(true); + aiNoiseSuppressionSetting.setValue(true); + const { track, setProcessor } = makeTrack(); + setProcessor.mockRejectedValue(new Error("wasm sagt nein")); + + await expect(applyAiNoiseSuppression(track, logger)).resolves.toBeUndefined(); + }); +}); diff --git a/src/livekit/aiNoiseSuppression.ts b/src/livekit/aiNoiseSuppression.ts index 05d8dcb5..46acddfe 100644 --- a/src/livekit/aiNoiseSuppression.ts +++ b/src/livekit/aiNoiseSuppression.ts @@ -9,13 +9,15 @@ ThreadNet-Fork-Anpassung (ADR-0018) - nicht Upstream. Siehe docs/axion1337-fork. import { type AudioProcessorOptions, + type LocalAudioTrack, type Track, type TrackProcessor, } from "livekit-client"; import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { type Logger, logger } from "matrix-js-sdk/lib/logger"; import { + aiNoiseSuppressionDevSetting, aiNoiseSuppressionLevelSetting, aiNoiseSuppressionSetting, } from "../settings/settings"; @@ -52,12 +54,17 @@ const ASSET_PFAD = "assets/dfn3"; export const AI_NOISE_SUPPRESSION_AVAILABLE = false; /** - * Ist der Filter wirksam? Nur wenn das Tor offen ist UND der Nutzer ihn - * eingeschaltet hat. Alle Entscheidungen (Optionen-Bau, UI) laufen ueber - * dieses eine Praedikat, damit es keinen zweiten, abweichenden Pfad gibt. + * Ist der Filter wirksam? Nur wenn das Tor offen ist (oder der + * Entwickler-Schalter fuer die Testausrollung gesetzt ist) UND der Nutzer ihn + * eingeschaltet hat. Alle Entscheidungen (Optionen-Bau, Track-Anbindung, UI) + * laufen ueber dieses eine Praedikat, damit es keinen zweiten, abweichenden + * Pfad gibt. */ export function isAiNoiseSuppressionEnabled(): boolean { - return AI_NOISE_SUPPRESSION_AVAILABLE && aiNoiseSuppressionSetting.getValue(); + return ( + (AI_NOISE_SUPPRESSION_AVAILABLE || aiNoiseSuppressionDevSetting.getValue()) && + aiNoiseSuppressionSetting.getValue() + ); } /** @@ -88,3 +95,57 @@ export function createAiNoiseSuppressionProcessor(): return undefined; } } + +/** + * Ein gemeinsamer AudioContext fuer die Prozessor-Anbindung - lazily erzeugt + * und wiederverwendet, weil Browser die Anzahl gleichzeitiger AudioContexte + * begrenzen und Gerätewechsel denselben Kontext weiterverwenden sollen. + */ +let sharedAudioContext: AudioContext | undefined; + +/** + * Weg B aus #0054: Haengt den Filter NACH der Publikation an den lokalen + * Mikrofon-Track - mit einem eigenen AudioContext NUR auf diesem Track. + * + * Warum nicht `webAudioMix` am Raum: das wuerde auch die Wiedergabe umbauen + * (Ausgabegeraete-Wahl ueber den AudioContext statt setSinkId, eigener + * Chrome-Echo-Workaround) - maximaler Wirkradius im empfindlichsten Pfad. + * `LocalAudioTrack.setAudioContext()` gibt genau dem einen Track, der den + * Prozessor braucht, was `setProcessor()` verlangt, und laesst alles andere + * unberuehrt. + * + * Warum nach der Publikation (LocalTrackPublished) und nicht in den + * audioCaptureDefaults: erstens war der Streuschluessel in den Defaults die + * Ursache des v0.5.0-Vorfalls (die Constraints muessen upstream-identisch + * bleiben), zweitens ist der Fehlerfall so beweisbar harmlos - der Track ist + * bereits publiziert, ein scheiternder Filter kann das Entmuten nicht mehr + * verhindern. + */ +export async function applyAiNoiseSuppression( + track: LocalAudioTrack, + parentLogger: Logger, +): Promise { + if (!isAiNoiseSuppressionEnabled()) return; + const processor = createAiNoiseSuppressionProcessor(); + if (!processor) return; + + try { + sharedAudioContext ??= new AudioContext({ + sampleRate: 48000, + latencyHint: "interactive", + }); + // Autoplay-Policy: der Kontext kann suspendiert starten; die Anbindung + // passiert aber ohnehin im Nachgang einer Nutzeraktion (Beitritt/Entmuten). + if (sharedAudioContext.state === "suspended") { + await sharedAudioContext.resume(); + } + track.setAudioContext(sharedAudioContext); + await track.setProcessor(processor); + parentLogger.info( + `KI-Geraeuschunterdrueckung aktiv (Staerke ${aiNoiseSuppressionLevelSetting.getValue()} %)`, + ); + } catch (e) { + // Track ist publiziert, der Call laeuft - der Filter faellt aus, mehr nicht. + parentLogger.error("KI-Geraeuschunterdrueckung konnte nicht angehaengt werden", e); + } +} diff --git a/src/settings/settings.ts b/src/settings/settings.ts index f879d0a7..29768138 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -242,6 +242,14 @@ export const aiNoiseSuppressionLevelSetting = new Setting( "ai-noise-suppression-level", 35, ); +// Entwickler-Schalter fuer die Testausrollung (Weg B, #0054): erlaubt einem +// EINZELNEN Test-Client, den Filter trotz geschlossenem Feature-Tor zu +// aktivieren (localStorage: matrix-setting-ai-noise-suppression-dev = true, +// zusaetzlich zur normalen Einstellung). Kein UI dafuer - bewusst. +export const aiNoiseSuppressionDevSetting = new Setting( + "ai-noise-suppression-dev", + false, +); /** * Seed setting defaults from config.json's media_quality section. diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 0d5f263a..2ddad3d1 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, @@ -30,6 +31,7 @@ import { trackProcessorSync, } from "../../../livekit/TrackProcessorContext.tsx"; import { getUrlParams } from "../../../UrlParams.ts"; +import { applyAiNoiseSuppression } from "../../../livekit/aiNoiseSuppression.ts"; import { observeTrackReference$ } from "../../observeTrackReference"; import { type Connection } from "../remoteMembers/Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; @@ -118,6 +120,17 @@ export class Publisher { }); } if (localTrackPublication.source === Track.Source.Microphone) { + // ThreadNet-Fork (ADR-0018, Weg B aus #0054): Filter erst NACH der + // Publikation anhaengen - der Track ist dann sicher da, und ein + // scheiternder Filter kann das Entmuten nicht mehr verhindern. Bei + // inaktivem Filter (Normalfall) kehrt der Aufruf sofort zurueck. + const publishedTrack = localTrackPublication.track; + if (publishedTrack) { + void applyAiNoiseSuppression( + publishedTrack as LocalAudioTrack, + this.logger, + ); + } const muteState = this.muteStates.audio; // skip this if a sync is in progress: enabled$ still reflects the old // state while the handler is mid-flight, so the handler itself will apply diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index c9658af1..70e0ad9c 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -39,10 +39,7 @@ import { noiseSuppressionSetting, autoGainControlSetting, } from "../../../settings/settings.ts"; -import { - createAiNoiseSuppressionProcessor, - isAiNoiseSuppressionEnabled, -} from "../../../livekit/aiNoiseSuppression.ts"; +import { isAiNoiseSuppressionEnabled } from "../../../livekit/aiNoiseSuppression.ts"; // TODO evaluate if this should be done like the Publisher Factory export interface ConnectionFactory { @@ -180,20 +177,19 @@ function generateRoomOption({ deviceId: devices.audioInput.selected$.value?.id, echoCancellation: echoCancellationSetting.getValue(), autoGainControl: autoGainControlSetting.getValue(), - // ThreadNet-Fork (ADR-0018): KI-Geraeuschunterdrueckung als bedingtes - // Spread. Bei AUS entsteht hier BEWUSST kein `processor`-Schluessel - - // LiveKit kopiert jeden Schluessel dieser Defaults bis in die - // getUserMedia-Constraints durch (mergeObjectWithoutOverwriting prueft - // nur auf undefined im Ziel, nicht in der Quelle), und ein - // `processor: undefined` hat in v0.5.0 das Entmuten gebrochen. Der - // Aus-Pfad muss identisch mit Upstream sein, auch in der Objektform. - // Bei AN: Browser-Rauschunterdrueckung aus, sonst arbeiten zwei Filter - // gegeneinander und der Browser schneidet dem Modell Signalanteile weg. + // ThreadNet-Fork (ADR-0018): Die KI-Geraeuschunterdrueckung setzt hier + // NIE einen `processor`-Schluessel - LiveKit kopiert jeden Schluessel + // dieser Defaults bis in die getUserMedia-Constraints durch + // (mergeObjectWithoutOverwriting prueft nur auf undefined im Ziel, nicht + // in der Quelle), und ein `processor: undefined` hat in v0.5.0 das + // Entmuten gebrochen. Der Filter wird stattdessen NACH der Publikation + // an den Track gehaengt (Weg B, siehe aiNoiseSuppression.ts / + // Publisher.onLocalTrackPublished). Hier bleibt nur: bei aktivem Filter + // die Browser-Rauschunterdrueckung ausschalten, sonst arbeiten zwei + // Filter gegeneinander und der Browser schneidet dem Modell bereits + // Signalanteile weg. ...(isAiNoiseSuppressionEnabled() - ? { - noiseSuppression: false, - processor: createAiNoiseSuppressionProcessor(), - } + ? { noiseSuppression: false } : { noiseSuppression: noiseSuppressionSetting.getValue() }), }, audioOutput: { diff --git a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts index 33ecce20..b6302f83 100644 --- a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts +++ b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts @@ -32,6 +32,7 @@ import { cameraBitrate, cameraCodec, aiNoiseSuppressionSetting, + aiNoiseSuppressionDevSetting, } from "../../../settings/settings.ts"; // At the top of your test file, after imports @@ -162,6 +163,25 @@ describe("ECConnectionFactory - AI noise suppression off-path (ADR-0018)", () => aiNoiseSuppressionSetting.setValue(false); }); + + test("even with the filter active (dev override) the defaults carry no processor key", () => { + // Weg B aus #0054: der Prozessor wird NACH der Publikation an den Track + // gehaengt, nie ueber die Capture-Defaults - die getUserMedia-Constraints + // muessen in JEDEM Zustand upstream-identisch bleiben. Hier aendert sich + // nur noiseSuppression, damit nicht zwei Filter gegeneinander arbeiten. + aiNoiseSuppressionDevSetting.setValue(true); + aiNoiseSuppressionSetting.setValue(true); + noiseSuppressionSetting.setValue(true); + + const options = buildRoomOptions(); + expect(options.audioCaptureDefaults).not.toHaveProperty("processor"); + expect(options.audioCaptureDefaults).toMatchObject({ + noiseSuppression: false, + }); + + aiNoiseSuppressionDevSetting.setValue(false); + aiNoiseSuppressionSetting.setValue(false); + }); }); describe("ECConnectionFactory - ControlledAudioDevice", () => {