The first production image carrying the filter broke unmuting for everyone. Records both causes (missing AudioContext on the on-path, a stray processor: undefined leaking into getUserMedia constraints on the off-path), the fix with its deliberately-red-first regression tests, the passed two-person acceptance call, and the standing lesson: for changes in the microphone path, a real-call acceptance is a rollout precondition, not an afterthought. The filter stays gated off until the webAudioMix decision - that follow-up is what makes ADR-0018 implementable or refutes it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
17 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.
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:
- Filter an:
LocalAudioTrack.setProcessor()verlangt einen AudioContext auf dem Track. Den gibt es nur mitwebAudioMixbeim 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. - Filter aus: Der Optionen-Bau setzte
processor: undefinedund schriebnoiseSuppressionum. LiveKit kopiert jeden Schluessel deraudioCaptureDefaultsbis in die getUserMedia-Constraints durch,undefinedeingeschlossen — 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.