大多数游戏音频是"播放一段录好的声音",但有些场景需要运行时合成:引擎音效音高随机化、按 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),它吸收一次帧抖动绰绰有余。
从正弦到有用的声音
单频正弦只是验证管线。实用一点的套路是波表查表(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 接口语义未变。