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_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