玩家放进一首歌,Boss 就随着这首歌改变动作。做 AUDIOGENIC 时,我想让音乐走到战斗里面:低频的冲击、密集的节拍、忽然安静下来的段落,都能改变一场战斗的安排。
一开始的想法很直接。浏览器分析音频,把段落特征发给模型,模型返回一份 JSON 格式的行为时间线,游戏照着执行。
然而,音频分析会认错半拍,弱段落提不出足够的特征,模型会重复招式,也会把两段计划写进同一段时间。到了战斗里,又有子弹刚生成就消失、预警和伤害范围对不上的问题。一场战斗看着不对劲,回头查时,却很难分清该从音乐、模型还是战斗代码查起。
后来我把中间过程拆开了。现在的链路大致如下:
音频
-> 分帧、频谱与节拍分析
-> 段落和音乐原语
-> DeepSeek 生成 PrimitivePlan
-> 本地编译成 BehaviorTimeline
-> 游戏运行时逐帧执行

先把音频变成可以处理的段落
音频在浏览器里用 Web Audio API 解码。分析前先把多声道混成单声道。这里遇到过一个不太显眼的问题:如果左右声道接近反相,直接求平均会把信号互相抵消。最后我同时计算下混后的能量和各个单声道的能量;如果下混能量不到最强声道的 5%,就直接使用最强声道。
否则,一首听起来正常的歌,到了节拍和频谱分析那里,可能已经只剩下接近静音的数据。
分帧窗口取约 46 毫秒,再向上调整到最近的 2 次幂,hop 是半个窗口。每帧乘 Hann 窗后做 FFT,记录短时能量、低频和高频占比、频谱质心以及频谱通量。低频目前看 40 到 250 Hz,高频看 2 到 8 kHz。段落划分会同时参考能量变化、频谱变化和检测到的节拍。
节拍检测常常在半速和双速之间认错:128 BPM 会被识别成 64 或 256;有时速度对了,第一拍却错开了。我把校准做成了战斗前的准备过程。玩家跟着歌点十下,程序用点击间隔重新比较半速和双速候选,再修正第一拍的位置,省去了手动填写 BPM 的步骤。

原本藏在分析程序里的 tempo candidate 和 downbeat,就这样变成了玩家进入战斗前“和音乐同步”的十次点击。
直接把段落特征交给模型不太好用
最早的行为层直接使用段落能量、频谱和节拍密度。模型也可以直接返回最终的 BehaviorTimeline。这样很快能跑起来,但调试比较麻烦。
比如某一段生成了激光。我能看到高频占比、能量和频谱通量,也能看到提示词,但很难说清模型究竟根据哪一个量选择了激光。模型换一次,结果又可能不同。规则模式和模型模式还各有一套映射方式,改一个招式往往要同时检查两条链路。
为此我在音频段落和行为计划之间加了六种音乐原语:
bass-impact表示低频冲击。bright-beam表示高频较强、声音偏亮。flux-break表示频谱变化突然增大。dense-pressure表示节拍和能量持续密集。stable-groove表示节奏相对稳定。climax表示高能量或高张力段落。
每个段落会给这些原语打分。低于 0.48 的结果先去掉,再留下最强的三个。这里限制三个主要是为了让输入保持简单。全部塞进去虽然信息更多,但模型几乎总能在每一段找到使用高潮技能的理由。
同一个 bass-impact,放在不同段落里,可能成为冲锋、震地或近战重击。具体用哪一招,还要看强度和前后阶段。原语记录音乐的特征,后面的编译器再把它转成游戏动作。
弱音频后来又带来一个边界情况。有些段落六种原语都达不到 0.48,行为计划因此留出空洞。现在遇到这种情况,程序会根据频谱倾斜、通量、能量和节拍密度补一个低置信度原语。补出的强度仍低于正常阈值,这样后面能继续编译,也不会把一个安静段落误报成强信号。
模型输出还要再编译一次
DeepSeek 收到 BPM、起拍位置、总时长、段落特征、节拍网格样本和原语列表。请求经过 Vite 的服务端代理,浏览器里没有 API Key。当前请求最多生成 2400 tokens,90 秒后超时。
模型返回的是 PrimitivePlan。每一步只有起止时间、引用的原语、阶段作用、组合方式、强度和一段解释。阶段作用限定为准备、施压、爆发、换位和恢复;组合方式限定为单招、叠加和高潮组合。
这份计划还不能直接运行。它会先检查字段、枚举、时间范围、原语引用、段落覆盖和步骤重叠,然后交给本地编译器。
编译器目前有八种移动方式和十三种攻击方式。它负责选择具体技能,并补齐子弹数量、速度、开火间隔、预警强度和压力等级。低能量段即使被模型标成高潮,也不会直接得到高压组合技。近战攻击如果没有追击或快速位移,编译器也会调整移动方式。
连续重复是另一个单独处理的问题。模型很容易抓住一个明显特征反复使用,例如低频较强时连续安排三次近战。现在编译后还会检查主攻击序列,连续三段相同时替换后续动作。
规则模式也先构造 PrimitivePlan,经过同一个编译器生成 BehaviorTimeline,与模型模式共用运行时。模型返回非法 JSON、时间线有缺口或请求失败时,就由规则计划接上。到了战斗代码这里,两种方式留下的都是同一种时间线。
这条管线目前已经能跑起来,各层的划分还可以继续调整。现在再遇到一场不对劲的战斗,我至少有了查找的顺序:音频特征回到分析,段落安排回到计划,招式数值回到编译器,预警和伤害回到运行时。最初混在一起的那些问题,终于各自有了可以追查的位置。