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