--- 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). ## Rollout 2026-08-16 — Auslieferungsweg korrigiert, wartet auf Publish ⚠️ **Beim Ausrollen kam heraus, dass die erste Umsetzung im Produktivpfad nicht funktioniert hätte.** Element Call läuft **nicht** als eigenes Image. Der Weg ist: ``` threadnet-call → npm-Paket @sorb/threadnet-call-embedded → Gitea → ThreadNet-Web (webpack kopiert nach webapp/widgets/element-call/) → threadnet-web-Image ``` Der Embedded-Build setzt `publicDir: false` — Upstream begründet das damit, `public/` enthalte nur das Favicon. Seit die Modell-Assets dort liegen, stimmt das nicht mehr. **Gemessen, nicht vermutet:** mit dem Upstream-Wert baut alles fehlerfrei, der Standalone-Build funktioniert, und **nur im Widget wäre der Filter tot** (404 auf das Modell). Genau der stille Fehlschlag, der ohne Prüfung des Auslieferungswegs live gegangen wäre. Behoben in `threadnet-call` `d270e0c`: `publicDir` aktiviert, Version auf `0.19.2-threadnet.8`, Fork-Doku um den Auslieferungsweg und den Rebase-Hinweis ergänzt (diese eine Zeile ist der wahrscheinlichste stille Rückfall beim nächsten Rebase). **Verifiziert:** - URL-Auflösung im Widget: `…/widgets/element-call/assets/dfn3/v3/pkg/df_bg.wasm` ✓ - CI-Pipeline #371 grün, `build_embedded` erfolgreich - **Das CI-Artefakt enthält die Assets** (24,3 MB) — nicht nur der lokale Build ### Korrektur einer Zahl aus der Entscheidungsvorlage Ich hatte beim Abfragen der Asset-Entscheidung gesagt, das npm-Paket wachse „von ~2 MB auf ~25 MB". **Gemessen: 41 → 66 MB.** Der Aufschlag stimmt (+24 MB), die Ausgangsbasis war falsch — das Paket enthielt bereits 17,6 MB Source-Maps und 16 MB Crypto-/Vision-wasm. Der relative Aufschlag ist also +60 %, nicht das Zwölffache. ### Offen — beides bewusst nicht von mir ausgelöst 1. **`publish_npm`** steht auf `manual`. Laut Fork-Doku §4 ist das Absicht: *„bewusst kein Automatismus — Veröffentlichen bleibt ein Akt."* Auslösen gehört sorb. 2. **Danach ThreadNet-Web:** Abhängigkeit auf `0.19.2-threadnet.8` anheben, bauen, Image in die Registry, Tag im gitops-Repo anheben. 3. **Abnahme im echten Call zu zweit** steht weiterhin aus. ## Vorfall 2026-08-16: v0.5.0 brach das Entmuten — behoben in v0.5.1, Filter stillgelegt ⚠️ **Das erste Produktivimage mit dem Filter (v0.5.0, embedded `.8`) hat das Entmuten fuer ALLE gebrochen** — nicht nur fuer Nutzer, die den Filter eingeschaltet hatten. Rueckrollung auf v0.4.3 stellte den Dienst wieder her; die Ursache wurde ueber ein von sorb angefordertes Diagnosefenster (v0.5.0 gezielt wieder ausgerollt, Browser-Konsole gesichert) festgenagelt. **Zwei getrennte Fehler derselben Aenderung:** 1. **Filter an:** `LocalAudioTrack.setProcessor()` verlangt einen AudioContext auf dem Track. Den gibt es nur mit `webAudioMix` beim Raum-Bau — und das steht in Element Call als unerprobtes Upstream-TODO auskommentiert. Konsole: *"Audio context needs to be set on LocalAudioTrack in order to enable processors"*; im SFU-Log erschien nie ein Track-Publish. **ADR-0018 hat die Integrationstiefe unterschaetzt:** der Prototyp verdrahtete seinen Audio-Graphen selbst und hat LiveKits Prozessor-Anbindung nie mitgeprueft. 2. **Filter aus:** Der Optionen-Bau setzte `processor: undefined` und schrieb `noiseSuppression` um. LiveKit kopiert **jeden** Schluessel der `audioCaptureDefaults` bis in die getUserMedia-Constraints durch, `undefined` eingeschlossen — in Safari brach damit das Entmuten trotz publiziertem Track. In Chromium unauffaellig; die fruehe Entlastung dieses Verdachts war ein Browser-uebergreifender Fehlschluss aus einem Chromium-Test. **Fix (threadnet-call `dcc8643`, embedded `.9`, ThreadNet-Web v0.5.1):** - Aus-Pfad als bedingtes Spread — identisch mit Upstream, kein `processor`-Schluessel. - Feature-Tor `AI_NOISE_SUPPRESSION_AVAILABLE = false`: neutralisiert auch Clients mit noch aktivierter Einstellung im localStorage (real existierender Fall), UI dahinter versteckt. - Zwei Regressionstests, **auf dem alten Stand nachweislich rot** — erst dadurch beweisen sie etwas. **Abnahme bestanden 2026-08-16:** Call sorb+frank auf v0.5.1, Muten/Entmuten beidseitig, sorbs Client mit unveraendertem localStorage. Konsolen-Fehlerbild identisch mit dem funktionierenden v0.4.3-Stand (bekanntes Rauschen, als eigene Beobachtung notiert: einmaliger 401 auf ein m.call-State-Event, einmaliger LiveKit-Connect-Fehlversuch mit erfolgreichem Folgeversuch). **Lehre (Wiederholung des Sitzungsmusters):** Die Auslieferungskette war penibel verifiziert — npm, node_modules, webpack, CI-Artefakt, Container, ausgelieferte URL. Geprueft wurde, ob die *Dateien ankommen*, nicht, ob die *Funktion im Zielsystem tut*. Der Call-Test zu zweit stand als „danach" auf der Liste statt als Voraussetzung. Fuer Aenderungen im Mikrofon-Pfad gilt ab jetzt: **Abnahme im echten Call ist Rollout-Voraussetzung, nicht Nacharbeit.** **Offen (Folge-Entscheidung, nicht Teil dieses Issues):** Der Filter bleibt stillgelegt, bis `webAudioMix` entschieden ist — Upstream-TODO mit Nebenwirkungen auf Echo-Unterdrueckung und Ausgabegeraete-Wahl. Erst diese Entscheidung macht ADR-0018 umsetzbar oder widerlegt ihn.