技术博客

AudioStreamPlayer 的播放调度与 pitch:变调不变速的实现

第 127 / 149 篇 · 2026-09-27 · godot · 音频系统播放调度重采样

AudioStreamPlayer.play() 是同步返回的,但声音真正从扬声器出来要等 5–20ms——这个延迟的来源不是 Godot,是操作系统的音频缓冲模型。而 pitch_scale = 1.5 会让音高和播放时长一起变化,很多第一次用的人以为这是 bug。这篇讲这两个"出乎意料"背后的机制,源码在 scene/audio/audio_stream_player.cpp 和 servers/audio_server.cpp。

播放调度:三层缓冲的接力

从 play() 到听见声音,数据要穿过三层缓冲:

  1. 引擎内部 mix buffer(audio/driver/mix_rate 决定采样率,buffer 长度由驱动协商,典型 512–2048 帧);
  2. 操作系统音频会话缓冲(CoreAudio/WASAPI 自己再缓冲一段,通常 2–10ms);
  3. 设备硬件 FIFO(声卡自身的输出队列,不可控)。

play() 做的事只是:把 active 标记置真,playback 对象注册进 AudioServer 的激活列表,下次 mix step 时开始产数据。所以从调用到出声的最短延迟是 一个 mix buffer + OS 缓冲 ≈ 10–25ms。这不是"Godot 有延迟",任何软件音频栈都如此——纯硬件直通(ASIO 低延迟模式)能压到几毫秒,但那是 DAW 的活,游戏一般不折腾。

seek 与 start offset 的边界在 _audio_stream_player.cpp 里体现为:play(from_position) 的语义是"从流的第 N 秒开始播"。对流式 OGG 这意味着要先执行 seek(八-2 说的 keyframe 定位 + 缓冲重灌),所以 play(0.5) 在流式源上有几百毫秒的建立延迟,而 WAV 是即时的。交互式音效(按键反馈音)如果要可预测的触发延迟,用短 WAV,别用 OGG。

pitch_scale:重采样相位步长

变调的原理一句话:以不同速度读采样。playback 内部维护一个浮点读指针 $p$,每产出一帧,$p$ 前进 pitch_scale 个输入帧:

$$ p_{t+1} = p_t + s,\qquad \text{out}[t] = \text{src}[\lfloor p_t \rfloor] \text{ 经插值} $$

$s = 1.5$ 时读指针跑得比产出快 50%,每秒产出 48000 帧需要读 72000 个输入帧——3 秒的音 2 秒就播完了,音高也升了纯五度。变调不变速在信号处理上是不适定问题:要变速不变调(time-stretch)需要相位声码器那类分析-合成算法,计算量是实时重采样的几十倍,所以游戏引擎的 pitch_scale 语义就是"两者一起变"。

真要在游戏里变速不变调(比如慢动作时 BGM 保持音高),方案是把 time-stretch 离线做进素材:用 Rubber Band 之类的库预渲染多个速度版本,运行时按速度档位切流。或者接受"降速 = 低沉"的听感——很多游戏(慢动作镜头)反而利用这一点做情绪渲染。

play() 到出声:三层缓冲接力;pitch 改的是读指针步长 play() 调用 同步返回,仅置 active 引擎 mix buffer(512–2048 帧) 下一个 mix step 开始产数据 OS 会话缓冲 + 硬件 FIFO 再加 5–15ms,不可控 重采样读指针(pitch 的实现) p(t+1) = p(t) + s;out[t] = src[floor(p)] 线性插值 s = 1.5 → 每秒读 72k 输入帧产 48k 输出帧:音高 +50%,时长 ×(1/1.5) s = 0.5 → 音高 −50%,时长 ×2(慢动作低沉的来源) 变速不变调 = 相位声码器,离线预渲染多速度版本更实际 交互音效要可预测延迟 → 短 WAV;OGG 流式首包有几百 ms 建立时间

play 的状态机:和直觉不同的一步

AudioStreamPlayer 的播放状态不是简单的 bool。内部状态机有三个状态:STOPPED(无 playback 对象)、PLAYING(active,有 playback)、以及一个容易被忽略的FADING(stop() 后 fade out 过渡中)。4.x 的 stop() 立即终止不淡出——想要淡出必须自己用 tween 调 volume_db 再 stop,或者挂 AudioEffect(比如让 tail bus 的混响自然衰减)。

另一个反直觉点:play() 在已 PLAYING 状态下调用是从头重播,不会叠加第二个声音。要叠加(比如连续 footsteps)得 duplicate() 节点或自己管理 player 池——这就是八-14 会展开的对象池问题,高频音效用 instantiate 一个 player 场景比运行时 new AudioStreamPlayer 快,因为 add_child 的场景就绪路径已经预热。

采样率匹配:mix_rate 与你的素材

AudioServer.get_mix_rate() 默认跟随输出设备(通常 48kHz),而你的 WAV 素材可能是 44.1kHz。引擎对所有非匹配采样率的流统一走重采样路径——这正是 pitch 机制那条路,$s = f_{\text{src}} / f_{\text{mix}}$。所以 44.1k 素材在 48k 混音器里一直微跑着 $s \approx 0.91875$,质量没问题,但意味着每条流都背着插值成本。素材量大时,统一在导入期转成混音采样率(WAV 导入设置里有 resample 选项)能省一点 CPU,虽然实测 saving 通常在 1–2% 量级,不算大钱。

本文基于 Godot 4.2–4.4,源码 scene/audio/audio_stream_player.cpp、servers/audio_server.cpp;变调不变调的工程方案(Rubber Band 离线渲染)为通用音频实践,非 Godot 特有。