热更音乐、按章节下载语音包、玩家自定义音效——这些需求都会撞上同一个操作:播放中替换 AudioStreamPlayer.stream。直觉上"换个流而已",实际行为是播放直接停掉,而且背后的原因牵扯到服务器侧 playback 句柄的归属问题。这篇把这个坑拆开,给三个层次的解法。
坑的本体:playback 句柄不属于你
AudioStreamPlayer.play() 之后,真正播放的是 AudioServer 里的一个 playback 实例(一-6 讲过 RID 句柄那套):混音器按句柄找 playback,playback 持有一个 AudioStream 指针,每周期从流里拉数据。AudioStreamPlayer 只是这个句柄的遥控器——它记录自己 active 的 playback RID,把 play/stop/seek 翻译成对句柄的命令(八-10 的命令队列)。
这个结构决定了换 stream 的语义:新 stream 和旧 playback 没有任何关系。player.stream = new_stream 只是把遥控器指向了一个新的"潜在播放对象",服务器里那个还在响的旧 playback 处于 orphan 状态,引擎的处理是把它停掉回收——于是你听到的就是声音戛然而止。同理 stop() 后再换流再 play(),等于把整条播放链(句柄分配、缓冲初始化、解码线程启动)重建一遍,OGG 还要从头解。
方案一:记录位置换流(够用就好)
业务上最常见的需求是"切歌但别跳"——BGM 换一首当然允许从头播,真正要无缝的是同一首的内部状态(比如换音质档、热补丁了一个 stem)。这时把位置和播放状态存下来就行:
func swap_stream(player: AudioStreamPlayer, new_stream: AudioStream) -> void:
var pos := player.get_playback_position()
var was_playing := player.playing
player.stop()
player.stream = new_stream
if was_playing:
player.play(pos) # 同一流 seek 便宜;跨流 seek 会触发解码定位(八-9)
成本是 play(pos) 的 seek 延迟(流式格式从头解到 pos,1:30 处可能要几十毫秒,做 UI 要给加载反馈)和一次可闻性存疑的重启——ogg 流式重新起播时缓冲要重新灌满,buffer_size 调小了会有短暂气口。
方案二:双 player 交叉(真无缝)
要绝对无缝(游戏里"音乐随剧情突变"的演出时刻),用两个 player 接力:
func crossfade_to(new_stream: AudioStream, duration := 1.0) -> void:
var old := active
standby.stream = new_stream
standby.volume_db = -60.0
standby.play() # 从 0 起播
var tw := create_tween()
tw.set_parallel()
tw.tween_property(standby, "volume_db", 0.0, duration)
tw.tween_property(old, "volume_db", -60.0, duration)
tw.chain().tween_callback(old.stop)
active, standby = standby, old
A 淡出的同时 B 淡入(八-11 的等功率思路:两条 tween 并行,dB 域近似等功率),旧流在淡出末尾才 stop。这个模式同时解决了"新流起始延迟"——B 在 A 还在响的时候就开始解码灌缓冲了,等 A 淡出完 B 已经稳定输出。
方案三:运行时构造流(热更的正路)
如果"热加载"指的是更新包里的新音频而不是换状态,路线完全不同:4.x 的 AudioStreamOggVorbis / AudioStreamMP3 提供 load_from_buffer(PackedByteArray) 静态方法,可以从任意字节构造流——配合 FileAccess 读下载目录或热更 PCK 外的文件(六-9 讲过 PCK 替换的边界),新音频完全不进导入管线:
var bytes := FileAccess.get_file_as_bytes("user://dlc/bgm_ch2.ogg")
var stream := AudioStreamOggVorbis.load_from_buffer(bytes)
注意 load_from_buffer 的流没有导入时烘焙的循环点元数据——导入管线设置的 loop begin/end 存在 .godot/imported 的 remap 里,手工构造的流只有代码里显式赋的 loop 布尔量,采样级循环点要自己设 loop_begin/loop_end 帧号。这是热更音频最容易漏的细节:编辑器里循环得好好的热更包版本,接缝多出一截。
排错速查
换流后没声音,按这个顺序查:一,load_from_buffer 的字节是不是完整文件(下载半截的 OGG 会构造失败返回 null);二,新流的 loop 和循环点;三,play(pos) 的 pos 是否超过了新流总长(行为是实现定义,别依赖);四,如果是 4.2 之前的版本,AudioStreamMP3.load_from_buffer 不存在(4.0 只有 OGG 有这条路)。最后记住八-10 的结论:这些操作全是命令化路径,频繁换流(比如逐帧换)不会有锁问题,但命令队列有自身延迟,别指望它帧级精确。
本文基于 Godot 4.2–4.4;load_from_buffer 系列的可用性按版本有差异,以所用版本的文档为准。