- 真根因(live span 8.57s < 文件 8.80s 铁证):SoundTouch Stretch 源结束时不冲刷 内部最后一个小节(~1 sequence),句尾 0.1-0.25s 从未交付音频图——任何平台皆然, ASR 因容错转写仍 sim=1(转写评审盲区的又一实证)。一修固定 300ms 排空窗只是 把这段不存在的交付空等成静音;二修 onEnd 保证「已交付必播完」但交付不了这段 - 三修:源尾垫 VOICE_TAIL_PAD_SEC(0.25s) 静音把真实句尾推出管线;完成 = onEnd (平台无关,disconnect 已交付帧仍播);1.5s 兜底;顶掉复核移至新句数据访问前 (垫长拷贝的 getChannelData 先于杀旧句会打乱探针时序) - live 探针适配:尾部垫使 SoundTouch 读未注册 buffer、gcd 抽读信号消失—— 清扫器加「仍有连接中语音 SPN 则不关段」守卫,断开即移出追踪集合 - engine-voice.test:PAD+I1/I2/I2b/I3 五组 19 断言(含垫长存在性、onEnd 结算、 兜底不早于 1.5s);10 套件全绿 - live 复验(男声 p1):span 8.57→9.04s(≥文件 8.80s),ASR sim=1 verdict=OK
260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
/**
|
||
* inject.js — 全拟真玩家 agent 页面探针(黑盒,不改引擎/游戏代码)。
|
||
*
|
||
* 由控制端通过 Page.addScriptToEvaluateOnNewDocument 注入,在页面任何脚本
|
||
* 之前运行。职责:
|
||
* 1. fetch 包装:记录 audio/ 资产请求与 404;ArrayBuffer → 来源 URL 关联。
|
||
* 2. decodeAudioData 包装:AudioBuffer → 来源 URL 关联(WeakMap)。
|
||
* 3. AudioBufferSourceNode start/stop 包装:bgm/sfx 播放事件(voice 不经此路径,
|
||
* voice 走 SoundTouch ScriptProcessorNode 管线,见第 4 条)。
|
||
* 4. AudioBuffer.prototype.getChannelData 包装:SoundTouch 的
|
||
* WebAudioBufferSource.extract 是引擎里唯一对 voice buffer 调
|
||
* getChannelData 的地方 → 用「voice buffer 被抽读」检出 voice 段
|
||
* (vstart/vend,带 url 与 buffer 时长)。ScriptProcessorNode 的
|
||
* disconnect 作为 voice 被 kill(顶掉/stopVoice/自然结束)的信号。
|
||
* 5. AudioNode.connect 包装:connect(ctx.destination) 改道经过 tap
|
||
* GainNode,同时接真 destination 与 MediaStreamDestination,用
|
||
* MediaRecorder 录真实混音 → base64 chunk 存 __live.chunks。
|
||
* 6. navigator.vibrate / console.error / window error / 长按快进事件记录。
|
||
*
|
||
* 所有事件 {t} 用 performance.now()(毫秒)。录音起点 __live.recStartT
|
||
* 是 recorder.start() 当刻的 performance.now();控制端用
|
||
* audio_t = (t - recStartT)/1000 把事件换算到录音时间轴(误差 ≈ AudioContext
|
||
* resume 延迟,几十 ms,ASR 窗口 ±0.3s 余量可吸收)。
|
||
*
|
||
* 暴露:window.__live = { events, chunks, recStartT, recState, stopRec() }
|
||
*/
|
||
(() => {
|
||
if (window.__live) return;
|
||
|
||
const now = () => performance.now();
|
||
const events = [];
|
||
const chunks = [];
|
||
/** @type {null|(() => Promise<void>)} */
|
||
let stopRecFn = null;
|
||
|
||
const live = {
|
||
events,
|
||
chunks,
|
||
recStartT: null, // recorder.start() 当刻 performance.now()(ms)
|
||
recState: 'none', // none | recording | stopped | unavailable
|
||
stopRec: () => (stopRecFn ? stopRecFn() : Promise.resolve()),
|
||
};
|
||
window.__live = live;
|
||
|
||
const push = (obj) => { obj.t = now(); events.push(obj); };
|
||
|
||
// ---------- console / error ----------
|
||
const origErr = console.error.bind(console);
|
||
console.error = (...args) => {
|
||
try { push({ ev: 'console.error', msg: args.map((a) => String(a)).join(' ').slice(0, 600) }); } catch (_) {}
|
||
origErr(...args);
|
||
};
|
||
const origWarn = console.warn.bind(console);
|
||
console.warn = (...args) => {
|
||
try { push({ ev: 'console.warn', msg: args.map((a) => String(a)).join(' ').slice(0, 600) }); } catch (_) {}
|
||
origWarn(...args);
|
||
};
|
||
window.addEventListener('error', (e) => push({ ev: 'pageerror', msg: String(e.message || e).slice(0, 600) }));
|
||
window.addEventListener('unhandledrejection', (e) => push({ ev: 'unhandledrejection', msg: String(e.reason).slice(0, 600) }));
|
||
|
||
// ---------- vibrate / 长按快进 ----------
|
||
if (navigator.vibrate) {
|
||
const ov = navigator.vibrate.bind(navigator);
|
||
navigator.vibrate = (p) => {
|
||
try { push({ ev: 'vibrate', pattern: JSON.stringify(p) }); } catch (_) {}
|
||
return ov(p);
|
||
};
|
||
}
|
||
window.addEventListener('soundgame:longpress', () => push({ ev: 'boost', on: true }));
|
||
window.addEventListener('soundgame:longpressend', () => push({ ev: 'boost', on: false }));
|
||
|
||
// ---------- fetch:audio/ 资产请求记录 + ArrayBuffer→URL ----------
|
||
const abUrl = new WeakMap();
|
||
try {
|
||
const origFetch = window.fetch.bind(window);
|
||
window.fetch = async (input, init) => {
|
||
const url = typeof input === 'string' ? input : (input && input.url) || String(input);
|
||
const t0 = now();
|
||
const resp = await origFetch(input, init);
|
||
try {
|
||
if (url.includes('audio/')) {
|
||
push({ ev: 'fetch', url, status: resp.status, ok: resp.ok, ms: Math.round(now() - t0) });
|
||
if (!resp.ok) push({ ev: 'asset404', url, status: resp.status });
|
||
const origAB = resp.arrayBuffer.bind(resp);
|
||
resp.arrayBuffer = async () => {
|
||
const ab = await origAB();
|
||
abUrl.set(ab, url);
|
||
return ab;
|
||
};
|
||
}
|
||
} catch (_) {}
|
||
return resp;
|
||
};
|
||
} catch (e) { push({ ev: 'probe_error', where: 'fetch', msg: String(e) }); }
|
||
|
||
// ---------- decodeAudioData:AudioBuffer→URL ----------
|
||
const bufUrl = new WeakMap();
|
||
const layerOf = (url) => (url.includes('/voice/') ? 'voice' : url.includes('/bgm/') ? 'bgm' : 'sfx');
|
||
try {
|
||
const protos = [];
|
||
if (window.BaseAudioContext) protos.push(window.BaseAudioContext.prototype);
|
||
if (window.AudioContext && !protos.includes(window.AudioContext.prototype)) protos.push(window.AudioContext.prototype);
|
||
for (const proto of protos) {
|
||
const origDec = proto.decodeAudioData;
|
||
proto.decodeAudioData = function (ab, ...rest) {
|
||
const src = abUrl.get(ab) || null;
|
||
const p = origDec.call(this, ab, ...rest);
|
||
if (p && typeof p.then === 'function') {
|
||
p.then((buf) => { if (src) bufUrl.set(buf, src); }).catch(() => {});
|
||
}
|
||
return p;
|
||
};
|
||
}
|
||
} catch (e) { push({ ev: 'probe_error', where: 'decodeAudioData', msg: String(e) }); }
|
||
|
||
// ---------- voice 段检测(getChannelData 抽读 + ScriptProcessorNode disconnect) ----------
|
||
// SoundTouch 内部 FIFO 攒批抽读:同一句内 getChannelData 间隔可达 ~0.5s,
|
||
// 所以段关闭主要靠 voice 节点 disconnect(引擎 killVoice 必走),间隙判定只作兜底。
|
||
const VOICE_GAP_MS = 900; // 抽读间隔超过此值 → 上一段已结束(兜底)
|
||
let seg = null; // {url, t0(ms), lastAct(ms), dur(s)}
|
||
const closeSeg = (tEnd, reason) => {
|
||
if (!seg) return;
|
||
events.push({ ev: 'vend', url: seg.url, t: tEnd, reason });
|
||
seg = null;
|
||
};
|
||
try {
|
||
const origGcd = AudioBuffer.prototype.getChannelData;
|
||
AudioBuffer.prototype.getChannelData = function (ch) {
|
||
const url = bufUrl.get(this);
|
||
if (url && layerOf(url) === 'voice') {
|
||
const t = now();
|
||
if (!seg || seg.url !== url || t - seg.lastAct > VOICE_GAP_MS) {
|
||
if (seg) closeSeg(seg.lastAct, 'preempt');
|
||
seg = { url, t0: t, lastAct: t, dur: this.duration };
|
||
events.push({ ev: 'vstart', url, t, dur: this.duration });
|
||
} else {
|
||
seg.lastAct = t;
|
||
}
|
||
}
|
||
return origGcd.call(this, ch);
|
||
};
|
||
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
const voiceNodes = new Set(); // 连接中的 SoundTouch ScriptProcessorNode(断开即移除)
|
||
if (AC) {
|
||
const origCSP = AC.prototype.createScriptProcessor;
|
||
AC.prototype.createScriptProcessor = function (...a) {
|
||
const n = origCSP.apply(this, a);
|
||
voiceNodes.add(n);
|
||
return n;
|
||
};
|
||
const origDisc = AudioNode.prototype.disconnect;
|
||
AudioNode.prototype.disconnect = function (...a) {
|
||
// voice 节点断开 = 引擎 killVoice(自然结束/被顶掉/stopVoice 都走这里)。
|
||
// 玩家实际听到的尾音就到此刻(FIFO 里未播部分被丢弃),用 now() 而非 lastAct。
|
||
if (voiceNodes.has(this)) {
|
||
voiceNodes.delete(this);
|
||
if (seg) closeSeg(now(), 'kill');
|
||
}
|
||
return origDisc.apply(this, a);
|
||
};
|
||
}
|
||
// 兜底清扫:长静默后关闭未结段。
|
||
// 守卫(2026-09 三修适配):引擎的尾部静音垫让 SoundTouch 读的是新建
|
||
// buffer(未注册 url),gcd 抽读信号消失、lastAct 停在句首——只要仍有
|
||
// 连接中的语音 SPN 就说明句在播,不许清扫;段关闭只信 disconnect。
|
||
setInterval(() => {
|
||
if (seg && now() - seg.lastAct > VOICE_GAP_MS && voiceNodes.size === 0) closeSeg(seg.lastAct, 'end');
|
||
}, 200);
|
||
} catch (e) { push({ ev: 'probe_error', where: 'voice-detect', msg: String(e) }); }
|
||
|
||
// ---------- AudioBufferSourceNode:bgm/sfx 播放事件 ----------
|
||
try {
|
||
let sid = 0;
|
||
const sidOf = new WeakMap();
|
||
const origStart = AudioBufferSourceNode.prototype.start;
|
||
AudioBufferSourceNode.prototype.start = function (when, ...rest) {
|
||
try {
|
||
const url = this.buffer ? bufUrl.get(this.buffer) || null : null;
|
||
const id = ++sid;
|
||
sidOf.set(this, id);
|
||
events.push({
|
||
ev: 'start', t: now(), sid: id, url, layer: url ? layerOf(url) : null,
|
||
dur: this.buffer ? this.buffer.duration : null,
|
||
rate: this.playbackRate ? this.playbackRate.value : 1,
|
||
loop: !!this.loop, when: when || 0,
|
||
});
|
||
} catch (_) {}
|
||
return origStart.call(this, when, ...rest);
|
||
};
|
||
const origStop = AudioBufferSourceNode.prototype.stop;
|
||
AudioBufferSourceNode.prototype.stop = function (when) {
|
||
try { events.push({ ev: 'stop', t: now(), sid: sidOf.get(this) || null, when: when || 0 }); } catch (_) {}
|
||
return origStop.call(this, when);
|
||
};
|
||
} catch (e) { push({ ev: 'probe_error', where: 'absn', msg: String(e) }); }
|
||
|
||
// ---------- connect 包装:destination 前插 tap → MediaRecorder 录混音 ----------
|
||
const abToB64 = (ab) => {
|
||
const u8 = new Uint8Array(ab);
|
||
let s = '';
|
||
for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192));
|
||
return btoa(s);
|
||
};
|
||
try {
|
||
const origConnect = AudioNode.prototype.connect;
|
||
const taps = new WeakMap(); // destination -> tap GainNode
|
||
const startRec = (ctx, tap) => {
|
||
try {
|
||
if (typeof MediaRecorder === 'undefined') {
|
||
live.recState = 'unavailable';
|
||
push({ ev: 'recfail', msg: 'MediaRecorder undefined(降级:只记事件不录音)' });
|
||
return;
|
||
}
|
||
const msDest = ctx.createMediaStreamDestination();
|
||
origConnect.call(tap, msDest);
|
||
const rec = new MediaRecorder(msDest.stream, { mimeType: 'audio/webm;codecs=opus' });
|
||
let chain = Promise.resolve(); // 保证 chunk 顺序
|
||
rec.ondataavailable = (e) => {
|
||
if (e.data && e.data.size) {
|
||
chain = chain.then(() => e.data.arrayBuffer()).then((ab) => { chunks.push(abToB64(ab)); }).catch(() => {});
|
||
}
|
||
};
|
||
rec.onerror = (e) => push({ ev: 'recfail', msg: String(e.error || e) });
|
||
rec.start(1000); // 1s timeslice
|
||
live.recStartT = now();
|
||
live.recState = 'recording';
|
||
push({ ev: 'recstart', mime: rec.mimeType });
|
||
stopRecFn = () => new Promise((res) => {
|
||
try {
|
||
rec.onstop = () => { chain.then(res); };
|
||
rec.stop();
|
||
} catch (_) { res(); }
|
||
live.recState = 'stopped';
|
||
push({ ev: 'recstop' });
|
||
});
|
||
} catch (e) {
|
||
live.recState = 'unavailable';
|
||
push({ ev: 'recfail', msg: String(e) });
|
||
}
|
||
};
|
||
AudioNode.prototype.connect = function (dest, ...rest) {
|
||
// 只改道「直连 ctx.destination」的边;AudioParam 形式的 connect 不动
|
||
if (dest && dest.context && dest.context.destination === dest) {
|
||
let tap = taps.get(dest);
|
||
if (!tap) {
|
||
tap = dest.context.createGain();
|
||
origConnect.call(tap, dest);
|
||
taps.set(dest, tap);
|
||
startRec(dest.context, tap);
|
||
}
|
||
return origConnect.call(this, tap, ...rest);
|
||
}
|
||
return origConnect.call(this, dest, ...rest);
|
||
};
|
||
} catch (e) { push({ ev: 'probe_error', where: 'connect', msg: String(e) }); }
|
||
|
||
push({ ev: 'probe_ready' });
|
||
})();
|