All repo checks green including the connection factory tests that cover the changed noiseSuppression logic. Two findings while building improved the numbers: the Dockerfile's gzip glob missed the model wasm in its subdirectory, costing 15.7 MB instead of 4.1 MB per client, and the 23 MB verifiably stay out of the JS bundle, so the opt-in lazy load works as designed. sorb chose to commit the assets rather than fetch them at build time — fetching would have reintroduced the very third-party dependency we removed at runtime, just moved to build time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
215 lines
12 KiB
Markdown
215 lines
12 KiB
Markdown
---
|
||
type: issue
|
||
id: "0054"
|
||
status: open
|
||
created: 2026-08-15
|
||
milestone: M4
|
||
priority: medium
|
||
area: element
|
||
related:
|
||
- "docs/issues/0029-ui-harmonisieren-gleiche-farben-und-formen.md"
|
||
---
|
||
# Tastaturgeräusche in Calls: quelloffene KI-Geräuschunterdrückung im Client prüfen
|
||
|
||
## Problem
|
||
|
||
Der WebRTC-Standardfilter (`noiseSuppression`) ist auf **stationäres** Rauschen ausgelegt
|
||
(Lüfter, Netzbrummen). **Transiente** Geräusche — Tastaturanschläge — rutschen durch: sie
|
||
haben eine sehr schnelle Anstiegszeit und ein unvorhersehbares Spektrum, sodass die
|
||
laufende Rauschprofil-Schätzung sie nicht als Störung erkennt. Betroffen sind ausdrücklich
|
||
**auch leise Chiclet-Tastaturen** (MacBook), nicht nur mechanische.
|
||
|
||
Grundlage ist eine externe Architekturspezifikation (Gemini Deep Research, 2026-07-29):
|
||
client-seitige KI-Filterung via WebAssembly, eingehängt über das LiveKit-`TrackProcessor`-
|
||
Interface, mit Intensitätsregler in den Audio-Einstellungen.
|
||
|
||
## Bewertung der Spezifikation
|
||
|
||
### Trägt
|
||
|
||
- **Diagnose stimmt.** Stationär vs. transient ist die richtige Erklärung dafür, warum die
|
||
vorhandenen Toggles nicht helfen.
|
||
- **Client-seitig ist der richtige Ort — und kein Widerspruch zur bisherigen Linie.**
|
||
`threadnet-call:docs/axion1337-fork.md` §5 verwirft ML-Rauschunterdrückung **server-seitig**
|
||
(LiveKit Agents), weil es dort keinen unterstützten Weg gibt, bereinigtes Audio an andere
|
||
Teilnehmer weiterzureichen. Genau dieser Einwand greift client-seitig **nicht**. Die
|
||
Spezifikation setzt die alte Entscheidung fort, statt ihr zu widersprechen.
|
||
- **AudioWorklet statt ScriptProcessor**, eigener hochpriorer Audio-Thread: richtig und
|
||
nicht verhandelbar.
|
||
- **Der 128-vs-480-Sample-Mismatch** (Web Audio liefert 128er-Blöcke, die Modelle brauchen
|
||
480) und der nötige Ringpuffer sind sauber benannt — daran scheitern naive Umsetzungen.
|
||
- **Chromium-AudioWorklet-Leak** und die Gegenmaßnahme (eigener `AudioContext`, hart
|
||
schließen) sind real und richtig adressiert.
|
||
- **Wasm SIMD** ist tatsächlich Voraussetzung, nicht Optimierung.
|
||
|
||
### Trägt nicht
|
||
|
||
1. ⚠️ **Konkreter Fehler: die Latenzkompensation fehlt.** Der finale Dry/Wet-Code mischt das
|
||
**unverzögerte** Original mit dem ~40 ms verzögerten KI-Signal. Das erzeugt Kammfilter und
|
||
Phasenauslöschung — hörbar als blechernes Echo, also genau das Gegenteil des Ziels. Ein
|
||
früherer Entwurf im selben Gespräch hatte dafür einen `DelayNode`; in der Endfassung ist er
|
||
verschwunden. Das ist kein Detail.
|
||
2. **Der Dry/Wet-Ansatz ist konzeptionell fragwürdig.** Die Begründung („neuronale Netze
|
||
kennen nur An/Aus") ist für DeepFilterNet **falsch** — es hat einen nativen Parameter zur
|
||
**Begrenzung der Dämpfung**. „Weniger aggressiv" heißt richtig: das Modell weniger dämpfen
|
||
lassen. Dry/Wet mischt stattdessen ungefiltertes Signal zurück — **inklusive der
|
||
Tastaturanschläge**, die man loswerden wollte.
|
||
3. **Die Zahlen taugen nicht als Entscheidungsgrundlage.** PESQ „RNNoise ~3.88" gegen „DFN3
|
||
3.5–4.34": die untere DFN3-Grenze läge unter RNNoise. Werte aus verschiedenen Testsets,
|
||
nicht vergleichbar.
|
||
4. **Bundle-Größe geschätzt, nicht gemessen** („15–25 MB"). Für eine Browser-App, die beim
|
||
Call-Start lädt, ist das der kritische Wert überhaupt — muss gemessen werden.
|
||
5. **Die genannten NPM-Pakete sind Experimente** (`deepfilternet3-worker-test`,
|
||
`…-noise-filter-trong`). Die Spec empfiehlt selbst, aus dem Rust-Quellcode zu bauen — dann
|
||
gehört ehrlich dazu: wir übernehmen eine **Rust/wasm-Toolchain in die Build-Kette**.
|
||
6. **Mobil fehlt.** Element Call läuft auf Telefonen; DFN3 auf einem Mittelklasse-Android ist
|
||
offen und wird mit „läuft auf modernen Prozessoren" abgetan.
|
||
7. **`getUserMedia`-Constraints fehlen im Code.** Wer selbst filtert, muss die Browser-eigene
|
||
`noiseSuppression` **abschalten** (sonst arbeiten zwei Filter gegeneinander) und
|
||
`echoCancellation` erhalten. Im Gespräch erwähnt, im finalen Code verschwunden.
|
||
8. **Erzwungene 48 kHz** ohne Fallback — Geräte mit 44,1 kHz brauchen einen Pfad.
|
||
9. ⚠️ **Der größte Posten fehlt ganz: Fork-Wartung.** Das wäre eine erhebliche
|
||
Eigenentwicklung in `threadnet-call`, die bei **jedem** Upstream-Rebase mitgeschleppt und
|
||
in `axion1337-fork.md` gepflegt werden muss. Die Spec erwähnt das mit keinem Wort.
|
||
10. **Lizenz nur behauptet.** DeepFilterNet-Code ist MIT/Apache-2.0 — die **Modellgewichte**
|
||
sind separat zu prüfen, bevor „null Lizenzkosten" behauptet wird.
|
||
|
||
## Vorgeschlagenes Vorgehen (vor jeder Zeile Produktivcode)
|
||
|
||
1. **Messen statt annehmen.** Reproduzierbarer A/B-Test mit mechanischer *und* Chiclet-Tastatur
|
||
gegen die heutigen Toggles — belegt das Problem und liefert die Referenz für „besser".
|
||
2. **Wegwerf-Prototyp außerhalb des Forks.** DFN3 als Wasm auf einer eigenen Testseite:
|
||
**Bundle-Größe, CPU und Latenz auf echten Geräten messen** (inkl. Telefon). Erst diese
|
||
Zahlen entscheiden über Modell und Machbarkeit.
|
||
3. **Regler über den Modellparameter**, nicht über Dry/Wet. Falls doch Dry/Wet: Delay-Node zur
|
||
Latenzkompensation ist Pflicht.
|
||
4. **Dann erst** Integration als `TrackProcessor` und Eintrag in `axion1337-fork.md`.
|
||
|
||
## Offen (Entscheidung sorb)
|
||
|
||
Ob der Aufwand lohnt. Die Plattform hat derzeit einen sehr kleinen Nutzerkreis; dem steht
|
||
eine dauerhaft zu pflegende Fork-Anpassung mit Rust/wasm-Build gegenüber. Die Alternative —
|
||
Tastaturgeräusche als hinnehmbar erklären und stattdessen Push-to-Talk bzw. bewusstes
|
||
Stummschalten dokumentieren — ist billiger und sollte bewusst verworfen werden, nicht
|
||
übersehen.
|
||
|
||
## Prototyp gebaut und getestet 2026-08-15
|
||
|
||
Wegwerf-Aufbau außerhalb des Forks (`deepfilternet3-noise-filter` v1.3.0 + `livekit-client`,
|
||
lokale Testseite, Assets **selbst ausgeliefert**). Ziel war, die drei ungeklärten Punkte der
|
||
Spezifikation durch Messung zu ersetzen.
|
||
|
||
### Ergebnis: es funktioniert — und zwar besser als nötig
|
||
|
||
**Höreindruck sorb:** *„die Tastatur ist weg, Stimme klingt natürlich"* — bei **35 %**
|
||
Dämpfung.
|
||
|
||
Das ist der wichtigste Einzelbefund, und er entscheidet die Reglerfrage:
|
||
|
||
- **Der Dry/Wet-Mix der Spezifikation entfällt ersatzlos.** Das Paket bietet
|
||
`setSuppressionLevel()`, und die wasm-Signatur trägt `atten_lim` — DeepFilterNet begrenzt
|
||
die Dämpfung **nativ**. Die Prämisse der Spec („neuronale Netze kennen nur An/Aus") ist
|
||
widerlegt. Damit entfallen zugleich der fehlende Delay-Node und das Phasenproblem —
|
||
es gibt gar keinen zweiten Signalpfad mehr, der phasenversetzt zurückgemischt werden müsste.
|
||
- **35 % statt 100 % als Vorgabe.** Die Spec setzt „standardmäßig 100 % Filter-Aktivität";
|
||
gemessen reicht gut ein Drittel für „Tastatur weg **und** Stimme natürlich". Weniger
|
||
Dämpfung heißt weniger Artefaktrisiko — der Standardwert sollte bei ~35 % liegen, nicht am
|
||
Anschlag.
|
||
|
||
### Gemessen (ersetzt die Schätzungen der Spec)
|
||
|
||
| Größe | Wert | Bemerkung |
|
||
|---|---|---|
|
||
| Download je Client | **23,27 MB** | 15,66 MB `df_bg.wasm` + 7,61 MB Modell — Spec-Schätzung („15–25 MB") bestätigt, oberer Rand |
|
||
| Vergleich RNNoise | 2,0–4,6 MB | rund ein Zehntel (`@jitsi/rnnoise-wasm`, `@shiguredo/rnnoise-wasm`) |
|
||
| Lizenz Paket | Apache-2.0 **oder** MIT | wie behauptet; Modellgewichte kommen aus dem DeepFilterNet-Projekt |
|
||
|
||
### ⚠️ Befund, der die Paketwahl bestimmt: fremdes CDN
|
||
|
||
`deepfilternet3-noise-filter` lädt Modell und wasm zur Laufzeit von **`cdn.mezon.ai`**. Für
|
||
eine selbstgehostete Plattform ist das nicht hinnehmbar: jeder Teilnehmer meldet bei jedem
|
||
Call-Start seine IP an einen Dritten, und die Verfügbarkeit des Calls hinge an fremder
|
||
Infrastruktur.
|
||
|
||
**Entschärft:** `assetConfig.cdnUrl` ist konfigurierbar. Der Prototyp liefert die Assets
|
||
bereits **lokal** aus — der CDN-freie Betrieb ist damit nachgewiesen, nicht nur angenommen.
|
||
Für eine Integration hieße das: die 23 MB gehören mit ausgeliefert (Image/Ingress), nicht
|
||
nachgeladen.
|
||
|
||
### Ebenfalls korrigiert gegenüber der Spec
|
||
|
||
Die `getUserMedia`-Constraints, die im finalen Spec-Code fehlten, sind im Prototyp gesetzt:
|
||
`noiseSuppression: false` (sonst arbeiten Browser-Filter und Modell gegeneinander),
|
||
`echoCancellation: true`.
|
||
|
||
### Weiterhin offen — und entscheidungsrelevant
|
||
|
||
1. **CPU-Last** (Jitter mit/ohne Filter) noch nicht abgelesen.
|
||
2. **Telefon.** Die eigentliche Härteprobe: 23 MB Download und DFN3-Inferenz auf einem
|
||
Mittelklasse-Gerät. Fällt das durch, braucht es einen Pfad (RNNoise als leichte Variante,
|
||
oder Filter auf Mobilgeräten aus).
|
||
3. **Fork-Wartung** bleibt der ungemessene Posten — die Anpassung muss jeden Upstream-Rebase
|
||
überleben.
|
||
|
||
## Entscheidung 2026-08-15 → ADR-0018
|
||
|
||
sorb: **integrieren, opt-in mit Nachladen, Checkbox plus Regler, Standard 35 %.**
|
||
Festgehalten als [ADR-0018](../adr/0018-ki-geraeuschunterdrueckung-clientseitig-opt-in.md).
|
||
|
||
Der Größeneinwand entfällt damit für alle, die den Filter nicht nutzen: erst das Einschalten
|
||
löst den Download aus. Der Telefontest wurde bewusst ausgesetzt — vertretbar, weil der Filter
|
||
auf schwachen Geräten schlicht aus bleibt.
|
||
|
||
### Umsetzung (offen)
|
||
|
||
1. `deepfilternet3-noise-filter` als Abhängigkeit in `threadnet-call`.
|
||
2. `TrackProcessor` an den lokalen Audio-Track hängen; `assetConfig.cdnUrl` auf das eigene
|
||
Deployment zeigen (**nicht** auf `cdn.mezon.ai`).
|
||
3. Assets (23,3 MB) in die Auslieferung aufnehmen.
|
||
4. Audio-Einstellungen: Checkbox + Regler, Standard aus / 35 %.
|
||
5. `getUserMedia`: `noiseSuppression: false`, `echoCancellation: true`.
|
||
6. Eintrag in `threadnet-call:docs/axion1337-fork.md` als Fork-Anpassung mit Portier-Hinweis
|
||
(§5 dort ergänzen — die Absage galt der Server-Seite und bleibt gültig).
|
||
|
||
Der Prototyp liegt außerhalb der Repos und ist Wegwerf-Material; er wird nicht eingecheckt.
|
||
|
||
## Umgesetzt 2026-08-15 — threadnet-call `3f17001`
|
||
|
||
Nach ADR-0018 gebaut. Alle Prüfungen des Repos grün: `tsc` 0, `eslint` 0, `prettier` sauber,
|
||
`ECConnectionFactory.test.ts` **9/9** (der Test deckt genau die geänderte
|
||
`noiseSuppression`-Logik ab), `pnpm build` erfolgreich.
|
||
|
||
| Datei | Änderung |
|
||
|---|---|
|
||
| `src/livekit/aiNoiseSuppression.ts` | **neu** — baut den TrackProcessor, `undefined` wenn ausgeschaltet |
|
||
| `src/settings/settings.ts` | `ai-noise-suppression` (false), `ai-noise-suppression-level` (35) |
|
||
| `ConnectionFactory.ts` | `processor:` in `audioCaptureDefaults`; Browser-NS aus bei aktivem Filter |
|
||
| `SettingsModal.tsx` | Checkbox + Regler im **Audio**-Tab (nicht im Entwickler-Tab) |
|
||
| `public/assets/dfn3/**` | Modell + wasm, selbst ausgeliefert |
|
||
| `Dockerfile` | gzip für das Modell-wasm |
|
||
| `docs/axion1337-fork.md` | §5b mit Portier-Checkliste |
|
||
|
||
### Zwei Funde beim Bauen, die die Zahlen verbessern
|
||
|
||
1. **Der Dockerfile hätte die 23 MB ungzippt ausgeliefert.** Sein `gzip`-Glob greift nur auf
|
||
oberster Ebene, das Modell-wasm liegt aber in einem Unterordner. Gemessen: **15,7 → 4,1 MB**
|
||
(26 %). Zusammen mit dem bereits komprimierten Modell sind es **11,7 statt 23,3 MB** pro
|
||
Client. Behoben.
|
||
2. **Die 23 MB landen nicht im JS-Bundle.** Verifiziert: `dist/assets/index-*.js` bleibt bei
|
||
2,9 MB, die Assets liegen daneben als statische Dateien. Das Opt-in-Nachladen funktioniert
|
||
also wie entworfen — wer den Filter aus lässt, lädt nichts.
|
||
|
||
### Entscheidung sorb: Assets ins Git
|
||
|
||
Statt beim Bauen zu holen. Begründung: ein Build-Zeit-Download von `cdn.mezon.ai` hätte genau
|
||
die Fremdabhängigkeit wieder eingeführt, die wir zur Laufzeit entfernt haben — nur verschoben.
|
||
So baut das Repo offline und aus sich heraus. Preis: +23 MB dauerhaft (bisher größte Datei:
|
||
1,4 MB), und dasselbe nochmal bei jedem Modell-Update.
|
||
|
||
### Offen
|
||
|
||
- **Image bauen und ausrollen** — bis dahin läuft die Änderung nirgends.
|
||
- **Echter Call zu zweit** als Abnahme: der Prototyp lief gegen die eigenen Kopfhörer, nicht
|
||
über die Leitung.
|
||
- **Mobil** weiterhin ungeprüft (ADR-0018, bewusst).
|