/** * 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 已解锁 * 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.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 } from './gesture.js'; import { SoundTouch, SimpleFilter, WebAudioBufferSource, getWebAudioNode } from './vendor/soundtouch.js'; const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav']; const LONGPRESS_BOOST = 1.7; // 长按快进倍速 /** * 语音风暴保险丝(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='轻触开始'] * @returns {Promise} resolve 时音频已解锁,可安全 start() */ showStartOverlay(opts = {}) { const { cover, text = '轻触开始' } = opts; 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; 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); }); } /** * 统一播放入口。按 id 前缀分发到三层。 * @param {string} id 'bgm/xxx' | 'voice/xxx' | 'sfx/xxx'(可带扩展名) * @param {object} [opts] * @param {number} [opts.volume] 本次播放音量 0~1(乘层音量) * @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//` 子目录, * 找不到再回退平铺路径(过渡期兼容)。 */ _candidates(id) { const name = id.slice(id.lastIndexOf('/') + 1); const hasExt = name.includes('.'); if (id.startsWith('voice/')) { const genderBase = this.basePath + 'voice/' + settings.get().voice + '/' + id.slice(6); const flatBase = this.basePath + id; if (hasExt) return [genderBase, flatBase]; return [ ...EXT_FALLBACKS.map((ext) => genderBase + ext), ...EXT_FALLBACKS.map((ext) => flatBase + 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 层缓存键带性别:切换 settings.voice 后不会复用旧性别的缓存结果 const key = id.startsWith('voice/') ? `${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 } = {}) { if (queue && this._voice) { this._voiceQueue.push({ id, volume, onComplete }); 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; } const st = new SoundTouch(); st.tempo = settings.get().speed; // 语速(不变调,下一句生效) const filter = new SimpleFilter(new WebAudioBufferSource(buf), st); const node = getWebAudioNode(this.ctx, filter); // ScriptProcessorNode,接入即开播 const gain = this.ctx.createGain(); gain.gain.value = this.voiceVolume * volume; node.connect(gain); gain.connect(this._voiceBus); const token = { node, gain, st, buf, stopped: false, onComplete, posSec: 0, // 已消耗的源时长(秒,按 tempo 累计) lastT: performance.now(), endTimer: null, }; // 顶掉语义必须在 await 之后复核(dungeon R2 D0-1 根因修复):_load 期间 // 可能又有一句 voice 开播(同 tick 双调度,如"反馈句+下一步指令句")。 // 若此处不补杀,早先的 token 会被 this._voice = token 静默顶号——它的 // 节点永远没人 disconnect(僵尸节点空转抽 buffer),endTimer 也因 // _finishVoice 的 token 校验提前返回而永不清理,语音引擎自此被"幽灵句" // 污染到页面关闭(R1 live:tut_echo_come 僵尸节点致 71 分钟抢占风暴)。 if (this._voice) { const cur = this._voice; this._voice = null; this._killVoice(cur); } this._voice = token; this._duckBgm(); this._armVoiceEnd(token); } /** 结算 voice 自然播完(onComplete 链 + 队列推进)。 */ _finishVoice(token) { if (this._voice !== token || token.stopped) return; // 被打断:不回调 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.buf.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 } = {}) { 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); gain.connect(this._sfxBus); source.start(); // 即发即弃,天然支持并发叠加 } }