Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions openless-all/app/assets/remote-input/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -1549,6 +1551,7 @@
setStatus(L.preparingBackend, 'work');
})
.catch(function (err) {
if (gen !== audioGen) return;
recording = false;
resetRemoteStreamState();
// 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在
Expand Down Expand Up @@ -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:浏览器不支持录音,请升级或换浏览器'));
Expand All @@ -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: {
Expand Down Expand Up @@ -1701,20 +1704,22 @@
.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();
});
}

// 建立 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,
Expand All @@ -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);
Expand Down Expand Up @@ -2035,6 +2041,7 @@
// ============================================================
// 仅停止"采集/推流"(断开节点),保留 audioCtx & mediaStream 以便快速重启。
function teardownAudioCapture() {
audioGen++; // 停止/取消也作废在途的 resume、麦克风、worklet 和准备超时回调。
releaseWakeLock();
if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint;
try {
Expand Down Expand Up @@ -2072,7 +2079,6 @@

// 彻底释放(断线时):停止麦克风轨道并关闭 ctx。
function teardownAudio() {
audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调
teardownAudioCapture();
if (mediaStream) {
try {
Expand All @@ -2093,7 +2099,6 @@
// 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉
// (resume 永不 settle),保留它只会让下次继续卡。
function resetAudioContext() {
audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调
teardownAudioCapture();
if (mediaStream) {
try {
Expand Down
246 changes: 246 additions & 0 deletions openless-all/app/scripts/remote-input-audio-generation.test.mjs
Original file line number Diff line number Diff line change
@@ -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']);
});