Rolling out revealed that the first implementation would not have worked in production: Element Call ships only as the embedded npm package, and that build sets publicDir: false because upstream's public/ holds nothing but a favicon. With that value everything builds, standalone works, and the filter is dead only inside the widget — verified, not assumed. Fixed in threadnet-call d270e0c and confirmed end to end: the widget URL resolves to the model path and the CI artifact carries the assets, not just the local build. Also corrects a number I gave when asking for the asset decision: the package goes from 41 to 66 MB, not from 2 to 25 — it already contained source maps and the crypto and vision wasm. publish_npm stays manual by design and is sorb's to trigger. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
255 lines
14 KiB
Markdown
255 lines
14 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).
|
||
|
||
## 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.
|