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 <noreply@anthropic.com>
This commit is contained in:
Thore Cimbal
2026-08-16 12:00:00 +00:00
co-authored by Claude Opus 4.8
parent dcc86431dc
commit df4e5eeb85
8 changed files with 246 additions and 24 deletions
+24 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+101
View File
@@ -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<typeof vi.fn>;
} => {
const calls: string[] = [];
const setAudioContext = vi.fn(() => {
calls.push("setAudioContext");
});
const setProcessor = vi.fn(async (): Promise<void> => {
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();
});
});
+66 -5
View File
@@ -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<void> {
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);
}
}
+8
View File
@@ -242,6 +242,14 @@ export const aiNoiseSuppressionLevelSetting = new Setting<number>(
"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<boolean>(
"ai-noise-suppression-dev",
false,
);
/**
* Seed setting defaults from config.json's media_quality section.
@@ -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
@@ -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: {
@@ -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", () => {