音频 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);
}

Voice 抢占证据图

正常时序

存在空槽时,请求直接进入活动集并返回 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 错误,而是必须确定化的资源仲裁。统一的排序合同让听感取舍、回放复现和故障证据使用同一套事实,而不是把过载结果交给偶然遍历顺序。