feat(audio): opt-in AI noise suppression against keyboard noise

The WebRTC noise suppression estimates a running noise profile, so it removes
stationary noise but not keystrokes, which are transient and never look like
noise to it. Chiclet keyboards are as affected as mechanical ones. DeepFilterNet3
runs client-side as a LiveKit track processor and removes them.

Off by default and the model is only fetched once a user switches it on, so
nobody pays the download who does not want the filter. Default strength is 35
percent rather than full: measured, that already gives keystrokes gone with the
voice still natural, and more attenuation only adds artefact risk.

Assets ship with us instead of the package's default CDN, which would report
every participant's IP to a third party at call start and tie call setup to
foreign infrastructure. The Dockerfile gzips the model wasm, which the existing
top-level glob missed — 4.1 MB instead of 15.7 MB per client.

Browser noise suppression is switched off while the filter runs so the two do not
work against each other. Regulation goes through the model's own attenuation
limit, so there is no dry/wet mixer and no delay compensation to get wrong.

Decision and measurements: management ADR-0018 and issue #0054.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thore Cimbal
2026-08-15 12:00:00 +00:00
co-authored by Claude Opus 4.8
parent c63be9ab94
commit 3f17001720
10 changed files with 232 additions and 14 deletions
+52 -12
View File
@@ -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
`<desc>Element Call (Beta)</desc>` 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.