Files
xsl 6f4f167efd fix(engine): voice 句尾剪字真根因根治(三修:源尾垫静音冲刷 SoundTouch 未冲刷小节)+ onEnd 完成信号 + live 探针适配
- 真根因(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
2026-09-07 23:18:11 +08:00

260 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' });
})();