Compare commits

20 Commits
Author SHA1 Message Date
Thore CimbalandClaude Opus 4.8 e2b46ee937 docs: phone test cancelled, not deferred (decision sorb 2026-08-18)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 fee9866e19 fix(audio): verify the sender actually carries the processed track
Field finding on Safari as the sending client: assets load, setProcessor
reports success, yet suppression level 0-100 makes no audible difference -
because LiveKit swaps the sender track via 'this.sender?.replaceTrack(...)',
and when the sender is not there at that instant the swap is skipped silently,
leaving the raw microphone on the wire. Chromium clients hit the timing,
Safari does not.

applyAiNoiseSuppression now verifies instead of trusting: wait for the sender
if needed, enforce the swap explicitly, and state the outcome in the log line
('Sendepfad gefiltert: ja/NEIN'). Three tests pin the sender cases, including
the exact Safari symptom of a sender still carrying the raw track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 e3f8a8570a feat(audio): open the AI noise suppression gate after passed acceptance
Two-person call on 2026-08-17: filter effective, keyboard gone, unmuting intact
on both sides - the acceptance that gates this flag, per the standing rule from
the v0.5.0 incident. The gate flips to true, which brings the checkbox and
slider back into the in-call settings.

The dev override stays in the code as the tool for the next test phase of this
kind; a test pins that it does nothing without the regular setting. The rollback
lever for any regression is the gate itself, not a deployment revert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 df4e5eeb85 feat(audio): attach the AI filter per-track after publication (way B, #0054)
Instead of webAudioMix on the room - which would also rewire playback (sink
selection through the AudioContext, LiveKit's Chrome echo workaround) - only the
local microphone track gets an AudioContext, via setAudioContext() right before
setProcessor(). The attach happens in onLocalTrackPublished, so a failing filter
can no longer prevent unmuting: the track is already published by then.

audioCaptureDefaults now never carry a processor key in any state; the
conditional spread only toggles noiseSuppression. A regression test covers the
active case too.

The gate stays closed. A single test client opts in via two localStorage keys
(ai-noise-suppression-dev plus the regular setting); the regular setting alone
stays inert. Four unit tests pin the attach order - setAudioContext before
setProcessor is exactly what v0.5.0 lacked - and the containment of a failing
attach. 77 tests green across the touched suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 dcc86431dc fix(audio): restore the upstream off-path, gate the AI filter off (v0.5.0 incident)
threadnet.8 broke unmuting in production, both ways. With the filter on, LiveKit
refuses the processor because the room is built without webAudioMix, so no local
track ever carries an AudioContext. With the filter off, the options builder
still emitted processor: undefined and rewrote noiseSuppression - LiveKit copies
every key of audioCaptureDefaults into the getUserMedia constraints, undefined
included, and Safari stopped unmuting over it.

The off-path is now a conditional spread that produces an object identical to
upstream: no processor key at all, noiseSuppression untouched. Two regression
tests pin this down and were demonstrably red on the old code.

The feature itself is hard-gated off (AI_NOISE_SUPPRESSION_AVAILABLE) until the
webAudioMix decision is made and tested in a two-person call. The gate also
neutralizes clients that enabled the setting before - that state lives in
localStorage and survives every deployment. The settings UI hides behind the
same gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 d270e0c706 fix(embedded): ship the model assets in the widget package
Element Call reaches production only as the embedded npm package, which webpack
copies into ThreadNet-Web under /widgets/element-call/. The embedded build sets
publicDir: false — upstream reasons that public/ holds nothing but the favicon,
which stopped being true when the model assets landed there.

Verified rather than assumed: with the upstream value everything builds, the
standalone bundle works, and the filter is dead only inside the widget, 404ing on
the model. That is the failure this would have shipped.

The package grows from about 41 to 66 MB, measured — most of what was already
there is source maps and the crypto and vision wasm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 3f17001720 feat(audio): opt-in AI noise suppression against keyboard noise
The WebRTC noise suppression estimates a running noise profile, so it removes
stationary noise but not keystrokes, which are transient and never look like
noise to it. Chiclet keyboards are as affected as mechanical ones. DeepFilterNet3
runs client-side as a LiveKit track processor and removes them.

Off by default and the model is only fetched once a user switches it on, so
nobody pays the download who does not want the filter. Default strength is 35
percent rather than full: measured, that already gives keystrokes gone with the
voice still natural, and more attenuation only adds artefact risk.

Assets ship with us instead of the package's default CDN, which would report
every participant's IP to a third party at call start and tie call setup to
foreign infrastructure. The Dockerfile gzips the model wasm, which the existing
top-level glob missed — 4.1 MB instead of 15.7 MB per client.

Browser noise suppression is switched off while the filter runs so the two do not
work against each other. Regulation goes through the model's own attenuation
limit, so there is no dry/wet mixer and no delay compensation to get wrong.

Decision and measurements: management ADR-0018 and issue #0054.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore CimbalandClaude Opus 4.8 c63be9ab94 docs: add group-rules pointer (CLAUDE.md) and repo-specific AGENTS.md
Field test F-011 found four of five components carried no pointer file, so a
session landing here had no path to the group rules at all. CLAUDE.md is the
one-line pointer the check looks for; AGENTS.md links the canonical rules in the
management repo (with the Gitea mirror URL for readers outside the lab) and
otherwise carries only what is specific and easy to get wrong here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 12:00:00 +00:00
Thore Cimbal d13cce7337 docs: describe the automated publish, not the manual process it replaced
Section 4 still described a manual/local npm publish with an untracked .npmrc - obsolete since publish_npm landed in .gitlab-ci.yml on 2026-08-06. Documents the actual job: manual trigger, GITEA_NPM_TOKEN, the empty-package guard, and the threadnet dist-tag.
2026-08-09 12:00:00 +00:00
Thore Cimbal 3675cab385 ci: stamp the widget build with its version
Without VITE_APP_VERSION the bundle calls itself dev - in developer settings and in every error message. Someone reporting a call problem then cannot say which build they were on.

Set to the package version plus the short commit: the first says what was published, the second what was actually built. Between two publishes only the latter changes, and then it is the only thing that tells two builds apart.

Closes axion1337.chat/threadnet-call#3
2026-08-09 12:00:00 +00:00
Thore Cimbal dc2a041b83 docs: repoint commit references after the history rewrite
The anonymisation rewrite of 2026-08-07 gave every touched commit a new SHA, leaving the references in these documents pointing at objects that no longer exist. The mapping was reconstructed from the backup branches and each pair verified by tree and commit message before substituting.

Prefix lookups were built for lengths 7 to 12 and any ambiguous prefix would have been skipped; none were ambiguous across all 251 pairs.
2026-08-09 12:00:00 +00:00
Thore Cimbal e793902ae0 ci: do not create a pipeline for docs-only commits
Same trap as in ThreadNet-Web: a commit touching only docs/ produced a pipeline with zero jobs, which GitLab marks as failed - pipeline 187 on 2026-08-06. build_embedded is skipped by its changes rule and publish_npm depends on it, so nothing is left.

The path list is now a YAML anchor shared with build_embedded instead of a second copy. CI_PIPELINE_SOURCE == web stays allowed so publish_npm remains reachable after a docs-only commit.

Refs axion1337.chat/threadnet-call#1
2026-08-07 12:00:00 +00:00
Thore Cimbal 846135e48d embedded: 0.19.2-threadnet.7 + Schutz gegen leere Pakete
0.19.2-threadnet.6 liegt mit 12,5 KB statt 12,8 MB in der Registry: package.json und Lizenzen, kein dist/. Woher der Upload kam, laesst sich nicht mehr klaeren - beide publish_npm-Laeufe von heute sind fehlgeschlagen, der eine vor dem Upload am fehlenden dist-tag, der andere danach mit 409. Die Nummer ist verbrannt, npm-Versionen sind nicht ueberschreibbar.

Der neue Check bricht ab, wenn embedded/web/dist weniger als 50 Dateien hat (erwartet ~150). Das kostet nichts und verhindert, dass diese Klasse Fehler noch einmal eine Versionsnummer verbrennt.
2026-08-06 12:00:00 +00:00
Thore Cimbal 6f94e6a8a3 CI: dist-tag beim npm-Publish setzen
npm bricht ab, sobald eine Version wie 0.19.2-threadnet.6 ohne --tag veroeffentlicht werden soll - der Suffix macht sie zur Prerelease. "threadnet" statt "latest", denn latest soll nicht auf eine Vorabversion zeigen.

Refs axion1337.chat/threadnet-call#2
2026-08-06 12:00:00 +00:00
Thore Cimbal 84419133b3 embedded: 0.19.2-threadnet.6 - Widget heisst jetzt aXion1337.Chat
Erste Version mit VITE_PRODUCT_NAME. Am Artefakt von Pipeline 189 verifiziert.

Refs axion1337.chat/threadnet-call#2
2026-08-06 12:00:00 +00:00
Thore Cimbal 3f0f55e6bd Doku: Logo-Inventur korrigieren und Verifikation nachtragen
Meine erste Aussage "Logo.svg und LogoLarge.svg sind unreferenziert" war falsch - der Grep hatte icons/ herausgefiltert. Alle vier Logo-SVGs sind importiert und im Bundle; gerendert wird im Widget trotzdem keins, jeweils durch dieselbe HeaderStyle.Standard-Bedingung gesperrt. Das ist der belastbarere Befund, weil er an der Render-Bedingung haengt statt an einem Suchtreffer.

Refs axion1337.chat/threadnet-call#2
2026-08-06 12:00:00 +00:00
Thore Cimbal ac688d36f7 CI: Build-Output an die Stelle legen, an der das npm-Paket ihn erwartet
build_embedded sammelte embedded/web/dist, Vite schreibt aber nach dist/ - der Job lief seit jeher gruen und lud dabei NICHTS hoch ("WARNING: embedded/web/dist: no matching files"). publish_npm haengt per needs:artifacts daran und haette ein Paket ohne dist/ veroeffentlicht.

Upstream setzt den Pfad ueber das Download-Ziel des Artefakts; hier wird nach dem Build verschoben.

Refs axion1337.chat/threadnet-call#2
2026-08-06 12:00:00 +00:00
Thore Cimbal a6e555ac3f CI: .env.production als Build-Trigger aufnehmen
Die Datei setzt VITE_PRODUCT_NAME und landet damit im Bundle, stand aber nicht in der changes:-Liste von build_embedded - der vorige Commit haette also den Produktnamen geaendert, ohne dass die CI ueberhaupt baut.

Refs axion1337.chat/threadnet-call#2
2026-08-06 12:00:00 +00:00
Thore Cimbal 0823c8a866 Produktname im Widget: aXion1337.Chat statt Element Call
VITE_PRODUCT_NAME ist upstream bereits an jeder Stelle vorgesehen (import.meta.env.VITE_PRODUCT_NAME || "Element Call"). Sie in .env.production zu setzen benennt das Widget vollstaendig um, ohne eine einzige Quelldatei anzufassen - also ohne Merge-Reibung beim naechsten Upstream-Update.

Die Inventur in docs/axion1337-fork.md haelt fest, was dadurch tatsaechlich umbenannt wird und was im Embedded-Build ohnehin nie erscheint: die Logo-SVGs rendern nur bei HeaderStyle.Standard, dem Nicht-Widget-Default; Startseite und Login gibt es nur standalone. Die deutschen matrixRTCMode-Beschreibungen bleiben bewusst stehen - sie reden ueber fremde Gegenstellen, nicht ueber uns.

Refs axion1337.chat/threadnet-call#2
2026-08-06 12:00:00 +00:00
Thore CimbalandClaude Fable 5 8fb630cfb3 ci: GitLab-Pipeline - build_embedded + manueller npm-Publish nach rohana (threadnet-call#1)
Registry-Entscheidung evidenzbasiert: das Package ist pnpm-Dependency von
ThreadNet-Webs apps/web, der Lockfile pinnt die Tarball-URL auf rohana -
Registry bleibt dort. Publish-Auth ueber CI-Variable GITEA_NPM_TOKEN statt
lokaler Klartext-.npmrc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:00:00 +00:00
19 changed files with 868 additions and 23 deletions
+10
View File
@@ -0,0 +1,10 @@
# ThreadNet-Fork: Produktname für Produktions-Builds (Standalone und Embedded).
#
# VITE_PRODUCT_NAME ist bereits upstream vorgesehen — jede Stelle im Code liest sie
# als `import.meta.env.VITE_PRODUCT_NAME || "Element Call"`. Sie hier zu setzen ist
# deshalb die vollständige Umbenennung ohne eine einzige Änderung an Quelldateien,
# und damit ohne Merge-Reibung beim Upstream-Update.
#
# Der Name folgt der Regel aus management/shared/branding.md: "in der Anwendung"
# heißt es aXion1337.Chat — und das Call-Widget läuft in der Anwendung.
VITE_PRODUCT_NAME=aXion1337.Chat
+73 -14
View File
@@ -3,6 +3,38 @@
# Trigger-Schema wie ThreadNet-Web: Pushes pruefen nur bei relevanten Pfaden,
# Veroeffentlichen ist ein bewusster manueller Akt.
# --- Wann ueberhaupt eine Pipeline entsteht ------------------------------------
# Gleiche Falle wie in ThreadNet-Web: Ein Commit, der nur docs/ anfasst, erzeugt
# eine Pipeline mit NULL Jobs - und die zaehlt als "failed". Real passiert am
# 2026-08-06 (Pipeline 187). Ursache ist die needs-Kette: build_embedded laeuft
# wegen changes: nicht, publish_npm haengt per needs daran, und damit bleibt gar
# nichts uebrig.
#
# ⚠️ Rot, das nichts bedeutet, gewoehnt einem das Hinsehen ab - und in diesem
# Verbund IST die rote Pipeline die Alarmanlage (siehe gitops/CLAUDE.md zur
# TURN-Rotation).
#
# CI_PIPELINE_SOURCE == "web" bleibt erlaubt, damit publish_npm auch nach einem
# reinen Doku-Commit ueber "Run pipeline" erreichbar ist.
.pfade_mit_pipeline: &pfade_mit_pipeline
- src/**/*
- embedded/**/*
- public/**/*
- package.json
- pnpm-lock.yaml
- vite*.config.*
- .env.production
- .gitlab-ci.yml
workflow:
rules:
- if: $CI_COMMIT_TAG =~ /^v/
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_PIPELINE_SOURCE == "web"
- if: $CI_COMMIT_BRANCH
changes: *pfade_mit_pipeline
- when: never
stages:
- build
- publish
@@ -13,21 +45,34 @@ build_embedded:
rules:
- if: $CI_COMMIT_TAG =~ /^v/
- if: $CI_COMMIT_BRANCH
changes:
- src/**/*
- embedded/**/*
- public/**/*
- package.json
- pnpm-lock.yaml
- vite*.config.*
- .gitlab-ci.yml
# derselbe Anker wie im workflow-Block oben - zwei Listen wuerden driften.
# .env.production steht dort mit drin: sie setzt VITE_PRODUCT_NAME und landet
# damit im Bundle - ohne den Eintrag aendert man den Produktnamen und die CI
# baut gar nicht erst.
changes: *pfade_mit_pipeline
before_script:
- corepack enable
script:
# Ohne VITE_APP_VERSION meldet sich das Widget als "dev" - im Entwicklermodus
# und in jeder Fehlermeldung. Wer einen Call-Fehler schildert, kann dann nicht
# sagen, welcher Stand lief (threadnet-call#3).
#
# Paketversion UND Commit: die erste sagt, was veroeffentlicht wurde, die
# zweite, was genau gebaut wurde. Zwischen zwei Publishes aendert sich nur die
# zweite - und genau dann ist sie die einzige Unterscheidung.
- |
VER=$(node -p "require('./embedded/web/package.json').version")
export VITE_APP_VERSION="${VER}+${CI_COMMIT_SHORT_SHA}"
echo "VITE_APP_VERSION=$VITE_APP_VERSION"
- pnpm install --frozen-lockfile
# baut mit der media_quality-Konfiguration aus vite-embedded.config.ts
# (1440p-Defaults, h264 - siehe docs/axion1337-fork.md)
- pnpm build:embedded
# Vite schreibt nach dist/, das npm-Paket (embedded/web/package.json, files: [dist])
# erwartet es aber unter embedded/web/dist. Upstream loest das ueber den Zielpfad des
# Artefakt-Downloads (.github/workflows/publish-embedded-packages.yaml); hier
# verschieben wir direkt, damit publish_npm es per needs:artifacts vorfindet.
- mv dist embedded/web/dist
artifacts:
paths:
- embedded/web/dist
@@ -49,9 +94,7 @@ publish_npm:
- if: $CI_COMMIT_TAG =~ /^v/
when: manual
allow_failure: true
# Arbeitsbranch des Forks ist livekit (nicht der ungenutzte Default-Branch main);
# livekit ist zudem protected, damit die maskierte GITEA_NPM_TOKEN-Variable ankommt
- if: $CI_COMMIT_BRANCH == "livekit"
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
script:
@@ -61,7 +104,23 @@ publish_npm:
@sorb:registry=https://rohana.axion1337.de/api/packages/sorb/npm/
//rohana.axion1337.de/api/packages/sorb/npm/:_authToken=${GITEA_NPM_TOKEN}
EOF
# Schutz gegen ein leeres Paket: am 2026-08-06 landete 0.19.2-threadnet.6 mit
# 12,5 KB statt 12,8 MB in der Registry - package.json und Lizenzen, kein dist/.
# Eine npm-Version laesst sich nicht ueberschreiben, die kaputte Nummer ist
# dauerhaft verbrannt. Lieber hier hart abbrechen als das nochmal zu erleben.
- |
N=$(find embedded/web/dist -type f 2>/dev/null | wc -l)
if [ "$N" -lt 50 ]; then
echo "embedded/web/dist enthaelt nur $N Dateien (erwartet: ~150)."
echo "build_embedded hat kein brauchbares Artefakt geliefert - Publish abgebrochen,"
echo "sonst verbrennt diese Versionsnummer mit einem leeren Paket."
exit 1
fi
- cd embedded/web
# --tag latest explizit: npm verweigert Prerelease-Versionen (x.y.z-threadnet.N)
# sonst komplett; latest entspricht dem bisherigen Konsum-Verhalten
- npm publish --access public --tag latest
# --tag ist Pflicht, nicht Geschmack: unsere Versionen tragen den Suffix
# -threadnet.N und gelten npm damit als Prerelease. Ohne expliziten dist-tag
# bricht npm ab ("You must specify a tag using --tag when publishing a
# prerelease version"). "threadnet" statt "latest" ist dabei das Richtige -
# latest soll nicht auf eine Vorabversion zeigen. Fuer den Konsumenten ist es
# ohnehin unerheblich, ThreadNet-Webs pnpm-lock.yaml pinnt die Tarball-URL.
- npm publish --access public --tag threadnet
+33
View File
@@ -0,0 +1,33 @@
# AGENTS.md — threadnet-call
> **Die Gruppenregeln sind kanonisch im `management`-Repo:**
> [`AGENTS.md`](https://git.lab/axion1337.chat/management/-/blob/main/AGENTS.md)
> — von außerhalb des Labs über den Gitea-Mirror lesbar:
> `https://rohana.axion1337.de/sorb/management`. Dort stehen Repo-Topologie und
> Mirror-Regeln, das Kanban-Framework (Status-Labels, WIP-Limit 2, ADR-Pflicht),
> Deploy-Übergabe und AAR-Verfahren, Secrets-Handhabung und die
> Karpathy-Leitlinien. Sie gelten für **jede** Session in diesem Repo.
> Hier steht nur, was zusätzlich für dieses Repository gilt.
## Was dieses Repo ist
Fork von **Element Call** (Basis `0.19.2`) für axion1337.chat — abgeleitet von
`emmick4/element-call`, Branch `livekit` (ein noch nicht gemergter Upstream-PR).
⚠️ **`README.md` und die übrigen `docs/`-Dateien sind unverändertes Upstream-Material** und
beschreiben diesen Fork nicht.
## Der eine Ort, der zählt
**[`docs/axion1337-fork.md`](docs/axion1337-fork.md)** — vollständige Liste der
Fork-Änderungen und Portier-Checkliste für Upstream-Upgrades. Jede Anpassung gehört dort
hinein, sonst überlebt sie den nächsten Rebase nicht.
⚠️ Die doppelte Ableitung (Upstream → `emmick4/livekit` → hier) macht Upgrades aufwendiger als
bei einem geraden Fork: Beim Anheben ist zuerst zu klären, ob der zugrundeliegende PR
inzwischen gemergt ist.
## Bauen & Ausrollen
Wie bei ThreadNet-Web: Image nach `rohana.axion1337.de`, Version im gitops-Repo anheben.
Betriebsseite: MatrixRTC/LiveKit, siehe Betriebs-Wiki `/betrieb/element-call`.
+1
View File
@@ -0,0 +1 @@
Read AGENTS.md — the canonical instruction file for this repository. All rules live there.
+6 -1
View File
@@ -4,7 +4,12 @@ COPY ./dist /dist
# Compress assets to work with nginx-gzip-static-module
WORKDIR /dist/assets
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
# ThreadNet-Fork: das DeepFilterNet3-wasm liegt in einem Unterordner und wird vom
# Glob oben nicht erfasst - ungzippt waeren das 15,7 MB statt 4,1 MB pro Client.
# Das .tar.gz des Modells ist bereits komprimiert und wird bewusst ausgelassen.
RUN find dfn3 -name '*.wasm' -exec gzip -k {} + || true
FROM nginxinc/nginx-unprivileged:alpine-slim
+178 -5
View File
@@ -40,17 +40,27 @@ umgestellt - live verifiziert (7/8 Tracks nativ H.264, 1 sauberer VP8-Fallback).
## 4. npm-Publish zu Gitea (`@sorb/threadnet-call-embedded`)
Das Embedded-Package (`embedded/web/package.json`, aktuell `0.19.2-threadnet.5`) wird zu
⚠️ Dieser Abschnitt beschrieb bis 2026-08-09 einen manuellen/lokalen Vorgang, der
seit dem 2026-08-06 nicht mehr zutrifft — korrigiert bei einer Doku-Durchsicht.
Das Embedded-Package (`embedded/web/package.json`, aktuell `0.19.2-threadnet.7`) wird zu
Gitea's npm-Registry veröffentlicht (`https://rohana.axion1337.de/api/packages/sorb/npm/`,
Scope `@sorb`). Der obere Versionierungs-Track hier (`0.19.2-threadnet.N`) ist unabhängig von
den Docker-Image-Tags, unter denen das fertig gebaute Widget im gitops-Repo deployt wird (z.B.
`v0.2.3-elementcall-h264` als `threadnet-web`-Image-Tag) - zwei getrennte Versionsschemata für
zwei verschiedene Artefakte (npm-Package vs. Docker-Image).
Der aktuelle Publish-Vorgang läuft manuell/lokal - das committete
`.github/workflows/publish-embedded-packages.yaml` zielt noch auf `registry.npmjs.org`/
`@element-hq`-Scope (Upstream-Konfiguration, nicht an die Gitea-Registry angepasst). Registry-
Zugangsdaten liegen in einer lokalen, **nicht committeten** `.npmrc` - nicht Teil dieses Repos.
**Der Publish läuft über den `publish_npm`-Job in `.gitlab-ci.yml`**, nicht mehr manuell:
`needs: build_embedded`, ausgelöst per Tag oder auf `main` (`when: manual`, bewusst kein
Automatismus - Veröffentlichen bleibt ein Akt). Bricht ab, wenn `embedded/web/dist` zu wenige
Dateien enthält (Schutz gegen die leeren Pakete aus threadnet-call#4), und veröffentlicht mit
`--tag threadnet` statt `latest`, weil unsere Versionen als npm-Prereleases gelten. Auth über
die CI-Variable `GITEA_NPM_TOKEN` (Gitea-Token, Scope `write:package`) - die frühere lokale,
untrackte `.npmrc` mit Klartext-Token ist damit hinfällig.
Das committete `.github/workflows/publish-embedded-packages.yaml` ist unbenutztes
Upstream-Material (zielt weiterhin auf `registry.npmjs.org`/`@element-hq`) - Gitea Actions
läuft hier nicht, siehe Repo-Topologie unten.
## 5. Bewusst keine Server-seitige ML-Rauschunterdrückung
@@ -60,6 +70,169 @@ ML-Noise-Cancellation (z.B. selbst gehostetes DTLN/RNNoise) - LiveKits eigene Do
beschreibt diesen Baustein als für AI-Voice-Agents gedacht, nicht für Mensch-zu-Mensch-Calls
(kein unterstützter Weg, bereinigtes Audio an andere Teilnehmer weiterzuleiten).
## 5b. KI-Geraeuschunterdrueckung client-seitig (ADR-0018)
Ergaenzt §5: dort ging es um **server-seitige** ML-Filterung, die verworfen bleibt. Diese
Anpassung ist **client-seitig** und damit von jenem Einwand nicht betroffen.
**Warum:** Der WebRTC-Standardfilter schaetzt ein laufendes Rauschprofil und filtert damit
STATIONAERE Stoerungen. Tastaturanschlaege sind TRANSIENT und werden nicht als Stoerung
erkannt - sie gehen ungefiltert raus. Betroffen sind auch leise Chiclet-Tastaturen.
**Was geaendert wurde:**
| Datei | Aenderung |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `src/livekit/aiNoiseSuppression.ts` | **neu** - baut den DeepFilterNet3-TrackProcessor |
| `src/settings/settings.ts` | zwei Settings: `ai-noise-suppression` (bool, Default **false**), `ai-noise-suppression-level` (number, Default **35**) |
| `src/state/CallViewModel/remoteMembers/ConnectionFactory.ts` | `processor:` in `audioCaptureDefaults`; `noiseSuppression` wird bei aktivem KI-Filter **abgeschaltet** |
| `src/settings/SettingsModal.tsx` | `AiNoiseSuppressionSettings` im Audio-Tab (Checkbox + Regler) |
| `public/assets/dfn3/**` | Modell + wasm, **23 MB**, selbst ausgeliefert |
| `package.json` | `deepfilternet3-noise-filter` |
**Drei Dinge, die beim Portieren nicht verlorengehen duerfen:**
1. **Assets selbst ausliefern.** Das Paket laedt Modell und wasm sonst von `cdn.mezon.ai` -
das meldet bei jedem Call-Start die IP jedes Teilnehmers an einen Dritten und haengt die
Verfuegbarkeit an fremde Infrastruktur. `assetConfig.cdnUrl` zeigt deshalb auf
`public/assets/dfn3/`. **Beim Aktualisieren des Pakets pruefen, ob sich die Asset-Pfade
geaendert haben** (`v3/pkg/df_bg.wasm`, `v3/models/DeepFilterNet3_onnx.tar.gz`).
2. **Browser-`noiseSuppression` bleibt bei aktivem KI-Filter AUS.** Sonst arbeiten zwei
Filter gegeneinander und der Browser schneidet dem Modell bereits Signalanteile weg.
3. **Default 35 %, nicht 100 %.** Gemessen reicht das fuer "Tastatur weg und Stimme
natuerlich"; mehr Daempfung erhoeht nur das Artefaktrisiko.
⚠️ **`vite-embedded.config.ts`: `publicDir` MUSS aktiv bleiben.** Upstream steht dort
`publicDir: false` mit der Begruendung, `public/` enthalte nur das Favicon - bei uns liegen
dort die Modell-Assets. Mit dem Upstream-Wert baut alles fehlerfrei, der Standalone-Build
funktioniert, und **nur im Widget ist der Filter tot** (404 auf das Modell). Genau diese
Zeile ist beim Rebase der wahrscheinlichste stille Rueckfall.
**Auslieferungsweg (nicht offensichtlich):** Element Call laeuft hier **nicht** als eigenes
Image. Der Weg ist `threadnet-call` -> npm-Paket `@sorb/threadnet-call-embedded` -> Gitea ->
`ThreadNet-Web` (webpack kopiert das Paket-`dist` nach `webapp/widgets/element-call/`) ->
`threadnet-web`-Image. Deshalb muessen die Assets **ins Paket**; das Widget loest sie relativ
zu seiner eigenen URL auf (`/widgets/element-call/assets/dfn3/...`). Das Paket waechst dadurch
von ~41 auf ~66 MB (gemessen; der Rest sind Source-Maps und die Crypto-/Vision-wasm).
**Kein** Rust/wasm-Build noetig (das fertige Paket genuegt), **kein** Dry/Wet-Mixer und
**kein** Delay-Node - geregelt wird ueber den Modellparameter (`noiseReductionLevel` /
`atten_lim`), nicht ueber Zurueckmischen des Originalsignals.
**Mobil ungeprueft — Telefontest gestrichen (Entscheidung sorb 2026-08-18).** Weil der
Filter opt-in und hinter dem Feature-Tor ist, traefe ein mobiler Fehlschlag nur den Nutzer,
der ihn einschaltet; sein Ausweg ist die Checkbox. Messung und Herleitung: management #0054.
### Vorfall v0.5.0 (2026-08-16): Filter stillgelegt, Tor in `aiNoiseSuppression.ts`
Die erste Fassung (`0.19.2-threadnet.8`) hat in Produktion das **Entmuten gebrochen** -
in beide Richtungen:
- **Filter an:** `LocalAudioTrack.setProcessor()` verlangt einen AudioContext auf dem
Track. Den gibt es nur, wenn der Raum mit `webAudioMix` gebaut wird - und das steht in
`ConnectionFactory.ts` als unerprobtes Upstream-TODO auskommentiert. Folge: *"Audio
context needs to be set on LocalAudioTrack in order to enable processors"*, es wird nie
ein Audio-Track publiziert.
- **Filter aus:** Der Optionen-Bau setzte `processor: undefined` und schrieb
`noiseSuppression` um. LiveKits `mergeObjectWithoutOverwriting` kopiert **jeden**
Schluessel der `audioCaptureDefaults` bis in die `getUserMedia`-Constraints durch -
auch einen mit Wert `undefined`. In Safari brach damit das Entmuten, obwohl der Track
publiziert wurde.
Daraus zwei **stehende Regeln** fuer diesen Fork:
1. **Der Aus-Pfad eines Opt-in-Features muss identisch mit Upstream sein** - auch in der
Objektform. Kein zusaetzlicher Schluessel, auch nicht mit Wert `undefined`, kein
umgeschriebenes Feld. Umgesetzt als bedingtes Spread in `generateRoomOption`
(`ConnectionFactory.ts`), abgesichert durch Regressionstests in
`ECConnectionFactory.test.ts` (die auf dem alten Stand nachweislich rot waren).
2. **Feature-Tor:** `AI_NOISE_SUPPRESSION_AVAILABLE` in `src/livekit/aiNoiseSuppression.ts`.
Seit der bestandenen Abnahme (2026-08-17, Call zu zweit: Filter wirksam, Tastatur weg,
Entmuten intakt) steht es auf `true`. **Bei einer Regression zuerst das Tor schliessen**
(`false`) - das legt Filter UND Bedienelemente still, neutralisiert auch Clients mit
aktivierter Einstellung im localStorage, und braucht kein Zurueckrollen der Auslieferung.
### Weg B (Entscheidung sorb 2026-08-16): AudioContext nur auf dem Mikrofon-Track
Statt `webAudioMix` am Raum (wuerde auch die Wiedergabe umbauen: Ausgabegeraete-Wahl
ueber den AudioContext statt `setSinkId`, eigener Chrome-Echo-Workaround in LiveKit)
bekommt **nur der lokale Mikrofon-Track** einen AudioContext - via
`LocalAudioTrack.setAudioContext()`, unmittelbar vor `setProcessor()`. Umgesetzt in
`applyAiNoiseSuppression()` (`aiNoiseSuppression.ts`), aufgerufen aus
`Publisher.onLocalTrackPublished` - also erst **nach** der Publikation: ein
scheiternder Filter kann das Entmuten dann nicht mehr verhindern, der Fehlerfall ist
per Konstruktion auf den Filter selbst begrenzt.
Die `audioCaptureDefaults` tragen damit in **keinem** Zustand mehr einen
`processor`-Schluessel (Regressionstest deckt auch den Aktiv-Fall ab); dort wird nur
noch `noiseSuppression` umgeschaltet, damit Browser- und KI-Filter nicht
gegeneinander arbeiten.
**Testausrollung (durchlaufen, 2026-08-17 bestanden):** Waehrend das Tor zu war,
aktivierte ein einzelner Test-Client den Filter ueber ZWEI localStorage-Schluessel:
`matrix-setting-ai-noise-suppression-dev = true` (Entwickler-Schalter, bewusst ohne UI)
**und** `matrix-setting-ai-noise-suppression = true`. Der Entwickler-Schalter bleibt im
Code (harmlos: ohne die eigentliche Einstellung wirkungslos, per Test abgesichert) -
er ist das Werkzeug fuer die naechste Testphase dieser Art. Die Bedienelemente sitzen
in den Einstellungen **im laufenden Call-Widget** (Audio-Reiter), nicht in Element Web.
Einstellungs-Aenderungen wirken ab dem naechsten Beitritt, nicht live im Call.
## 6. Produktname im Widget (`.env.production`)
`VITE_PRODUCT_NAME=aXion1337.Chat` in `.env.production` — eine Zeile, keine
Quelldatei angefasst. Upstream liest den Namen an jeder Stelle als
`import.meta.env.VITE_PRODUCT_NAME || "Element Call"`; die Variable zu setzen ist
deshalb die vollständige Umbenennung **ohne Merge-Reibung**.
Der Name folgt der Regel aus `management/shared/branding.md`: „in der Anwendung"
heißt es aXion1337.Chat — und das Call-Widget läuft in der Anwendung.
**Was dadurch tatsächlich umbenannt wird** (Inventur 2026-08-06, embedded-Modus):
| Stelle | sichtbar wo |
| ------------------------------------ | ------------------------------------------------------- |
| `error.matrix_rtc_transport_missing` | Fehlermeldung „Der Server ist nicht für … konfiguriert" |
| `error.open_elsewhere_description` | „… wurde in einem anderen Tab geöffnet" |
| `DeveloperSettingsTab` Versionszeile | Einstellungen → Entwicklermodus |
| `usePageTitle``document.title` | im iframe unsichtbar, der Vollständigkeit halber |
**Was im Embedded-Build ohnehin nie erscheint** — und deshalb _nicht_ angefasst
wurde:
- **Die Logo-SVGs.** Alle vier sind importiert und liegen im Bundle — gerendert wird
im Widget aber keins, und zwar jeweils durch dieselbe Bedingung gesperrt:
| SVG | gerendert von | Sperre |
| ---------------------- | --------------------------- | ----------------------------------------------------------------- |
| `Logo` | `Header.tsx` (`HeaderLogo`) | `FullScreenView` zeigt den Header nur bei `header === "standard"` |
| `LogoMark`, `LogoType` | `CallFooter.tsx` | `showLogo = headerStyle === HeaderStyle.Standard` |
| `LogoLarge` | `LoginPage`, `RegisterPage` | gibt es nur standalone |
`HeaderStyle.Standard` ist der Default für **Nicht**-Widget-Nutzung; als Widget ist
der Header `None` (Desktop) oder `AppBar` (Web/Mobile) — siehe `UrlParams.ts`. Das
gilt auch für Fehlerseiten: `FullScreenView` prüft dieselbe Bedingung, ein Absturz
im Widget zeigt also kein Logo.
⚠️ Nicht danach greppen, ob ein Logo „irgendwo vorkommt" — im gebauten Bundle steht
`<desc>Element Call (Beta)</desc>` aus `Logo.svg` drin, obwohl es nie gezeichnet
wird. Maßgeblich ist die Render-Bedingung, nicht der Treffer.
- **`header_label` („Element Call-Startseite") und `login_subheading`.** Startseite
und Login gibt es nur im Standalone-Modus.
- **Die drei deutschen `developer_mode.matrixRTCMode.*.description`.** Die reden über
_Gegenstellen_ („alle beteiligten Element Call Clients v0.17.0 oder neuer") — das
ist eine Kompatibilitätsaussage über fremde Clients, keine Selbstbezeichnung. Sie
umzubenennen wäre inhaltlich falsch.
⚠️ `build:embedded:development` läuft mit `--mode development` und lädt
`.env.production` nicht — ein Dev-Build zeigt weiter „Element Call". Beabsichtigt;
ausgeliefert wird der Produktions-Build.
**Verifiziert** am CI-Artefakt von Pipeline 189 (`ac688d3`), nicht am Quelltext: das
gebaute Bundle enthält „aXion1337.Chat"; die verbliebenen „Element Call"-Treffer sind
Source-Maps, die 22 Sprachdateien (`header_label`, standalone) und die
`<desc>`-Zeile aus `Logo.svg` — keine davon im Widget sichtbar.
## Repo-Topologie (seit 2026-07-31)
**Kanonisch ist `git.lab/axion1337.chat/threadnet-call`** (Homelab-GitLab) — die Kopie auf
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sorb/threadnet-call-embedded",
"version": "0.19.2-threadnet.6",
"version": "0.19.2-threadnet.12",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+1
View File
@@ -90,6 +90,7 @@
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"deepfilternet3-noise-filter": "^1.3.0",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
+13
View File
@@ -168,6 +168,9 @@ importers:
copy-to-clipboard:
specifier: ^3.3.3
version: 3.3.3
deepfilternet3-noise-filter:
specifier: ^1.3.0
version: 1.3.0(livekit-client@2.19.2(@types/dom-mediacapture-record@1.0.22))
eslint:
specifier: ^8.14.0
version: 8.57.1
@@ -4659,6 +4662,12 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepfilternet3-noise-filter@1.3.0:
resolution: {integrity: sha512-yYFUlPuvPguqcd/R6/OSsr0noGqlqOE50JkCWYHogk+PjLj9qrNgwTt5zKraVkKnw0l4+eXJagkz2SUWtUl4sQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
livekit-client: ^2.0.0
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
@@ -11994,6 +12003,10 @@ snapshots:
deep-is@0.1.4: {}
deepfilternet3-noise-filter@1.3.0(livekit-client@2.19.2(@types/dom-mediacapture-record@1.0.22)):
dependencies:
livekit-client: 2.19.2(@types/dom-mediacapture-record@1.0.22)
default-browser-id@5.0.1: {}
default-browser@5.5.0:
Binary file not shown.
+155
View File
@@ -0,0 +1,155 @@
/*
Copyright 2026 aXion1337.chat
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
ThreadNet-Fork-Anpassung (ADR-0018) - nicht Upstream. Siehe docs/axion1337-fork.md.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type LocalAudioTrack } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import {
applyAiNoiseSuppression,
ensureSenderCarriesProcessed,
} from "./aiNoiseSuppression";
import {
aiNoiseSuppressionDevSetting,
aiNoiseSuppressionSetting,
} from "../settings/settings";
// jsdom hat keinen AudioContext - wir stellen einen minimalen bereit, der
// sich merkt, dass er existiert. Der echte Kontextaufbau ist Browserarbeit
// und wird im Call zu zweit abgenommen; hier geht es um die REIHENFOLGE.
class FakeAudioContext {
public state = "running";
public resume = vi.fn().mockResolvedValue(undefined);
}
const makeTrack = (): {
track: LocalAudioTrack;
calls: string[];
setProcessor: ReturnType<typeof vi.fn>;
} => {
const calls: string[] = [];
const setAudioContext = vi.fn(() => {
calls.push("setAudioContext");
});
const setProcessor = vi.fn(async (): Promise<void> => {
calls.push("setProcessor");
await Promise.resolve();
});
return {
track: { setAudioContext, setProcessor } as unknown as LocalAudioTrack,
calls,
setProcessor,
};
};
beforeEach(() => {
vi.stubGlobal("AudioContext", FakeAudioContext);
});
afterEach(() => {
aiNoiseSuppressionDevSetting.setValue(false);
aiNoiseSuppressionSetting.setValue(false);
vi.unstubAllGlobals();
});
describe("applyAiNoiseSuppression (Weg B, #0054)", () => {
test("does nothing at all when the filter is not enabled", async () => {
aiNoiseSuppressionSetting.setValue(false);
const { track, calls } = makeTrack();
await applyAiNoiseSuppression(track, logger);
// Der Normalfall aller Nutzer: der Track wird nicht einmal beruehrt.
expect(calls).toEqual([]);
});
test("the dev override alone (setting off) touches nothing", async () => {
// UND-Verknuepfung im Praedikat: der Schalter der Testphase darf ohne die
// eigentliche Einstellung nichts bewirken.
aiNoiseSuppressionDevSetting.setValue(true);
aiNoiseSuppressionSetting.setValue(false);
const { track, calls } = makeTrack();
await applyAiNoiseSuppression(track, logger);
expect(calls).toEqual([]);
});
test("sets the audio context BEFORE attaching the processor", async () => {
// Genau die Reihenfolge, an der v0.5.0 gescheitert ist: setProcessor
// ohne AudioContext auf dem Track wirft in LiveKit.
aiNoiseSuppressionSetting.setValue(true);
const { track, calls } = makeTrack();
await applyAiNoiseSuppression(track, logger);
expect(calls).toEqual(["setAudioContext", "setProcessor"]);
});
test("a failing processor attach is contained and does not throw", async () => {
// Der Track ist zu diesem Zeitpunkt publiziert - ein Filterfehler darf
// den Call nicht mehr erreichen.
aiNoiseSuppressionSetting.setValue(true);
const { track, setProcessor } = makeTrack();
setProcessor.mockRejectedValue(new Error("wasm sagt nein"));
await expect(applyAiNoiseSuppression(track, logger)).resolves.toBeUndefined();
});
});
describe("ensureSenderCarriesProcessed (Safari-Sendepfad, #0054)", () => {
const processed = { id: "gefiltert" } as unknown as MediaStreamTrack;
const trackMitSender = (
sender: { track: MediaStreamTrack | null; replaceTrack: ReturnType<typeof vi.fn> } | undefined,
): never => ({ sender }) as never;
test("sender already carries the processed track: nothing to do", async () => {
const replaceTrack = vi.fn();
const ok = await ensureSenderCarriesProcessed(
trackMitSender({ track: processed, replaceTrack }),
{ processedTrack: processed },
2,
1,
);
expect(ok).toBe(true);
expect(replaceTrack).not.toHaveBeenCalled();
});
test("sender still carries the RAW track: swap is enforced", async () => {
// Genau der Safari-Befund: setProcessor meldete Erfolg, der Sender sendet
// trotzdem das rohe Mikro - Staerke 0-100 ohne hoerbare Wirkung.
const roh = { id: "roh" } as unknown as MediaStreamTrack;
const sender = {
track: roh as MediaStreamTrack | null,
replaceTrack: vi.fn(async (t: MediaStreamTrack) => {
sender.track = t;
await Promise.resolve();
}),
};
const ok = await ensureSenderCarriesProcessed(
trackMitSender(sender),
{ processedTrack: processed },
2,
1,
);
expect(ok).toBe(true);
expect(sender.replaceTrack).toHaveBeenCalledWith(processed);
});
test("sender never appears: reports false instead of pretending", async () => {
const ok = await ensureSenderCarriesProcessed(
trackMitSender(undefined),
{ processedTrack: processed },
3,
1,
);
expect(ok).toBe(false);
});
});
+189
View File
@@ -0,0 +1,189 @@
/*
Copyright 2026 aXion1337.chat
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
ThreadNet-Fork-Anpassung (ADR-0018) - nicht Upstream. Siehe docs/axion1337-fork.md.
*/
import {
type AudioProcessorOptions,
type LocalAudioTrack,
type Track,
type TrackProcessor,
} from "livekit-client";
import { DeepFilterNoiseFilterProcessor } from "deepfilternet3-noise-filter";
import { type Logger, logger } from "matrix-js-sdk/lib/logger";
import {
aiNoiseSuppressionDevSetting,
aiNoiseSuppressionLevelSetting,
aiNoiseSuppressionSetting,
} from "../settings/settings";
/**
* KI-Geraeuschunterdrueckung (DeepFilterNet3) als LiveKit-TrackProcessor.
*
* Warum ueberhaupt: Der WebRTC-Standardfilter schaetzt ein laufendes Rauschprofil
* und filtert damit STATIONAERE Stoerungen (Luefter, Brummen). Tastaturanschlaege
* sind TRANSIENT - sehr kurzer Anstieg, unvorhersehbares Spektrum - und werden
* nicht als Stoerung erkannt. Genau die filtert DeepFilterNet3 weg.
*
* Warum die Assets von uns kommen: Das Paket laedt Modell und wasm sonst von
* cdn.mezon.ai. Fuer eine selbstgehostete Plattform hiesse das, dass jeder
* Teilnehmer bei jedem Call-Start seine IP an einen Dritten meldet und die
* Verfuegbarkeit an fremder Infrastruktur haengt. `assetConfig.cdnUrl` zeigt
* deshalb auf unsere eigene Auslieferung (public/assets/dfn3/).
*/
const ASSET_PFAD = "assets/dfn3";
/**
* Feature-Tor: bei `false` ist der Filter fuer ALLE Clients aus - auch fuer
* solche, die die Einstellung frueher aktiviert haben (localStorage).
*
* Geschichte: v0.5.0 setzte den Prozessor ueber die Capture-Defaults, ohne
* dass der Track einen AudioContext hatte - LiveKit warf "Audio context needs
* to be set on LocalAudioTrack in order to enable processors", und das
* Entmuten brach fuer alle (Produktionsvorfall 2026-08-16). Seit Weg B
* (#0054) haengt sich der Filter NACH der Publikation an den Mikrofon-Track,
* mit eigenem AudioContext nur dort. Abnahme im Call zu zweit bestanden am
* 2026-08-17 (Filter wirksam, Tastatur weg, Entmuten intakt) - seitdem ist
* das Tor offen. Bei einer Regression zuerst wieder auf `false` stellen:
* das legt den Filter still, ohne die Auslieferung anzufassen.
*/
export const AI_NOISE_SUPPRESSION_AVAILABLE = true;
/**
* Ist der Filter wirksam? Nur wenn das Tor offen ist (oder der
* Entwickler-Schalter fuer die Testausrollung gesetzt ist) UND der Nutzer ihn
* eingeschaltet hat. Alle Entscheidungen (Optionen-Bau, Track-Anbindung, UI)
* laufen ueber dieses eine Praedikat, damit es keinen zweiten, abweichenden
* Pfad gibt.
*/
export function isAiNoiseSuppressionEnabled(): boolean {
return (
(AI_NOISE_SUPPRESSION_AVAILABLE || aiNoiseSuppressionDevSetting.getValue()) &&
aiNoiseSuppressionSetting.getValue()
);
}
/**
* Baut den Prozessor - oder gibt `undefined` zurueck, wenn der Filter nicht
* wirksam ist (Tor zu oder Nutzer hat ihn aus).
*
* Der Import ist statisch, kostet aber nur den ~23-KB-Wrapper. Die ~23 MB
* Modell-Assets holt das Paket erst in seinem `init()`, also erst wenn der
* Prozessor wirklich an einen Track gehaengt wird. Damit bleibt das Opt-in aus
* ADR-0018 auch wirtschaftlich eines: wer den Filter aus laesst, laedt nichts.
*/
export function createAiNoiseSuppressionProcessor():
| TrackProcessor<Track.Kind.Audio, AudioProcessorOptions>
| undefined {
if (!isAiNoiseSuppressionEnabled()) return undefined;
try {
return new DeepFilterNoiseFilterProcessor({
sampleRate: 48000,
noiseReductionLevel: aiNoiseSuppressionLevelSetting.getValue(),
assetConfig: {
cdnUrl: new URL(ASSET_PFAD, window.location.href).href,
},
}) as unknown as TrackProcessor<Track.Kind.Audio, AudioProcessorOptions>;
} catch (e) {
// Bewusst kein Abbruch: lieber ein Call ohne Filter als kein Call.
logger.error("KI-Geraeuschunterdrueckung nicht verfuegbar", e);
return undefined;
}
}
/**
* Ein gemeinsamer AudioContext fuer die Prozessor-Anbindung - lazily erzeugt
* und wiederverwendet, weil Browser die Anzahl gleichzeitiger AudioContexte
* begrenzen und Gerätewechsel denselben Kontext weiterverwenden sollen.
*/
let sharedAudioContext: AudioContext | undefined;
/**
* Weg B aus #0054: Haengt den Filter NACH der Publikation an den lokalen
* Mikrofon-Track - mit einem eigenen AudioContext NUR auf diesem Track.
*
* Warum nicht `webAudioMix` am Raum: das wuerde auch die Wiedergabe umbauen
* (Ausgabegeraete-Wahl ueber den AudioContext statt setSinkId, eigener
* Chrome-Echo-Workaround) - maximaler Wirkradius im empfindlichsten Pfad.
* `LocalAudioTrack.setAudioContext()` gibt genau dem einen Track, der den
* Prozessor braucht, was `setProcessor()` verlangt, und laesst alles andere
* unberuehrt.
*
* Warum nach der Publikation (LocalTrackPublished) und nicht in den
* audioCaptureDefaults: erstens war der Streuschluessel in den Defaults die
* Ursache des v0.5.0-Vorfalls (die Constraints muessen upstream-identisch
* bleiben), zweitens ist der Fehlerfall so beweisbar harmlos - der Track ist
* bereits publiziert, ein scheiternder Filter kann das Entmuten nicht mehr
* verhindern.
*/
export async function applyAiNoiseSuppression(
track: LocalAudioTrack,
parentLogger: Logger,
): Promise<void> {
if (!isAiNoiseSuppressionEnabled()) return;
const processor = createAiNoiseSuppressionProcessor();
if (!processor) return;
try {
sharedAudioContext ??= new AudioContext({
sampleRate: 48000,
latencyHint: "interactive",
});
// Autoplay-Policy: der Kontext kann suspendiert starten; die Anbindung
// passiert aber ohnehin im Nachgang einer Nutzeraktion (Beitritt/Entmuten).
if (sharedAudioContext.state === "suspended") {
await sharedAudioContext.resume();
}
track.setAudioContext(sharedAudioContext);
await track.setProcessor(processor);
// Verifizieren statt vertrauen: LiveKits setProcessor tauscht den
// Sender-Track per `this.sender?.replaceTrack(...)` - ist der Sender in
// dem Moment nicht da (Safari-Timing), wird der Tausch STUMM uebersprungen
// und das rohe Mikro bleibt auf der Leitung. Symptom: Filter "an", Assets
// geladen, aber Staerke 0-100 ohne jede hoerbare Wirkung.
const angekommen = await ensureSenderCarriesProcessed(track, processor);
parentLogger.info(
`KI-Geraeuschunterdrueckung aktiv (Staerke ${aiNoiseSuppressionLevelSetting.getValue()} %), ` +
`Sendepfad gefiltert: ${angekommen ? "ja" : "NEIN - Sender nicht gefunden"}`,
);
} catch (e) {
// Track ist publiziert, der Call laeuft - der Filter faellt aus, mehr nicht.
parentLogger.error("KI-Geraeuschunterdrueckung konnte nicht angehaengt werden", e);
}
}
/**
* Stellt sicher, dass der RTCRtpSender wirklich den GEFILTERTEN Track sendet.
* Wartet notfalls auf den Sender (er kann beim LocalTrackPublished-Event je
* nach Engine noch fehlen) und zieht den Tausch explizit nach.
*
* Gibt `true` zurueck, wenn der Sender nachweislich den gefilterten Track
* traegt - das ist die Aussage, die in der Log-Zeile oben landet.
*/
export async function ensureSenderCarriesProcessed(
track: LocalAudioTrack,
processor: { processedTrack?: MediaStreamTrack },
versuche = 20,
intervallMs = 250,
): Promise<boolean> {
const processed = processor.processedTrack;
if (!processed) return false;
for (let i = 0; i < versuche; i++) {
// `sender` ist in LiveKit als @internal markiert, aber oeffentlich lesbar -
// es ist die einzige Stelle, an der sich die Wahrheit pruefen laesst.
const sender = (track as unknown as { sender?: RTCRtpSender }).sender;
if (sender) {
if (sender.track === processed) return true;
await sender.replaceTrack(processed);
return sender.track === processed;
}
await new Promise((r) => setTimeout(r, intervallMs));
}
return false;
}
+72
View File
@@ -21,6 +21,8 @@ import { useMediaDevices } from "../MediaDevicesContext";
import { widget } from "../widget";
import {
useSetting,
aiNoiseSuppressionSetting,
aiNoiseSuppressionLevelSetting,
soundEffectVolume as soundEffectVolumeSetting,
backgroundBlur as backgroundBlurSetting,
developerMode,
@@ -39,6 +41,7 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
import { AI_NOISE_SUPPRESSION_AVAILABLE } from "../livekit/aiNoiseSuppression";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { MediaQualitySettings } from "./MediaQualitySettings";
import { FieldRow, InputField } from "../input/Input";
@@ -109,6 +112,73 @@ export const SettingsModal: FC<Props> = ({
);
};
// ThreadNet-Fork (ADR-0018): KI-Geraeuschunterdrueckung gegen Tastaturgeraeusche.
// Bewusst OPT-IN - die Modell-Assets sind ~23 MB und werden erst geladen, wenn
// der Filter eingeschaltet ist. Standard-Daempfung 35 %: gemessen reicht das
// fuer "Tastatur weg und Stimme natuerlich", mehr erhoeht nur das
// Artefaktrisiko (#0054).
const AiNoiseSuppressionSettings: React.FC = (): ReactNode => {
const [aiActive, setAiActive] = useSetting(aiNoiseSuppressionSetting);
const [level, setLevel] = useSetting(aiNoiseSuppressionLevelSetting);
const [levelRaw, setLevelRaw] = useState(level);
// Tor zu (siehe aiNoiseSuppression.ts): keine Bedienelemente anbieten,
// solange der Filter mangels webAudioMix nicht funktionieren KANN. Eine
// sichtbare Checkbox ohne Wirkung waere schlimmer als keine.
if (!AI_NOISE_SUPPRESSION_AVAILABLE) return null;
return (
<>
<h4>
{t("settings.ai_noise_suppression_header", "AI noise suppression")}
</h4>
<FieldRow>
<InputField
id="activateAiNoiseSuppression"
label={t(
"settings.ai_noise_suppression_label",
"Filter keyboard and background noise",
)}
description={t(
"settings.ai_noise_suppression_description",
"Downloads a ~23 MB model the first time it is switched on. Applies on the next call join.",
)}
type="checkbox"
checked={aiActive}
onChange={(e): void => setAiActive(e.target.checked)}
/>
</FieldRow>
{aiActive && (
<div className={styles.volumeSlider}>
<label>
{t("settings.ai_noise_suppression_strength_label", "Strength")}
{": "}
<span className={styles.settingValue}>{levelRaw}%</span>
</label>
<p>
{t(
"settings.ai_noise_suppression_strength_description",
"Lower keeps the room sound natural, higher isolates the voice more strictly.",
)}
</p>
<Slider
label={t(
"settings.ai_noise_suppression_strength_label",
"Strength",
)}
value={levelRaw}
onValueChange={setLevelRaw}
onValueCommit={setLevel}
min={0}
max={100}
step={5}
/>
</div>
)}
</>
);
};
const devices = useMediaDevices();
useEffect(() => {
if (open) devices.requestDeviceNames(); // No-op after the first call
@@ -181,6 +251,8 @@ export const SettingsModal: FC<Props> = ({
step={0.01}
/>
</div>
<AiNoiseSuppressionSettings />
</Form>
</>
),
+25
View File
@@ -226,6 +226,31 @@ export const autoGainControlSetting = new Setting<boolean>(
true,
);
// KI-Geraeuschunterdrueckung (ThreadNet-Fork, ADR-0018).
// Bewusst OPT-IN: die Modell-Assets sind ~23 MB und werden erst geladen, wenn
// der Prozessor tatsaechlich an den Track gehaengt wird. Wer den Filter nicht
// einschaltet, laedt nichts.
export const aiNoiseSuppressionSetting = new Setting<boolean>(
"ai-noise-suppression",
false,
);
// Daempfung in Prozent. 35 statt 100 ist gemessen, nicht geraten: bei ~35 %
// waren Tastaturanschlaege weg UND die Stimme klang natuerlich (#0054).
// Mehr Daempfung heisst mehr Artefaktrisiko, nicht mehr Nutzen.
export const aiNoiseSuppressionLevelSetting = new Setting<number>(
"ai-noise-suppression-level",
35,
);
// Entwickler-Schalter fuer die Testausrollung (Weg B, #0054): erlaubt einem
// EINZELNEN Test-Client, den Filter trotz geschlossenem Feature-Tor zu
// aktivieren (localStorage: matrix-setting-ai-noise-suppression-dev = true,
// zusaetzlich zur normalen Einstellung). Kein UI dafuer - bewusst.
export const aiNoiseSuppressionDevSetting = new Setting<boolean>(
"ai-noise-suppression-dev",
false,
);
/**
* Seed setting defaults from config.json's media_quality section.
* Call this after Config.init() has resolved.
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
ConnectionState as LivekitConnectionState,
type LocalAudioTrack,
type LocalTrackPublication,
LocalVideoTrack,
ParticipantEvent,
@@ -30,6 +31,7 @@ import {
trackProcessorSync,
} from "../../../livekit/TrackProcessorContext.tsx";
import { getUrlParams } from "../../../UrlParams.ts";
import { applyAiNoiseSuppression } from "../../../livekit/aiNoiseSuppression.ts";
import { observeTrackReference$ } from "../../observeTrackReference";
import { type Connection } from "../remoteMembers/Connection.ts";
import { ObservableScope } from "../../ObservableScope.ts";
@@ -118,6 +120,17 @@ export class Publisher {
});
}
if (localTrackPublication.source === Track.Source.Microphone) {
// ThreadNet-Fork (ADR-0018, Weg B aus #0054): Filter erst NACH der
// Publikation anhaengen - der Track ist dann sicher da, und ein
// scheiternder Filter kann das Entmuten nicht mehr verhindern. Bei
// inaktivem Filter (Normalfall) kehrt der Aufruf sofort zurueck.
const publishedTrack = localTrackPublication.track;
if (publishedTrack) {
void applyAiNoiseSuppression(
publishedTrack as LocalAudioTrack,
this.logger,
);
}
const muteState = this.muteStates.audio;
// skip this if a sync is in progress: enabled$ still reflects the old
// state while the handler is mid-flight, so the handler itself will apply
@@ -39,6 +39,7 @@ import {
noiseSuppressionSetting,
autoGainControlSetting,
} from "../../../settings/settings.ts";
import { isAiNoiseSuppressionEnabled } from "../../../livekit/aiNoiseSuppression.ts";
// TODO evaluate if this should be done like the Publisher Factory
export interface ConnectionFactory {
@@ -175,8 +176,21 @@ function generateRoomOption({
...liveKitOptions.audioCaptureDefaults,
deviceId: devices.audioInput.selected$.value?.id,
echoCancellation: echoCancellationSetting.getValue(),
noiseSuppression: noiseSuppressionSetting.getValue(),
autoGainControl: autoGainControlSetting.getValue(),
// ThreadNet-Fork (ADR-0018): Die KI-Geraeuschunterdrueckung setzt hier
// NIE einen `processor`-Schluessel - LiveKit kopiert jeden Schluessel
// dieser Defaults bis in die getUserMedia-Constraints durch
// (mergeObjectWithoutOverwriting prueft nur auf undefined im Ziel, nicht
// in der Quelle), und ein `processor: undefined` hat in v0.5.0 das
// Entmuten gebrochen. Der Filter wird stattdessen NACH der Publikation
// an den Track gehaengt (Weg B, siehe aiNoiseSuppression.ts /
// Publisher.onLocalTrackPublished). Hier bleibt nur: bei aktivem Filter
// die Browser-Rauschunterdrueckung ausschalten, sonst arbeiten zwei
// Filter gegeneinander und der Browser schneidet dem Modell bereits
// Signalanteile weg.
...(isAiNoiseSuppressionEnabled()
? { noiseSuppression: false }
: { noiseSuppression: noiseSuppressionSetting.getValue() }),
},
audioOutput: {
// When using controlled audio devices, we don't want to set the
@@ -31,6 +31,8 @@ import {
cameraFramerate,
cameraBitrate,
cameraCodec,
aiNoiseSuppressionSetting,
aiNoiseSuppressionDevSetting,
} from "../../../settings/settings.ts";
// At the top of your test file, after imports
@@ -107,6 +109,80 @@ describe("ECConnectionFactory - Audio inputs options", () => {
);
});
// ThreadNet-Fork (ADR-0018): Regressionstests zum Produktionsvorfall v0.5.0.
// LiveKit kopiert JEDEN Schluessel der audioCaptureDefaults bis in die
// getUserMedia-Constraints durch - ein `processor: undefined` hat das Entmuten
// gebrochen. Der Aus-Pfad muss deshalb identisch mit Upstream sein: kein
// `processor`-Schluessel, `noiseSuppression` unveraendert.
describe("ECConnectionFactory - AI noise suppression off-path (ADR-0018)", () => {
const buildRoomOptions = (): Record<string, unknown> => {
const RoomConstructor = vi.mocked(LivekitRoom);
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
"!roomid:example.org",
mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
false,
);
ecConnectionFactory.createConnection(
testScope,
exampleTransport,
ownMemberMock,
logger,
);
return RoomConstructor.mock.lastCall![0] as Record<string, unknown>;
};
test("audioCaptureDefaults carries no processor key when the filter is off", () => {
aiNoiseSuppressionSetting.setValue(false);
noiseSuppressionSetting.setValue(true);
const options = buildRoomOptions();
// `objectContaining` kann Abwesenheit nicht pruefen - deshalb direkt.
expect(options.audioCaptureDefaults).not.toHaveProperty("processor");
expect(options.audioCaptureDefaults).toMatchObject({
noiseSuppression: true,
});
});
test("with the filter active the defaults carry no processor key either (way B)", () => {
// Weg B aus #0054: der Prozessor wird NACH der Publikation an den Track
// gehaengt, nie ueber die Capture-Defaults - die getUserMedia-Constraints
// muessen in JEDEM Zustand upstream-identisch bleiben. Hier aendert sich
// nur noiseSuppression, damit nicht zwei Filter gegeneinander arbeiten.
aiNoiseSuppressionSetting.setValue(true);
noiseSuppressionSetting.setValue(true);
const options = buildRoomOptions();
expect(options.audioCaptureDefaults).not.toHaveProperty("processor");
expect(options.audioCaptureDefaults).toMatchObject({
noiseSuppression: false,
});
aiNoiseSuppressionSetting.setValue(false);
});
test("the dev override alone (setting off) changes nothing", () => {
// Das Praedikat ist eine UND-Verknuepfung: der Entwickler-Schalter der
// Testphase darf ohne die eigentliche Einstellung nichts bewirken.
aiNoiseSuppressionDevSetting.setValue(true);
aiNoiseSuppressionSetting.setValue(false);
noiseSuppressionSetting.setValue(true);
const options = buildRoomOptions();
expect(options.audioCaptureDefaults).not.toHaveProperty("processor");
expect(options.audioCaptureDefaults).toMatchObject({
noiseSuppression: true,
});
aiNoiseSuppressionDevSetting.setValue(false);
});
});
describe("ECConnectionFactory - ControlledAudioDevice", () => {
test.each([{ controlled: true }, { controlled: false }])(
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
+7 -1
View File
@@ -18,7 +18,13 @@ export default defineConfig((env) =>
fullConfig({ ...env, packageType: "embedded" }),
defineConfig({
base, // Use relative URLs to allow the app to be hosted under any path
publicDir: false, // Don't serve the public directory which only contains the favicon
// ThreadNet-Fork (ADR-0018): Upstream steht hier `publicDir: false` mit der
// Begruendung, public/ enthalte nur das Favicon. Bei uns liegen dort auch die
// DeepFilterNet3-Assets, und das Widget ist der einzige Weg, auf dem Element
// Call ausgeliefert wird - ohne sie waere der Filter im Widget tot (404 auf das
// Modell), waehrend er im Standalone-Build funktioniert. Das Widget bleibt damit
// in sich geschlossen: alles, was es braucht, liegt unter /widgets/element-call/.
publicDir: "public",
plugins: [
generateFile([
{