5.1 混音图与音频总线
第 4 章我们把物理接进了主循环,但《微光岛》还是一片死寂。从这一章起补上音频子系统。先划边界:音频引擎不负责「音频怎么好听」——那是内容与混音师的事;它负责把多个音源按增益与路由求和成一路输出,并让这件事每帧都廉价、可复现。
这一节只做混音图:把「音源 → 总线 → 主输出」建模成一张有向图,节点带线性增益,边是数据流。DSP 数学(分贝换算、声像、求和、路由)全部抽成纯 TS 在 Node 里真跑,再用 headless Chromium 的 OfflineAudioContext 复算同一张图,逐样本比对。
本章与站内既有内容的分工
音频原理站内已经写透,本章不重推,只做「在 helio 里从零实现 + 每节跑出真实产物」:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/audio-mixing-mastering/ | 混音/母带的听感与工程流程 | 只做可跑的总线路由与求和 |
/audio-web-audio-api/ | Web Audio 的节点模型与 API 全貌 | 把图落成可渲染的 DSP,不依赖浏览器 API |
/audio-dsp-filters/ | 滤波与频域处理 | 不碰滤波,只做增益、求和、路由 |
为什么音频也要一张图
引擎里的声音需求很朴素:几十个音效、一条 BGM、偶尔的环境音。天真的做法是「每个声音自己乘音量再相加」,但这样两件事会失控——分组控制(把音效全调小、BGM 不变)和复用(一个音源同时进干声与混响两条链)。
混音图用一句话解决:让信号流过节点,节点只做两件事——乘自己的增益、把结果送给下游。于是「音效总线」只是一个增益节点,「静音音效」只是把它设成 0,「音源 fan-out 到干湿两路」只是连两条边。这与卷二渲染图(frame graph)是同一个思路:先把拓扑定下来,再按拓扑序求值。
- 节点:
source携带一个量子长度的 PCM;bus没有自身信号,只累加上游。 - 边:
src → dst,允许 fan-out(一个输出接多个下游)。 - 求值:按拓扑序(生产者先于消费者),每个节点先把收到的输入求和,乘上增益,再推给下游。
可运行产物:一张可渲染的混音图(L1)
下面是完整的 src/subsys/audio/mix-graph.ts。它含分贝换算、等功率声像、软限幅三个纯函数,以及 MixGraph 本身:connect 会拒绝成环,render 按 Kahn 拓扑序累加并逐节点乘增益。
// file: src/subsys/audio/mix-graph.ts
// tier: L1
// verify: npx vitest run src/subsys/audio/mix-graph.test.ts
//
// helio 子系统 5.1:混音图与音频总线。音源 → 总线 → 主输出建模成有向图,
// 节点有线性增益,渲染一个量子时按拓扑序累加、逐节点乘增益。纯 DSP,Node 26 直跑。
/** 分贝 → 线性增益:0 dB = 1.0,-6 dB ≈ 0.5012。 */
export function dbToGain(db: number): number { return 10 ** (db / 20); }
/** 线性增益 → 分贝;0 与负值没有有限分贝。 */
export function gainToDb(gain: number): number {
return gain > 0 ? 20 * Math.log10(gain) : Number.NEGATIVE_INFINITY;
}
/** 等功率声像:pan∈[-1,1] → [左, 右],中心处各为 √2/2,平方和恒为 1。 */
export function equalPowerPan(pan: number): [number, number] {
const a = ((Math.max(-1, Math.min(1, pan)) + 1) / 2) * (Math.PI / 2);
return [Math.cos(a), Math.sin(a)];
}
/** tanh 软限幅:|x|→∞ 时趋近 ±1,小信号近似线性。 */
export function softClip(x: number): number { return Math.tanh(x); }
interface MixNode {
readonly kind: 'source' | 'bus';
gain: number;
muted: boolean;
samples: Float32Array | null; // 仅 source 有:一个量子长度的单声道 PCM
readonly outputs: number[]; // 数据流方向:输出流向这些下游节点(fan-out)
}
/** 一张混音图。节点用整数句柄寻址;source 携带 PCM,bus 只做求和与增益。 */
export class MixGraph {
#nodes: MixNode[] = [];
#master = -1;
/** 建一个总线节点(无自身信号,只累加上游)。 */
createBus(gainDb = 0): number {
this.#nodes.push({ kind: 'bus', gain: dbToGain(gainDb), muted: false, samples: null, outputs: [] });
return this.#nodes.length - 1;
}
/** 建一个音源节点,绑定一个量子长度的单声道 PCM(不足补零)。 */
createSource(samples: Float32Array, gainDb = 0): number {
this.#nodes.push({ kind: 'source', gain: dbToGain(gainDb), muted: false, samples, outputs: [] });
return this.#nodes.length - 1;
}
/** 把 src 的输出接到 dst 的输入;自环或成环抛错。 */
connect(src: number, dst: number): void {
if (src === dst) throw new Error('connect: self-loop');
if (!this.#nodes[src] || !this.#nodes[dst]) throw new Error('connect: bad node');
if (this.#reaches(dst, src)) throw new Error(`connect: would create a cycle ${src}->${dst}`);
if (!this.#nodes[src]!.outputs.includes(dst)) this.#nodes[src]!.outputs.push(dst);
}
setGainDb(id: number, db: number): void { this.#nodes[id]!.gain = dbToGain(db); }
setMuted(id: number, muted: boolean): void { this.#nodes[id]!.muted = muted; }
setMaster(id: number): void { this.#master = id; }
/** 拓扑序:生产者一定排在消费者之前(Kahn)。 */
topologicalOrder(): number[] {
const n = this.#nodes.length;
const indeg = new Int32Array(n);
for (const node of this.#nodes) for (const o of node.outputs) indeg[o]!++;
const queue: number[] = [];
for (let i = 0; i < n; i++) if (indeg[i] === 0) queue.push(i);
const order: number[] = [];
for (let h = 0; h < queue.length; h++) {
const u = queue[h]!;
order.push(u);
for (const v of this.#nodes[u]!.outputs) if (--indeg[v]! === 0) queue.push(v);
}
if (order.length !== n) throw new Error('render: graph has a cycle');
return order;
}
/** 渲染一个量子:返回 master 节点的输出(frames 个采样)。 */
render(frames: number): Float32Array {
if (this.#master < 0) throw new Error('render: no master set');
const n = this.#nodes.length;
const acc: Float32Array[] = Array.from({ length: n }, () => new Float32Array(frames));
const out: Float32Array[] = Array.from({ length: n }, () => new Float32Array(frames));
for (const id of this.topologicalOrder()) {
const node = this.#nodes[id]!;
const a = acc[id]!;
if (node.kind === 'source' && node.samples) {
for (let i = 0; i < frames; i++) a[i] += i < node.samples.length ? node.samples[i]! : 0;
}
const g = node.muted ? 0 : node.gain;
const o = out[id]!;
for (let i = 0; i < frames; i++) o[i] = a[i]! * g;
for (const dst of node.outputs) {
const b = acc[dst]!;
for (let i = 0; i < frames; i++) b[i] += o[i]!;
}
}
return out[this.#master]!;
}
#reaches(from: number, target: number): boolean {
const seen = new Uint8Array(this.#nodes.length);
const stack = [from];
while (stack.length) {
const u = stack.pop()!;
if (u === target) return true;
if (seen[u]) continue;
seen[u] = 1;
for (const v of this.#nodes[u]!.outputs) stack.push(v);
}
return false;
}
}
// ---- 直接运行:两个音源经两条 -6 dB 总线汇入 master(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
const g = new MixGraph();
const master = g.createBus();
const sfx = g.createBus(-6);
const music = g.createBus(-6);
const hit = g.createSource(Float32Array.from([0.5, 0.5]));
const bgm = g.createSource(Float32Array.from([0.25, 0.25]));
g.connect(hit, sfx); g.connect(bgm, music);
g.connect(sfx, master); g.connect(music, master);
g.setMaster(master);
console.log('order', g.topologicalOrder().join(','));
console.log('out', Array.from(g.render(2)).map((v) => v.toFixed(6)).join(', '));
}
在 /tmp/eb-subsys/ch5 里 node src/subsys/audio/mix-graph.ts(本机实测):
order 3,4,1,2,0
out 0.375890, 0.375890
拓扑序把两个音源(3、4)排在最前,两条总线(1、2)居中,master(0)最后。输出 0.375890 正好是 (0.5 + 0.25) × 10^(-6/20) = 0.75 × 0.501187——两个音源各自的 -6 dB 总线增益叠加后求和,与手算一致。vitest 里另有 11 条断言覆盖分贝 roundtrip、等功率声像平方和为 1、静音节点恒为 0、fan-out 求和、成环保护。
可运行产物:在真实 Web Audio 里复算同一张图(L2)
L1 证明了「我们的求和是对的」,但没证明「我们的求和等于浏览器会做的事」。src/subsys/audio/mix-graph-l2.ts(L2)在 headless Chromium 里用 OfflineAudioContext 复刻同一张图——两个 AudioBufferSourceNode 经两个 GainNode 汇入 master——离线渲染后回读 Float32Array,与 L1 逐样本比对:
// file: src/subsys/audio/mix-graph-l2.ts
// tier: L2
// verify: node run-l2-local.mjs src/subsys/audio/mix-graph-l2.ts
//
// 5.1 的 L2 验收:把与 L1 完全相同的「两音源 → 两总线 → master」图,
// 在真实 Web Audio 里用 OfflineAudioContext 离线渲染一遍,再回读 Float32
// 采样,与 L1 MixGraph.render() 的输出逐样本比对。需 headless Chromium。
import { MixGraph, dbToGain } from './mix-graph.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }
const FRAMES = 128;
const RATE = 48000;
/** 在 OfflineAudioContext 里复刻 L1 的混音图并离线渲染,返回单声道采样。 */
async function renderWebAudio(): Promise<Float32Array> {
const ctx = new OfflineAudioContext(1, FRAMES, RATE);
const master = ctx.createGain();
const sfx = ctx.createGain();
const music = ctx.createGain();
master.gain.value = dbToGain(0);
sfx.gain.value = dbToGain(-6);
music.gain.value = dbToGain(-6);
const mk = (level: number): AudioBufferSourceNode => {
const buf = ctx.createBuffer(1, FRAMES, RATE);
const ch = buf.getChannelData(0);
for (let i = 0; i < FRAMES; i++) ch[i] = level;
const src = ctx.createBufferSource();
src.buffer = buf;
return src;
};
const hit = mk(0.5);
const bgm = mk(0.25);
hit.connect(sfx); bgm.connect(music);
sfx.connect(master); music.connect(master);
master.connect(ctx.destination);
hit.start(); bgm.start();
const rendered = await ctx.startRendering();
return rendered.getChannelData(0).slice();
}
/** L1 侧的同一张图:常量音源经 -6 dB 总线汇入 master。 */
function renderL1(): Float32Array {
const g = new MixGraph();
const master = g.createBus();
const sfx = g.createBus(-6);
const music = g.createBus(-6);
const a = g.createSource(Float32Array.from({ length: FRAMES }, () => 0.5));
const b = g.createSource(Float32Array.from({ length: FRAMES }, () => 0.25));
g.connect(a, sfx); g.connect(b, music);
g.connect(sfx, master); g.connect(music, master);
g.setMaster(master);
return g.render(FRAMES);
}
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
const push = (name: string, ok: boolean, detail: unknown): void => {
checks.push({ name, ok, detail: String(detail) });
};
const wa = await renderWebAudio();
const l1 = renderL1();
const expect = (0.5 + 0.25) * dbToGain(-6);
push('OfflineAudioContext 可用', typeof OfflineAudioContext === 'function', 'ctor ok');
push('Web Audio 输出非静音', wa.some((v) => v !== 0), `first=${wa[0]!.toFixed(6)}`);
push('Web Audio 首样本等于解析值', Math.abs(wa[0]! - expect) <= 1e-6,
`wa=${wa[0]!.toFixed(8)} expect=${expect.toFixed(8)}`);
let maxErr = 0;
for (let i = 0; i < FRAMES; i++) maxErr = Math.max(maxErr, Math.abs(wa[i]! - l1[i]!));
push('Web Audio 与 L1 逐样本一致', maxErr <= 1e-6, `maxErr=${maxErr.toExponential(2)}`);
return { checks, data: { frames: FRAMES, rate: RATE, expect, webAudio0: wa[0], l1_0: l1[0], maxErr } };
}
本机实跑(headless Chromium,node run-l2-local.mjs src/subsys/audio/mix-graph-l2.ts):
PASS OfflineAudioContext 可用 ctor ok
PASS Web Audio 输出非静音 first=0.375890
PASS Web Audio 首样本等于解析值 wa=0.37589040 expect=0.37589043
PASS Web Audio 与 L1 逐样本一致 maxErr=0.00e+0
注意 maxErr = 0.00e+0:Web Audio 的增益求和与我们的 L1 实现逐样本完全相同(两者都在 f32 上运算,量化误差没有出现)。这说明混音图的数学层可以脱离浏览器开发与测试,浏览器只是它的一个后端。这条 L2 用 OfflineAudioContext 离线渲染,不需要真实音频设备,所以在无头环境里可复现。
小结
混音图的价值不在 DSP,而在结构:把「怎么分组、怎么复用」变成一张图的形状,把「音量」变成节点上的一个标量。求值是纯数据的拓扑遍历,与卷二渲染图同构。
但 render 里每个 source 只绑了一个量子的 PCM,而且我们还没管「同时能响几个声音、谁该被抢」。下一节处理 voice 池、优先级抢占与流式 BGM。
阅读导航:上一节:回到目录 · 下一节:5.2 音效池、BGM 与流式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。