《游戏引擎实现原理》5.2 音效池、BGM 与流式

混音图解决了「怎么求和」,这一节解决「同时能响几个」。用固定容量 voice 池做音效管理,配优先级抢占与线性淡入淡出包络避免爆音;BGM 则走流式:解码器按源采样率吐 PCM 分块,混音器按设备采样率拉帧,中间的线性插值重采样与欠载检测都抽成纯 TS 真跑并配 vitest。

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 的限制与解锁 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径