配对码与认领(B): - POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏 - 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化 - api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22 键盘语音双套资产(A,路线 a): - 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原) - 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条) - check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成) - voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引 - mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动 回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12) 同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
646 lines
30 KiB
JavaScript
646 lines
30 KiB
JavaScript
/**
|
||
* 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/<gender>/intro.{ogg,m4a,mp3,wav}`,找不到回退平铺的
|
||
* `audio/voice/intro.*`(过渡期资产未分性别也不会炸)。游戏代码不用改。
|
||
* - 输入模式(settings.input + gesture.inputMode() 运行时跟随):**生效模式为键盘**
|
||
* (settings.input==='key',或 auto 且最近使用键盘/纯桌面初始判定)时,voice
|
||
* 候选链最前面多一跳 `audio/voice/<gender>/key/<id>.*`——键盘措辞变体资产
|
||
* (P1 双套资产路线 a,仅覆盖含操作词的句子),缺失时自然回退
|
||
* `voice/<gender>/` 主资产 → 平铺,触屏措辞资产零改动。缓存键带输入模式,
|
||
* 模式切换后从下一句起用另一套(与性别切换同语义)。
|
||
* - 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<AudioBuffer|null>(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<void>}
|
||
*/
|
||
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<void>} 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.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/<gender>/` 子目录,
|
||
* 找不到再回退平铺路径(过渡期兼容)。
|
||
* **键盘变体(P1 路线 a)**:生效输入模式为键盘(gesture.inputMode()==='key',
|
||
* 即 settings.input==='key' 或 auto 且键盘在跟)时,候选链前置
|
||
* `voice/<gender>/key/<id>` 一跳——键盘措辞资产只有含操作词的句子,其余
|
||
* id 此跳 404 后自然落回 `voice/<gender>/` 主资产 → 平铺,游戏无感。
|
||
*/
|
||
_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 } = {}) {
|
||
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; }
|
||
|
||
// 顶掉语义必须在 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);
|
||
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 } = {}) {
|
||
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(); // 即发即弃,天然支持并发叠加
|
||
}
|
||
}
|