☰
Euphony:Web Audio API 的 MIDI 播放器与可视化实践
2026/10/1 4:11:36 网站建设 项目流程

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 各管一段

我最终落地的方案是三件套,各司其职。

文件解析用原生的FileReader+DataView,不引第三方 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/2,1 最常见 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 off,0x9是 note on,0xA是复音触后,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 = 480,tempo = 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 type="file" 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 消息。这时候就不需要文件解析了,把实时消息送进同一个可视化管线,弹一个音画一个音,用来给别人讲"音符和声音是怎么对应的",效果比任何静态图都好。

我自己在这个项目上花的时间,大概有三分之一在写解析和调度,三分之二在调那些"看起来能跑但就是有点不对"的细节。最后想分享一个小体会:调试音频程序的时候,先把可视化做出来再做播放,顺序反了会很痛苦。因为一旦你能看见音符在哪,声音不对的时候你就知道是时间算错了还是合成器配错了,排查效率能差好几倍。这也是我现在做类似工具的一个固定套路——先让数据可见,再让它可听。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询