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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d270e0c706
commit
dcc86431dc
@@ -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
|
⚠️ **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.
|
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`)
|
## 6. Produktname im Widget (`.env.production`)
|
||||||
|
|
||||||
`VITE_PRODUCT_NAME=aXion1337.Chat` in `.env.production` — eine Zeile, keine
|
`VITE_PRODUCT_NAME=aXion1337.Chat` in `.env.production` — eine Zeile, keine
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@sorb/threadnet-call-embedded",
|
"name": "@sorb/threadnet-call-embedded",
|
||||||
"version": "0.19.2-threadnet.8",
|
"version": "0.19.2-threadnet.9",
|
||||||
"files": [
|
"files": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE-AGPL-3.0",
|
"LICENSE-AGPL-3.0",
|
||||||
|
|||||||
@@ -37,8 +37,32 @@ import {
|
|||||||
const ASSET_PFAD = "assets/dfn3";
|
const ASSET_PFAD = "assets/dfn3";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut den Prozessor - oder gibt `undefined` zurueck, wenn der Nutzer den Filter
|
* Feature-Tor: solange `false`, ist der Filter fuer ALLE Clients aus - auch
|
||||||
* nicht eingeschaltet hat.
|
* 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
|
* 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
|
* 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():
|
export function createAiNoiseSuppressionProcessor():
|
||||||
| TrackProcessor<Track.Kind.Audio, AudioProcessorOptions>
|
| TrackProcessor<Track.Kind.Audio, AudioProcessorOptions>
|
||||||
| undefined {
|
| undefined {
|
||||||
if (!aiNoiseSuppressionSetting.getValue()) return undefined;
|
if (!isAiNoiseSuppressionEnabled()) return undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new DeepFilterNoiseFilterProcessor({
|
return new DeepFilterNoiseFilterProcessor({
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
|||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
import { DeviceSelection } from "./DeviceSelection";
|
import { DeviceSelection } from "./DeviceSelection";
|
||||||
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
||||||
|
import { AI_NOISE_SUPPRESSION_AVAILABLE } from "../livekit/aiNoiseSuppression";
|
||||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||||
import { MediaQualitySettings } from "./MediaQualitySettings";
|
import { MediaQualitySettings } from "./MediaQualitySettings";
|
||||||
import { FieldRow, InputField } from "../input/Input";
|
import { FieldRow, InputField } from "../input/Input";
|
||||||
@@ -121,6 +122,11 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
const [level, setLevel] = useSetting(aiNoiseSuppressionLevelSetting);
|
const [level, setLevel] = useSetting(aiNoiseSuppressionLevelSetting);
|
||||||
const [levelRaw, setLevelRaw] = useState(level);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h4>
|
<h4>
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ import {
|
|||||||
echoCancellationSetting,
|
echoCancellationSetting,
|
||||||
noiseSuppressionSetting,
|
noiseSuppressionSetting,
|
||||||
autoGainControlSetting,
|
autoGainControlSetting,
|
||||||
aiNoiseSuppressionSetting,
|
|
||||||
} from "../../../settings/settings.ts";
|
} 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
|
// TODO evaluate if this should be done like the Publisher Factory
|
||||||
export interface ConnectionFactory {
|
export interface ConnectionFactory {
|
||||||
@@ -177,16 +179,22 @@ function generateRoomOption({
|
|||||||
...liveKitOptions.audioCaptureDefaults,
|
...liveKitOptions.audioCaptureDefaults,
|
||||||
deviceId: devices.audioInput.selected$.value?.id,
|
deviceId: devices.audioInput.selected$.value?.id,
|
||||||
echoCancellation: echoCancellationSetting.getValue(),
|
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(),
|
autoGainControl: autoGainControlSetting.getValue(),
|
||||||
// ThreadNet-Fork: KI-Geraeuschunterdrueckung gegen Tastaturgeraeusche.
|
// ThreadNet-Fork (ADR-0018): KI-Geraeuschunterdrueckung als bedingtes
|
||||||
// `undefined`, wenn nicht eingeschaltet - dann wird auch nichts geladen.
|
// 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(),
|
processor: createAiNoiseSuppressionProcessor(),
|
||||||
|
}
|
||||||
|
: { noiseSuppression: noiseSuppressionSetting.getValue() }),
|
||||||
},
|
},
|
||||||
audioOutput: {
|
audioOutput: {
|
||||||
// When using controlled audio devices, we don't want to set the
|
// When using controlled audio devices, we don't want to set the
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
cameraFramerate,
|
cameraFramerate,
|
||||||
cameraBitrate,
|
cameraBitrate,
|
||||||
cameraCodec,
|
cameraCodec,
|
||||||
|
aiNoiseSuppressionSetting,
|
||||||
} from "../../../settings/settings.ts";
|
} from "../../../settings/settings.ts";
|
||||||
|
|
||||||
// At the top of your test file, after imports
|
// 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<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("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", () => {
|
describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
||||||
test.each([{ controlled: true }, { controlled: false }])(
|
test.each([{ controlled: true }, { controlled: false }])(
|
||||||
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
|
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
|
||||||
|
|||||||
Reference in New Issue
Block a user