UI SFX播放器引擎深潜:8声音高、语义冷却与循环迁移的实现细节

原创2026-10-24 19:44:281,457 阅读

UI SFX播放器引擎深潜:8声音高、语义冷却与循环迁移的实现细节

UI SFX(UI Sound Effects)是一个开源的 Web 界面音效库:78 个语义声音 × 12 个声音包,声音全部由确定性配方在本地渲染,无需加载任何音频文件。这篇文章深入 uisfx 包的播放器引擎 player.ts,讲解 8 声音高、语义冷却、循环迁移 这三个最实用的设计,看看它如何让"界面声音"不变成"界面噪音"。

UI SFX 开源界面音效库横幅:78 个语义声音与 12 个声音包

为什么界面音效播放器需要一套"播放策略"

如果你直接在点击回调里 new Audio().play(),就会遇到这些典型问题:用户连点时声音越叠越响、loading 循环音停不下来、鼠标扫过的 hover 音效高频到令人烦躁。

UI SFX 的解法不是多写音效,而是把一组播放策略做进引擎:

  • 声音高(Voice Ceiling):同时最多允许多少个声音叠加,默认 8 个
  • 语义冷却(Semantic Cooldown):同一个声音至少间隔多久才能再次播放
  • 重触发策略(Retrigger):同一声音再次触发时,是重启、叠加还是忽略

这三套机制的源码集中在 player.ts,并由 player.test.ts 逐条固定。下面逐一拆解。

8 声音高:防止"声音爆炸"的断路器

UI SFX mechanical 机械风格声音包视觉图:齿轮与拨杆

maxVoices 选项会被夹在 1~32 之间,默认 8(见 player.ts#L70-L74)。当新声音到来时,引擎先数一数当前活跃的音源数量;达到上限就执行三步:

  1. 找到最老的非循环声音(loading 这类循环音会被优先保护);
  2. 停掉它,音量在 15ms 内线性淡出到零;
  3. 把新声音精确安排在上一个声音淡出结束的时刻(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)。

循环迁移:循环播放中无缝切换声音包

UI SFX scifi 科幻风格声音包视觉图:全息声呐环

音效库最"坑"的场景:recording 循环音正在播,用户把声音包从 minimal 切到 scifi。朴素做法"停旧循环、播新循环"会留下一个无声间隙,而且你之前拿到的 stop() 句柄立刻失效。

UI SFX 的 setPack(player.ts#L342-L351)是这样做的:

  1. 只迁移正在循环的声音,一次性声音让它自然播完;
  2. 旧循环音源以 18ms 淡出(STOP_FADE_SECONDS)停掉;
  3. 立刻从新包的配方渲染并启动新循环音源——缓冲区按 pack:cue:sampleRate 缓存,首次调用时即时渲染(player.ts#L196-L209);
  4. 全程复用同一个 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 播放器引擎的价值,就浓缩在这三个机制里:

  1. 8 声音高保证声场有"天花板",连点狂点也不会炸;
  2. 语义冷却让高频声音保持克制,低频声音得以长期共存;
  3. 循环迁移让声音包切换无缝,业务代码"换装不换形"。

想进一步了解 12 个声音包的性格与全部 API,可以从 README.md 读起。

登录后查看全文
uisfx