MIDI 序列降噪与平滑算法:过滤大模型生成音符中的超短重叠毛刺

📅 发布时间:2026/10/12 8:12:35
MIDI 序列降噪与平滑算法:过滤大模型生成音符中的超短重叠毛刺
用生成式大模型做符号音乐Symbolic Music生成时最令人头疼的不是和声走向翻车而是解码采样时不可避免引入的“机械噪点”。自回归模型在逐 Token 预测 MIDI 消息如 MIDI-like 或 REMI 表达时受注意力跨度和随机采样Top-p / Temperature影响经常吐出持续时间只有两三个 Tick 的超短“幽灵音符”Phantom Notes或者在同一声轨同一音高上生成重叠的 Note On / Note Off 冲突。这些毛刺扔进软音源或 Web Audio 合成器后会直接触发高频爆音Clicking、音色相位抵消或者因并发发音数Polyphony瞬间打满导致声卡缓冲区爆掉。排练室里鼓手如果打出一个不稳定的 64 分音符拖音底鼓就会发闷前端音频引擎拿到这堆脏数据下场也一样。要让大模型生成的乐段在浏览器端平滑回放前端必须在拿到 MIDI 事件流之后、推给合成器之前做一道硬核的序列清洗流水线。脏数据的主要形态与破坏力我们在浏览器端接入音乐生成模型流式输出时抓包分析过几千条原始 MIDI 序列主要的音符毛刺集中在以下三类亚毫秒级幽灵音符Micro Ticks音符的持续时长小于人耳感知阈值或合成器包络发生器的 Attack 时间通常为 515ms在 120BPM、480 PPQ 下约为 412 个 Tick。这种音符不会带来旋律感只会让振荡器突然阶跃产生爆破杂音。同音高粘连碰撞Note Collision / Overlap模型在尚未发送前一个音符的Note Off或velocity: 0时就提前发出了相同音高的下一个Note On。在标准 MIDI 状态机里这会导致后一个 Note On 截断前一个发音或者后一个 Note Off 把两个音同时掐断形成突兀的哑音。极低力度悬浮音Ghost Velocities模型在采样尾部给出了 1~3 这类几乎听不见但仍占用合成器振荡器资源的力度值严重消耗 AudioContext 的并发通道。解决这些问题的核心思路是建立时间窗桶实行最小持续时间阈值过滤、同音高时间重叠熔断以及动态量化吸附Quantize Snapping。降噪与平滑状态机设计我们需要将乱序或带有时序误差的流式 MIDI 事件规整为纯净的音符结构体Note Object在结构体级别完成碰撞检测和吸附最后重新展平为严谨的 MIDI 事件队列。export interface RawMidiNote { id: string; channel: number; pitch: number; // 0 - 127 velocity: number; // 1 - 127 startTick: number; // 起始绝对 Tick durationTicks: number;// 持续 Tick 长度 } export interface SanitizerConfig { ppq: number; // 每四分音符脉冲数标准一般为 480 或 960 bpm: number; // 曲目速度 minDurationMs: number; // 最小保留时长毫秒低于该值直接剔除 minVelocity: number; // 最小保留力度 quantizeGrid: number; // 量化网格16 表示 16 分音符32 表示 32 分音符 enableLegatoCorrection: boolean; // 是否启用连奏间隙修正 } export class MidiSequenceSanitizer { private config: SanitizerConfig; constructor(config: PartialSanitizerConfig {}) { this.config { ppq: 480, bpm: 120, minDurationMs: 15, minVelocity: 5, quantizeGrid: 32, enableLegatoCorrection: true, ...config, }; } /** * 将毫秒转换为 Tick 数量 */ private msToTicks(ms: number): number { const msPerBeat (60 * 1000) / this.config.bpm; return Math.round((ms / msPerBeat) * this.config.ppq); } /** * 核心清洗函数过滤短刺、修复重叠碰撞、执行节拍微吸附 */ public sanitize(notes: RawMidiNote[]): RawMidiNote[] { const minTicks this.msToTicks(this.config.minDurationMs); const gridTicks (this.config.ppq * 4) / this.config.quantizeGrid; // 1. 过滤低力度与超短时长的虚假音符 const validNotes notes.filter((note) { return note.velocity this.config.minVelocity note.durationTicks minTicks; }); // 2. 按通道和音高分桶处理同声部同键位碰撞 const buckets new Mapstring, RawMidiNote[](); for (const note of validNotes) { const key ${note.channel}-${note.pitch}; const group buckets.get(key) || []; group.push(note); buckets.set(key, group); } const sanitizedNotes: RawMidiNote[] []; // 3. 对每个音高轨道进行时间线上的碰撞裁决 for (const [, trackNotes] of buckets) { // 必须按 startTick 升序排列 trackNotes.sort((a, b) a.startTick - b.startTick); for (let i 0; i trackNotes.length; i) { const current trackNotes[i]; const next trackNotes[i 1]; if (next) { const currentEndTick current.startTick current.durationTicks; // 发生重叠前一个音还没结束后一个音就砸进来了 if (currentEndTick next.startTick) { // 策略硬截断前一个音留出 2 个 Tick 的呼吸空隙避免硬件丢掉 Note Off const truncatedDuration Math.max(minTicks, next.startTick - current.startTick - 2); current.durationTicks truncatedDuration; } else if ( this.config.enableLegatoCorrection next.startTick - currentEndTick minTicks ) { // 微小缝隙连奏平滑如果两个音之间只差极小微秒直接粘合成平滑 Legato current.durationTicks next.startTick - current.startTick; } } // 4. 网格对齐量化修正轻微漂移但保留音符长度弹性 const snappedStart Math.round(current.startTick / gridTicks) * gridTicks; // 如果微弱漂移在半个网格以内允许轻度吸附 if (Math.abs(snappedStart - current.startTick) gridTicks * 0.25) { const shift snappedStart - current.startTick; current.startTick snappedStart; current.durationTicks Math.max(minTicks, current.durationTicks - shift); } sanitizedNotes.push(current); } } // 重新全局按时间排序输出 return sanitizedNotes.sort((a, b) a.startTick - b.startTick); } }事件流重构与 Web Audio 派发数据清洗之后不能直接傻瓜式把数组用setTimeout调度。JavaScript 的主线程定时器具有 4ms~15ms 的不可控抖动会彻底毁掉音乐的 Groovy 感。正确的架构是利用 Web Audio 的精确硬件时钟AudioContext.currentTime。我们需要一个将清洗后的RawMidiNote转为事件对并提前向声卡缓冲注水的调度器export interface TimedMidiMessage { timeSeconds: number; data: [number, number, number]; // [status, data1, data2] } export class MidiEventScheduler { private ctx: AudioContext; private ppq: number; private bpm: number; constructor(ctx: AudioContext, ppq 480, bpm 120) { this.ctx ctx; this.ppq ppq; this.bpm bpm; } public scheduleNotes( notes: RawMidiNote[], startOffsetSec: number, onNoteTrigger: (msg: TimedMidiMessage) void ) { const secondsPerTick 60 / (this.bpm * this.ppq); for (const note of notes) { const noteOnTime startOffsetSec note.startTick * secondsPerTick; const noteOffTime noteOnTime note.durationTicks * secondsPerTick; // Note On 消息 (0x90 | channel) onNoteTrigger({ timeSeconds: noteOnTime, data: [0x90 | (note.channel 0x0f), note.pitch, note.velocity], }); // Note Off 消息 (0x80 | channel) onNoteTrigger({ timeSeconds: noteOffTime, data: [0x80 | (note.channel 0x0f), note.pitch, 0], }); } } }性能与工程落地考量在真实的 Web 端工作流中大模型生成的 MIDI 数据往往是通过 WebSocket 或 Server-Sent Events (SSE) 分块流式送达的。我们不可能等 3 分钟的交响乐全部生成完才启动清洗流水线。滑动窗口清洗Sliding Window Pipeline维护一个 2 个小节Bar长度的前向观察窗。当接收到新的 Note Token 时只对窗口内已经明确了起止时间的音符执行碰撞裁决未闭合的音符挂起在pendingNotes集合中。垃圾回收与对象复用单首复杂曲目可能包含上万个 Note。避免在每帧频繁解构与重新分配对象定义固定大小的Float64Array存储起始 Tick、持续 Tick、音高和力度用 TypedArray 操作替代对象垃圾回收能把 GC 停顿完全压制在 0.5ms 以内。人性化弹性保全Humanize Preservation大模型生成的音符如果完全被网格强行吸附Hard Quantize音乐就会失去摇摆感Swing和呼吸感听起来像上世纪 80 年代廉价电子琴内置伴奏。因此在上述代码中网格吸附只在音符偏移量极小小于网格四分之一时触发微调严防“过度修正”。把不稳定的生成序列转化为声学物理上严丝合缝的干净音轨这是任何 Web AI 乐器应用必须打好的地基。底子扎实了上层的合成器与动效才不会变成噪音发射机。