5.2 音效池、BGM 与流式
5.1 的混音图解决了「怎么求和」,但没解决「同时能响几个」。一段连击里可能瞬间触发十几个脚步声,而硬件与性能都不允许无上限地开 voice。这一节补上音效池(voice pool):固定容量、优先级抢占、线性淡入淡出;以及 BGM 的流式播放:不把整首歌解进内存,而是边解码边喂给混音器。
两件事都能脱离浏览器真跑——它们本质是「槽位管理 + 包络 DSP + 重采样」,全部抽成纯 TS 在 Node 里验证,浏览器只是最终的后端。
voice 管理:容量、优先级与抢占
一个 voice 就是「一路正在发声的实例」。池的容量由引擎定(比如 32),每次请求播放一个 clip 就申请一个槽。核心是三件事:
- 优先级。脚步(低)、受击(中)、剧情语音(高)各带一个整数优先级。
- 抢占。池满时,只有新请求优先级严格高于当前最低优先级 voice 才抢占那个槽;同级直接拒绝,避免连击时把刚起的脚步互相打断。
- 淡入淡出。抢占或停止时不能让波形从满幅瞬间掉到 0,否则会有「啪」的爆音。用线性 attack/release 包络把电平在若干帧内推上去、拉下来。
被抢占的 voice 立即进入 release 淡出,等它归零才真正释放槽位——所以「抢占」不会漏音,只是让旧声音体面地退场。
可运行产物:音效池与线性包络(L1)
下面是完整的 src/subsys/audio/voice-pool.ts。它把「申请槽位 / 抢占决策 / 包络推进 / 混音」都放进一个类,render 逐帧推进每个 voice 的包络并与 PCM 相乘后求和。
// file: src/subsys/audio/voice-pool.ts
// tier: L1
// verify: npx vitest run src/subsys/audio/voice-pool.test.ts
//
// helio 子系统 5.2:音效池。固定容量 voice 槽位 + 优先级抢占 + 线性淡入淡出包络。
// 纯逻辑与 DSP,Node 26 直跑,不碰 Web Audio。
export type VoicePhase = 'idle' | 'attack' | 'sustain' | 'release';
interface Voice {
clipId: number;
priority: number;
samples: Float32Array | null;
phase: VoicePhase;
gain: number;
attackStep: number;
releaseStep: number;
startedAt: number;
}
/**
* 固定容量音效池。抢占策略:**仅当新请求优先级严格高于当前最低优先级 voice 时**才抢占
* 那个槽(同级不抢占,直接拒绝);被抢占的 voice 立即进入 release 淡出。
*/
export class VoicePool {
readonly capacity: number;
#voices: Voice[];
#clock = 0;
constructor(capacity: number) {
this.capacity = capacity;
this.#voices = Array.from({ length: capacity }, (): Voice => ({
clipId: -1, priority: 0, samples: null, phase: 'idle',
gain: 0, attackStep: 0, releaseStep: 0, startedAt: 0,
}));
}
get activeCount(): number {
let n = 0;
for (const v of this.#voices) if (v.phase !== 'idle') n++;
return n;
}
/** 只读快照:测试与调试用,避免外部改到内部槽。 */
voice(slot: number): { slot: number; clipId: number; priority: number; phase: VoicePhase; gain: number } {
const v = this.#voices[slot]!;
return { slot, clipId: v.clipId, priority: v.priority, phase: v.phase, gain: v.gain };
}
/** 申请一个 voice:优先空槽;满则按策略抢占最低优先级。被拒时 slot = -1。 */
play(clipId: number, samples: Float32Array, priority: number, attackFrames = 0, releaseFrames = 0): { slot: number; stolen: number } {
let slot = this.#voices.findIndex((v) => v.phase === 'idle');
let stolen = -1;
if (slot < 0) {
const victim = this.#lowestPriority();
if (victim < 0 || this.#voices[victim]!.priority >= priority) return { slot: -1, stolen: -1 };
slot = victim;
stolen = victim;
this.#voices[victim]!.phase = 'release'; // 旧 voice 立即淡出
}
const v = this.#voices[slot]!;
v.clipId = clipId; v.priority = priority; v.samples = samples;
v.phase = 'attack'; v.gain = 0;
v.attackStep = attackFrames > 0 ? 1 / attackFrames : 1;
v.releaseStep = releaseFrames > 0 ? 1 / releaseFrames : 1;
v.startedAt = this.#clock++;
return { slot, stolen };
}
/** 主动停止一个槽:进入 release。 */
stop(slot: number): void {
const v = this.#voices[slot]!;
if (v.phase !== 'idle' && v.phase !== 'release') v.phase = 'release';
}
/** 推进 frames 帧并返回混音结果(各 voice 的 PCM × 包络 求和)。 */
render(frames: number): Float32Array {
const out = new Float32Array(frames);
for (const v of this.#voices) {
if (v.phase === 'idle') continue;
for (let i = 0; i < frames; i++) {
const g = this.#advance(v);
const s = v.samples && i < v.samples.length ? v.samples[i]! : 0;
out[i] += s * g;
if ((v.phase as VoicePhase) === 'idle') break; // #advance 可能已释放槽位
}
}
return out;
}
/** 单帧推进包络,返回推进后的增益;淡出到 0 则释放槽位。 */
#advance(v: Voice): number {
if (v.phase === 'attack') {
v.gain += v.attackStep;
if (v.gain >= 1) { v.gain = 1; v.phase = 'sustain'; }
} else if (v.phase === 'release') {
v.gain -= v.releaseStep;
if (v.gain <= 0) { v.gain = 0; v.phase = 'idle'; v.clipId = -1; v.samples = null; }
}
return v.gain;
}
/** 最低优先级槽;同级时取最早启动的(最老的先被抢)。 */
#lowestPriority(): number {
let best = -1;
for (let i = 0; i < this.#voices.length; i++) {
const v = this.#voices[i]!;
if (v.phase === 'idle' || v.phase === 'release') continue;
if (best < 0) { best = i; continue; }
const b = this.#voices[best]!;
if (v.priority < b.priority || (v.priority === b.priority && v.startedAt < b.startedAt)) best = i;
}
return best;
}
}
// ---- 直接运行:容量 2 的池,第三个高优先级请求抢走最低优先级的 voice ----
if ((import.meta as { main?: boolean }).main) {
const pool = new VoicePool(2);
const clip = Float32Array.from([1, 1, 1, 1]);
console.log('play p5', JSON.stringify(pool.play(100, clip, 5)));
console.log('play p2', JSON.stringify(pool.play(101, clip, 2)));
console.log('play p9', JSON.stringify(pool.play(102, clip, 9)));
console.log('play p1', JSON.stringify(pool.play(103, clip, 1)));
console.log('active', pool.activeCount, 'slot1.priority', pool.voice(1).priority);
}
在 /tmp/eb-subsys/ch5 里 node src/subsys/audio/voice-pool.ts(本机实测):
play p5 {"slot":0,"stolen":-1}
play p2 {"slot":1,"stolen":-1}
play p9 {"slot":1,"stolen":1}
play p1 {"slot":-1,"stolen":-1}
active 2 slot1.priority 9
读法:p5、p2 依次占满两个槽;p9 因高于当前最低优先级 p2,抢走槽 1(stolen:1);此时池里是 p5 与 p9,最低优先级已是 p5,p1 不高于它,故被拒(slot:-1)。最终 active 2,槽 1 的优先级是 9。vitest 另有 7 条断言覆盖「抢占的是最低优先级而非最早启动」「淡入 4 帧得 0.25/0.5/0.75/1」「淡出到 0 后释放槽位」等。
可运行产物:流式解码缓冲与重采样(L1)
BGM 通常几分钟长,整段解成 PCM 要几十 MB,不能常驻。做法是流式:解码线程按源采样率吐出一块块 PCM,混音器按设备采样率拉帧,中间用一个小缓冲衔接,并做线性插值重采样(源 22050 Hz → 设备 48000 Hz 这类比例)。下面是完整的 src/subsys/audio/stream-decoder.ts:
// file: src/subsys/audio/stream-decoder.ts
// tier: L1
// verify: npx vitest run src/subsys/audio/stream-decoder.test.ts
//
// helio 子系统 5.2:BGM 流式解码缓冲。解码器按源采样率吐出 PCM 分块,
// 混音器按设备采样率拉取;中间的线性插值重采样与「不足则报 underrun」在这里。
// 纯 DSP,Node 26 直跑。
export interface PullResult {
readonly out: Float32Array; // 长度等于请求帧数;不足处补 0
readonly underrun: boolean; // 可用源帧不够,发生欠载
}
/**
* 流式 PCM 缓冲:push 源采样率分块,pull 出设备采样率的连续帧。
* 内部用「已消费前缀即丢弃」的紧凑数组 + 一个分数读取游标,线性插值重采样。
*/
export class StreamBuffer {
readonly srcRate: number;
readonly dstRate: number;
#data: Float32Array;
#len = 0; // 有效源帧数
#readPos = 0; // 分数读取位置(源帧为单位)
constructor(srcRate: number, dstRate: number, capacity = 4096) {
this.srcRate = srcRate;
this.dstRate = dstRate;
this.#data = new Float32Array(capacity);
}
/** 源采样率下的重采样比:每输出一帧前进多少源帧。 */
get ratio(): number { return this.srcRate / this.dstRate; }
/** 尚可读取的源帧数(含分数部分)。 */
get bufferedSourceFrames(): number { return Math.max(0, this.#len - this.#readPos); }
/** 解码线程推入一块 PCM(源采样率)。 */
push(chunk: Float32Array): void {
if (this.#len + chunk.length > this.#data.length) {
const grown = new Float32Array(Math.max(this.#data.length * 2, this.#len + chunk.length));
grown.set(this.#data.subarray(0, this.#len));
this.#data = grown;
}
this.#data.set(chunk, this.#len);
this.#len += chunk.length;
}
/** 拉取 frames 个设备采样率的帧;不足则补 0 并置 underrun。 */
pull(frames: number): PullResult {
const out = new Float32Array(frames);
let underrun = false;
for (let j = 0; j < frames; j++) {
const i0 = Math.floor(this.#readPos);
if (i0 + 1 >= this.#len) { underrun = true; break; } // 需要 i0 与 i0+1 两帧
const frac = this.#readPos - i0;
out[j] = this.#data[i0]! * (1 - frac) + this.#data[i0 + 1]! * frac;
this.#readPos += this.ratio;
}
// 紧凑化:丢掉被完整消费的源帧前缀
const drop = Math.floor(this.#readPos);
if (drop > 0) {
this.#data.copyWithin(0, drop, this.#len);
this.#len -= drop;
this.#readPos -= drop;
}
return { out, underrun };
}
}
// ---- 直接运行:22050 Hz 源 → 48000 Hz 设备,拉 6 帧看线性插值 ----
if ((import.meta as { main?: boolean }).main) {
const ramp = Float32Array.from({ length: 8 }, (_, i) => i);
const sb = new StreamBuffer(22050, 48000);
sb.push(ramp);
console.log('ratio', sb.ratio.toFixed(6), 'buffered', sb.bufferedSourceFrames);
const r = sb.pull(6);
console.log('out', Array.from(r.out).map((v) => v.toFixed(4)).join(', '), 'underrun', r.underrun);
const r2 = sb.pull(6);
console.log('out2', Array.from(r2.out).map((v) => v.toFixed(4)).join(', '), 'underrun', r2.underrun);
}
本机实跑(node src/subsys/audio/stream-decoder.ts):
ratio 0.459375 buffered 8
out 0.0000, 0.4594, 0.9187, 1.3781, 1.8375, 2.2969 underrun false
out2 2.7562, 3.2156, 3.6750, 4.1344, 4.5938, 5.0531 underrun false
输入是 0..7 的斜坡,比例 22050/48000 = 0.459375:每输出一帧读取游标前进约 0.46 个源帧,于是输出是源帧之间的插值——第一段落在 0 与 0.46 之间,第二段接着 2.76 往后,两段首尾相接、相位连续(out 末位 2.2969 之后 out2 从 2.7562 继续),这正是「分数游标跨 push 边界不丢相位」的验证。vitest 里另有 7 条断言覆盖 1:1 直通、ratio=0.5 时落在 0/0.5/1/1.5、欠载补 0 并置 underrun、bufferedSourceFrames 随消费下降。
小结
音效池和流式缓冲是音频子系统里最「工程」的两块,但它们其实没有一行依赖浏览器:槽位与优先级是数据结构,包络与重采样是纯算术。把它们抽成 L1 意味着可以在 CI 里对「抢占选谁」「淡出是否归零」「重采样相位是否连续」逐条断言,而不用去听。
下一节转向这套代码最终要落的那个平台——Web Audio 的自动播放策略、resume 解锁、时间精度与延迟,这些只能在浏览器里手动验证,我们会把其中能算的部分单独抽出来真跑。
阅读导航:上一节:5.1 混音图与音频总线 · 下一节:5.3 Web Audio 的限制与解锁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。