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:
co-authored by
Claude Opus 4.8
parent
dcc86431dc
commit
df4e5eeb85
+24
-1
@@ -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,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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user