From 9479279eb99a42085e685a11c3db8f87e0e1b37b Mon Sep 17 00:00:00 2001 From: Ethan <257484394+EthanYoQ@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:36:17 +0800 Subject: [PATCH] fix(remote-input): ignore stale audio preparation after stop or cancel Invalidate in-flight audio setup on every capture teardown and guard resume, microphone, worklet and timeout continuations by generation. Preserve normal microphone reuse and add deterministic regression tests. --- openless-all/app/assets/remote-input/app.js | 19 +- .../remote-input-audio-generation.test.mjs | 246 ++++++++++++++++++ 2 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 openless-all/app/scripts/remote-input-audio-generation.test.mjs diff --git a/openless-all/app/assets/remote-input/app.js b/openless-all/app/assets/remote-input/app.js index d3ed74b01..a07ac65a8 100644 --- a/openless-all/app/assets/remote-input/app.js +++ b/openless-all/app/assets/remote-input/app.js @@ -1537,8 +1537,10 @@ setStatus(L.preparingMic, 'work'); clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字 + var gen = audioGen; withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT') .then(function () { + if (gen !== audioGen) return; if (!recording) { // 期间已被取消/松手 teardownAudioCapture(); @@ -1549,6 +1551,7 @@ setStatus(L.preparingBackend, 'work'); }) .catch(function (err) { + if (gen !== audioGen) return; recording = false; resetRemoteStreamState(); // 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在 @@ -1639,6 +1642,7 @@ // 确保 AudioContext / getUserMedia / 采集节点就绪并开始推流。 // 必须在用户手势调用栈内(startRecording 由手势触发)。 function ensureAudio() { + var gen = audioGen; // 不支持 getUserMedia if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); @@ -1665,11 +1669,10 @@ return resumeP .then(function () { + if (gen !== audioGen) return null; // 2) 麦克风流(已存在则复用) if (mediaStream) return mediaStream; - // 捕获当前代际:迟到 resolve 时若代际已变(超时重置/断线释放),停掉轨道并放弃, - // 避免泄漏麦克风或覆盖重试成功的新流。 - var gen = audioGen; + // 使用准备开始时的代际;停止/取消后迟到的流必须释放,不能覆盖下一次录音。 return navigator.mediaDevices .getUserMedia({ audio: { @@ -1701,7 +1704,7 @@ .then(function (stream) { // 3) 建立采集图(若已建好则跳过)。audioCtx 可能在准备超时后被 resetAudioContext // 置空(本次 getUserMedia 迟到 resolve),此时直接放弃,避免对 null ctx 建图报错。 - if (sourceNode || !audioCtx || !stream) return; + if (gen !== audioGen || sourceNode || !audioCtx || !stream) return; sourceNode = audioCtx.createMediaStreamSource(stream); return buildCaptureGraph(); }); @@ -1709,12 +1712,14 @@ // 建立 AudioWorklet(优先)或 ScriptProcessor(兜底) function buildCaptureGraph() { + var gen = audioGen; var inSr = audioCtx.sampleRate || 48000; // 优先 AudioWorklet if (audioCtx.audioWorklet && typeof AudioWorkletNode !== 'undefined') { return loadWorklet() .then(function () { + if (gen !== audioGen) return; workletNode = new AudioWorkletNode(audioCtx, 'ol-pcm-worklet', { numberOfInputs: 1, numberOfOutputs: 0, @@ -1723,12 +1728,13 @@ }); workletNode.port.onmessage = function (e) { // e.data 是已转换好的 Int16 LE ArrayBuffer - sendAudio(e.data); + if (gen === audioGen) sendAudio(e.data); }; sourceNode.connect(workletNode); usingWorklet = true; }) .catch(function () { + if (gen !== audioGen) return; // worklet 加载失败 → 回退 ScriptProcessor usingWorklet = false; buildScriptProcessor(inSr); @@ -2035,6 +2041,7 @@ // ============================================================ // 仅停止"采集/推流"(断开节点),保留 audioCtx & mediaStream 以便快速重启。 function teardownAudioCapture() { + audioGen++; // 停止/取消也作废在途的 resume、麦克风、worklet 和准备超时回调。 releaseWakeLock(); if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint; try { @@ -2072,7 +2079,6 @@ // 彻底释放(断线时):停止麦克风轨道并关闭 ctx。 function teardownAudio() { - audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { @@ -2093,7 +2099,6 @@ // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 // (resume 永不 settle),保留它只会让下次继续卡。 function resetAudioContext() { - audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { diff --git a/openless-all/app/scripts/remote-input-audio-generation.test.mjs b/openless-all/app/scripts/remote-input-audio-generation.test.mjs new file mode 100644 index 000000000..bd991d2f7 --- /dev/null +++ b/openless-all/app/scripts/remote-input-audio-generation.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +// Exercise the production lifecycle functions, with only browser/transport effects +// replaced. Deferred promises and explicit timers make late callbacks deterministic. +const source = readFileSync(new URL('../assets/remote-input/app.js', import.meta.url), 'utf8'); +const names = [ + 'withTimeout', + 'startRecording', + 'stopRecording', + 'cancelRecording', + 'ensureAudio', + 'buildCaptureGraph', + 'clearPendingPcm', + 'resetRemoteStreamState', + 'teardownAudioCapture', + 'teardownAudio', + 'resetAudioContext', +]; +const lifecycle = names + .map((name) => { + const start = source.indexOf(` function ${name}(`); + const end = source.indexOf('\n }', start); + assert.ok(start >= 0 && end > start, `production function ${name} must exist`); + return source.slice(start, end + '\n }'.length); + }) + .join('\n'); +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; +const makeStream = () => { + const track = { stopped: false, stop() { this.stopped = true; } }; + return { track, getTracks: () => [track] }; +}; +const makeNode = () => ({ + disconnected: false, + port: {}, + connect() {}, + disconnect() { this.disconnected = true; }, +}); + +function harness({ worklet = false, suspended = false } = {}) { + const calls = { mic: [], resume: [], worklet: [], nodes: [], sent: [], statuses: [], pcm: [] }; + const timers = new Map(); + let timerId = 0; + let scriptBuilds = 0; + class AudioContext { + constructor() { + this.state = suspended ? 'suspended' : 'running'; + this.sampleRate = 48000; + this.audioWorklet = worklet ? {} : undefined; + } + resume() { const d = deferred(); calls.resume.push(d); return d.promise; } + suspend() { this.state = 'suspended'; return Promise.resolve(); } + close() { this.state = 'closed'; return Promise.resolve(); } + createMediaStreamSource() { return makeNode(); } + } + const state = { + recording: false, startSent: false, awaitingResult: false, + ws: { readyState: 1 }, audioGen: 0, audioCtx: null, mediaStream: null, + sourceNode: null, workletNode: null, scriptNode: null, usingWorklet: false, + remoteSessionId: '', remoteSequence: 0, finishAfterStarted: '', + pendingPcm: [], pendingPcmBytes: 0, resampleState: { phase: 0, last: 0, hasLast: false }, + wakeLockHint: null, TARGET_SR: 16000, MIC_PREP_TIMEOUT_MS: 10000, + L: { preparingMic: 'mic', preparingBackend: 'backend', micTimeout: 'timeout', ready: 'ready' }, + window: { AudioContext }, + navigator: { mediaDevices: { getUserMedia() { + const d = deferred(); calls.mic.push(d); return d.promise; + } } }, + AudioWorkletNode: class { constructor() { const n = makeNode(); calls.nodes.push(n); return n; } }, + setTimeout(fn) { const id = ++timerId; timers.set(id, fn); return id; }, + clearTimeout(id) { timers.delete(id); }, + loadWorklet() { const d = deferred(); calls.worklet.push(d); return d.promise; }, + buildScriptProcessor() { scriptBuilds++; state.scriptNode = makeNode(); }, + wsSendJSON(message) { calls.sent.push(message.type); }, + sendAudio(pcm) { calls.pcm.push(pcm); }, + setStatus(message) { calls.statuses.push(message); }, + micErrorText(error) { return error.name; }, + interruptRecording() { throw new Error('unexpected interruption'); }, + }; + for (const name of [ + 'clearRecoveryTimer', 'acquireWakeLock', 'releaseWakeLock', 'clearReadyTimer', + 'clearWorkTimeout', 'updateRecordBtnUI', 'clearResult', 'detachHoldEnd', + 'setLevel', 'enterTranscribing', 'armWorkTimeout', 'saveRecoverySession', + ]) state[name] = () => {}; + vm.runInNewContext(lifecycle, state); + return { state, calls, timers, get scriptBuilds() { return scriptBuilds; } }; +} + +async function startPending(h) { + h.state.startRecording(); + await settle(); +} +async function resolveMic(h, index) { + const stream = makeStream(); + h.calls.mic[index].resolve(stream); + await settle(); + return stream; +} +function assertActive(h, stream, node) { + assert.equal(h.state.recording, true, 'old callback must not stop the new recording'); + assert.equal(h.state.startSent, true); + assert.equal(h.state.mediaStream, stream, 'old stream must not replace the new microphone'); + assert.equal(h.state.sourceNode, node); + assert.equal(node.disconnected, false); + assert.equal(stream.track.stopped, false); + assert.deepEqual(h.calls.sent, ['start'], 'only the current attempt may send start'); +} + +for (const end of ['stopRecording', 'cancelRecording']) { + test(`late microphone success after ${end} cannot replace a retry`, async () => { + const h = harness(); + await startPending(h); + h.state[end](); + await startPending(h); + const current = await resolveMic(h, 1); + const node = h.state.sourceNode; + const stale = await resolveMic(h, 0); + assert.equal(stale.track.stopped, true, 'cancelled microphone request must release its tracks'); + assertActive(h, current, node); + }); +} + +test('late microphone rejection cannot stop a retry', async () => { + const h = harness(); + await startPending(h); + h.state.stopRecording(); + await startPending(h); + const current = await resolveMic(h, 1); + const node = h.state.sourceNode; + h.calls.mic[0].reject(Object.assign(new Error('old request'), { name: 'NotAllowedError' })); + await settle(); + assertActive(h, current, node); + assert.equal(h.calls.statuses.at(-1), 'backend'); +}); + +test('old preparation timeout cannot close the current AudioContext', async () => { + const h = harness(); + await startPending(h); + const oldTimeout = [...h.timers.values()][0]; + h.state.stopRecording(); + await startPending(h); + const current = await resolveMic(h, 1); + const node = h.state.sourceNode; + const context = h.state.audioCtx; + oldTimeout(); + await settle(); + assertActive(h, current, node); + assert.equal(h.state.audioCtx, context); + assert.equal(context.state, 'running'); +}); + +test('late resume cannot start another microphone request after cancellation', async () => { + const h = harness({ suspended: true }); + await startPending(h); + h.state.stopRecording(); + h.calls.resume[0].resolve(); + await settle(); + assert.equal(h.calls.mic.length, 0, 'cancelled resume must not request microphone permission'); + assert.deepEqual(h.calls.sent, []); +}); + +test('late resume cannot borrow a new recording and send a second start', async () => { + const h = harness({ suspended: true }); + await startPending(h); + h.state.stopRecording(); + await startPending(h); + h.state.audioCtx.state = 'running'; + h.calls.resume[1].resolve(); + await settle(); + const current = await resolveMic(h, 0); + const node = h.state.sourceNode; + h.calls.resume[0].resolve(); + await settle(); + assertActive(h, current, node); +}); + +for (const outcome of ['resolve', 'reject']) { + test(`late worklet ${outcome} cannot rebuild the current capture graph`, async () => { + const h = harness({ worklet: true }); + await startPending(h); + const current = await resolveMic(h, 0); + h.state.stopRecording(); + await startPending(h); + h.calls.worklet[1].resolve(); + await settle(); + const node = h.state.sourceNode; + const currentWorklet = h.state.workletNode; + h.calls.worklet[0][outcome](new Error('old module')); + await settle(); + assertActive(h, current, node); + assert.equal(h.state.workletNode, currentWorklet); + assert.equal(h.calls.nodes.length, 1); + assert.equal(h.scriptBuilds, 0, 'stale worklet failure must not start fallback capture'); + assert.equal(h.state.usingWorklet, true); + }); +} + +test('current worklet failure still falls back to ScriptProcessor', async () => { + const h = harness({ worklet: true }); + await startPending(h); + await resolveMic(h, 0); + h.calls.worklet[0].reject(new Error('worklet unavailable')); + await settle(); + assert.equal(h.scriptBuilds, 1); + assert.equal(h.state.recording, true); + assert.deepEqual(h.calls.sent, ['start']); +}); + +test('current timeout still resets the context and releases a late microphone', async () => { + const h = harness(); + await startPending(h); + [...h.timers.values()][0](); + await settle(); + assert.equal(h.state.recording, false); + assert.equal(h.state.audioCtx, null); + assert.equal(h.calls.statuses.at(-1), 'timeout'); + const stale = await resolveMic(h, 0); + assert.equal(stale.track.stopped, true); + assert.deepEqual(h.calls.sent, []); +}); + +test('normal stop still pairs start/stop and reuses the microphone on the next attempt', async () => { + const h = harness(); + await startPending(h); + const stream = await resolveMic(h, 0); + h.state.remoteSessionId = 'active-session'; + h.state.stopRecording(); + assert.deepEqual(h.calls.sent, ['start', 'stop']); + assert.equal(h.state.awaitingResult, true); + assert.equal(stream.track.stopped, false); + h.state.awaitingResult = false; // Backend completed the preceding recording. + await startPending(h); + assert.equal(h.calls.mic.length, 1); + assert.equal(h.state.mediaStream, stream); + assert.deepEqual(h.calls.sent, ['start', 'stop', 'start']); +});