/** * audio.js — Web Audio 三层音频引擎,对齐 Android DefaultAudioEngine 行为。 * * 三层架构(id 前缀即层名): * | 层 | id 前缀 | 特性 | * |------|-----------|------------------------------------------------------------| * | BGM | 'bgm/' | 循环播放,切换时双 BufferSource 交叉淡入淡出(默认 800ms) | * | 对白 | 'voice/' | 单通道;播放期间 BGM duck 到 20%,播完恢复; | * | | | onComplete 回调驱动链播;被打断(stopVoice/新 voice 顶掉) | * | | | 时**不触发** onComplete——游戏层可依赖"打断即作废语音链" | * | 音效 | 'sfx/' | 预解码缓存,低延迟即时播放,可并发叠加 | * * 统一入口 play(id, opts),id 形如 'bgm/theme' 'voice/intro' 'sfx/hit', * 自动拼路径 `${basePath}${id}.ogg`;也可带扩展名('sfx/hit.mp3')。 * id 不带扩展名时依次尝试 .ogg → .m4a → .mp3 → .wav(对齐 Android 的 * resolveAssetPath 容错;**.ogg 在 iOS Safari 上无法解码,建议给 iOS 准备 * .m4a/.mp3 副本**,详见 docs/web-engine-api.md)。 * 资产缺失只 console.error 静默跳过,不抛异常(对齐 Android)。 * * **自动播放限制**:iOS / Chrome 要求 AudioContext 必须在用户手势中 resume。 * 标准流程(必须遵守,否则无声): * const audio = new WebAudioEngine('audio/'); * await audio.showStartOverlay({ cover: 'cover.png' }); // 遮罩:封面 + 提示文字 * // 此处 AudioContext 已解锁(触摸/鼠标点击或空格/回车 keydown 均可解锁) * game.start(); * * 与 Android 的一个差异:Web 端 sfx 是按需解码并缓存 AudioBuffer,解码完成后 * 即可即时播放,**不需要** Android SoundPool 那种"音量 0 预播预热"; * prewarm(ids) 的作用是提前解码(消除首次播放的网络+解码延迟)。 * * **用户设置(engine/settings.js)自动生效**: * - settings.voice:voice 层按性别解析路径——play('voice/intro') 实际依次尝试 * `audio/voice//intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的 * `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。 * - 输入模式(settings.input + gesture.inputMode() 运行时跟随):**生效模式为键盘** * (settings.input==='key',或 auto 且最近使用键盘/纯桌面初始判定)时,voice * 候选链最前面多一跳 `audio/voice//key/.*`——键盘措辞变体资产 * (P1 双套资产路线 a,仅覆盖含操作词的句子),缺失时自然回退 * `voice//` 主资产 → 平铺,触屏措辞资产零改动。缓存键带输入模式, * 模式切换后从下一句起用另一套(与性别切换同语义)。 * - settings.speed:voice 层语速,**变速不变调**——voice 走 SoundTouch 时间 * 拉伸管线(vendor/soundtouch.js,ScriptProcessorNode),从下一句开始生效。 * - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。 * 为 0 时不加载不播放 BGM(play('bgm/...') 记为"想播"直接跳过; * 调回非 0 时若此前有想播的 BGM 会自动补播——**状态 BGM 约定**:游戏按状态 * (菜单/探索/战斗/结算)直接 play('bgm/xxx') 切换即可,bgm=0 时切换只更新 * "想播",重新打开时引擎补播最后一个状态 BGM,游戏层无需特殊处理。 * * **爆音防护**:master 链末端挂 DynamicsCompressorNode 限幅 * (threshold -6dB / ratio 12 / attack 3ms / release 250ms),sfx 总线基础 * 增益 0.8——多路叠加超 0dBFS 时压限而非硬削波。 * * **长按快进**:voice 播放中长按屏幕(gesture.js 派发的 window 事件 * 'soundgame:longpress')→ 旁白临时 1.7 倍速(不变调),松开 * ('soundgame:longpressend')恢复 settings.speed。游戏代码零改动; * 演奏类阶段可 setRateBoostEnabled(false) 按 game 豁免(见该方法)。 */ import { settings } from './settings.js'; import { setLongpressBoostEventsEnabled, inputMode } from './gesture.js'; import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from './vendor/soundtouch.js'; const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav']; const LONGPRESS_BOOST = 1.7; // 长按快进倍速 const VOICE_TAIL_PAD_SEC = 0.25; // 源尾静音垫:把句尾推出 SoundTouch 未冲刷的最后小节(三修) const VOICE_STREAM_EPS_MS = 30; // onEnd(管线已全部交付)后的小缓冲再结算 const VOICE_DRAIN_FALLBACK_MS = 1500; // onEnd 未触发时的兜底结算上限(宁迟勿剪) /** * 语音风暴保险丝(dungeon R2 D0-1,全线保护):同一 voiceId 在 3s 窗口内被调度 * ≥5 次 = 病理循环(正常玩法最快也要 ≥600ms 才会重复同一句),第 5 次起丢弃后续 * 调度并 console.error 一次(每 id 只报一次,不刷屏)。命中说明上游存在调度死循环 * (如 R1 教学反馈与导航提示互抢),熔断保证玩家耳机里不再出现机关枪碎句。 */ const FUSE_WINDOW_MS = 3000; const FUSE_MAX_HITS = 5; export class WebAudioEngine { /** * @param {string} basePath 音频根目录,默认 'audio/'(相对游戏页面) * @param {object} [opts] * @param {number} [opts.bgmVolume=1] BGM 基础音量 0~1 * @param {number} [opts.voiceVolume=1] 对白音量 * @param {number} [opts.sfxVolume=1] 音效音量 * @param {number} [opts.duckVolume=0.2] 对白播放时 BGM duck 到的比例 * @param {number} [opts.fadeMs=800] BGM 交叉淡入时长 ms * @param {number} [opts.duckMs=300] duck / 恢复时长 ms */ constructor(basePath = 'audio/', opts = {}) { this.basePath = basePath.endsWith('/') ? basePath : basePath + '/'; this.bgmVolume = opts.bgmVolume ?? 1; this.voiceVolume = opts.voiceVolume ?? 1; this.sfxVolume = opts.sfxVolume ?? 1; this.duckVolume = opts.duckVolume ?? 0.2; this.fadeMs = opts.fadeMs ?? 800; this.duckMs = opts.duckMs ?? 300; /** @type {AudioContext|null} */ this.ctx = null; this.unlocked = false; this._master = null; // 总线 this._bgmBus = null; // BGM 总线(duck 作用于此) this._bgmPrefBus = null; // BGM 偏好总线(settings.bgm 增益作用于此) this._voiceBus = null; this._sfxBus = null; this._cache = new Map(); // key -> Promise(voice 的 key 含性别) this._bgm = null; // { id, source, gain } 当前 BGM this._bgmWanted = null; // { id, opts } 最近一次 BGM 请求(bgm=0 时只记不播) this._voice = null; // 当前对白 token(SoundTouch 管线,见 _playVoice) this._voiceQueue = []; // queue:true 时的等待队列 this._rateBoostEnabled = true; // 长按快进开关(setRateBoostEnabled per-game 控制) this._voiceFuseLog = []; // [{ id, t }] 保险丝调度流水(play 入口记录) this._voiceFuseErrored = new Set(); // 已报过 console.error 的 id(每 id 一次) // 设置变更实时生效 settings.subscribe((_, changed) => this._onSettingsChange(changed)); // 长按快进:voice 播放中长按 → 临时 1.7 倍速(不变调),松开恢复设置语速 if (typeof window !== 'undefined' && window.addEventListener) { window.addEventListener('soundgame:longpress', () => this._voiceBoost(true)); window.addEventListener('soundgame:longpressend', () => this._voiceBoost(false)); } } /** * 创建/恢复 AudioContext。必须在用户手势回调中调用(showStartOverlay 已处理)。 * @returns {Promise} */ async unlock() { if (!this.ctx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) throw new Error('Web Audio API 不可用'); this.ctx = new AC(); // master → 限幅器 → destination:多路叠加超 0dBFS 时压限而非硬削波 this._master = this.ctx.createGain(); this._limiter = this.ctx.createDynamicsCompressor(); this._limiter.threshold.value = -6; this._limiter.ratio.value = 12; this._limiter.attack.value = 0.003; this._limiter.release.value = 0.25; this._master.connect(this._limiter); this._limiter.connect(this.ctx.destination); this._bgmPrefBus = this.ctx.createGain(); this._bgmPrefBus.gain.value = settings.get().bgm; this._bgmPrefBus.connect(this._master); this._bgmBus = this.ctx.createGain(); this._bgmBus.connect(this._bgmPrefBus); this._voiceBus = this.ctx.createGain(); this._voiceBus.connect(this._master); this._sfxBus = this.ctx.createGain(); this._sfxBus.gain.value = 0.8; // sfx 总线基础增益下调,多音叠加防过载 this._sfxBus.connect(this._master); } if (this.ctx.state !== 'running') await this.ctx.resume(); this.unlocked = true; } /** * 启动遮罩层:全屏封面 + 提示文字。第一次点按/按键时解锁音频并移除遮罩。 * @param {object} [opts] * @param {string} [opts.cover] 封面图地址(如 'cover.png'),缺省纯黑背景 * @param {string} [opts.text] 提示文字;缺省按输入模式自适应—— * gesture.inputMode()==='key' →「按空格开始」,否则「轻触开始」 * (settings.input / 最近使用通道决定,见 gesture.js)。 * 显式传 text 的调用完全覆盖(各游戏页现状)。 * @returns {Promise} resolve 时音频已解锁,可安全 start() */ showStartOverlay(opts = {}) { const { cover } = opts; const text = opts.text ?? (inputMode() === 'key' ? '按空格开始' : '轻触开始'); return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.id = 'start-overlay'; if (cover) { const img = document.createElement('img'); img.className = 'cover'; img.src = cover; img.alt = ''; overlay.appendChild(img); } const hint = document.createElement('div'); hint.className = 'hint'; hint.textContent = text; overlay.appendChild(hint); document.body.appendChild(overlay); let done = false; const onTap = () => { if (done) return; done = true; if (hasWin) window.removeEventListener('keydown', onKey); this.unlock().then(() => { overlay.remove(); resolve(); }); }; // 同时监听 touchend 与 click;unlock() 必须在手势回调里同步发起。 // 触摸路径必须 preventDefault(需 passive:false 才有效):否则浏览器 // 会为这次触摸补发合成 mousedown/mouseup/click。遮罩移除后合成事件 // 落到手势 root 上,gesture.js 的鼠标回退通道会把它误判成一次 tap—— // "解锁单击"误杀开场旁白的根因(gesture.js 另有 document 级 700ms // 合成鼠标守卫兜底,双保险)。 overlay.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false }); overlay.addEventListener('touchend', (e) => { e.preventDefault(); onTap(); }, { passive: false }); // 桌面鼠标 / 辅助技术触发的 click 不受影响,照常解锁 overlay.addEventListener('click', onTap); // 键盘解锁(PC 平行通道):空格/回车 keydown 同属 user activation,回调 // 内同步发起 unlock() 即可满足自动播放限制。挂 window 而非遮罩(游戏页 // 焦点在 body,keydown 不落在遮罩元素上),解锁后移除。此时 gesture.js // 尚未 attach(遮罩先于 attachGestures),键盘解锁必须在这里做。 const hasWin = typeof window !== 'undefined' && typeof window.addEventListener === 'function'; const onKey = (e) => { if (e.repeat) return; if (e.code !== 'Enter' && e.code !== 'Space') return; e.preventDefault(); onTap(); }; if (hasWin) window.addEventListener('keydown', onKey); }); } /** * 统一播放入口。按 id 前缀分发到三层。 * @param {string} id 'bgm/xxx' | 'voice/xxx' | 'sfx/xxx'(可带扩展名) * @param {object} [opts] * @param {number} [opts.volume] 本次播放音量 0~1(乘层音量) * @param {number} [opts.pan] 可选声像 -1~1(sfx/voice 两层):非 0 有限数时 * 在 gain 与层总线之间插 StereoPannerNode;缺省/0 * 走原路径(零行为变化);老 Safari 无 * createStereoPanner 时静默忽略(23-hunt §9) * @param {number} [opts.fadeMs] 仅 BGM:本次交叉淡入时长 * @param {boolean} [opts.loop=true] 仅 BGM:是否循环 * @param {() => void} [opts.onComplete] 仅 voice:自然播完回调(被打断不触发) * @param {boolean} [opts.queue=false] 仅 voice:true 时排队等待当前对白播完, * false(默认)顶掉当前对白(对齐 Android) */ play(id, opts = {}) { const slash = id.indexOf('/'); const layer = slash > 0 ? id.slice(0, slash) : ''; if (!this.unlocked) { console.warn('[WebAudioEngine] 音频尚未解锁(需先 showStartOverlay/unlock),跳过:', id); return; } if (layer === 'bgm') this._playBgm(id, opts); else if (layer === 'voice') { if (this._voiceFuseTripped(id)) return; // 风暴保险丝:丢弃病理重复调度 this._playVoice(id, opts); } else if (layer === 'sfx') this._playSfx(id, opts); else console.error('[WebAudioEngine] 未知音频层(id 需以 bgm/ voice/ sfx/ 开头):', id); } /** * 风暴保险丝(D0-1):同一 voiceId 在 3s 窗口内被调度 ≥5 次 → 丢弃本次及后续 * 调度并 console.error 一次。queue:true 的排队同样计入(排队也是调度)。 * @returns {boolean} true = 本次调度已丢弃 */ _voiceFuseTripped(id) { const now = performance.now(); const log = this._voiceFuseLog; while (log.length && now - log[0].t > FUSE_WINDOW_MS) log.shift(); let hits = 0; for (const e of log) if (e.id === id) hits++; log.push({ id, t: now }); if (hits + 1 < FUSE_MAX_HITS) return false; if (!this._voiceFuseErrored.has(id)) { this._voiceFuseErrored.add(id); console.error(`[WebAudioEngine] 语音风暴保险丝:'${id}' 在 ${FUSE_WINDOW_MS}ms 内被调度 ${hits + 1} 次,已丢弃后续调度(上游存在调度循环,请排查)`); } return true; } /** 预解码一批音频(消除首次播放延迟),id 同 play()。 */ prewarm(ids) { for (const id of ids) this._load(id); } /** 停止 BGM(淡出后释放)。 */ stopBgm(fadeOutMs = this.fadeMs) { this._bgmWanted = null; const cur = this._bgm; if (!cur || !this.ctx) return; this._bgm = null; this._fadeGain(cur.gain, 0, fadeOutMs); setTimeout(() => { try { cur.source.stop(); } catch (_) { /* 已停止 */ } }, fadeOutMs + 60); } /** * 打断当前对白并清空等待队列。**不触发** onComplete(对齐 Android, * 游戏层依赖此实现"打断即作废整条语音链")。 */ stopVoice() { this._voiceQueue.length = 0; const cur = this._voice; if (!cur) return; this._voice = null; this._killVoice(cur); this._unduckBgm(); } /** * 跳过当前对白:停掉当前 voice 并**视为这句自然播完**——触发其 * onComplete(链播下一句)并继续等待队列。与 stopVoice 的差异: * stopVoice 作废整条链,skipVoice 让链继续走。 */ skipVoice() { const cur = this._voice; if (!cur) return; this._voice = null; this._killVoice(cur); // 以下与 _playVoice 自然播完路径一致 if (this._voiceQueue.length === 0) this._unduckBgm(); if (cur.onComplete) cur.onComplete(); const next = this._voiceQueue.shift(); if (next) this._playVoice(next.id, next); } /** 停止 BGM + 对白(sfx 都很短,不主动停)。 */ stopAll() { this.stopBgm(200); this.stopVoice(); } /** * 当前是否有对白在播(含 queue 排队中的不算,只看正在播的这句)。 * 收编各游戏对私有字段 _voice 的只读判定(如"单击是否跳句"的语境判断)。 * @returns {boolean} */ isVoicePlaying() { return this._voice !== null; } /** * 长按快进(旁白临时 1.7 倍速)的 per-game 开关,默认开启(保持全局行为)。 * rhythm/space 等演奏/对局阶段传入 false 豁免——长按不再触发快进; * 若快进正在生效会立即恢复设置语速。回菜单/结算时传 true 恢复。 * @param {boolean} on */ setRateBoostEnabled(on) { this._rateBoostEnabled = on !== false; // 豁免期同步抑制 gesture.js 的长按 boost 窗口事件(D1-6:对局长按不再有 // boost on→off 事件闪跳;事件只服务快进通道,游戏手势回调不受影响) setLongpressBoostEventsEnabled(this._rateBoostEnabled); if (!this._rateBoostEnabled) this._voiceBoost(false); // 已在快进则立即还原 } // ---------------- 内部实现 ---------------- /** * id -> 候选路径列表(带扩展名直接用,否则按回退表依次尝试)。 * voice 层按 settings.voice 优先尝试 `voice//` 子目录, * 找不到再回退平铺路径(过渡期兼容)。 * **键盘变体(P1 路线 a)**:生效输入模式为键盘(gesture.inputMode()==='key', * 即 settings.input==='key' 或 auto 且键盘在跟)时,候选链前置 * `voice//key/` 一跳——键盘措辞资产只有含操作词的句子,其余 * id 此跳 404 后自然落回 `voice//` 主资产 → 平铺,游戏无感。 */ _candidates(id) { const name = id.slice(id.lastIndexOf('/') + 1); const hasExt = name.includes('.'); if (id.startsWith('voice/')) { const rest = id.slice(6); const gender = settings.get().voice; const genderBase = this.basePath + 'voice/' + gender + '/' + rest; const flatBase = this.basePath + id; // key 候选只按「生效模式」动态决定(无独立缓存层,见 _load 的缓存键) const bases = inputMode() === 'key' ? [this.basePath + 'voice/' + gender + '/key/' + rest, genderBase, flatBase] : [genderBase, flatBase]; if (hasExt) return bases; return bases.flatMap((b) => EXT_FALLBACKS.map((ext) => b + ext)); } const base = this.basePath + id; if (hasExt) return [base]; return EXT_FALLBACKS.map((ext) => base + ext); } /** * 加载并解码,缓存 Promise(含失败结果 null,避免重复 404)。 * 资产缺失/无法解码时 console.error 并返回 null,不抛异常。 */ _load(id) { // voice 层缓存键带性别 + 输入模式(key 变体/主资产/平铺是三套不同音频): // 切换 settings.voice 或输入模式后不会复用旧缓存结果,自然从下一句生效 const key = id.startsWith('voice/') ? `${inputMode() === 'key' ? 'key' : 'main'}|${settings.get().voice}|${id}` : id; if (this._cache.has(key)) return this._cache.get(key); const p = (async () => { if (!this.ctx) return null; for (const url of this._candidates(id)) { try { const resp = await fetch(url); if (!resp.ok) continue; const raw = await resp.arrayBuffer(); return await this.ctx.decodeAudioData(raw); } catch (_) { // 解码失败(如 iOS Safari 遇到 .ogg)→ 尝试下一个扩展名 } } console.error('[WebAudioEngine] 资产缺失或无法解码:', id, this._candidates(id)); return null; })(); this._cache.set(key, p); return p; } _fadeGain(gainNode, target, ms) { const t = this.ctx.currentTime; const g = gainNode.gain; g.cancelScheduledValues(t); g.setValueAtTime(g.value, t); g.linearRampToValueAtTime(target, t + ms / 1000); } _duckBgm() { if (this._bgmBus) this._fadeGain(this._bgmBus, this.duckVolume, this.duckMs); } /** * 设置变更实时生效(settings.subscribe 回调)。 * - bgm 改值:进行中的 BGM 立即渐变到新增益(150ms); * 改到 0:立即淡出停止(保留"想播"意图);从 0 改回:补播最近想播的 BGM。 * - voice/speed:无需处理——voice 播放在创建 source 时实时读取设置, * 自然从下一句生效。 */ _onSettingsChange(changed) { if (changed.bgm === undefined) return; const v = changed.bgm; if (v === 0) { const wanted = this._bgmWanted; this.stopBgm(200); // stopBgm 会清 _bgmWanted,先存后恢复 this._bgmWanted = wanted; } else { if (this.ctx && this._bgmPrefBus) this._fadeGain(this._bgmPrefBus, v, 150); if (!this._bgm && this._bgmWanted && this.unlocked) { const w = this._bgmWanted; this._playBgm(w.id, w.opts); } } } _unduckBgm() { if (this._bgmBus) this._fadeGain(this._bgmBus, 1, this.duckMs); } async _playBgm(id, { volume = 1, fadeMs = this.fadeMs, loop = true } = {}) { this._bgmWanted = { id, opts: { volume, fadeMs, loop } }; if (settings.get().bgm === 0) return; // BGM 关闭:只记意图,不加载不播放 if (this._bgm && this._bgm.id === id) return; // 同一首不重启 const buf = await this._load(id); if (!buf || (this._bgm && this._bgm.id === id)) return; const source = this.ctx.createBufferSource(); source.buffer = buf; source.loop = loop; const gain = this.ctx.createGain(); gain.gain.value = 0; source.connect(gain); gain.connect(this._bgmBus); const old = this._bgm; this._bgm = { id, source, gain }; source.start(); // 交叉淡入:新 0→目标,旧 →0 后释放(对齐 Android fadeVolume 时序) this._fadeGain(gain, this.bgmVolume * volume, fadeMs); if (old) { this._fadeGain(old.gain, 0, fadeMs); setTimeout(() => { try { old.source.stop(); } catch (_) { /* 已停止 */ } }, fadeMs + 60); } } /** * voice 播放(SoundTouch 时间拉伸管线,变速不变调): * WebAudioBufferSource → SimpleFilter(SoundTouch) → ScriptProcessorNode * → gain → voiceBus。自然结束用定时器判定(源消耗位置 ÷ 当前 tempo), * 支持中途改 tempo(长按快进)并随之修正结束时刻。 */ async _playVoice(id, { volume = 1, onComplete, queue = false, pan } = {}) { if (queue && this._voice) { this._voiceQueue.push({ id, volume, onComplete, pan }); return; } // 默认顶掉当前对白(不打 stopVoice() 以免清掉 queue 语义) if (this._voice) { const cur = this._voice; this._voice = null; this._killVoice(cur); } const buf = await this._load(id); if (!buf) { this._unduckBgm(); return; } // 顶掉语义必须在 await 之后、新句任何音频数据被访问之前复核(dungeon R2 // D0-1 根因修复):_load 期间可能又有一句 voice 开播(同 tick 双调度)。 // 若此处不补杀,早先的 token 会被 this._voice = token 静默顶号——节点 // 永远无人 disconnect(僵尸节点空转抽 buffer),endTimer 也因 _finishVoice // 的 token 校验提前返回而永不清理(R1 live:71 分钟僵尸节点抢占风暴)。 // 顺序要求(三修补充):必须先杀旧句再读新句 buffer(垫长拷贝会调 // getChannelData),否则探针会先记新句 vstart 再被旧句 disconnect 误关。 if (this._voice) { const cur = this._voice; this._voice = null; this._killVoice(cur); } const st = new SoundTouch(); st.tempo = settings.get().speed; // 语速(不变调,下一句生效) // 尾部保真(三修,2026-09 真机人耳反馈):SoundTouch Stretch 在源结束时 // 不冲刷内部最后一个小节(~1 个 sequence),句尾 0.1-0.25s 永远停留在 // 管线里不会播出(桌面/手机皆然;一修的固定排空窗与二修的 onEnd 都只能 // 保证「已交付的播完」,交付不了这段)。根治 = 源尾垫 VOICE_TAIL_PAD_SEC // 静音,把真实句尾完整推出管线;垫的静音走完后 onEnd 才触发,衔接节奏 // 与旧实现的排空窗相当,多出的静音不改变听感。 // ctx.createBuffer 缺失(playtest mock)时跳过垫长——mock 走 consumed=0 // 旧路径,语义不受影响。 let srcBuf = buf; if (typeof this.ctx.createBuffer === 'function') { const padFrames = Math.ceil(VOICE_TAIL_PAD_SEC * buf.sampleRate); srcBuf = this.ctx.createBuffer(buf.numberOfChannels, buf.length + padFrames, buf.sampleRate); for (let ch = 0; ch < buf.numberOfChannels; ch++) { if (srcBuf.copyToChannel) srcBuf.copyToChannel(buf.getChannelData(ch), ch); else srcBuf.getChannelData(ch).set(buf.getChannelData(ch)); } } const filter = new SimpleFilter(new WebAudioBufferSource(srcBuf), st); const node = getWebAudioNode(this.ctx, filter); // ScriptProcessorNode,接入即开播 const gain = this.ctx.createGain(); gain.gain.value = this.voiceVolume * volume; node.connect(gain); // 可选声像(23-hunt §9,对称能力一次给足;本作旁白不使用):pan ∈ [-1,1] // 时 panner 插在 voice gain 与 voiceBus 之间;缺省/0 → 原路径不变 const panner = this._makePanner(pan); if (panner) { gain.connect(panner); panner.connect(this._voiceBus); } else { gain.connect(this._voiceBus); } const token = { node, gain, st, buf, filter, srcBuf, // srcBuf = 尾部垫静音后的播放源 stopped: false, streamEnded: false, // SoundTouch onEnd:管线内帧已全部交付音频图 onComplete, posSec: 0, // 已消耗的源时长(秒,按 tempo 累计) lastT: performance.now(), endTimer: null, }; // 尾部保真(二修):完成信号用管线自带的 onEnd(extract 返回 0 = // SoundTouch 内部已无未交付帧),它在任何平台都不可能早于「最后一个 // 样本已交付」——此时 disconnect 已交付帧仍会播完。固定排空窗 // (一修 300ms)在手机端不够(未交付量随平台回调相位浮动),弃用。 filter.callback = () => this._onVoiceStreamEnd(token); this._voice = token; this._duckBgm(); this._armVoiceEnd(token); } /** * SoundTouch onEnd:管线内帧已全部交付音频图(ScriptProcessor 抽空)。 * 这是「句尾已真正送进硬件」的平台无关信号——再断开不可能剪字。 * 注意它在 onaudioprocess 内同步触发,结算延后一个小缓冲执行。 */ _onVoiceStreamEnd(token) { if (token.streamEnded || token.stopped) return; token.streamEnded = true; if (this._voice !== token) return; // 已被顶掉:由 _killVoice 收尾 if (token.endTimer !== null) { clearTimeout(token.endTimer); token.endTimer = null; } token.endTimer = setTimeout(() => this._finishVoice(token), VOICE_STREAM_EPS_MS); } /** * 结算 voice 自然播完(onComplete 链 + 队列推进)。 * 完成优先级:streamEnded(管线排空信号)> 定时器兜底。 * 定时器路径仅用于:①源未耗尽时等待(80ms 重排);②onEnd 在异常平台 * 未触发时的兜底(VOICE_DRAIN_FALLBACK_MS,宁迟勿剪)。 * consumedSec==0(mock 环境/SPN 首回调未到)走旧立即结算,零行为变化。 */ _finishVoice(token) { if (this._voice !== token || token.stopped) return; // 被打断:不回调 const consumedSec = token.filter.sourcePosition / token.srcBuf.sampleRate; if (consumedSec > 0 && !token.streamEnded) { if (consumedSec < token.srcBuf.duration - 0.02) { // 源未耗尽:墙钟先到/主线程卡顿/后台节流——等真实消耗追上再结算 token.endTimer = setTimeout(() => this._finishVoice(token), 80); return; } if (!token.drained) { // 源已耗尽但 onEnd 未至:挂兜底定时器,等管线排空信号 token.drained = true; token.endTimer = setTimeout(() => this._finishVoice(token), VOICE_DRAIN_FALLBACK_MS); return; } // 兜底触发(onEnd 异常缺失):宁可迟结算也不剪尾 } token.stopped = true; this._voice = null; this._killVoice(token); if (this._voiceQueue.length === 0) this._unduckBgm(); if (token.onComplete) token.onComplete(); const next = this._voiceQueue.shift(); if (next) this._playVoice(next.id, next); } /** 按当前 tempo 排结束定时器;tempo 变化时重排。 */ _armVoiceEnd(token) { const remainSec = Math.max(0.05, (token.srcBuf.duration - token.posSec) / token.st.tempo); token.lastT = performance.now(); token.endTimer = setTimeout(() => this._finishVoice(token), remainSec * 1000 + 120); } /** 累计源消耗位置(tempo 变速前调用,保持剩余时长估算准确)。 */ _updateVoicePos(token) { const now = performance.now(); token.posSec += ((now - token.lastT) / 1000) * token.st.tempo; token.lastT = now; } /** 终止 voice 播放(断开处理节点 + 清结束定时器),不触发任何回调。 */ _killVoice(token) { token.stopped = true; if (token.endTimer !== null) { clearTimeout(token.endTimer); token.endTimer = null; } try { token.node.disconnect(); } catch (_) { /* 已断开 */ } token.node.onaudioprocess = null; } /** 长按快进:voice 播放中临时提速到 1.7(不变调),松开恢复设置语速。 */ _voiceBoost(on) { const cur = this._voice; if (!cur || cur.stopped) return; if (on && !this._rateBoostEnabled) return; // per-game 豁免(演奏期不快进) const target = on ? LONGPRESS_BOOST : settings.get().speed; if (cur.st.tempo === target) return; this._updateVoicePos(cur); cur.st.tempo = target; if (cur.endTimer !== null) clearTimeout(cur.endTimer); this._armVoiceEnd(cur); } async _playSfx(id, { volume = 1, pan } = {}) { const buf = await this._load(id); if (!buf) return; const source = this.ctx.createBufferSource(); source.buffer = buf; const gain = this.ctx.createGain(); gain.gain.value = this.sfxVolume * volume; source.connect(gain); // 可选声像(23-hunt §9):pan ∈ [-1,1] 时在 gain 与总线之间插 // StereoPannerNode;缺省/0 → 原路径逐字节不变(不建 panner 节点) const panner = this._makePanner(pan); if (panner) { gain.connect(panner); panner.connect(this._sfxBus); } else { gain.connect(this._sfxBus); } source.start(); // 即发即弃,天然支持并发叠加 } /** * 可选声像节点(opts.pan,-1..1)。pan 缺省/非有限数/0 → null(调用方走 * 原路径,零行为变化);老 Safari 无 ctx.createStereoPanner → null * (静默降级:不炸、不报错音,声音照常从原路径出)。 */ _makePanner(pan) { if (typeof pan !== 'number' || !isFinite(pan) || pan === 0) return null; if (!this.ctx || typeof this.ctx.createStereoPanner !== 'function') return null; const panner = this.ctx.createStereoPanner(); panner.pan.value = Math.max(-1, Math.min(1, pan)); return panner; } }