音频 Voice 预算:确定性抢占必须先排序牺牲者
战斗、环境、脚步和 UI 音效会在同一帧竞争有限 Voice。容量满时若依赖容器遍历顺序抢占,回放相同输入仍可能听到不同结果;若一律拒绝新声音,首领技能又会被远处环境声遮蔽。2026-07-31-1930 探针把容量和排序规则收进一个状态所有者,过载只能产生唯一决定。
// .tmp/daily-labs/2026-07-31-1930/Program.cs
readonly record struct VoiceRequest(
long Sequence,
string Id,
int Priority,
float Distance);
readonly record struct VoiceDecision(
string Action,
string Voice,
string? Victim);

所有权
VoiceBudget 独占活动 Voice 集合与容量,音频触发源只能提交 VoiceRequest,播放层只执行 VoiceDecision。不变量有三项:活动数量不得超过容量;相同 ID 的重复请求不得占用第二个槽;相同输入序列必须选出相同牺牲者。容量在构造时拒绝零和负值,避免把不可播放配置推迟到战斗帧才暴露。
这里的 ID 表示一次逻辑发声,而不是音频资源名。同一资源可由多个实体同时播放,因此调用者应组合实体与发声序号;反之,一个逻辑发声重投时必须保持 ID,才能命中 keep 而不是重启采样。
// Program.cs: VoiceBudget
sealed class VoiceBudget
{
private readonly int capacity;
private readonly List<VoiceRequest> active = [];
public VoiceBudget(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
this.capacity = capacity;
}
public string[] ActiveIds => active
.OrderBy(v => v.Id)
.Select(v => v.Id)
.ToArray();
}
抢占排序
牺牲者排序为最低优先级、同优先级最远距离、再次相同则最早序号。新请求只有严格胜过牺牲者才可抢占;同等级且不更近的请求被拒绝,避免 Voice 在相近声源之间抖动。序号不是时间测量,而是调用者提供的单调提交顺序,因此结果不依赖浮动时钟。
// Program.cs: VoiceBudget.Request / Compare
var victim = active
.OrderBy(v => v.Priority)
.ThenByDescending(v => v.Distance)
.ThenBy(v => v.Sequence)
.First();
if (Compare(incoming, victim) <= 0)
return new("reject", incoming.Id, null);
active.Remove(victim);
active.Add(incoming);
return new("steal", incoming.Id, victim.Id);
private static int Compare(VoiceRequest a, VoiceRequest b)
{
var priority = a.Priority.CompareTo(b.Priority);
if (priority != 0) return priority;
var distance = b.Distance.CompareTo(a.Distance);
if (distance != 0) return distance;
return b.Sequence.CompareTo(a.Sequence);
}

正常时序
存在空槽时,请求直接进入活动集并返回 start。固定容量为 3,环境风声、脚步和 UI 先占满槽位;优先级 9 的首领请求到达后,优先级 1 且距离 20 的风声成为牺牲者,最终活动集为 boss、step、ui。播放层无需重新比较,也不能擅自更改决定。
// Program.cs: VoiceBudget.Request 空槽与重复路径
public VoiceDecision Request(VoiceRequest incoming)
{
if (active.Any(v => v.Id == incoming.Id))
return new("keep", incoming.Id, null);
if (active.Count < capacity)
{
active.Add(incoming);
return new("start", incoming.Id, null);
}
var victim = active.OrderBy(v => v.Priority)
.ThenByDescending(v => v.Distance)
.ThenBy(v => v.Sequence).First();
if (Compare(incoming, victim) <= 0)
return new("reject", incoming.Id, null);
active.Remove(victim);
active.Add(incoming);
return new("steal", incoming.Id, victim.Id);
}
失败路径
低优先级环境声在容量满时返回 reject,活动集不变;同 ID 重投返回 keep,也不重启采样位置。看似合理的随机抢占能分散听感,却破坏回放定位和自动测试;只按距离又会让近处低价值粒子音效压过关键 UI。优先级承担产品语义,距离只作为同级退化规则。
拒绝不是异常,它是容量合同允许的退化结果;真正的失败是播放层执行了不同牺牲者,或被抢占 Voice 未释放底层通道。为此决定同时返回新 Voice 与 Victim,使执行层无需再次扫描活动集合。
// Program.cs: Probe.Main 回归
Check("lower_priority_is_rejected", () =>
{
var b = new VoiceBudget(1);
b.Request(new(1, "ui", 9, 0));
if (b.Request(new(2, "amb", 1, 3)).Action != "reject")
throw new Exception();
});
Check("higher_priority_steals_lowest", () =>
{
var b = new VoiceBudget(2);
b.Request(new(1, "wind", 1, 20));
b.Request(new(2, "step", 3, 2));
var d = b.Request(new(3, "boss", 9, 5));
if (d.Victim != "wind") throw new Exception("wrong victim");
});
Check("tie_break_is_deterministic", () =>
{
var b = new VoiceBudget(2);
b.Request(new(1, "a", 2, 10));
b.Request(new(2, "b", 2, 10));
if (b.Request(new(3, "c", 3, 1)).Victim != "a")
throw new Exception("unstable victim");
});
执行结果
本次 .NET 9 Release 运行通过四个断言。7.619 ms 包含断言与证据序列化,不是混音线程基准;文章只用它证明容量、拒绝和抢占状态符合固定输入。
cd $WORK_DIR/.tmp/daily-labs/2026-07-31-1930
dotnet run -c Release
# exit code: 0
PASS free_slot_starts_voice
PASS lower_priority_is_rejected
PASS higher_priority_steals_lowest
PASS tie_break_is_deterministic
FIXED capacity=3 action=steal victim=wind active=boss,step,ui
RESULT passed=4 failed=0 skipped=0 elapsed_ms=7.619 exit=0
演进条件
当前每次满载请求都排序活动集,复杂度为 O(n log n),适合几十到数百 Voice。若分析显示每帧请求数与 Voice 上限共同上升,或排序进入音频线程热点,应维护按三元组排序的最小堆;若需要淡出,还应把“逻辑释放槽位”和“物理停止播放”拆成两个时刻。
优先级表应由内容配置拥有,但排序含义必须由运行时代码固定。允许每个音效自定义比较器会让全局容量失去可解释性;需要分组保障时,应先给 UI、对白等类别预留子预算,再在组内复用同一确定性规则。
架构判断是:Voice 不足不是音频 API 错误,而是必须确定化的资源仲裁。统一的排序合同让听感取舍、回放复现和故障证据使用同一套事实,而不是把过载结果交给偶然遍历顺序。