fix(denoise-tester): dispose model nodes on playback stop; guard async lifecycle
Settings → Calls A/B denoise tester leaked audio resources: - play() built a denoise model node (DeepFilterNet/DTLN worker/WASM) + optional gate but stopPlayback only closed the AudioContext, never disposing them — each A/B playback-through-a-model leaked a worker. stopPlayback now mirrors stopLive (gate.disconnect → model.dispose → node.disconnect). - A generation token (playGenRef, bumped by stopPlayback) makes play() discard what it built if superseded during the async WASM/worklet load — closing the same leak in the rapid-Play-click race, the Stop-during-load case, and the unmount-during-load case, and stopping a superseded rejection from tearing down the winning playback. - A mountedRef guards the getUserMedia paths (startLive/startRecord) so closing Settings during the mic permission prompt doesn't create untracked resources / setState-after-unmount; its effect sets true on mount (not only false on cleanup) so it survives a StrictMode/Activity same-fiber remount. Bug-hunt findings from LOTUS_TODO. Three review passes: the first two confirmed the base fixes and surfaced the concurrent-load leak + StrictMode fragility; a third traced all six play() interleavings of the generation token. Gate-green (tsc, eslint, prettier, 914 tests, build). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
try {
|
try {
|
||||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
|
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
ctx.close().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
const inAnalyser = ctx.createAnalyser();
|
const inAnalyser = ctx.createAnalyser();
|
||||||
inAnalyser.fftSize = 1024;
|
inAnalyser.fftSize = 1024;
|
||||||
@@ -182,7 +187,12 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
timer: number;
|
timer: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const clipRef = useRef<AudioBuffer | null>(null);
|
const clipRef = useRef<AudioBuffer | null>(null);
|
||||||
const playRef = useRef<{ ctx: AudioContext; source: AudioBufferSourceNode } | null>(null);
|
const playRef = useRef<{
|
||||||
|
ctx: AudioContext;
|
||||||
|
source: AudioBufferSourceNode;
|
||||||
|
model: DenoiseNode | null;
|
||||||
|
gate: AudioWorkletNode | null;
|
||||||
|
} | null>(null);
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [recDb, setRecDb] = useState(-100);
|
const [recDb, setRecDb] = useState(-100);
|
||||||
const [hasClip, setHasClip] = useState(false);
|
const [hasClip, setHasClip] = useState(false);
|
||||||
@@ -208,6 +218,10 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
const startRecord = async () => {
|
const startRecord = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
const source = ctx.createMediaStreamSource(stream);
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
const analyser = ctx.createAnalyser();
|
const analyser = ctx.createAnalyser();
|
||||||
@@ -249,7 +263,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bumped whenever a playback starts or stops. An in-flight `play()` compares
|
||||||
|
// the generation it claimed against this after its awaits; if it no longer
|
||||||
|
// matches (a newer play, a Stop, or unmount happened during model load) it
|
||||||
|
// discards what it built instead of orphaning it — closes the rapid-click /
|
||||||
|
// unmount-during-load leak.
|
||||||
|
const playGenRef = useRef(0);
|
||||||
|
|
||||||
const stopPlayback = useCallback(() => {
|
const stopPlayback = useCallback(() => {
|
||||||
|
playGenRef.current += 1;
|
||||||
const p = playRef.current;
|
const p = playRef.current;
|
||||||
playRef.current = null;
|
playRef.current = null;
|
||||||
if (p) {
|
if (p) {
|
||||||
@@ -259,6 +281,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
} catch {
|
} catch {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
// Mirror stopLive: dispose the model node (worker/WASM) + gate, else each
|
||||||
|
// A/B playback through a model leaks a DeepFilterNet/DTLN worker.
|
||||||
|
p.gate?.disconnect();
|
||||||
|
p.model?.dispose();
|
||||||
|
p.model?.node.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
p.ctx.close().catch(() => undefined);
|
p.ctx.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
setPlaying(null);
|
setPlaying(null);
|
||||||
@@ -268,37 +299,73 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
|||||||
stopPlayback();
|
stopPlayback();
|
||||||
const clip = clipRef.current;
|
const clip = clipRef.current;
|
||||||
if (!clip) return;
|
if (!clip) return;
|
||||||
|
// Claim this generation AFTER stopPlayback's bump; a later play/stop/unmount
|
||||||
|
// moves it past `gen`, signalling us to discard what we built below.
|
||||||
|
const gen = playGenRef.current;
|
||||||
try {
|
try {
|
||||||
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
|
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
|
||||||
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
|
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
|
||||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||||
const source = ctx.createBufferSource();
|
const source = ctx.createBufferSource();
|
||||||
source.buffer = clip;
|
source.buffer = clip;
|
||||||
|
let playGate: AudioWorkletNode | null = null;
|
||||||
|
let playModelNode: DenoiseNode | null = null;
|
||||||
if (playModel) {
|
if (playModel) {
|
||||||
let head: AudioNode = source;
|
let head: AudioNode = source;
|
||||||
if (useGate) {
|
if (useGate) {
|
||||||
const gate = await buildGateNode(ctx, gateThreshold);
|
playGate = await buildGateNode(ctx, gateThreshold);
|
||||||
head.connect(gate);
|
head.connect(playGate);
|
||||||
head = gate;
|
head = playGate;
|
||||||
}
|
}
|
||||||
const denoise = await buildModelNode(ctx, playModel);
|
playModelNode = await buildModelNode(ctx, playModel);
|
||||||
head.connect(denoise.node);
|
head.connect(playModelNode.node);
|
||||||
denoise.node.connect(ctx.destination);
|
playModelNode.node.connect(ctx.destination);
|
||||||
} else {
|
} else {
|
||||||
source.connect(ctx.destination);
|
source.connect(ctx.destination);
|
||||||
}
|
}
|
||||||
|
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
||||||
|
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
||||||
|
// it — otherwise its worker/WASM + ctx would leak and its audio would play
|
||||||
|
// over the winner.
|
||||||
|
if (playGenRef.current !== gen || !mountedRef.current) {
|
||||||
|
try {
|
||||||
|
playGate?.disconnect();
|
||||||
|
playModelNode?.dispose();
|
||||||
|
playModelNode?.node.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
ctx.close().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
source.onended = () => {
|
source.onended = () => {
|
||||||
if (playRef.current?.ctx === ctx) stopPlayback();
|
if (playRef.current?.ctx === ctx) stopPlayback();
|
||||||
};
|
};
|
||||||
playRef.current = { ctx, source };
|
playRef.current = { ctx, source, model: playModelNode, gate: playGate };
|
||||||
source.start();
|
source.start();
|
||||||
setPlaying(label);
|
setPlaying(label);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[denoise-tester] playback failed', e);
|
console.error('[denoise-tester] playback failed', e);
|
||||||
stopPlayback();
|
// Only tear down if we're still the current playback — a superseded
|
||||||
|
// invocation must not stop the winner that replaced it.
|
||||||
|
if (playGenRef.current === gen) stopPlayback();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guards the async getUserMedia paths: if Settings closes while the mic
|
||||||
|
// permission prompt is open, the resolved stream/ctx would otherwise be
|
||||||
|
// created after the unmount cleanup already ran, leaking + setState-after-
|
||||||
|
// unmount. Own [] effect so it only flips on real unmount.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
// Set on mount (not just cleared on unmount) so a setup→cleanup→setup
|
||||||
|
// remount of the same fiber (StrictMode/Activity) leaves it true.
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
stopLive();
|
stopLive();
|
||||||
|
|||||||
Reference in New Issue
Block a user