2026-08-15 12:00:00 +00:00
|
|
|
/*
|
|
|
|
|
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,
|
2026-08-16 12:00:00 +00:00
|
|
|
type LocalAudioTrack,
|
2026-08-15 12:00:00 +00:00
|
|
|
type Track,
|
|
|
|
|
type TrackProcessor,
|
|
|
|
|
} from "livekit-client";
|
|
|
|
|
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
|
2026-08-16 12:00:00 +00:00
|
|
|
import { type Logger, logger } from "matrix-js-sdk/lib/logger";
|
2026-08-15 12:00:00 +00:00
|
|
|
|
|
|
|
|
import {
|
2026-08-16 12:00:00 +00:00
|
|
|
aiNoiseSuppressionDevSetting,
|
2026-08-15 12:00:00 +00:00
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-17 12:00:00 +00:00
|
|
|
* Feature-Tor: bei `false` ist der Filter fuer ALLE Clients aus - auch fuer
|
|
|
|
|
* solche, die die Einstellung frueher aktiviert haben (localStorage).
|
2026-08-16 12:00:00 +00:00
|
|
|
*
|
2026-08-17 12:00:00 +00:00
|
|
|
* 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.
|
2026-08-16 12:00:00 +00:00
|
|
|
*/
|
2026-08-17 12:00:00 +00:00
|
|
|
export const AI_NOISE_SUPPRESSION_AVAILABLE = true;
|
2026-08-16 12:00:00 +00:00
|
|
|
|
|
|
|
|
/**
|
2026-08-16 12:00:00 +00:00
|
|
|
* 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.
|
2026-08-16 12:00:00 +00:00
|
|
|
*/
|
|
|
|
|
export function isAiNoiseSuppressionEnabled(): boolean {
|
2026-08-16 12:00:00 +00:00
|
|
|
return (
|
|
|
|
|
(AI_NOISE_SUPPRESSION_AVAILABLE || aiNoiseSuppressionDevSetting.getValue()) &&
|
|
|
|
|
aiNoiseSuppressionSetting.getValue()
|
|
|
|
|
);
|
2026-08-16 12:00:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Baut den Prozessor - oder gibt `undefined` zurueck, wenn der Filter nicht
|
|
|
|
|
* wirksam ist (Tor zu oder Nutzer hat ihn aus).
|
2026-08-15 12:00:00 +00:00
|
|
|
*
|
|
|
|
|
* 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 {
|
2026-08-16 12:00:00 +00:00
|
|
|
if (!isAiNoiseSuppressionEnabled()) return undefined;
|
2026-08-15 12:00:00 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-16 12:00:00 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-08-17 12:00:00 +00:00
|
|
|
// 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);
|
2026-08-16 12:00:00 +00:00
|
|
|
parentLogger.info(
|
2026-08-17 12:00:00 +00:00
|
|
|
`KI-Geraeuschunterdrueckung aktiv (Staerke ${aiNoiseSuppressionLevelSetting.getValue()} %), ` +
|
|
|
|
|
`Sendepfad gefiltert: ${angekommen ? "ja" : "NEIN - Sender nicht gefunden"}`,
|
2026-08-16 12:00:00 +00:00
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Track ist publiziert, der Call laeuft - der Filter faellt aus, mehr nicht.
|
|
|
|
|
parentLogger.error("KI-Geraeuschunterdrueckung konnte nicht angehaengt werden", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 12:00:00 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|