harness: 全拟真live试玩管线(CDP真机驱动chromium+WebAudio输出采集+FunASR/SenseVoice增量转写,判旁白OK/TRUNCATED/MISMATCH/SILENCE;每游戏独立控制端口8123-8128)+ serve.sh去硬编码路径 + gitignore屏蔽live/out产物

This commit is contained in:
xsl
2026-09-01 21:59:45 +08:00
parent 2de1fecbc6
commit 624fec8a23
7 changed files with 1297 additions and 1 deletions
+251
View File
@@ -0,0 +1,251 @@
/**
* 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;
if (AC) {
const voiceNodes = new WeakSet(); // SoundTouch 管线创建的 ScriptProcessorNode
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) && seg) closeSeg(now(), 'kill');
return origDisc.apply(this, a);
};
}
// 兜底清扫:长静默后关闭未结段
setInterval(() => { if (seg && now() - seg.lastAct > VOICE_GAP_MS) 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' });
})();