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>
14 KiB
type, id, status, created, milestone, priority, area, related
| type | id | status | created | milestone | priority | area | related | |
|---|---|---|---|---|---|---|---|---|
| issue | 0054 | open | 2026-08-15 | M4 | medium | element |
|
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
- ⚠️ 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. - 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.
- 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.
- 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.
- 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. - Mobil fehlt. Element Call läuft auf Telefonen; DFN3 auf einem Mittelklasse-Android ist offen und wird mit „läuft auf modernen Prozessoren" abgetan.
getUserMedia-Constraints fehlen im Code. Wer selbst filtert, muss die Browser-eigenenoiseSuppressionabschalten (sonst arbeiten zwei Filter gegeneinander) undechoCancellationerhalten. Im Gespräch erwähnt, im finalen Code verschwunden.- Erzwungene 48 kHz ohne Fallback — Geräte mit 44,1 kHz brauchen einen Pfad.
- ⚠️ Der größte Posten fehlt ganz: Fork-Wartung. Das wäre eine erhebliche
Eigenentwicklung in
threadnet-call, die bei jedem Upstream-Rebase mitgeschleppt und inaxion1337-fork.mdgepflegt werden muss. Die Spec erwähnt das mit keinem Wort. - 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)
- 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".
- 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.
- Regler über den Modellparameter, nicht über Dry/Wet. Falls doch Dry/Wet: Delay-Node zur Latenzkompensation ist Pflicht.
- Dann erst Integration als
TrackProcessorund Eintrag inaxion1337-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ägtatten_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
- CPU-Last (Jitter mit/ohne Filter) noch nicht abgelesen.
- 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).
- 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.
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)
deepfilternet3-noise-filterals Abhängigkeit inthreadnet-call.TrackProcessoran den lokalen Audio-Track hängen;assetConfig.cdnUrlauf das eigene Deployment zeigen (nicht aufcdn.mezon.ai).- Assets (23,3 MB) in die Auslieferung aufnehmen.
- Audio-Einstellungen: Checkbox + Regler, Standard aus / 35 %.
getUserMedia:noiseSuppression: false,echoCancellation: true.- Eintrag in
threadnet-call:docs/axion1337-fork.mdals 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
- 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. - Die 23 MB landen nicht im JS-Bundle. Verifiziert:
dist/assets/index-*.jsbleibt 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_embeddederfolgreich - 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
publish_npmsteht aufmanual. Laut Fork-Doku §4 ist das Absicht: „bewusst kein Automatismus — Veröffentlichen bleibt ein Akt." Auslösen gehört sorb.- Danach ThreadNet-Web: Abhängigkeit auf
0.19.2-threadnet.8anheben, bauen, Image in die Registry, Tag im gitops-Repo anheben. - Abnahme im echten Call zu zweit steht weiterhin aus.