Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b46ee937 | ||
|
|
fee9866e19 | ||
|
|
e3f8a8570a | ||
|
|
df4e5eeb85 | ||
|
|
dcc86431dc | ||
|
|
d270e0c706 | ||
|
|
3f17001720 | ||
|
|
c63be9ab94 | ||
|
|
d13cce7337 | ||
|
|
3675cab385 | ||
|
|
dc2a041b83 | ||
|
|
e793902ae0 | ||
|
|
846135e48d | ||
|
|
6f94e6a8a3 | ||
|
|
84419133b3 | ||
|
|
3f0f55e6bd | ||
|
|
ac688d36f7 | ||
|
|
a6e555ac3f | ||
|
|
0823c8a866 | ||
|
|
8fb630cfb3 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
Read AGENTS.md — the canonical instruction file for this repository. All rules live there.
|
||||
+6
-1
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+13
@@ -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.
Binary file not shown.
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user