语音已经切换,旧环境音为什么还能覆盖播放缓冲
角色语音开始播放后,后台解码的环境音才完成。如果完成回调直接把 PCM 数组写进共享播放缓冲,玩家会听见一次很短的旧声音回跳。另一个看似相反的问题也来自同一个边界:语音块只解出四分之三,系统为了避免欠载把现有缓冲提前换掉,结果剩余四分之一不是旧数据就是静音。
这两种破音都不能交给音频线程“尽量处理”。回调通常只知道下一块样本在哪里,不应该在实时路径里判断哪个异步任务仍代表当前播放意图。流身份、请求修订和候选块完整性应在提交点一次裁决;音频线程只取得一份已经成立的活动快照。

解码完成只是一份候选结果
最小探针把一次解码请求表示为 DecodeRequest。StreamId 区分环境音与语音,Revision 区分先后两次播放意图,BlockIndex 标明流内位置。候选 AudioBlock 必须带回这三个字段,而不是在完成时重新读取“当前流”。后者会把旧任务伪装成新任务。
AudioStreamCommitter 是唯一能替换活动块的对象。它先比较请求,再核对候选身份、通道数、帧数与样本有效性;任一条件失败都直接返回,Active 保持不变。数组在提交时复制,防止解码器复用候选存储后继续改写音频线程正在读取的数据。
enum CommitResult
{
Applied,
StaleRequest,
WrongStream,
WrongChannelCount,
IncompleteBlock,
NonFiniteSample
}
readonly record struct DecodeRequest(int StreamId, int Revision, int BlockIndex);
sealed record AudioBlock(
int StreamId,
int Revision,
int BlockIndex,
int Channels,
int Frames,
float[] Samples);
sealed class AudioStreamCommitter
{
private readonly int channels;
private readonly int framesPerBlock;
private DecodeRequest currentRequest;
public AudioStreamCommitter(int channels, int framesPerBlock)
{
if (channels <= 0 || framesPerBlock <= 0)
throw new ArgumentOutOfRangeException(nameof(channels));
this.channels = channels;
this.framesPerBlock = framesPerBlock;
Active = new AudioBlock(
0, 0, -1, channels, framesPerBlock,
new float[channels * framesPerBlock]);
}
public AudioBlock Active { get; private set; }
public DecodeRequest Request(int streamId, int blockIndex)
{
currentRequest = new DecodeRequest(
streamId, currentRequest.Revision + 1, blockIndex);
return currentRequest;
}
public CommitResult Complete(
DecodeRequest request,
AudioBlock candidate)
{
if (request != currentRequest)
return CommitResult.StaleRequest;
if (candidate.StreamId != request.StreamId ||
candidate.Revision != request.Revision ||
candidate.BlockIndex != request.BlockIndex)
return CommitResult.WrongStream;
if (candidate.Channels != channels)
return CommitResult.WrongChannelCount;
if (candidate.Frames != framesPerBlock ||
candidate.Samples.Length != channels * framesPerBlock)
return CommitResult.IncompleteBlock;
if (candidate.Samples.Any(sample => !float.IsFinite(sample)))
return CommitResult.NonFiniteSample;
Active = candidate with
{
Samples = (float[])candidate.Samples.Clone()
};
return CommitResult.Applied;
}
}
实现位于 $LAB_DIR/Program.cs 的 AudioStreamCommitter。这里的“完整”不是解码器返回成功,而是候选恰好包含配置要求的双声道四帧,样本数量等于 channels * framesPerBlock,且每个样本都是有限值。真实项目会使用更大的块,但判断单位不变。
旧任务与短块应在同一道门外停止
固定序列先请求环境音块,再请求语音块。环境音虽然完整,却携带修订 1;当前请求已经是语音修订 2,所以它得到 StaleRequest。随后语音只提供 3/4 帧,身份正确仍得到 IncompleteBlock。只有 4/4 帧候选能够成为活动块。

对应回归没有只检查返回枚举,还检查失败前后的活动身份。OldDecodeCannotReplaceCurrentStream 证明旧完成不能覆盖当前流;ShortBlockKeepsActiveSnapshot 证明短块不能推进块索引;CommittedSamplesAreIsolated 在提交后修改候选数组,活动首样本仍保持 0.5。
这些拒绝原因不应合并成一个 Failed。StaleRequest 表示调度延迟已经跨过播放意图,持续增长时应检查取消传播与解码队列积压;IncompleteBlock 表示当前任务仍有效,但生产者没有交付约定的播放单位,应检查读取尾部、解码器输出或块组装。两者都保持活动快照,却对应不同的运维动作。提交门既是正确性边界,也是最接近根因的观测位置。
cd "$LAB_DIR"
dotnet restore AudioStreamCommitLab.csproj
dotnet build AudioStreamCommitLab.csproj -c Release --no-restore
dotnet run --project AudioStreamCommitLab.csproj -c Release --no-build
决定性输出如下:
Build succeeded.
0 Warning(s)
0 Error(s)
late_decode stream=Ambience revision=1 result=StaleRequest active_stream=20
short_voice frames=3/4 result=IncompleteBlock active_stream=20
full_voice frames=4/4 result=Applied active_stream=20 first_sample=0.75
tests pass=8 fail=0 skip=0 duration_ms=63.25
恢复、构建与运行退出码均为 0,墙钟耗时分别为 1.36、7.28 和 1.94 秒。耗时只说明本次探针确实执行,不代表音频回调延迟、解码吞吐或设备性能。
活动快照还不是完整的实时音频方案
探针验证的是提交语义,没有模拟真实设备回调、无锁队列、重采样、跨淡化和环形缓冲水位。生产实现不能让音频回调取得锁,也不能在回调中复制大数组。更合适的接入方式是:工作线程完成候选块,控制线程执行相同校验并把所有权移入预分配队列,音频线程在每个块开始时只读取一次稳定引用。
若产品允许短尾块,它必须带有明确的有效帧数和终止语义,并在剩余区域写入确定的静音;不能把任意欠载都解释成合法尾块。若两个流需要平滑切换,跨淡化参数、两侧块身份与生效采样位置应组成更大的提交包。那会扩大活动快照,却不应放松请求代次与完整性检查。
环形队列也不能只用数组槽位代表音频块。槽位会被复用,真正可提交的身份至少还包含流、请求修订和块索引;若允许多个块并行解码,还要明确按序提交还是允许有界重排。监控中若旧请求拒绝率升高,应先缩短无效工作生命周期;若完整块长期赶不上消费水位,才需要调整块大小、预取深度或解码容量。把两类信号混在一次“缓冲不足”统计里,会让容量问题和身份问题互相掩盖。
开头的旧环境音与短语音块因此不需要在实时线程里补救。前者失去当前请求身份,后者没有形成完整播放单位,两者都停在提交门外;只有当前、完整且样本有效的语音块才能替换活动快照。这个边界先消除了异步结果污染,后续无锁化和缓冲水位设计才有一份稳定状态可以优化。