Files
SoundGame/web/harness/live/inject.js
T

252 lines
11 KiB
JavaScript
Raw 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;
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' });
})();