Files
threadnet-call/src/livekit/aiNoiseSuppression.ts
T
Thore CimbalandClaude Opus 4.8 fee9866e19 fix(audio): verify the sender actually carries the processed track
Field finding on Safari as the sending client: assets load, setProcessor
reports success, yet suppression level 0-100 makes no audible difference -
because LiveKit swaps the sender track via 'this.sender?.replaceTrack(...)',
and when the sender is not there at that instant the swap is skipped silently,
leaving the raw microphone on the wire. Chromium clients hit the timing,
Safari does not.

applyAiNoiseSuppression now verifies instead of trusting: wait for the sender
if needed, enforce the swap explicitly, and state the outcome in the log line
('Sendepfad gefiltert: ja/NEIN'). Three tests pin the sender cases, including
the exact Safari symptom of a sender still carrying the raw track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:00:00 +00:00

190 lines
7.8 KiB
TypeScript

/*
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 {
type AudioProcessorOptions,
type LocalAudioTrack,
type Track,
type TrackProcessor,
} from "livekit-client";
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
import { type Logger, logger } from "matrix-js-sdk/lib/logger";
import {
aiNoiseSuppressionDevSetting,
aiNoiseSuppressionLevelSetting,
aiNoiseSuppressionSetting,
} from "../settings/settings";
/**
* KI-Geraeuschunterdrueckung (DeepFilterNet3) als LiveKit-TrackProcessor.
*
* Warum ueberhaupt: Der WebRTC-Standardfilter schaetzt ein laufendes Rauschprofil
* und filtert damit STATIONAERE Stoerungen (Luefter, Brummen). Tastaturanschlaege
* sind TRANSIENT - sehr kurzer Anstieg, unvorhersehbares Spektrum - und werden
* nicht als Stoerung erkannt. Genau die filtert DeepFilterNet3 weg.
*
* Warum die Assets von uns kommen: Das Paket laedt Modell und wasm sonst von
* cdn.mezon.ai. Fuer eine selbstgehostete Plattform hiesse das, dass jeder
* Teilnehmer bei jedem Call-Start seine IP an einen Dritten meldet und die
* Verfuegbarkeit an fremder Infrastruktur haengt. `assetConfig.cdnUrl` zeigt
* deshalb auf unsere eigene Auslieferung (public/assets/dfn3/).
*/
const ASSET_PFAD = "assets/dfn3";
/**
* Feature-Tor: bei `false` ist der Filter fuer ALLE Clients aus - auch fuer
* solche, die die Einstellung frueher aktiviert haben (localStorage).
*
* Geschichte: v0.5.0 setzte den Prozessor ueber die Capture-Defaults, ohne
* dass der Track einen AudioContext hatte - LiveKit warf "Audio context needs
* to be set on LocalAudioTrack in order to enable processors", und das
* Entmuten brach fuer alle (Produktionsvorfall 2026-08-16). Seit Weg B
* (#0054) haengt sich der Filter NACH der Publikation an den Mikrofon-Track,
* mit eigenem AudioContext nur dort. Abnahme im Call zu zweit bestanden am
* 2026-08-17 (Filter wirksam, Tastatur weg, Entmuten intakt) - seitdem ist
* das Tor offen. Bei einer Regression zuerst wieder auf `false` stellen:
* das legt den Filter still, ohne die Auslieferung anzufassen.
*/
export const AI_NOISE_SUPPRESSION_AVAILABLE = true;
/**
* 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 || aiNoiseSuppressionDevSetting.getValue()) &&
aiNoiseSuppressionSetting.getValue()
);
}
/**
* Baut den Prozessor - oder gibt `undefined` zurueck, wenn der Filter nicht
* wirksam ist (Tor zu oder Nutzer hat ihn aus).
*
* Der Import ist statisch, kostet aber nur den ~23-KB-Wrapper. Die ~23 MB
* Modell-Assets holt das Paket erst in seinem `init()`, also erst wenn der
* Prozessor wirklich an einen Track gehaengt wird. Damit bleibt das Opt-in aus
* ADR-0018 auch wirtschaftlich eines: wer den Filter aus laesst, laedt nichts.
*/
export function createAiNoiseSuppressionProcessor():
| TrackProcessor<Track.Kind.Audio, AudioProcessorOptions>
| undefined {
if (!isAiNoiseSuppressionEnabled()) return undefined;
try {
return new DeepFilterNoiseFilterProcessor({
sampleRate: 48000,
noiseReductionLevel: aiNoiseSuppressionLevelSetting.getValue(),
assetConfig: {
cdnUrl: new URL(ASSET_PFAD, window.location.href).href,
},
}) as unknown as TrackProcessor<Track.Kind.Audio, AudioProcessorOptions>;
} catch (e) {
// Bewusst kein Abbruch: lieber ein Call ohne Filter als kein Call.
logger.error("KI-Geraeuschunterdrueckung nicht verfuegbar", e);
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);
// Verifizieren statt vertrauen: LiveKits setProcessor tauscht den
// Sender-Track per `this.sender?.replaceTrack(...)` - ist der Sender in
// dem Moment nicht da (Safari-Timing), wird der Tausch STUMM uebersprungen
// und das rohe Mikro bleibt auf der Leitung. Symptom: Filter "an", Assets
// geladen, aber Staerke 0-100 ohne jede hoerbare Wirkung.
const angekommen = await ensureSenderCarriesProcessed(track, processor);
parentLogger.info(
`KI-Geraeuschunterdrueckung aktiv (Staerke ${aiNoiseSuppressionLevelSetting.getValue()} %), ` +
`Sendepfad gefiltert: ${angekommen ? "ja" : "NEIN - Sender nicht gefunden"}`,
);
} catch (e) {
// Track ist publiziert, der Call laeuft - der Filter faellt aus, mehr nicht.
parentLogger.error("KI-Geraeuschunterdrueckung konnte nicht angehaengt werden", e);
}
}
/**
* Stellt sicher, dass der RTCRtpSender wirklich den GEFILTERTEN Track sendet.
* Wartet notfalls auf den Sender (er kann beim LocalTrackPublished-Event je
* nach Engine noch fehlen) und zieht den Tausch explizit nach.
*
* Gibt `true` zurueck, wenn der Sender nachweislich den gefilterten Track
* traegt - das ist die Aussage, die in der Log-Zeile oben landet.
*/
export async function ensureSenderCarriesProcessed(
track: LocalAudioTrack,
processor: { processedTrack?: MediaStreamTrack },
versuche = 20,
intervallMs = 250,
): Promise<boolean> {
const processed = processor.processedTrack;
if (!processed) return false;
for (let i = 0; i < versuche; i++) {
// `sender` ist in LiveKit als @internal markiert, aber oeffentlich lesbar -
// es ist die einzige Stelle, an der sich die Wahrheit pruefen laesst.
const sender = (track as unknown as { sender?: RTCRtpSender }).sender;
if (sender) {
if (sender.track === processed) return true;
await sender.replaceTrack(processed);
return sender.track === processed;
}
await new Promise((r) => setTimeout(r, intervallMs));
}
return false;
}