技术博客

AudioStreamGenerator 实时合成:在 GDScript 里手搓波形

第 130 / 149 篇 · 2025-05-08 · godot · 音频系统实时合成实战

大多数游戏音频是"播放一段录好的声音",但有些场景需要运行时合成:引擎音效音高随机化、按 UI 参数实时生成的提示音、给音频玩法(比如用麦克风频率驱动粒子)做信号源。AudioStreamGenerator 就是为这个准备的——它不读文件,而是让脚本往一个环形缓冲里推采样帧,混音器到点就拿去混。这篇讲它的工作原理和一个能跑的正弦合成器。

推模型:脚本当解码器

普通 AudioStream 的模型是拉:混音器问"下一帧数据呢",流式解码器(八-9 会展开)从文件里解出来给它。AudioStreamGenerator 反过来了,是推:混音器照常每 10.7ms(512 帧 @ 48kHz)来取一次数据,但它的缓冲里有什么完全取决于你 push_frame() 推了什么。推不够,输出就是数字零——没有爆音,是一段静音,这比解码器 underrun 友好得多。

var playback: AudioStreamGeneratorPlayback

func _ready():
    var gen := AudioStreamGenerator.new()
    gen.mix_rate = 48000
    gen.buffer_length = 0.5          # 环形缓冲容量(秒)
    $Player.stream = gen
    $Player.play()
    playback = $Player.get_stream_playback()

关键约束:AudioStreamGeneratorPlayback 只能在 play() 之后拿到,因为它绑定了混音器分配的 playback 实例。mix_rate 决定了推进来的采样帧被解释的频率——和混音器的 mix rate 不同时内部会 resample,推之前想清楚自己按哪个时钟生成。

环形缓冲的三个水位

AudioStreamGenerator 内部就是一段长度为 buffer_length × mix_rate 的 AudioFrame 环形数组,写指针在脚本侧(push_frame),读指针在音频线程侧(混音器消耗)。Playback 暴露了三个查询:

  • get_frames_available():缓冲里还能推多少帧,这是推之前必须查的;
  • can_push_frame():等价于 available > 0;
  • get_skips():脚本推得太满被丢弃的帧数——诊断溢出用。

推循环的标准写法放在 _process 里,每帧把缓冲填到接近满:

const FREQ := 440.0
var phase := 0.0

func _process(_dt):
    var to_push := playback.get_frames_available()
    var step := TAU * FREQ / 48000.0      # 每帧相位增量
    for i in to_push:
        playback.push_frame(Vector2(sin(phase), sin(phase)))  # 左右同相 = 单声道居中
        phase = fmod(phase + step, TAU)

数学上这是相位累加器(phase accumulator):不是每次算 sin(2πft)——那样 t 大了会丢精度而且每次一次乘法变一次 sin——而是维护相位 $\varphi$,每帧 $\varphi \gets (\varphi + \Delta\varphi) \bmod 2\pi$,其中 $\Delta\varphi = 2\pi f / f_s$。代价是频率分辨率受浮点尾数限制:$\Delta\varphi$ 的量化误差累积成频率偏移 $\Delta f \approx f_s \cdot \epsilon / (2\pi)$,$\epsilon$ 是 double 的步长(64 位下 ~1e-16),换算出来几亿年才漂 1 Hz,不用管;但如果你用 float 存相位,$2^{24}$ 帧(0.35 秒 @ 48k)就到整数精度边界了——所以 GDScript 的 float(64 位)反而是对的,别为了"性能"转 32 位。

缓冲深度 = 延迟

buffer_length 是容量,但你持续填充的深度决定了这条音轨的固有延迟:混音器消费的是读指针处最早推入的帧,所以声音从你生成到被听到,隔了

$$\tau = \frac{N_{\text{filled}}}{f_s}$$

$N_{\text{filled}}$ 是你维持在缓冲里的帧数。上面的写法每帧把 frames_available 全部填满,等于把延迟顶到 buffer_length(0.5 秒)——做实时交互音源(比如跟手按的键盘发声)这是灾难。正确的做法是保持浅水位:每帧只推 _process 流逝的时间对应的帧数(dt × mix_rate,还要补上 Engine.get_frames_per_second 和音频时钟的漂移),让混音器始终在"追"你的写指针。水位太浅的代价是 _process 卡顿一帧(GC、物理 spike)就直接 underrun 出静音。经验值:保持 1–2 个 mix buffer 的深度(10–25ms),它吸收一次帧抖动绰绰有余。

AudioStreamGenerator 的环形缓冲:脚本推,混音器拉 已消费 旧数据,可覆写 缓冲中的帧 深度决定延迟 空格,安全区 push_frame()(游戏线程,查 available 再推) 混音器按 mix buffer 周期拉取(音频线程) 水位策略 实时交互音源:每帧只推 dt × 48k 帧,保持 10–25ms 浅水 长音垫 / 预渲染:一次填满,延迟 = buffer_length,换取绝不 underrun

从正弦到有用的声音

单频正弦只是验证管线。实用一点的套路是波表查表(wavetable):预生成一个周期的波形数组(锯齿、方波、任意采样的音色),播放时相位直接作为数组下标,一次浮点乘加代替一次 sin 调用。GDScript 里 sin 本身不慢,真正拖后腿的是每帧几百次 push_frame 的调用开销——把 512 帧的生成打包成一个内层 for(如上),不要一帧调一次 _process 推一帧。

两个常见的坑收尾:一是左右声道相位,Vector2(sin, sin) 是同相声源居中的正确写法,写反成 Vector2(sin, -sin) 会得到反相双声道——单听没问题,叠到 mono 上直接抵消没了;二是 push_frame(Vector2.ZERO) 显式推静音和"不推"不同——推零是干净的无声,不推是 get_skips 计数上涨,排查"为什么我的合成器会顿一下"时先看这两个指标。

本文基于 Godot 4.2–4.4;AudioStreamGenerator 自 3.x 就存在,4.x 的 push/available 接口语义未变。