Files
SoundGame/web/engine/audio.js
T
xsl 005851d01a feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决
- 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏
- 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言
- 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步
- 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿
- 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描)
- 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
2026-09-13 11:08:01 +08:00

679 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.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/<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, 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;
}
}