/** * 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)} */ 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' }); })();