UI SFX播放器引擎深潜:8声音高、语义冷却与循环迁移的实现细节
UI SFX播放器引擎深潜:8声音高、语义冷却与循环迁移的实现细节
UI SFX(UI Sound Effects)是一个开源的 Web 界面音效库:78 个语义声音 × 12 个声音包,声音全部由确定性配方在本地渲染,无需加载任何音频文件。这篇文章深入 uisfx 包的播放器引擎 player.ts,讲解 8 声音高、语义冷却、循环迁移 这三个最实用的设计,看看它如何让"界面声音"不变成"界面噪音"。
为什么界面音效播放器需要一套"播放策略"
如果你直接在点击回调里 new Audio().play(),就会遇到这些典型问题:用户连点时声音越叠越响、loading 循环音停不下来、鼠标扫过的 hover 音效高频到令人烦躁。
UI SFX 的解法不是多写音效,而是把一组播放策略做进引擎:
- 声音高(Voice Ceiling):同时最多允许多少个声音叠加,默认 8 个
- 语义冷却(Semantic Cooldown):同一个声音至少间隔多久才能再次播放
- 重触发策略(Retrigger):同一声音再次触发时,是重启、叠加还是忽略
这三套机制的源码集中在 player.ts,并由 player.test.ts 逐条固定。下面逐一拆解。
8 声音高:防止"声音爆炸"的断路器
maxVoices 选项会被夹在 1~32 之间,默认 8(见 player.ts#L70-L74)。当新声音到来时,引擎先数一数当前活跃的音源数量;达到上限就执行三步:
- 找到最老的非循环声音(
loading这类循环音会被优先保护); - 停掉它,音量在 15ms 内线性淡出到零;
- 把新声音精确安排在上一个声音淡出结束的时刻(0.018 秒后)开始播放。
核心逻辑在 player.ts#L218-L228。它的意义是:无论用户多快地点按钮,空气中最多只有 8 个声音叠加,且新旧切换没有"截断爆音"。测试 player.test.ts#L223-L233 连续触发 9 个声音,验证第一个声音恰好在 0.018 秒处停止、第九个声音在同一时刻接上。
语义冷却:高频声音的 60~80ms 限流器
悬停、聚焦、音量轮这些声音每秒可能触发几十次,全播出来用户会想直接静音。所以引擎内置了一张语义冷却表(player.ts#L80-L85):
| 声音(cue) | 默认冷却 |
|---|---|
hover |
60 ms |
focus |
80 ms |
progress-step |
80 ms |
volume-change |
60 ms |
规则写在 play 方法里(player.ts#L262-L268):如果同一声音在冷却阈值内已经启动过,就不再开新的,直接返回正在播放的句柄。效果是鼠标快速扫过一排按钮时只播第一声,耳朵听起来是"自然"的。
注意 typing 不在这张表里——逐键反馈音必须每次击键都播,这一行为由测试 player.test.ts#L212-L221 固定下来。
冷却值还支持两级覆盖:
ui.play('hover', { cooldownMs: 0 }) // 单次播放覆盖
const ui = createUISFX({ cooldownMs: 120 }) // 全局默认值
所有值会被统一夹在 0~10,000ms 之间(player.ts#L89-L93)。
循环迁移:循环播放中无缝切换声音包
音效库最"坑"的场景:recording 循环音正在播,用户把声音包从 minimal 切到 scifi。朴素做法"停旧循环、播新循环"会留下一个无声间隙,而且你之前拿到的 stop() 句柄立刻失效。
UI SFX 的 setPack(player.ts#L342-L351)是这样做的:
- 只迁移正在循环的声音,一次性声音让它自然播完;
- 旧循环音源以 18ms 淡出(
STOP_FADE_SECONDS)停掉; - 立刻从新包的配方渲染并启动新循环音源——缓冲区按
pack:cue:sampleRate缓存,首次调用时即时渲染(player.ts#L196-L209); - 全程复用同一个
ActivePlayback对象,外部的stop()与ended句柄保持不变。
结果就是循环音"走"到新包里,没有间隙、没有叠影,业务代码一行都不用改。这一行为由测试 player.test.ts#L235-L247 完整固定:切包后旧音源在 0.018 秒处停止,而原来那个 loop.stop() 依然能停掉新音源。
引擎里其他值得知道的小细节
🔊 顺带一览这个引擎的几处实现细节:
- 重触发策略:一次性声音默认
restart(新的success会重启旧的),循环声音默认ignore(不叠影),需要真实叠加时显式传overlap; - 停止淡出:一切停止都走 15ms 线性淡出(player.ts#L159-L170),杜绝"截断爆音";
- 总音量渐变:
setVolume不是硬切换,而是在 20ms 内滑到新值(player.ts#L355-L367),对正在播放的声音也生效; - 协作式预加载:
preload每渲染一个声音就让出一次主线程,并支持AbortSignal取消,不会卡页面; - 零网络:声音不是从文件播放,而是由确定性配方实时渲染(recipes.ts + synth.ts),整个运行时压缩后约 12 kB。
78 个声音与 12 个声音包的目录定义在 catalog.ts;bindUISFX 还可以直接用 HTML 的 data-uisfx 属性绑定声音,无需手写事件回调。
这些行为如何被验证
UI SFX 用两层关卡保证引擎行为:
- 自动化层:player.test.ts 等单元测试运行在 vitest 下,用假
AudioContext逐条断言声音高、冷却与循环迁移的时刻表; - 听觉层:listening-protocol.md 要求由三人试听小组把
hover、typing等高频声音以真实节奏重复 30 次,确认"没人想调低音量"后,版本才能发布。
小结
UI SFX 播放器引擎的价值,就浓缩在这三个机制里:
- 8 声音高保证声场有"天花板",连点狂点也不会炸;
- 语义冷却让高频声音保持克制,低频声音得以长期共存;
- 循环迁移让声音包切换无缝,业务代码"换装不换形"。
想进一步了解 12 个声音包的性格与全部 API,可以从 README.md 读起。


