diff --git a/Dockerfile b/Dockerfile index bf34c6c3..8d9953ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,12 @@ COPY ./dist /dist # Compress assets to work with nginx-gzip-static-module WORKDIR /dist/assets -RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json +RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json + +# ThreadNet-Fork: das DeepFilterNet3-wasm liegt in einem Unterordner und wird vom +# Glob oben nicht erfasst - ungzippt waeren das 15,7 MB statt 4,1 MB pro Client. +# Das .tar.gz des Modells ist bereits komprimiert und wird bewusst ausgelassen. +RUN find dfn3 -name '*.wasm' -exec gzip -k {} + || true FROM nginxinc/nginx-unprivileged:alpine-slim diff --git a/docs/axion1337-fork.md b/docs/axion1337-fork.md index c639b95e..d06499e7 100644 --- a/docs/axion1337-fork.md +++ b/docs/axion1337-fork.md @@ -70,6 +70,45 @@ ML-Noise-Cancellation (z.B. selbst gehostetes DTLN/RNNoise) - LiveKits eigene Do beschreibt diesen Baustein als für AI-Voice-Agents gedacht, nicht für Mensch-zu-Mensch-Calls (kein unterstützter Weg, bereinigtes Audio an andere Teilnehmer weiterzuleiten). +## 5b. KI-Geraeuschunterdrueckung client-seitig (ADR-0018) + +Ergaenzt §5: dort ging es um **server-seitige** ML-Filterung, die verworfen bleibt. Diese +Anpassung ist **client-seitig** und damit von jenem Einwand nicht betroffen. + +**Warum:** Der WebRTC-Standardfilter schaetzt ein laufendes Rauschprofil und filtert damit +STATIONAERE Stoerungen. Tastaturanschlaege sind TRANSIENT und werden nicht als Stoerung +erkannt - sie gehen ungefiltert raus. Betroffen sind auch leise Chiclet-Tastaturen. + +**Was geaendert wurde:** + +| Datei | Aenderung | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `src/livekit/aiNoiseSuppression.ts` | **neu** - baut den DeepFilterNet3-TrackProcessor | +| `src/settings/settings.ts` | zwei Settings: `ai-noise-suppression` (bool, Default **false**), `ai-noise-suppression-level` (number, Default **35**) | +| `src/state/CallViewModel/remoteMembers/ConnectionFactory.ts` | `processor:` in `audioCaptureDefaults`; `noiseSuppression` wird bei aktivem KI-Filter **abgeschaltet** | +| `src/settings/SettingsModal.tsx` | `AiNoiseSuppressionSettings` im Audio-Tab (Checkbox + Regler) | +| `public/assets/dfn3/**` | Modell + wasm, **23 MB**, selbst ausgeliefert | +| `package.json` | `deepfilternet3-noise-filter` | + +**Drei Dinge, die beim Portieren nicht verlorengehen duerfen:** + +1. **Assets selbst ausliefern.** Das Paket laedt Modell und wasm sonst von `cdn.mezon.ai` - + das meldet bei jedem Call-Start die IP jedes Teilnehmers an einen Dritten und haengt die + Verfuegbarkeit an fremde Infrastruktur. `assetConfig.cdnUrl` zeigt deshalb auf + `public/assets/dfn3/`. **Beim Aktualisieren des Pakets pruefen, ob sich die Asset-Pfade + geaendert haben** (`v3/pkg/df_bg.wasm`, `v3/models/DeepFilterNet3_onnx.tar.gz`). +2. **Browser-`noiseSuppression` bleibt bei aktivem KI-Filter AUS.** Sonst arbeiten zwei + Filter gegeneinander und der Browser schneidet dem Modell bereits Signalanteile weg. +3. **Default 35 %, nicht 100 %.** Gemessen reicht das fuer "Tastatur weg und Stimme + natuerlich"; mehr Daempfung erhoeht nur das Artefaktrisiko. + +**Kein** Rust/wasm-Build noetig (das fertige Paket genuegt), **kein** Dry/Wet-Mixer und +**kein** Delay-Node - geregelt wird ueber den Modellparameter (`noiseReductionLevel` / +`atten_lim`), nicht ueber Zurueckmischen des Originalsignals. + +⚠️ **Mobil ungeprueft.** Der Telefontest wurde bewusst ausgesetzt; weil der Filter opt-in +ist, bleibt er auf schwachen Geraeten schlicht aus. Messung und Herleitung: management #0054. + ## 6. Produktname im Widget (`.env.production`) `VITE_PRODUCT_NAME=aXion1337.Chat` in `.env.production` — eine Zeile, keine @@ -82,24 +121,24 @@ heißt es aXion1337.Chat — und das Call-Widget läuft in der Anwendung. **Was dadurch tatsächlich umbenannt wird** (Inventur 2026-08-06, embedded-Modus): -| Stelle | sichtbar wo | -|---|---| +| Stelle | sichtbar wo | +| ------------------------------------ | ------------------------------------------------------- | | `error.matrix_rtc_transport_missing` | Fehlermeldung „Der Server ist nicht für … konfiguriert" | -| `error.open_elsewhere_description` | „… wurde in einem anderen Tab geöffnet" | -| `DeveloperSettingsTab` Versionszeile | Einstellungen → Entwicklermodus | -| `usePageTitle` → `document.title` | im iframe unsichtbar, der Vollständigkeit halber | +| `error.open_elsewhere_description` | „… wurde in einem anderen Tab geöffnet" | +| `DeveloperSettingsTab` Versionszeile | Einstellungen → Entwicklermodus | +| `usePageTitle` → `document.title` | im iframe unsichtbar, der Vollständigkeit halber | -**Was im Embedded-Build ohnehin nie erscheint** — und deshalb *nicht* angefasst +**Was im Embedded-Build ohnehin nie erscheint** — und deshalb _nicht_ angefasst wurde: - **Die Logo-SVGs.** Alle vier sind importiert und liegen im Bundle — gerendert wird im Widget aber keins, und zwar jeweils durch dieselbe Bedingung gesperrt: - | SVG | gerendert von | Sperre | - |---|---|---| - | `Logo` | `Header.tsx` (`HeaderLogo`) | `FullScreenView` zeigt den Header nur bei `header === "standard"` | - | `LogoMark`, `LogoType` | `CallFooter.tsx` | `showLogo = headerStyle === HeaderStyle.Standard` | - | `LogoLarge` | `LoginPage`, `RegisterPage` | gibt es nur standalone | + | SVG | gerendert von | Sperre | + | ---------------------- | --------------------------- | ----------------------------------------------------------------- | + | `Logo` | `Header.tsx` (`HeaderLogo`) | `FullScreenView` zeigt den Header nur bei `header === "standard"` | + | `LogoMark`, `LogoType` | `CallFooter.tsx` | `showLogo = headerStyle === HeaderStyle.Standard` | + | `LogoLarge` | `LoginPage`, `RegisterPage` | gibt es nur standalone | `HeaderStyle.Standard` ist der Default für **Nicht**-Widget-Nutzung; als Widget ist der Header `None` (Desktop) oder `AppBar` (Web/Mobile) — siehe `UrlParams.ts`. Das @@ -109,10 +148,11 @@ wurde: ⚠️ Nicht danach greppen, ob ein Logo „irgendwo vorkommt" — im gebauten Bundle steht `Element Call (Beta)` aus `Logo.svg` drin, obwohl es nie gezeichnet wird. Maßgeblich ist die Render-Bedingung, nicht der Treffer. + - **`header_label` („Element Call-Startseite") und `login_subheading`.** Startseite und Login gibt es nur im Standalone-Modus. - **Die drei deutschen `developer_mode.matrixRTCMode.*.description`.** Die reden über - *Gegenstellen* („alle beteiligten Element Call Clients v0.17.0 oder neuer") — das + _Gegenstellen_ („alle beteiligten Element Call Clients v0.17.0 oder neuer") — das ist eine Kompatibilitätsaussage über fremde Clients, keine Selbstbezeichnung. Sie umzubenennen wäre inhaltlich falsch. diff --git a/package.json b/package.json index 01f77d0f..4ebe04b3 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "babel-plugin-transform-vite-meta-env": "^1.0.3", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.3", + "deepfilternet3-noise-filter": "^1.3.0", "eslint": "^8.14.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80d50605..f9af938b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: copy-to-clipboard: specifier: ^3.3.3 version: 3.3.3 + deepfilternet3-noise-filter: + specifier: ^1.3.0 + version: 1.3.0(livekit-client@2.19.2(@types/dom-mediacapture-record@1.0.22)) eslint: specifier: ^8.14.0 version: 8.57.1 @@ -4659,6 +4662,12 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepfilternet3-noise-filter@1.3.0: + resolution: {integrity: sha512-yYFUlPuvPguqcd/R6/OSsr0noGqlqOE50JkCWYHogk+PjLj9qrNgwTt5zKraVkKnw0l4+eXJagkz2SUWtUl4sQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + livekit-client: ^2.0.0 + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -11994,6 +12003,10 @@ snapshots: deep-is@0.1.4: {} + deepfilternet3-noise-filter@1.3.0(livekit-client@2.19.2(@types/dom-mediacapture-record@1.0.22)): + dependencies: + livekit-client: 2.19.2(@types/dom-mediacapture-record@1.0.22) + default-browser-id@5.0.1: {} default-browser@5.5.0: diff --git a/public/assets/dfn3/v3/models/DeepFilterNet3_onnx.tar.gz b/public/assets/dfn3/v3/models/DeepFilterNet3_onnx.tar.gz new file mode 100644 index 00000000..1c4f4ffe Binary files /dev/null and b/public/assets/dfn3/v3/models/DeepFilterNet3_onnx.tar.gz differ diff --git a/public/assets/dfn3/v3/pkg/df_bg.wasm b/public/assets/dfn3/v3/pkg/df_bg.wasm new file mode 100644 index 00000000..2b7782bf Binary files /dev/null and b/public/assets/dfn3/v3/pkg/df_bg.wasm differ diff --git a/src/livekit/aiNoiseSuppression.ts b/src/livekit/aiNoiseSuppression.ts new file mode 100644 index 00000000..533d0a61 --- /dev/null +++ b/src/livekit/aiNoiseSuppression.ts @@ -0,0 +1,66 @@ +/* +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 Track, + type TrackProcessor, +} from "livekit-client"; +import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { + 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"; + +/** + * Baut den Prozessor - oder gibt `undefined` zurueck, wenn der Nutzer den Filter + * nicht eingeschaltet hat. + * + * 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 (!aiNoiseSuppressionSetting.getValue()) 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; + } +} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 58efeb25..ec22fa2e 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -21,6 +21,8 @@ import { useMediaDevices } from "../MediaDevicesContext"; import { widget } from "../widget"; import { useSetting, + aiNoiseSuppressionSetting, + aiNoiseSuppressionLevelSetting, soundEffectVolume as soundEffectVolumeSetting, backgroundBlur as backgroundBlurSetting, developerMode, @@ -109,6 +111,68 @@ export const SettingsModal: FC = ({ ); }; + // ThreadNet-Fork (ADR-0018): KI-Geraeuschunterdrueckung gegen Tastaturgeraeusche. + // Bewusst OPT-IN - die Modell-Assets sind ~23 MB und werden erst geladen, wenn + // der Filter eingeschaltet ist. Standard-Daempfung 35 %: gemessen reicht das + // fuer "Tastatur weg und Stimme natuerlich", mehr erhoeht nur das + // Artefaktrisiko (#0054). + const AiNoiseSuppressionSettings: React.FC = (): ReactNode => { + const [aiActive, setAiActive] = useSetting(aiNoiseSuppressionSetting); + const [level, setLevel] = useSetting(aiNoiseSuppressionLevelSetting); + const [levelRaw, setLevelRaw] = useState(level); + + return ( + <> +

