/** * 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 层 source.playbackRate(语速),从下一句开始生效。 * - settings.bgm:BGM 层增益;改动立即作用于进行中的 BGM(150ms 渐变)。 * 为 0 时不加载不播放 BGM(play('bgm/...') 记为"想播"直接跳过; * 调回非 0 时若此前有想播的 BGM 会自动补播)。 */ import { settings } from './settings.js'; const EXT_FALLBACKS = ['.ogg', '.m4a', '.mp3', '.wav']; 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; // { source, gain, stopped } 当前对白 token this._voiceQueue = []; // queue:true 时的等待队列 // 设置变更实时生效 settings.subscribe((_, changed) => this._onSettingsChange(changed)); } /** * 创建/恢复 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(); this._master = this.ctx.createGain(); this._master.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.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() 必须在手势回调里同步发起 overlay.addEventListener('touchend', onTap); 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') this._playVoice(id, opts); else if (layer === 'sfx') this._playSfx(id, opts); else console.error('[WebAudioEngine] 未知音频层(id 需以 bgm/ voice/ sfx/ 开头):', id); } /** 预解码一批音频(消除首次播放延迟),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; cur.stopped = true; this._voice = null; try { cur.source.stop(); } catch (_) { /* 已停止 */ } this._unduckBgm(); } /** 停止 BGM + 对白(sfx 都很短,不主动停)。 */ stopAll() { this.stopBgm(200); this.stopVoice(); } // ---------------- 内部实现 ---------------- /** * 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); } } 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; cur.stopped = true; this._voice = null; try { cur.source.stop(); } catch (_) { /* 已停止 */ } } const buf = await this._load(id); if (!buf) { this._unduckBgm(); return; } const source = this.ctx.createBufferSource(); source.buffer = buf; source.playbackRate.value = settings.get().speed; // 语速(下一句生效) const gain = this.ctx.createGain(); gain.gain.value = this.voiceVolume * volume; source.connect(gain); gain.connect(this._voiceBus); const token = { source, gain, stopped: false }; this._voice = token; this._duckBgm(); source.onended = () => { if (this._voice !== token || token.stopped) return; // 被打断:不回调 this._voice = null; if (this._voiceQueue.length === 0) this._unduckBgm(); if (onComplete) onComplete(); const next = this._voiceQueue.shift(); if (next) this._playVoice(next.id, next); }; source.start(); } 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(); // 即发即弃,天然支持并发叠加 } }