
1. 从浏览器里听见一张乐谱Euphony 的设计取舍第一次动手写 Euphony动机特别朴素我在浏览器里点开一个 .mid 文件希望它立刻响起来并且在响的同时能看见东西——看见音符像瀑布一样往下掉看见频谱随着和弦跳动看见每条通道各干各的活。这件事听着不复杂真拆开就是三个独立的问题怎么把二进制文件读成人能理解的事件表、怎么让这些事件在正确的时间点出声、怎么让画面和声音在同一条时间轴上走。Web 端 MIDI 播放器与可视化工具这个定位本质上就是把这三个问题串成一个能跑通的管线。Euphony 适合谁如果你是前端或者全栈想找一个能把 Web Audio API 真正用起来的实战项目它足够有料如果你是音乐制作方向的学习者想搞清楚 MIDI 文件内部到底存了什么、为什么同一个文件在别的播放器里时长不一样它能给你答案如果你只是想给自己的网页加一个会动的音符可视化那更简单挑走渲染那一层就能用。我做这个项目最大的收获不是写出了一个播放器而是彻底搞懂了时间在音频程序里到底是个什么概念——这事儿比想象中要绕。1.1 需求倒推Euphony 要解决的真实痛点市面上的 MIDI 播放器大多有两个毛病。要么是桌面软件装完一堆依赖分享给朋友等于让对方先配一次环境光是音源库的路径问题就能劝退一半人要么是网页版但做成了黑盒——你听得见声音看不见任何内部结构想调速度、想单独听某条轨道、想看某个通道的控制器曲线全都没门。Euphony 想填的就是这个中间地带打开即用但内部全部透明。我把需求写成了四条后面的所有技术选择都是围绕这四条来的。第一零安装。用户只需要一个现代浏览器不需要装任何插件。这一条直接排除了需要本地服务或者特殊运行时的方案。第二能播也能看。播放和可视化必须是同一个时间轴上的两件事不能做成先渲染一帧看进度条这种伪可视化。用户拖到第 12 秒画面和声音必须同时落到第 12 秒。第三解析要完整。不能只挑 note on / note off 处理那些才是 MIDI 里最无趣的部分。tempo 变化、拍号、轨道名、通道信息、控制器、弯音轮这些才是让一首曲子有表情的东西必须解析并且可以被可视化调用。第四性能要撑住。一首四五分钟的钢琴曲音符数量轻松过几千。如果每播一个音就创建一个新对象、每渲染一帧就遍历全部事件页面在第三个八度就开始掉帧。把需求翻译成技术约束其实就一句话用 Web Audio 的采样时钟当唯一时间基准用 Canvas 做纯渲染中间不引入任何同步层。1.2 技术选型三个 API 各管一段我最终落地的方案是三件套各司其职。文件解析用原生的FileReaderDataView不引第三方 MIDI 解析库。理由是解析逻辑本身不到三百行而且第三方库往往会把原始事件抽象成自己的数据结构反而挡住了后面做可视化时需要的细节。自己写一遍每个字节怎么来的心里有数。这里顺带说一句市面上那些把黑盒服务变成看得见列表和图的可视化工具——比如大家常用的 Redis 可视化工具、MySQL 可视化工具、Kafka 可视化工具——思路跟 Euphony 是一脉相承的把不可见的结构摊开给人看只不过它们摊开的是数据结构Euphony 摊开的是时间结构。发声用 Web Audio API。它的核心优势是自带一条独立于主线程的音频渲染线程AudioContext.currentTime这个时钟精度远高于Date.now()和performance.now()而且是单调递增、不会被系统时间调整影响的。MIDI 播放最怕的就是时钟漂移用它等于省掉了一半的同步代码。可视化用 Canvas 2D。这一条我纠结过一阵最终还是放弃了 WebGL。原因很简单Euphony 的可视化元素以矩形、线条、渐变为主元素数量在千级Canvas 2D 完全扛得住而 WebGL 带来的是着色器代码、纹理管理、上下文丢失处理这一整套复杂度。为了几千个矩形上手写 GLSL投入产出比不划算。当然如果你后面想加粒子效果、光晕、后期处理那另说我留了扩展余地。不用 SVG的原因也顺手记一下DOM 节点数量一上去浏览器的样式计算和重排就成了瓶颈。几百个音符还行上千个就明显卡了。Canvas 是位图绘制节点数量对它几乎没有成本。1.3 模块划分与数据流向整个项目我拆成了五个模块数据是单向流动的这样排查问题的时候能一层一层往回找。模块职责关键输出加载层读取文件、校验 MThd 头ArrayBuffer解析层拆 chunk、拆事件、处理 running status原始事件数组 元信息时间层建立 tempo map、tick 转秒按秒排序的事件数组 总时长调度层lookahead 定时器 Web Audio 节点实际发声渲染层requestAnimationFrame Canvas 绘制画面这五层里最容易出错的是时间层。解析层再乱错了会直接抛异常调度层错了声音会明显不对。只有时间层错了是静悄悄错——音符都在响就是整体节奏慢慢偏了半拍你还以为是音源的问题。所以后面我会单独把时间换算拎出来讲。写到这里有一点想强调不要一开始就想把五个模块都写完整。我踩过的坑是先写了一个看起来完备的解析器支持所有 meta 事件结果发现调试的时候根本不知道哪个字节错了。后来改成先只处理 note on / note off / tempo 三类跑通一首曲子再往上加控制器和弯音效率高了一倍不止。2. 核心原理拆解音符、时间与声音怎么对上这一章是本篇的重头戏。Euphony 之所以能做出拖动进度条声音和画面同时到位的效果靠的不是什么黑魔法而是几处关键换算和一个严格的时钟纪律。把这些讲清楚你就算不做这个项目以后写任何跟音频相关的网页都会轻松很多。2.1 扒开 MIDI 文件MThd、MTrk 与可变长度量标准 MIDI 文件SMF的结构非常工整就是一堆 chunk 拼起来。所有数据都是大端序这一点特别重要——DataView.getUint16默认就是大端别手贱去传小端参数。文件开头一定是MThd这四个 ASCII 字符接着是一个 4 字节的长度字段固定为 6然后是六个字节的有效数据function parseHeader(view) { const tag String.fromCharCode( view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3) ); if (tag ! MThd) throw new Error(不是合法的 MIDI 文件缺少 MThd 头); const headerLength view.getUint32(4); const format view.getUint16(8); // 0/1/21 最常见 const ntrks view.getUint16(10); // 轨道数 const division view.getUint16(12); // 时间分辨率 return { headerLength, format, ntrks, division }; }division这里有个陷阱必须判断最高位。如果最高位是 0剩下的 15 位表示每四分音符多少个 tick常见值有 96、192、480、960。如果最高位是 1那就是 SMPTE 时间码格式高字节是负的帧率低字节是每帧的 tick 数。绝大多数流行曲子的 MIDI 都是前者所以你至少要先判断一下遇到后者的文件直接给用户一个提示别让它静默解析出一堆负数。MThd之后就是一串MTrk块。每个块的结构是4 字节的MTrk标识、4 字节的数据长度、然后是事件流。事件流里的每条事件长这样提示delta-time 是相对上一条事件的时间差不是绝对时间。这意味着解析的时候必须维护一个累加的 tick 计数器千万别拿 delta 直接当时间用。每条事件的开头是一个可变长度量VLQ也就是 delta-time。VLQ 的规则是每个字节用低 7 位存数据最高位是还有后续字节的标志位。所以读的时候要一直读到最高位为 0 为止function readVarInt(view, offset) { let value 0; let byte; do { byte view.getUint8(offset); value (value 7) | (byte 0x7f); } while (byte 0x80); return { value, offset }; }VLQ 最多 4 字节所以最大 28 位用 JavaScript 的位运算不会溢出。但如果你图省事用value value * 128 (byte 0x7f)其实更稳妥因为位运算超过 31 位就会变成负数虽然这个场景碰不到但养成习惯没坏处。delta-time 之后就是事件本体。状态字节的高四位决定类型0x8是 note off0x9是 note on0xA是复音触后0xB是控制器0xC是音色切换0xD是通道触后0xE是弯音。低四位是通道号0 到 15。另外还有两个特殊值0xF0和0xF7是系统独占消息0xFF是元事件。元事件的结构是0xFF 类型字节 VLQ 长度 数据。最关键的三个类型0x51是 tempo数据是 3 字节表示每个四分音符多少微秒0x2F是曲末标记0x03是轨道名一般是 UTF-8 文本。tempo 这个值要特别注意单位它存的是微秒不是 BPM。默认值 500000 微秒对应 120 BPM这个换算后面要用。还有一个必须处理的东西叫running status。为了省空间MIDI 文件里如果连续多条事件的状态字节相同后面的事件可以省略状态字节直接写数据。所以解析循环里的判断逻辑应该是如果当前字节小于 0x80说明它不是状态字节那就沿用上一次的状态字节并且把读指针退回去一字节。这个逻辑如果漏了整条轨道的解析会从这里开始全乱事件全部错位最后的结果往往是一堆莫名其妙的随机音符。2.2 时间换算从 tick 到秒一步都不能省这是整个项目里最核心的一段数学。MIDI 文件里的时间单位是 tick而 Web Audio 的时间单位是秒。换算公式看起来很简单秒数 tick数 / division * (tempo微秒 / 1_000_000)但关键在于tempo 是会中途变化的。一首曲子可能开头 90 BPM副歌切到 140 BPM结尾又慢下来。所以你不能只读第一个 tempo 事件然后一算了之必须建立一张 tempo map。先验算一下数量级这样你对精度有直观感觉。假设 division 480tempo 500000 微秒也就是 120 BPM那么一个四分音符 480 tick 0.5 秒一个 tick 0.5 / 480 ≈ 1.0417 毫秒也就是说一个 tick 大约对应 1 毫秒。而浏览器定时器的抖动通常在几毫秒这个量级setInterval(fn, 25)实际可能在 23 到 30 毫秒之间飘。这就是为什么后面调度层必须用 lookahead不能靠定时器到点就发声——误差累积起来四分钟的曲子能偏出大半秒听起来就是明显的节奏不稳。建立 tempo map 的做法是维护一个累加器function buildTempoMap(tempoEvents, division, totalTicks) { // tempoEvents 必须按 tick 升序排好且已保证第一项在 tick 0 const map []; let elapsedSec 0; let prevTick 0; let prevTempo 500000; for (const t of tempoEvents) { elapsedSec ((t.tick - prevTick) / division) * (prevTempo / 1e6); map.push({ tick: t.tick, time: elapsedSec, tempo: t.usPerQuarter }); prevTick t.tick; prevTempo t.usPerQuarter; } // 补一个哨兵方便二分查找算尾部 map.push({ tick: totalTicks, time: elapsedSec ((totalTicks - prevTick) / division) * (prevTempo / 1e6), tempo: prevTempo }); return map; }拿到 map 之后任意 tick 转秒就是一次二分查找找到它落在哪一段再用那一段的 tempo 线性插值。同理反过来某个秒数对应哪个 tick也是二分查找加插值拖动进度条的时候要用到。这里有两个实操心得。第一多个轨道可能都有 tempo 事件格式 1 的 MIDI 通常只在第一条轨道放 tempo但有些导出工具会到处撒。稳妥做法是把所有轨道的 tempo 事件合并后按 tick 排序同一 tick 上如果重复保留最后一条。第二第一个 tempo 事件不一定在第 0 tick如果它出现在第 1000 tick那么 0 到 1000 之间必须用默认的 500000 填充否则开头那一小段的时间就是错的。这个 bug 特别隐蔽因为它只影响开头不到一秒很多人根本注意不到。2.3 声音合成用 Web Audio 手搓一个够用的合成器Euphony 没有加载几十兆的音源采样库而是用振荡器加包络现搓音色。这个选择有得有失但我觉得对于看结构这个目标来说得大于失。一个音符的发声链路是这样的OscillatorNode振荡器决定基频和波形→GainNode增益决定包络和音量→ 主输出总线。音符结束时不需要手动断开只要让振荡器 stop垃圾回收会处理。包络我用的是经典 ADSR 四段const PRESETS { piano: { wave: triangle, attack: 0.004, decay: 0.35, sustain: 0.18, release: 0.28, level: 0.85 }, pad: { wave: sawtooth, attack: 0.25, decay: 0.4, sustain: 0.6, release: 1.2, level: 0.35 }, bell: { wave: sine, attack: 0.001, decay: 1.2, sustain: 0.02, release: 0.9, level: 0.7 } };每个参数都不是随便填的。attack之所以用 4 毫秒而不是 0是因为从 0 直接跳到峰值会产生咔的一声爆音这是数字音频里非常典型的问题专业术语叫 click。给一个极短的渐变听感就干净了。release为什么要有因为如果音符一关就瞬间静音听起来像被人掐断脖子给一点衰减尾巴才有乐器自然衰减的感觉。钢琴的sustain压得很低也是因为钢琴是衰减型乐器按住键也不会一直响。具体发声函数长这样function playNote(ctx, dest, freq, startTime, durationSec, velocity, preset) { const osc ctx.createOscillator(); const gain ctx.createGain(); osc.type preset.wave; osc.frequency.setValueAtTime(freq, startTime); const peak Math.max(0.0001, (velocity / 127) * preset.level); const sustainLevel Math.max(0.0001, peak * preset.sustain); const attackEnd startTime preset.attack; const decayEnd attackEnd preset.decay; const releaseStart Math.max(decayEnd, startTime durationSec); gain.gain.setValueAtTime(0.0001, startTime); gain.gain.linearRampToValueAtTime(peak, attackEnd); gain.gain.exponentialRampToValueAtTime(sustainLevel, decayEnd); gain.gain.setValueAtTime(sustainLevel, releaseStart); gain.gain.exponentialRampToValueAtTime(0.0001, releaseStart preset.release); osc.connect(gain); gain.connect(dest); osc.start(startTime); osc.stop(releaseStart preset.release 0.02); }这个函数里有三个必须记住的点。第一exponentialRampToValueAtTime的目标值不能是 0。指数曲线在数学上是永远逼近而不到达的Web Audio 对 0 会直接抛异常。所有衰减目标我都写 0.0001也就是 -80 dB人耳已经完全听不到了效果上等效于静音。第二包络节点的顺序不能乱。Web Audio 的时间参数是按调用顺序排队执行的如果你先写了 decay 的 ramp又回头setValueAtTime一个更早的时间点行为会变得不可预测。所以我的做法是严格按照时间顺序写起始点、attack 终点、decay 终点、release 起点、release 终点。第三osc.stop的时间必须留一点余量。我加了 0.02 秒的冗余是因为浮点时间比较偶尔会让 stop 早于包络结束导致尾音被切掉一点点。多给 20 毫秒完全听不出来但能避免尾音断裂。频率换算也用得上一个公式MIDI 音高 69 号也就是中央 A对应 440 Hz每升一个半音乘 2 的 1/12 次方。const midiToFreq (pitch) 440 * Math.pow(2, (pitch - 69) / 12);多声部的时候把所有 GainNode 都连到同一个主 GainNode主节点再连ctx.destination。这么做的好处是总音量控制只需要改一个节点而且可以对整条总线加一个压缩器避免同时按下二十个琴键时削波失真。2.4 可视化与音频的同步只用一条时间轴可视化最容易翻车的地方是各算各的时间。渲染循环里用performance.now()算进度音频用AudioContext的时钟两个时钟的起点、漂移特性都不一样跑上几分钟必然对不上。我的做法非常极端整个应用只承认一个时钟就是audioContext.currentTime。每次渲染先去读当前的音频时钟把它换算成当前播放位置秒然后所有绘制都基于这个位置来计算。requestAnimationFrame传进来的时间戳我完全不用它唯一的作用是告诉我该重绘了。let startOffset 0; // 播放起点在曲目中的秒数 let startedAt 0; // 开始播放那一刻的 audioContext.currentTime function currentPosition() { return startOffset (ctx.currentTime - startedAt); }暂停的时候把startOffset更新为当前currentPosition()恢复播放的时候把startedAt重置为ctx.currentTime就这么两个变量暂停、继续、拖动进度条三种操作全都覆盖了。渲染这一层我做成上下两层上层是钢琴卷帘瀑布横轴是时间、纵轴是音高音符从右往左或者从上往下滚动下层是实时频谱用AnalyserNode取 FFT 数据画柱状图。两层共享同一个currentPosition()所以天然同步。钢琴卷帘的关键是坐标映射。给定一个音符的音高pitch它的纵坐标是const NOTE_HEIGHT 6; const LOWEST_PITCH 21; // 钢琴最低音 A0 function pitchToY(pitch, canvasHeight) { const y canvasHeight - (pitch - LOWEST_PITCH 1) * NOTE_HEIGHT; return y; }横坐标则用当前播放位置减去音符的起始时间得到距离现在还有多少秒再乘以每秒对应多少像素就得到了它应该出现在屏幕上的位置。这里我把滚动速度做成可调的默认一秒对应 160 像素快曲子可以调到 300。注意可视化里千万不要对每个音符每帧都新建对象或者数组。我早期版本每帧map一遍全部事件数组几千个音符 × 每秒 60 帧垃圾回收器直接被打爆。正确做法是预先按时间排序用游标只取当前可见窗口内的那几十个音符。3. 从零落地Euphony 关键环节的实现细节原理讲完了这一章就是动手。我会按实际开发顺序把关键代码贴出来每一步都说明为什么这么做以及我当时错在哪。如果你是照着想复现一遍建议按这个顺序来每一步都能跑出可验证的结果。3.1 第一步把 .mid 文件读成一张事件表先解决最底层的事情选文件、读成 ArrayBuffer、解析成事件数组。用input typefile accept.mid,.midi就够了不需要拖拽也能活。解析的主循环我写成两层结构外层遍历 chunk内层遍历事件。内层这里就是 running status 的处理现场。function parseTrack(view, start, end) { const events []; let offset start; let tick 0; let runningStatus 0; while (offset end) { const dt readVarInt(view, offset); offset dt.offset; tick dt.value; let status view.getUint8(offset); if (status 0x80) { // running status沿用上一次的状态字节指针不动 status runningStatus; } else { offset 1; } if (status 0xff) { const type view.getUint8(offset); const len readVarInt(view, offset); offset len.offset; const data new Uint8Array(view.buffer, view.byteOffset offset, len.value); events.push({ tick, type: meta, metaType: type, data }); offset len.value; if (type 0x2f) break; // 曲末别再往后读了 continue; } if (status 0xf0 || status 0xf7) { const len readVarInt(view, offset); offset len.offset len.value; continue; // 系统独占消息本项目直接跳过 } runningStatus status; const command status 0xf0; const channel status 0x0f; // 0xC 和 0xD 只有一个数据字节其余都是两个 const dataLen (command 0xc0 || command 0xd0) ? 1 : 2; const d1 view.getUint8(offset); const d2 dataLen 2 ? view.getUint8(offset 1) : 0; offset dataLen; events.push({ tick, type: channel, command, channel, d1, d2 }); } return events; }这段代码里有三个地方是踩过坑才写对的。runningStatus的更新时机。我一开始写成了读到状态字节就更新结果发现元事件和系统独占消息后面也会错误地把 running status 覆盖掉。正确的做法是只有通道消息才更新 running status。数据字节长度的判断。0xC0音色切换和0xD0通道触后只有一个数据字节其它通道消息都是两个。如果统一按两个读从第一条音色切换开始整条轨道就会错位两个字节。这个 bug 我在一个多乐器编排的 MIDI 上遇到过表现是解析没报错但音符全跑到奇怪的音高上去了查了半天才定位到。曲末标记的处理。0x2F元事件后面理论上不会有数据了但有些导出工具的轨道长度字段会留冗余break掉比继续读安全。另外还有一个非常经典的问题note on 的力度值为 0等价于 note off。这是 MIDI 规范里为省字节设计的小技巧如果你不判断音符就会永远按住不放整首曲子变成一片嗡嗡声。// 在把事件转成音符的时候 if (command 0x90 d2 0) { activeNotes.set(channel * 128 d1, { startTick: tick, velocity: d2 }); } else if (command 0x80 || (command 0x90 d2 0)) { const key channel * 128 d1; const note activeNotes.get(key); if (note) { notes.push({ ...note, pitch: d1, channel, endTick: tick }); activeNotes.delete(key); } }用channel * 128 pitch作为键是为了解决同一个音高在同一个通道上还没抬指又按了一次的情况。如果只用音高当键遇到快速重复音就会丢音或者把两个音合成一个。用 map 结构而不是数组是因为查找复杂度是 O(1)解析一千个音符不会有任何压力。3.2 第二步调度器——lookahead 到底在解决什么问题调度是整个项目里我改版最多的地方前后写了三版才稳定下来。第一版定时器直接发声。思路是根据事件的绝对时间用setTimeout在那一刻调用playNote。结果是节奏一塌糊涂。原因很明确setTimeout的最小分辨率大概在 4 毫秒以上而且是不早于而不是正好在再加上主线程一旦被渲染任务占用回调延后几十毫秒都是常事。几百个音符累积下来偏出半秒太正常了。第二版提前一点调度。我改成提前 30 毫秒调度好了一些但依然会飘。因为定时器的抖动本身就有几十毫秒提前量小了不起作用提前量大了又控制不了精度。第三版lookahead 窗口。这才是标准答案。核心思路是把什么时候决定和什么时候发声彻底分开。定时器只负责提前把未来一小段时间内该发声的音符排进 Web Audio 的队列而 Web Audio 的节点本身支持传入精确的未来时间点它的音频线程会以采样精度执行。定时器抖动个十几毫秒完全无所谓因为音符的播放时间是以参数形式精确指定的。const LOOKAHEAD_SEC 0.15; // 提前排程的窗口长度 const TIMER_INTERVAL_MS 25; // 定时器检查频率 let cursor 0; function tickScheduler() { const now ctx.currentTime; const horizon now LOOKAHEAD_SEC; while (cursor timeline.length timeline[cursor].time horizon) { const ev timeline[cursor]; const when Math.max(ev.time, now 0.005); // 防止排到过去 triggerEvent(ev, when); cursor; } }Math.max(ev.time, now 0.005)这个保护特别重要。因为万一主线程被卡了一下导致某些事件的时间已经过去了如果直接把过去的时间传给osc.start()Web Audio 会立即播放结果是好几个音符同时炸出来。加 5 毫秒的缓冲至少能让它们按顺序排开。LOOKAHEAD_SEC的取值是个权衡。太小主线程一卡就来不及补太大用户拖进度条之后会听到一段已经排进去的旧音符。150 毫秒是我实测下来比较舒服的值。但这里有个坑必须提前说浏览器对后台标签页的定时器有节流。页面切到后台setInterval可能被压到 1000 毫秒一次。这时候 150 毫秒的窗口就不够了会出现声音断续。处理办法有两个一是监听visibilitychange切到后台时把窗口动态放大到 1.2 秒二是干脆不管它——反正用户看不见画面音乐断断续续也不影响。我选了第一种代码量很小体验完整得多。3.3 第三步画布渲染——高 DPI 和性能这两关渲染这一层的代码量最大但逻辑最直白。我把 canvas 分成两块各画各的互不干扰。第一关是高分屏适配。这个问题几乎所有人第一次写 Canvas 都会踩在 Retina 屏上画出来的线条是模糊的因为 canvas 的像素尺寸和 CSS 尺寸是两回事。解决办法是根据devicePixelRatio放大位图尺寸再用变换矩阵把坐标系缩回来。function fitCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width Math.round(rect.width * dpr); canvas.height Math.round(rect.height * dpr); const c2d canvas.getContext(2d); c2d.setTransform(dpr, 0, 0, dpr, 0, 0); return c2d; }窗口大小变化和拖动浏览器窗口分隔条都要重新调用一次记得用ResizeObserver而不是window.onresize因为容器宽度可能跟窗口无关比如侧边栏折叠。第二关是只画该画的东西。瀑布流的绘制逻辑是从当前播放位置往前推 N 秒也就是屏幕左侧对应的位置往后推 M 秒屏幕右侧只取这个区间内的音符。function renderRoll(c2d, notes, cursorIdx, pos, width, height) { c2d.clearRect(0, 0, width, height); const pxPerSec 160; const visibleStart pos - 0.2; const visibleEnd pos width / pxPerSec; for (let i cursorIdx; i notes.length; i) { const n notes[i]; if (n.time visibleEnd) break; if (n.time n.duration visibleStart) continue; const x (n.time - pos) * pxPerSec; const w Math.max(2, n.duration * pxPerSec); const y height - (n.pitch - 21 1) * 6; c2d.fillStyle channelColor(n.channel, n.velocity); c2d.fillRect(x, y, w, 5); } }cursorIdx是从左边界推算出来的起始索引可以每次循环用二分查找定位避免从头遍历。这个优化在几千音符规模下能省掉大量无效循环。颜色的处理也有讲究。我按通道分配色相按力度调整明度hsl(hue, 70%, 45% velocity/127*20%)。这样一眼就能看出哪条轨道在活跃、哪个音弹得重。视觉上这一点点差异比单纯的单色矩形信息量大得多。频谱那一层更简单AnalyserNode的getByteFrequencyData拿到 1024 个数值每帧画 64 根柱子。function renderSpectrum(c2d, analyser, width, height) { const bins new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(bins); c2d.clearRect(0, 0, width, height); const bars 64; const step Math.floor(bins.length * 0.7 / bars); // 只取低频到中频 const barW width / bars; for (let i 0; i bars; i) { const v bins[i * step] / 255; const h v * height; c2d.fillStyle hsl(${190 i * 2}, 80%, ${35 v * 30}%); c2d.fillRect(i * barW, height - h, barW - 1, h); } }只取前 70% 的频段是有原因的AnalyserNode默认 FFT 大小 2048频率覆盖到 20 kHz 以上而音符的基频基本都在 4 kHz 以下高频部分全是泛音画出来就是一片矮矮的噪声视觉上不好看。截掉高频段柱子会更有起伏看起来更有音乐感。3.4 第四步播放控制——暂停、拖动、循环的三个细节播放控制看着简单但细节不少我逐个说。暂停恢复。前面提过startOffset和startedAt两个变量。暂停时除了更新变量还要把所有正在发声的音符立即释放否则暂停了还会有一串余音在响。function pause() { startOffset currentPosition(); isPlaying false; activeVoices.forEach(v v.stop(0)); // 立刻停掉 activeVoices.clear(); clearInterval(timerId); }拖动进度条。这是坑最多的操作。如果只是改一下startOffset和startedAt会出现两个问题一是已经排进队列但还没发声的音符会继续响形成一段错位的杂音二是cursor还停在旧位置会从错误的地方继续排程。处理办法是拖动时先暂停所有发声、重建cursor二分查找到新位置对应的第一个事件索引、重置startOffset再把startedAt设为当前音频时钟。如果拖动前处于播放状态拖动结束后要重新启动定时器。function seek(seconds) { const wasPlaying isPlaying; if (wasPlaying) pause(); startOffset Math.max(0, Math.min(seconds, totalDuration)); cursor lowerBound(timeline, startOffset); startedAt ctx.currentTime; if (wasPlaying) play(); drawFrame(); // 立刻重绘一帧避免画面上还停着旧位置 }lowerBound就是标准二分查找返回第一个时间大于等于目标值的事件索引。循环播放。循环一段 ABC 区间逻辑上就是把播放位置在到达 B 点时跳回 A 点。这里有个容易忽略的问题跨循环边界的音符要单独处理。如果一个音符从 1.8 秒开始、持续 0.5 秒而循环边界在 2.0 秒正常情况下它应该被截断但如果它的 release 尾巴超过了边界跳回去之后旧音符还在响就会和新的重叠。我的处理是让所有音符的时长在排程时就被循环边界裁剪同时循环跳转时清空当前所有活跃音符。听起来有点粗暴但听感上完全没问题因为循环边界通常选在乐句的停顿处。心得不要试图在循环边界上做无缝衔接。Web Audio 的调度是提前量驱动的做到采样级无缝需要预渲染整段音频复杂度会陡增。对于 Euphony 这种教学和观察用途的工具把边界处的音符干净地掐掉体验比努力无缝但偶尔错乱要好得多。4. 常见问题与排查技巧实录Euphony 我前后改了两个月遇到的问题能列出一长串。这一章把最典型的整理成速查表再挑几个讲讲定位过程希望能帮你少走弯路。我特别想说的是音频问题的排查思路和普通前端 Bug 完全不一样——普通 Bug 会报错音频问题往往是静悄悄地不对所以建立一套分层的排查路径比记住具体解法更重要。4.1 音频类问题从完全没声音到音色怪现象最可能的原因快速定位方法处理完全没声音控制台无报错AudioContext 处于 suspended 状态打印ctx.state在用户点击事件里调ctx.resume()有波形但不发声某个节点忘了 connect 到 destination从末端往前逐个检查 connect 链补上master.connect(ctx.destination)声音极小主增益被多次相乘衰减检查包络峰值和总线增益的乘积主总线保持 1.0只在音符层做力度有咔咔爆音attack 时间为 0 或音符被硬切听是否每次出音都响一下attack 给 3 到 5 毫秒尾音被切断osc.stop时间早于包络结束对比 stop 时间和 release 结束时间stop 时间加 20 毫秒余量同音反复丢失note on 力度 0 未识别检查是否有音符永远按着把力度 0 当 note off快速乐段糊成一片音符时长计算到了曲末打印最长音符的时长给 duration 加上限播放到某个音崩溃指数曲线目标值为 0看异常是否提到 ramp所有目标值用 0.0001关于第一项我想多讲两句。浏览器的自动播放策略规定在用户没有交互之前AudioContext会以 suspended 状态创建。我一开始是在页面加载时就创建 AudioContext 并启动播放结果什么都听不到控制台干净得像没事一样。后来才明白必须把ctx.resume()放在一个真实的用户手势点击、触摸回调里。更稳妥的做法是延迟创建 AudioContext等到用户第一次点播放按钮时才 new这样连状态判断都省了。还有一个隐蔽的问题如果页面里有多个 AudioContext 实例它们各自的时钟是独立的同步就无从谈起。整个应用只能有一个 AudioContext用单例模式管住它。我在重构时发现自己不小心在初始化函数里 new 了两次一个用于播放一个用于分析结果就是可视化永远慢半拍。4.2 解析与时间轴问题为什么这首歌在我这儿变长了这类问题的典型表现是能播但总时长不对或者中段节奏突变。我印象最深的一次是测试一首变换拍子的曲子前半段完全正常到 1 分 20 秒左右突然整体加快。定位过程是这样的先把解析出的 tempo 事件全部打印出来发现第 1 分 20 秒处确实有一个 tempo 从 500000 变成 750000 的事件也就是从 120 BPM 降到 80 BPM。但我实现的换算里这个 tempo 事件生效的时机用错了——我在处理 tick 转秒时把 tempo 变化点之后的所有事件都用新 tempo 从头算了一遍而不是分段累加。这是个低级错误但因为它只影响变化点之后前半段完全正常所以特别容易漏。分段累加的正确逻辑在 2.2 节的buildTempoMap里已经写清楚了核心是那个elapsedSec累加器。这里再强调一次每遇到一个 tempo 事件先把从上一个 tempo 点到这个 tempo 点的时间累加进去再更新 tempo 值。还有一个问题是多轨合并。格式 1 的 MIDI 里每条轨道都是独立的 tick 时间轴合并的时候必须把所有事件按 tick 排序而不是简单地按轨道顺序拼接。我最早的实现是轨道一的所有事件 轨道二的所有事件结果就是所有音符按照轨道分成了几大块前面全是低音、后面全是旋律。这个 bug 一眼就能从可视化里看出来反过来说可视化本身就是最好的调试工具——如果你能看到音符的位置很多解析错误会以非常直观的形态暴露出来。再补一个细节轨道名和乐器名通常是 UTF-8 编码的文本解码要用new TextDecoder(utf-8).decode(bytes)不要用String.fromCharCode逐个拼那样中文轨道名会变乱码。这个在中文用户的 MIDI 文件里出现频率挺高。4.3 性能与视觉问题掉帧的两个真凶性能问题我只遇到过两个真正严重的其他都是小打小闹。真凶一每帧重建数组。早期版本我在渲染循环里用notes.filter(...)筛选可见音符。几千个元素的数组每秒 filter 六十次内存分配和垃圾回收直接把主线程压满表现是画面卡顿且音频偶尔断续因为调度定时器也被挤了。改成游标加提前 break 之后帧率从 30 稳定到 60。真凶二振荡器泄漏。有一段时间我发现页面播放几分钟后内存持续上涨。查了半天发现是某些事件分支里创建了振荡器但没调用stop()。Web Audio 节点在播放结束后如果不 stop会一直挂在图上不被回收。解决办法是在所有分支上都保证osc.stop()被调用并且用一个活跃节点集合记录它们在暂停和停止时统一清理。视觉上的问题主要就两个高 DPI 模糊前面讲过用 dpr 缩放解决以及滚动时的抖动感。抖动的根源是渲染位置直接用了ctx.currentTime而 rAF 的回调时机和音频时钟不完全对齐导致同一帧内的位置有微小跳变。我的处理是给位置加一点平滑——保留上一帧的位置按 0.2 的系数做插值。注意这只是视觉平滑调度层绝对不能做平滑那是会导致音画错位的。4.4 一份踩坑清单直接抄把上面这些浓缩成一份清单你复现的时候可以对照着检查解析前先校验MThd不合法直接给用户提示别硬解析。division的高位一定要判断SMPTE 格式单独处理。running status 只对通道消息生效元事件不更新它。0xC0和0xD0只有一个数据字节。note on 力度 0 等于 note off。用通道号 * 128 音高当活跃音符的键。tempo map 必须分段累加不能只读第一条。第一个 tempo 事件之前要用默认 500000 填充。调度器用 lookahead定时器只负责排程不负责发声。排程时间要加Math.max(eventTime, now 0.005)保护。后台标签页定时器会被节流提前量要动态放大。全局只能有一个 AudioContext。ctx.resume()必须在用户手势里调用。指数曲线的目标值不能为 0用 0.0001。包络的时间点必须按顺序设置。osc.stop()时间要比包络结束晚 20 毫秒。暂停和 seek 时清空所有活跃音符。Canvas 必须按 devicePixelRatio 缩放。渲染用游标不用 filter只在可见窗口内遍历。可视化平滑可以做调度平滑绝对不行。5. 让 Euphony 再往前走几步基础版本跑通之后我陆续加了几个功能也列了一些还没做的想法。这部分不算教程更像是给自己和别人留的路标——如果你也想写一个类似的东西可以按这个顺序往上叠每一步都能独立看到效果。5.1 音色这一层能做到什么程度现在用的是振荡器加包络好处是零资源、秒启动坏处是音色电子味很重跟真实乐器差距明显。想把音色往上提一个档次有三条路。最省事的是多加几层振荡器做叠加。一个音符同时用两个或三个振荡器彼此稍微失谐比如相差 3 到 7 音分听起来会宽厚很多这就是俗称的 detune。代价是节点数量翻两三倍几千音符的曲子要留意性能。进阶一点的是加一个滤波器。给每个音符接一个低通滤波器用包络控制截止频率模拟钢琴或弦乐那种起音明亮、随后变暗的特性。这一步的效果提升很明显参数也不复杂截止频率从 4000 Hz 在 0.4 秒内衰减到 800 Hz听起来就有木质乐器的感觉了。最彻底的是加载 SoundFont 采样。这条路能拿到接近真实乐器的音色但代价是要么把几十兆的采样打进包里要么从远端流式加载还要处理采样循环、力度分层、音域映射。如果 Euphony 的目标是当作练习和观察工具我觉得前两条路就够了第三条更适合专门的音乐软件。顺带提一个容易被忽略的点通道 10索引 9是打击乐通道里面的音高不是音阶而是不同打击乐器的编号。如果按普通音高去合成听起来会是一串莫名其妙的音。处理办法是给这个通道单独一套短促的噪声音色或者简单地用一个很短的包络。5.2 可视化还能怎么玩现在的钢琴卷帘加频谱只是基本款可视化的空间其实很大。一个方向是给每条通道做独立泳道。现在的实现把八个通道叠在同一张卷帘上靠颜色区分通道一多就有点花。改成上下分泳道每条通道一行一眼就能看出哪条轨道什么时候进来、什么时候休息对分析编曲结构特别有用。这个改动主要是布局计算不涉及新的绘制技术。另一个方向是把控制器数据也画出来。MIDI 里的控制器 1 号调制轮和 11 号表情是让音乐有呼吸感的关键把它们画成随时间变化的曲线叠在卷帘下方你就能直观看到这个长音为什么听起来在起伏。这个功能实现起来不难控制器事件解析出来之后跟音符一样做坐标映射即可视觉上的信息增益却很大。再进一步可以试试把音符映射成三维的粒子系统。这个时候 WebGL 就值得上了因为粒子的数量级和混合模式是 Canvas 2D 撑不住的。不过要提醒一句视觉花哨和能看懂是两回事我做可视化的原则始终是图形要能回答这是什么和现在到哪了这两个问题纯粹为了好看而加的动效加之前先想清楚它是不是在传递信息。5.3 工程化和分发上的几个小决定最后说几个工程层面的决定这些不影响功能但影响别人用不用得起来。离线导出。我加了一个导出 WAV功能用的是OfflineAudioContext。它可以在不发声的情况下以远超实时的速度把整首曲子渲染成一段音频缓冲然后编码成 WAV 下载。实现思路是把所有音符按时间一次性排进离线上下文调startRendering()拿到 AudioBuffer 之后写 WAV 头。这个功能对于我想把这个 MIDI 转成音频发给人听的场景特别实用而且因为离线渲染不受实时调度抖动影响导出来的音质反而比在线播放更整齐。把解析放到 Worker 里。一个几十万字节的 MIDI 文件解析过程可能占用几十毫秒如果放在主线程会让页面卡一下。把解析层挪到 Web Worker主线程只管接收结果和渲染体感会顺畅很多。数据传递的时候用Transferable转移 ArrayBuffer避免结构化克隆的开销。接入外部 MIDI 键盘。如果你的目标是做教学演示可以试试navigator.requestMIDIAccess()直接接收真实键盘的 MIDI 消息。这时候就不需要文件解析了把实时消息送进同一个可视化管线弹一个音画一个音用来给别人讲音符和声音是怎么对应的效果比任何静态图都好。我自己在这个项目上花的时间大概有三分之一在写解析和调度三分之二在调那些看起来能跑但就是有点不对的细节。最后想分享一个小体会调试音频程序的时候先把可视化做出来再做播放顺序反了会很痛苦。因为一旦你能看见音符在哪声音不对的时候你就知道是时间算错了还是合成器配错了排查效率能差好几倍。这也是我现在做类似工具的一个固定套路——先让数据可见再让它可听。