Files
threadnet-call/src/livekit/aiNoiseSuppression.ts
T

190 lines
7.8 KiB
TypeScript
Raw Normal View History

/*
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;
}