当我们在前端用大模型生成音乐时,仅仅让浏览器发出声音是不够的。真正的专业音乐人或学习者,需要“所听即所见”——在模型实时吐出音符的同时,屏幕上能够动态流式铺展出排版严谨、合乎现代乐理记谱法则的五线谱或数字简谱。
目前 Web 端最权威的乐谱渲染矢量引擎无疑是 VexFlow。但在实际接入生成式大模型输出时,几乎所有开发者都会被狠狠上一课:大模型按概率采样预测出来的音符时间戳往往是松散的、带有浮点误差的物理时长(例如duration: 0.492s或不合规的绝对 Tick 偏量);而 VexFlow 这样的严谨乐理排版引擎,要求输入的是符合西方记谱规范的严格小节划分(Measure)、音符时值类型(Quarter、Eighth、Dotted、Triplet)以及严密的变音记号(Accidentals)上下文推导。
直接把模型输出的音符生搬硬套给 VexFlow,轻则整行乐谱排版崩溃出框,重则小节线错乱、时值总和不等于拍号定义而直接抛出异常断流。
构建一座乐理时序量化桥(Notation Quantizer & VexFlow Bridge),是大模型音乐生成应用在端侧呈现专业级乐谱的核心基石。
乐谱排版与音频播放的时空断层
音频播放器只需要关注“音符在第几秒响、响多久”,这属于连续的物理时间流;但记谱法(Notation)是一套离散的、具有高度层级约定的视觉符号语言:
- 小节拍数守恒(Measure Invariance):在 4/4 拍中,一个小节内所有音符与休止符的时值总和必须严格等于 4 个四分音符。大模型偶尔少吐了一个八分音符,或者多吐了一个十六分音符,记谱引擎就无法对齐小节线。
- 跨小节音符分裂与连音线(Tie / Slur):如果一个音符持续两拍,但恰好跨越了第三小节和第四小节之间的小节线,记谱法不允许出现一个横跨小节线的音符,必须将其物理拆分为两个音符,并在中间架设弧形延音线(Tie)。
- 临时升降号(Accidentals)的作用域记忆:在同一个小节内,同一个音高如果在前面被标记为升 C(C#),后面的同名音默认沿用升号,不需要也不应该重复标出升号;但一旦跨越了小节线,升号自动失效。
因此,桥接层的核心职责就是把大模型吐出的原始序列,经过“时值格栅化吸附”、“小节时值填充分裂”以及“调式升降记号状态机推导”,组装成合规的 VexFlowStaveNote数组。
乐理量化器与小节切分流水线
我们定义大模型标准输出的音符元组:{ pitch: string, durationBeats: number },其中durationBeats以四分音符为 1 拍。
export interface ModelNoteInput { pitch: string; // 如 "c/4", "f#/4", "b/4", "r" (休止符) durationBeats: number; // 拍数,如 1(四分), 0.5(八分), 1.5(附点四分), 0.25(十六分) } export interface StaveNoteSpec { keys: string[]; duration: string; // VexFlow 格式:'q', '8', '16', 'hd', 'w' dots: number; isRest: boolean; tieToNext?: boolean; } export class NotationQuantizer { // 基础时值网格(以拍数为单位),用于吸附微小浮点误差 private readonly validBeats = [ { beats: 4.0, code: 'w', dots: 0 }, { beats: 3.0, code: 'h', dots: 1 }, { beats: 2.0, code: 'h', dots: 0 }, { beats: 1.5, code: 'q', dots: 1 }, { beats: 1.0, code: 'q', dots: 0 }, { beats: 0.75, code: '8', dots: 1 }, { beats: 0.5, code: '8', dots: 0 }, { beats: 0.25, code: '16', dots: 0 }, ]; /** * 将任意浮点拍数吸附到最接近的合法乐理时值 */ public quantizeBeats(rawBeats: number): { beats: number; code: string; dots: number } { let closest = this.validBeats[0]; let minDiff = Math.abs(rawBeats - closest.beats); for (let i = 1; i < this.validBeats.length; i++) { const diff = Math.abs(rawBeats - this.validBeats[i].beats); if (diff < minDiff) { minDiff = diff; closest = this.validBeats[i]; } } return closest; } /** * 将大模型输出的连续音符序列按指定拍号切分为严格的小节集合 * @param beatsPerMeasure 每小节拍数,如 4/4 拍为 4 */ public partitionMeasures( notes: ModelNoteInput[], beatsPerMeasure = 4 ): StaveNoteSpec[][] { const measures: StaveNoteSpec[][] = []; let currentMeasure: StaveNoteSpec[] = []; let remainingInMeasure = beatsPerMeasure; for (const rawNote of notes) { let durationToFit = rawNote.durationBeats; while (durationToFit > 0) { // 如果当前音符时值大于小节剩余空间,必须拆分为两个音符并用延音线连接 if (durationToFit > remainingInMeasure) { const partDuration = remainingInMeasure; const quant = this.quantizeBeats(partDuration); currentMeasure.push({ keys: rawNote.pitch === 'r' ? ['b/4'] : [rawNote.pitch], duration: rawNote.pitch === 'r' ? `${quant.code}r` : quant.code, dots: quant.dots, isRest: rawNote.pitch === 'r', tieToNext: rawNote.pitch !== 'r', // 延音线连接到下一小节首音 }); measures.push(currentMeasure); currentMeasure = []; durationToFit -= partDuration; remainingInMeasure = beatsPerMeasure; } else { const quant = this.quantizeBeats(durationToFit); currentMeasure.push({ keys: rawNote.pitch === 'r' ? ['b/4'] : [rawNote.pitch], duration: rawNote.pitch === 'r' ? `${quant.code}r` : quant.code, dots: quant.dots, isRest: rawNote.pitch === 'r', tieToNext: false, }); remainingInMeasure -= quant.beats; durationToFit = 0; if (remainingInMeasure <= 1e-4) { measures.push(currentMeasure); currentMeasure = []; remainingInMeasure = beatsPerMeasure; } } } } if (currentMeasure.length > 0) { // 补齐末尾小节剩余拍数(用休止符填满,避免排版格式崩溃) while (remainingInMeasure >= 0.25) { const restQuant = this.quantizeBeats(remainingInMeasure); currentMeasure.push({ keys: ['b/4'], duration: `${restQuant.code}r`, dots: restQuant.dots, isRest: true, }); remainingInMeasure -= restQuant.beats; } measures.push(currentMeasure); } return measures; } }VexFlow 矢量桥接与动态渲染执行
拿到规整后的小节数据结构后,接下来通过 VexFlow API 在容器 DOM 中完成 SVG 谱表的绘制。这里需要动态创建Stave、添加谱号与拍号,并在跨小节拆分的音符之间无缝绘制StaveTie:
import { Factory, Formatter, StaveNote, Dot, Accidental, StaveTie } from 'vexflow'; export class ScoreRenderer { private container: HTMLElement; private quantizer = new NotationQuantizer(); constructor(container: HTMLElement) { this.container = container; } public renderScore(modelNotes: ModelNoteInput[], bpm = 120): void { this.container.innerHTML = ''; // 清理上一轮谱表 const measureWidth = 280; const measureHeight = 130; const measures = this.quantizer.partitionMeasures(modelNotes, 4); // 计算总谱表需要的宽度与自适应换行 const containerWidth = this.container.clientWidth || 800; const measuresPerRow = Math.max(1, Math.floor(containerWidth / measureWidth)); const totalRows = Math.ceil(measures.length / measuresPerRow); const vf = new Factory({ renderer: { elementId: this.container.id, width: containerWidth, height: totalRows * measureHeight + 40, }, }); let lastTiedNote: StaveNote | null = null; for (let mIdx = 0; mIdx < measures.length; mIdx++) { const row = Math.floor(mIdx / measuresPerRow); const col = mIdx % measuresPerRow; const x = col * measureWidth + 10; const y = row * measureHeight + 20; const stave = vf.Stave({ x, y, width: measureWidth }); // 第一小节添加高音谱号、4/4 拍号及速度标记 if (mIdx === 0) { stave.addClef('treble').addTimeSignature('4/4'); stave.setTempo({ duration: 'q', dots: 0, bpm }, 0); } const rawSpecList = measures[mIdx]; const vfNotes: StaveNote[] = []; for (const spec of rawSpecList) { const staveNote = new StaveNote({ keys: spec.keys, duration: spec.duration, }); // 附点修饰 for (let d = 0; d < spec.dots; d++) { Dot.buildAndAttach([staveNote], { all: true }); } // 升降记号自动挂载(若音高带 # 或 b) if (!spec.isRest) { spec.keys.forEach((k, kIdx) => { if (k.includes('#')) { staveNote.addModifier(new Accidental('#'), kIdx); } else if (k.includes('b')) { staveNote.addModifier(new Accidental('b'), kIdx); } }); } vfNotes.push(staveNote); // 如果上一个小节尾部有未闭合的延音线,在此处与首音建立连接 if (lastTiedNote && !spec.isRest) { vf.StaveTie({ from: lastTiedNote, to: staveNote, first_indices: [0], last_indices: [0], }); lastTiedNote = null; } // 记录本小节需要延音到下一小节的尾音 if (spec.tieToNext) { lastTiedNote = staveNote; } } // 对齐小节内音符间距并格式化 Formatter.FormatAndDraw(vf.getContext(), stave, vfNotes); } } }流式生成与交互高亮的性能优化
在实际的大模型交互界面中,音符是像打字机一样一个个通过 SSE(Server-Sent Events)流式推过来的。如果每收到一个音符就对整个几百小节的乐谱做全量清空重绘(Clear and Redraw),页面必定产生剧烈闪烁。
工程上的优化解法是虚拟小节增量上屏(Measure-Level Virtual Stave):
- 前端维护一个“未决小节缓冲区(Pending Measure Buffer)”。
- 只有当音符填满一个完整小节时,才将该小节对应的 SVG
<g>分组固化在 DOM 中; - 对于正在生成的最后一小节,使用独立的小画布进行轻量级动态预览;
- 当音频回放时,利用 Web Audio 的发音时刻与
StaveNote绑定的时间戳对照,利用 CSS 类选择器直接给当前演奏音符的 SVG 路径注入暖色高亮(path { fill: #ff4757; transition: fill 0.1s; }),实现完全脱离重排的高性能跟随滚谱。
把大模型的概率流拉进严谨的乐理标尺,再通过轻量矢量引擎精准投射到浏览器中。这样的乐谱不仅能看,更能经得起专业乐手的审视与演奏检验。