+ {t("settings.ai_noise_suppression_header", "AI noise suppression")} +

+ + setAiActive(e.target.checked)} + /> + + {aiActive && ( +
+ +

+ {t( + "settings.ai_noise_suppression_strength_description", + "Lower keeps the room sound natural, higher isolates the voice more strictly.", + )} +

+ +
+ )} + + ); + }; + const devices = useMediaDevices(); useEffect(() => { if (open) devices.requestDeviceNames(); // No-op after the first call @@ -181,6 +245,8 @@ export const SettingsModal: FC = ({ step={0.01} /> + + ), diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 44dcf1b0..f879d0a7 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -226,6 +226,23 @@ export const autoGainControlSetting = new Setting( true, ); +// KI-Geraeuschunterdrueckung (ThreadNet-Fork, ADR-0018). +// Bewusst OPT-IN: die Modell-Assets sind ~23 MB und werden erst geladen, wenn +// der Prozessor tatsaechlich an den Track gehaengt wird. Wer den Filter nicht +// einschaltet, laedt nichts. +export const aiNoiseSuppressionSetting = new Setting( + "ai-noise-suppression", + false, +); + +// Daempfung in Prozent. 35 statt 100 ist gemessen, nicht geraten: bei ~35 % +// waren Tastaturanschlaege weg UND die Stimme klang natuerlich (#0054). +// Mehr Daempfung heisst mehr Artefaktrisiko, nicht mehr Nutzen. +export const aiNoiseSuppressionLevelSetting = new Setting( + "ai-noise-suppression-level", + 35, +); + /** * Seed setting defaults from config.json's media_quality section. * Call this after Config.init() has resolved. diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 0a0bd752..51fc532b 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -38,7 +38,9 @@ import { echoCancellationSetting, noiseSuppressionSetting, autoGainControlSetting, + aiNoiseSuppressionSetting, } from "../../../settings/settings.ts"; +import { createAiNoiseSuppressionProcessor } from "../../../livekit/aiNoiseSuppression.ts"; // TODO evaluate if this should be done like the Publisher Factory export interface ConnectionFactory { @@ -175,8 +177,16 @@ function generateRoomOption({ ...liveKitOptions.audioCaptureDefaults, deviceId: devices.audioInput.selected$.value?.id, echoCancellation: echoCancellationSetting.getValue(), - noiseSuppression: noiseSuppressionSetting.getValue(), + // Bei aktiver KI-Filterung die Browser-Rauschunterdrueckung ausschalten: + // sonst arbeiten zwei Filter gegeneinander und der Browser schneidet dem + // Modell bereits Signalanteile weg (ADR-0018). + noiseSuppression: + noiseSuppressionSetting.getValue() && + !aiNoiseSuppressionSetting.getValue(), autoGainControl: autoGainControlSetting.getValue(), + // ThreadNet-Fork: KI-Geraeuschunterdrueckung gegen Tastaturgeraeusche. + // `undefined`, wenn nicht eingeschaltet - dann wird auch nichts geladen. + processor: createAiNoiseSuppressionProcessor(), }, audioOutput: { // When using controlled audio devices, we don't want to set the