diff --git a/docs/axion1337-fork.md b/docs/axion1337-fork.md index 3790b072..e7b7324e 100644 --- a/docs/axion1337-fork.md +++ b/docs/axion1337-fork.md @@ -122,6 +122,35 @@ von ~41 auf ~66 MB (gemessen; der Rest sind Source-Maps und die Crypto-/Vision-w ⚠️ **Mobil ungeprueft.** Der Telefontest wurde bewusst ausgesetzt; weil der Filter opt-in ist, bleibt er auf schwachen Geraeten schlicht aus. 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` + steht auf `false`, bis `webAudioMix` entschieden und im Call zu zweit getestet ist. + Das Tor neutralisiert auch Clients, die die Einstellung bereits aktiviert hatten + (localStorage) - deren Zustand ueberlebt jedes Deployment. Die Bedienelemente in + `SettingsModal.tsx` sind hinter demselben Tor versteckt. + ## 6. Produktname im Widget (`.env.production`) `VITE_PRODUCT_NAME=aXion1337.Chat` in `.env.production` — eine Zeile, keine diff --git a/embedded/web/package.json b/embedded/web/package.json index 778442be..0e07b66b 100644 --- a/embedded/web/package.json +++ b/embedded/web/package.json @@ -1,6 +1,6 @@ { "name": "@sorb/threadnet-call-embedded", - "version": "0.19.2-threadnet.8", + "version": "0.19.2-threadnet.9", "files": [ "README.md", "LICENSE-AGPL-3.0", diff --git a/src/livekit/aiNoiseSuppression.ts b/src/livekit/aiNoiseSuppression.ts index 533d0a61..05d8dcb5 100644 --- a/src/livekit/aiNoiseSuppression.ts +++ b/src/livekit/aiNoiseSuppression.ts @@ -37,8 +37,32 @@ import { const ASSET_PFAD = "assets/dfn3"; /** - * Baut den Prozessor - oder gibt `undefined` zurueck, wenn der Nutzer den Filter - * nicht eingeschaltet hat. + * Feature-Tor: solange `false`, ist der Filter fuer ALLE Clients aus - auch + * fuer solche, die die Einstellung frueher aktiviert haben (localStorage). + * + * Warum: `LocalAudioTrack.setProcessor()` verlangt einen AudioContext auf dem + * Track. Den bekommt er nur, wenn der LiveKit-Raum mit `webAudioMix` gebaut + * wird - und genau das steht in `ConnectionFactory.ts` als unerprobtes + * Upstream-TODO auskommentiert. Ohne AudioContext wirft LiveKit beim Entmuten + * "Audio context needs to be set on LocalAudioTrack in order to enable + * processors", und es wird nie ein Audio-Track publiziert (Produktionsvorfall + * 2026-08-16, v0.5.0). Erst freischalten, wenn `webAudioMix` entschieden und + * im Call zu zweit getestet ist (Folgearbeit zu ADR-0018). + */ +export const AI_NOISE_SUPPRESSION_AVAILABLE = false; + +/** + * Ist der Filter wirksam? Nur wenn das Tor offen ist UND der Nutzer ihn + * eingeschaltet hat. Alle Entscheidungen (Optionen-Bau, UI) laufen ueber + * dieses eine Praedikat, damit es keinen zweiten, abweichenden Pfad gibt. + */ +export function isAiNoiseSuppressionEnabled(): boolean { + return AI_NOISE_SUPPRESSION_AVAILABLE && 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 @@ -48,7 +72,7 @@ const ASSET_PFAD = "assets/dfn3"; export function createAiNoiseSuppressionProcessor(): | TrackProcessor | undefined { - if (!aiNoiseSuppressionSetting.getValue()) return undefined; + if (!isAiNoiseSuppressionEnabled()) return undefined; try { return new DeepFilterNoiseFilterProcessor({ diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index ec22fa2e..107c947a 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -41,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"; @@ -121,6 +122,11 @@ export const SettingsModal: FC = ({ 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 ( <>

diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 51fc532b..c9658af1 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -38,9 +38,11 @@ import { echoCancellationSetting, noiseSuppressionSetting, autoGainControlSetting, - aiNoiseSuppressionSetting, } from "../../../settings/settings.ts"; -import { createAiNoiseSuppressionProcessor } from "../../../livekit/aiNoiseSuppression.ts"; +import { + createAiNoiseSuppressionProcessor, + isAiNoiseSuppressionEnabled, +} from "../../../livekit/aiNoiseSuppression.ts"; // TODO evaluate if this should be done like the Publisher Factory export interface ConnectionFactory { @@ -177,16 +179,22 @@ function generateRoomOption({ ...liveKitOptions.audioCaptureDefaults, deviceId: devices.audioInput.selected$.value?.id, echoCancellation: echoCancellationSetting.getValue(), - // Bei aktiver KI-Filterung die Browser-Rauschunterdrueckung ausschalten: - // sonst arbeiten zwei Filter gegeneinander und der Browser schneidet dem - // Modell bereits Signalanteile weg (ADR-0018). - noiseSuppression: - noiseSuppressionSetting.getValue() && - !aiNoiseSuppressionSetting.getValue(), autoGainControl: autoGainControlSetting.getValue(), - // ThreadNet-Fork: KI-Geraeuschunterdrueckung gegen Tastaturgeraeusche. - // `undefined`, wenn nicht eingeschaltet - dann wird auch nichts geladen. - processor: createAiNoiseSuppressionProcessor(), + // ThreadNet-Fork (ADR-0018): KI-Geraeuschunterdrueckung als bedingtes + // Spread. Bei AUS entsteht hier BEWUSST kein `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 + // Aus-Pfad muss identisch mit Upstream sein, auch in der Objektform. + // Bei AN: Browser-Rauschunterdrueckung aus, sonst arbeiten zwei Filter + // gegeneinander und der Browser schneidet dem Modell Signalanteile weg. + ...(isAiNoiseSuppressionEnabled() + ? { + noiseSuppression: false, + processor: createAiNoiseSuppressionProcessor(), + } + : { noiseSuppression: noiseSuppressionSetting.getValue() }), }, audioOutput: { // When using controlled audio devices, we don't want to set the diff --git a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts index 411ae200..33ecce20 100644 --- a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts +++ b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts @@ -31,6 +31,7 @@ import { cameraFramerate, cameraBitrate, cameraCodec, + aiNoiseSuppressionSetting, } from "../../../settings/settings.ts"; // At the top of your test file, after imports @@ -107,6 +108,62 @@ 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 => { + const RoomConstructor = vi.mocked(LivekitRoom); + const ecConnectionFactory = new ECConnectionFactory( + mockClient, + "!roomid:example.org", + mockMediaDevices({}), + new BehaviorSubject({ + supported: true, + processor: undefined, + }), + undefined, + false, + ); + ecConnectionFactory.createConnection( + testScope, + exampleTransport, + ownMemberMock, + logger, + ); + return RoomConstructor.mock.lastCall![0] as Record; + }; + + 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("a stale enabled setting (localStorage) is neutralized by the feature gate", () => { + // Genau der Zustand eines Clients, der den Filter aktiviert hatte, bevor + // das Tor geschlossen wurde: Einstellung an, Tor zu. + aiNoiseSuppressionSetting.setValue(true); + noiseSuppressionSetting.setValue(true); + + const options = buildRoomOptions(); + expect(options.audioCaptureDefaults).not.toHaveProperty("processor"); + expect(options.audioCaptureDefaults).toMatchObject({ + noiseSuppression: true, + }); + + aiNoiseSuppressionSetting.setValue(false); + }); +}); + describe("ECConnectionFactory - ControlledAudioDevice", () => { test.each([{ controlled: true }, { controlled: false }])( "it sets controlledAudioDevice=$controlled then uses deviceId accordingly",