Files
management/docs/issues/0054-ki-geraeuschunterdrueckung-element-call.md
T
Thore CimbalandClaude Opus 4.8 755aa3f2b5 docs(issues): #0054 — v0.5.0 unmute incident, fixed and accepted in v0.5.1
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>
2026-08-16 12:00:00 +00:00

17 KiB
Raw Blame History

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
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.54.34": die untere DFN3-Grenze läge unter RNNoise. Werte aus verschiedenen Testsets, nicht vergleichbar.
  4. Bundle-Größe geschätzt, nicht gemessen („1525 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 („1525 MB") bestätigt, oberer Rand
Vergleich RNNoise 2,04,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.

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.