Web MIDI 播放器:从二进制解析到音频调度与可视化
做 Web 端 MIDI 播放器这件事我最早的动机特别朴素不想为了听一段自己刚写完的旋律专门装一个几百 MB 的桌面音序器。浏览器里能播音频是常识但换成 MIDI 就完全是另一回事——MIDI 文件里没有一个采样点只有第几拍、按下哪个键、力度多大、什么时候松开这类指令。所以 Euphony 这个项目本质上被掰成了两半一半是跑在浏览器里的Web 端 MIDI 播放器负责把指令翻译成真正能听见的声音另一半是可视化工具负责把抽象的时间轴变成人眼能追得上的画面。很多人以为可视化只是顺便画个频谱但真做下来会发现它和播放引擎共享同一根时间轴两者一旦对不齐体验立刻崩掉。Euphony 适合谁看如果你正在做音乐相关的 Web 项目、想搞清楚 Web Audio 的调度机制、或者只是好奇浏览器怎么凭空发出钢琴声这篇都会有用。它不依赖任何特定框架前端用原生 JS 加 Canvas 就能跑起来也不需要后端——所有解析、调度、发声、绘制都在客户端完成。下面我把从二进制解析到画面渲染的整条链路拆开讲包括我踩过的那些坑以及为什么某些看起来很顺手的写法其实是错的。1. 浏览器里放 MIDI难点从来不在放1.1 先把 MIDI 文件、MIDI 消息和音频流分清楚这三个概念混在一起是绝大多数人一开始就走偏的原因。音频流是振幅随时间变化的采样序列44.1kHz 意味着一秒钟有 44100 个数直接存体积巨大。MIDI 文件Standard MIDI File简称 SMF走的是完全相反的路线它只记录演奏动作一个音符通常只占 3 到 4 个字节。MIDI 消息是运行时的指令包比如0x90 0x3C 0x64表示通道 0按下中央 C力度 100。理解这个差别就能明白为什么 MIDI 文件动辄只有几十 KB却能表现出几十秒甚至几分钟的完整编曲。也正因为如此播放 MIDI 需要一个翻译层把动作还原成声音。这个翻译层由谁来当决定了整个项目的架构——是浏览器内置的合成器、你自己写的振荡器、还是加载好的采样音源。我一开始天真地想过能不能直接找个audio标签播不行。MIDI 不是音频格式浏览器不会替你合成。这就是 Euphony 必须自己动手的根本原因。1.2 Euphony 的四层结构项目跑通之后我回头整理发现它的结构其实很清晰四层各管一件事层职责关键技术解析层把 .mid 二进制拆成事件表DataView、VLQ 解码、tick 换算调度层决定每个音符在什么时刻发声AudioContext.currentTime、前瞻调度发声层把音符变成听得见的声音OscillatorNode 或采样缓冲区渲染层把当前播放位置画出来requestAnimationFrame、Canvas 2D这四层里只有发声层是必须实时的其余三层都可以提前算好或者按帧刷新。把边界划清楚的好处是解析层可以完全同步地跑完不需要考虑性能抖动渲染层掉帧也不会影响声音因为它只读时间戳不做决策。我之前写过一个版本把渲染和调度混在一个循环里结果就是画得越复杂、声音越不稳排查了很久才发现是架构问题而不是性能问题。1.3 为什么不建议直接套一个现成播放库市面上确实有能播 MIDI 的 JS 库但我不建议在学习和可控性优先的场景下直接套。原因有三个。第一时间轴的所有权。库内部通常有自己的调度器和计时器你想在上面叠可视化就得反向去猜它的当前播放位置误差很难压住。第二音色不可控。多数轻量库用的是简单的振荡器或者固定音色遇到钢琴、弦乐、鼓组这种差异极大的音色需求就抓瞎。第三调试黑盒。MIDI 解析出问题时你拿到的是一个播放异常的表象很难定位到底是 VLQ 读错了还是 tempo 换算错了。自己写的好处是每一层的中间结果都能打印出来对比。我调试时最常用的手段就是把解析出来的事件表导出成 JSON和桌面软件里的钢琴卷帘逐个对照错一个 tick 都能立刻发现。1.4 一个容易被忽略的前提时间精度浏览器里能拿到的时间源有好几个精度和适用场景完全不同选错了后面全是坑时间源精度是否受主线程阻塞影响适合用来做什么Date.now()毫秒级是日志、粗略计时performance.now()亚毫秒、单调递增是性能测量、非音频逻辑setTimeout/setInterval毫秒级实际延迟不可控是轮询检查不能直接排音符requestAnimationFrame跟随刷新率后台会被节流是画面刷新AudioContext.currentTime采样级精度独立线程推进否音频调度、时间轴基准结论很直接只要是和发声相关的时刻计算一律用AudioContext.currentTime。它由音频线程推进主线程哪怕卡住 200 毫秒这个值也不会跑偏。这一条是 Euphony 后面所有设计的地基。2. 把 .mid 拆成二进制块解析层踩过的编码细节2.1 块结构与 VLQ两个必须先啃下来的基础SMF 的结构是经典的块套块。文件开头一定是 14 字节的头部块MThd4 字节 头部长度4 字节固定为 6 格式2 字节 轨道数2 字节 时间分辨率2 字节。头部之后是按顺序排列的轨道块每个以MTrk开头跟着 4 字节的数据长度然后是这一轨的全部事件。时间分辨率division这个字段有两种含义靠最高位区分最高位为 0 时表示每四分音符多少 tick比如 480最高位为 1 时表示 SMPTE 时间码用负数表示每秒多少帧。日常见到的文件基本都是前者但代码里最好两种都兜住不然遇到老旧文件会直接算错时间。块内部每个事件前面都有一个可变长度数量Variable-Length QuantityVLQ表示距离上一个事件过了多少 tick。它的编码方式是用字节的最高位当续接标志function readVarInt(view, offset) { let value 0; let byte; do { byte view.getUint8(offset); // 用乘法而不是左移避免超过 31 位后符号位出问题 value value * 128 (byte 0x7f); } while (byte 0x80); return { value, offset }; }这里有个细节值得单独说网上不少示例用(value 7) | (byte 0x7f)。在值不大的时候没问题但 JavaScript 的位运算会先把操作数转成 32 位有符号整数一旦 delta time 超过 2^31结果就会变成负数甚至完全错乱。改成乘加就没有这个隐患。我是在处理一首现场录音转出来的 MIDI 时踩到这个坑的那首曲子前奏有长达几十秒的空白delta time 特别大前面几百个音符全是错的。2.2 事件分类与 running status最容易解析错的四类字节读完 VLQ接下来的字节决定事件类型。高位为 1 的是状态字节高位为 0 的是数据字节。这里的关键机制叫running status如果当前字节是数据字节说明它复用了上一个事件的状态字节。这是为了压缩体积设计的但解析时如果忘了维护上一个状态整个事件流会立刻错位。事件大致分三类处理方式完全不同类型首字节特征数据长度处理要点MIDI 通道事件0x8n~0xEn1 或 2 字节需要判断通道注意力度 0 的 NoteOn系统专用消息0xF0/0xF7带 VLQ 长度直接跳过不要尝试解释内容元事件0xFF类型 1 字节 VLQ 长度只关心少数几种其余跳过通道事件里最需要小心的是NoteOn 力度为 0 等价于 NoteOff。这是 MIDI 规范里为了进一步压缩数据留下的设计解析器必须把它转成结束语义否则音符会一直响下去。我第一次测试时听到一片持续不断的嗡鸣就是这个原因。元事件里真正影响播放的只有三种Tempo0x513 字节单位是每四分音符多少微秒、Time Signature0x58用于显示小节线、End of Track0x2F。其余的比如 Track Name、Lyric、Marker虽然也能读但对播放没有影响我的做法是选择性读取并挂到轨道对象上方便界面里显示标题。2.3 变速点与 tick 到秒的换算这是解析层里最需要动脑的部分。MIDI 事件的时间是 tick但播放需要的是秒中间隔着 tempo。默认 tempo 是 500000 微秒每四分音符也就是 120 BPM。问题是曲子里经常有变速甚至同一时刻多个轨道各自插入 tempo 事件。正确的做法是把所有轨道的事件按 tick 归并排序然后扫描一遍遇到 tempo 事件就更新当前速度并把从上一个变速点到现在累积了多少秒记下来。最终每个事件都带上一个秒数// tempoMap: [{ tick, usPerQuarter }]按 tick 升序 function buildTimeMap(tempoMap, division) { const map [{ tick: 0, time: 0, usPerQuarter: 500000 }]; for (let i 0; i tempoMap.length; i) { const prev map[map.length - 1]; const deltaTicks tempoMap[i].tick - prev.tick; const seconds deltaTicks / division * (prev.usPerQuarter / 1e6); map.push({ tick: tempoMap[i].tick, time: prev.time seconds, usPerQuarter: tempoMap[i].usPerQuarter }); } return map; } function ticksToSeconds(map, division, tick) { let lo 0, hi map.length - 1; while (lo hi) { const mid (lo hi 1) 1; if (map[mid].tick tick) lo mid; else hi mid - 1; } const seg map[lo]; return seg.time (tick - seg.tick) / division * (seg.usPerQuarter / 1e6); }注意这里每个区间的换算用的是区间起点的 tempo而不是终点。tick 是相对量变速点之后的部分才使用新速度这一点如果搞反整首曲子会越到后面偏得越多。我为了验证这一块专门做了一组单元测试手工构造几个带变速的 MIDI用工具算出预期时长再和代码结果比对误差超过一毫秒就说明换算有问题。2.4 解析结果该长什么样事件表的归一化设计解析完之后不要直接把原始事件扔给调度层中间做一层归一化会省掉后面大量麻烦。我的做法是把音符事件单独抽出来形成一张扁平表{ time: 1.234, dur: 0.5, midi: 60, vel: 96, ch: 0, track: 2 }time和dur都是秒midi是音高编号vel是力度ch是通道用来区分不同乐器track是轨道索引用来做静音独奏。这张表按time升序排好之后调度器只需要一个游标往前推不需要反复搜索。同时保留一份轨道元数据通道、乐器号Program Change 决定、名字、音符数量、音量。界面上要做轨道列表、静音、独奏全靠这份数据。这里补一个经验单轨道里同一音高的重复音符必须成对匹配。钢琴曲里同一个键连按两次很常见如果只是简单地在 NoteOn 时记录开始、NoteOff 时结束遇到重叠就会算出负时长。稳妥做法是用一个栈或者队列按通道加音高做键先进先出匹配。我在处理一首装饰音很多的曲子时被这个坑绊过画出来的音符有负宽度Canvas 上直接看不见。3. 用音频时钟而不是 JS 定时器来排音符3.1 setTimeout 为什么会飘最直觉的写法是给每个音符算好毫秒数然后setTimeout(() play(note), ms)。这个方案在几百个音符以内可能听不出问题但只要曲子长一点、音符密一点就会明显糊掉尤其是鼓点。原因有两层。表层原因是setTimeout的最小延迟不稳定浏览器规范要求至少 4 毫秒实际还受主线程负载影响一次垃圾回收就能让它晚几十毫秒。深层原因是它没有补偿能力每个回调都是独立触发的前一个晚了 30 毫秒后一个不会自动提前误差会不断累积。更麻烦的是主线程一旦被长任务占住所有回调会挤在一起集中触发听起来就是一阵密集的突突声。这就是为什么专业做法都把什么时候发声这件事交给音频线程。3.2 前瞻调度器25 毫秒轮询100 毫秒预排Euphony 用的是经典的前瞻调度lookahead scheduling。原理很简单用一个低频的定时器定期醒来检查未来一小段时间内有哪些音符要响把这些音符提前交给 Web Audio 去精确安排而不是等到时刻到了才播。const LOOKAHEAD_MS 25; // 检查间隔 const SCHEDULE_AHEAD 0.12; // 提前排入的秒数 let cursor 0; // 事件表游标 let startTime 0; // 音频时钟上的播放起点 function schedulerTick() { const now ctx.currentTime; const horizon now SCHEDULE_AHEAD; while (cursor events.length) { const ev events[cursor]; const when startTime ev.time; if (when horizon) break; if (when now - 0.05) { // 已经过期的直接丢弃避免追着补 scheduleNote(ev, when); } cursor; } } setInterval(schedulerTick, LOOKAHEAD_MS);两个参数的含义要分清楚LOOKAHEAD_MS是多久检查一次它只影响排入的及时性不影响音准SCHEDULE_AHEAD是提前多久排进去它必须大于定时器可能的最大延迟。25 毫秒检查、120 毫秒预排是我在桌面和手机上反复试出来的一个平衡点预排太短主线程偶尔卡一下就会漏排预排太长暂停和跳转的响应就会变迟钝因为已经排进去的音符不好撤销。这里必须强调一点setInterval在这个架构里只负责叫醒调度器它晚了 50 毫秒也没关系因为音符的准确时刻是when这个从音频时钟推导出来的绝对时间由osc.start(when)保证精度。这就是用两个时钟的核心思路——按毫秒级的粗糙时钟做决策按采样级的精确时钟做执行。3.3 暂停、跳转、循环三种状态下的事件清理只要涉及提前排入就必须处理排错了怎么办。这是前瞻调度器最麻烦的地方也是我改得最多的一块。暂停的时候已经排进音频线程的未来音符不会自动取消它们会在你按下暂停后继续响一小段。解决办法是把所有活跃音源节点存起来暂停时逐个stop()。同时务必做一次全音符关闭All Notes Off也就是给每个通道发0xBn 0x7B 0x00不然在某些合成实现下会留下挂住的音。跳转seek需要重建游标把cursor用二分查找定位到目标时间之后第一个事件把startTime重设为ctx.currentTime - targetSeconds。注意这里不要把两边的数据都丢掉——已经发声并且还没结束的音符应该让它自然衰减完硬切会听到明显的咔声。我一般加一个 30 到 50 毫秒的淡出听起来干净很多。循环是在每个循环段结束时把游标重置回段起点同时把startTime加上段长度。这里有个小陷阱如果循环段很短比如不到一秒调度器可能在一次醒来时就把下一轮也排进去导致节奏错乱。我的做法是给调度加一个当前轮次已排到的时间上限超出循环终点就停手等游标重置后再继续。3.4 复音数控制与抢音策略MIDI 文件里同时发声的音符数量可以很夸张一台钢琴的延音踏板效果加上伴奏峰值上百个音很正常。每个音都创建一个振荡器加增益节点手机上大概率直接爆音或者卡顿。Euphony 的做法是设一个复音上限比如 24 或 32 个超了就按抢音处理。抢音的策略也有讲究策略做法听感表现丢弃新音超过上限就不发声密集段落会丢音但节奏稳抢占最弱音找当前力度最低的活跃音提前结束音色完整度损失最小抢占最旧音结束发声最早的那个长音会被切断适合打击乐按通道限额给每个通道单独限额保证主旋律不被伴奏挤掉我最后选的是按通道限额 抢占最弱音的组合给旋律通道通常是通道 0留够余量伴奏通道共享剩余额度超了就牺牲力度最小的那个。这样即使遇到编曲很满的 MIDI主旋律也始终清晰。4. 可视化把音乐的时间轴映射到画布上4.1 用音频时钟驱动渲染而不是数帧可视化最容易犯的错误是数帧推进。比如在requestAnimationFrame里每帧把播放位置加 16.7 毫秒看起来没问题但帧率一波动就全错了。60Hz 屏幕上是这个数120Hz 屏上就快一倍切到后台再回来直接跑飞。正确答案只有一个每一帧都从音频时钟重新算当前位置。let startTime 0; function renderLoop() { const elapsed ctx.currentTime - startTime; drawScene(elapsed); requestAnimationFrame(renderLoop); }这行代码看起来平平无奇但它带来两个决定性好处。一是画面和声音永远同步因为它们读的是同一个时钟。二是掉帧不会累积误差——这一帧画慢了下一帧直接从正确位置继续不会越拖越远。还有个小细节ctx.currentTime的更新粒度受音频缓冲区大小影响通常每 128 个采样更新一次也就是大约 2.9 毫秒。对画面来说够用了但如果要做精确到像素的播放头可以在两次音频时钟更新之间做一次线性插值用performance.now()补上那几毫秒的差。实测下来画面上几乎看不出跳跃。4.2 钢琴卷帘与瀑布流两种布局的取舍可视化布局我做了两套切换着用。钢琴卷帘是横轴时间、纵轴音高音符是横向的矩形条跟大多数音序器软件的编辑视图一致。瀑布流是音高在横轴、时间在纵轴向下落音符像雨点一样落到底部更接近演奏游戏的感觉。两种布局的取舍很实际维度钢琴卷帘瀑布流时间方向横向滚动纵向下落适合看什么整体结构、声部走向当前时刻、即将到来的音长曲表现需要横向缩放天然只显示窗口实现难度要处理滚动和缩放只需一个固定窗口观众友好度熟悉音乐的人看得懂不懂音乐也能感受到节奏我个人的偏好是看结构用卷帘看节奏用瀑布流。Euphony 里做成可切换切换时只换坐标映射函数绘制的核心逻辑是共用的——都遍历同一张事件表只画可视时间窗口内的音符。4.3 频谱和波形交给 AnalyserNode播放的同时要画频谱不要自己去做 FFTAnalyserNode已经把活干完了const analyser ctx.createAnalyser(); analyser.fftSize 2048; analyser.smoothingTimeConstant 0.8; masterGain.connect(analyser); const freqData new Uint8Array(analyser.frequencyBinCount); function drawSpectrum() { analyser.getByteFrequencyData(freqData); // freqData 已经是 0-255 的幅度直接映射成柱状高度即可 }几个参数的经验值fftSize决定频率分辨率2048 大约是 1024 个频率点屏幕宽度一千多像素刚好一对一smoothingTimeConstant控制柱子跳动的平滑程度0.8 左右看起来舒服调到 0 会闪得厉害调到 0.95 又显得迟钝。要画波形就把getByteFrequencyData换成getByteTimeDomainData拿到的是时域数据用来画示波器。两条数据可以同时取互不影响。注意AnalyserNode 采集的是它上游节点的输出。如果你的合成器有多个分支要确保 analyser 接在总线上否则只会分析到某一路通道。4.4 上千个音符的渲染优化一首五六分钟的交响曲音符数量破万很正常。如果每帧都全量重绘中低端设备会直接掉到 20 帧以下。Euphony 用了三个手段把开销压下来。第一静态层预渲染。音符的位置是固定的只有时间窗口在变。所以可以把整张事件表预先画成一张超大的离屏画布播放时只做一次裁切拷贝。我在桌面端试过两万音符的离屏图大约 4000×800 像素内存占用可以接受之后每帧绘制开销几乎是常数。代价是缩放时需要重画所以缩放结束再做一次预渲染。第二只画可视窗口内的音符。事件表按时间排序用二分查找定位窗口的起止索引中间那一段才进入绘制循环。这一步在长曲上效果非常明显因为可见范围通常只占全曲的百分之几。第三按音高排序批量绘制。同一音高的音符颜色相同把绘制调用按颜色聚拢可以减少状态切换。如果用 WebGL把所有音符塞进一个顶点缓冲区、一次drawArrays画完性能还能再上一个台阶。Euphony 目前只用 Canvas 2D因为对可视化工具来说文本标签、圆角矩形这些细节在 2D 上下文里写起来快得多而且实测两万音符也能稳在 60 帧。另外别忘了高分屏适配。devicePixelRatio是 2 的屏幕上如果画布只设了 CSS 尺寸画面会糊成一片const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);设置完之后所有绘制坐标都按 CSS 像素来写省得每个地方都乘一遍 DPR。5. 那些在真机上才会暴露的问题5.1 自动播放策略与 AudioContext 的挂起状态本地开发一切正常一上线就没声音——这是我第一次部署 Euphony 遇到的情况。原因在于浏览器的自动播放策略页面加载时创建的AudioContext会处于suspended状态必须在用户交互之后调用resume()才能启动。所以播放按钮的处理逻辑必须是这样async function onPlayClick() { if (ctx.state suspended) { await ctx.resume(); } startPlayback(); }有几点值得注意。第一resume()返回 Promise要等它完成再开始计时否则startTime会算早。第二如果resume()失败比如被判定为非用户手势要有降级提示不要静默失败。第三播放按钮上不要包一层防抖某些实现里延迟调用会被判定为脱离手势上下文。我一开始给按钮加了 200 毫秒防抖结果 iOS 上一直没声音查了半天才发现是这个原因。5.2 切后台之后画面和声音对不上这个现象很有迷惑性切到别的标签页待一会儿再回来画面上的播放头位置明显落后于实际听到的声音。切后台时requestAnimationFrame会被浏览器节流甚至完全暂停但音频线程不受影响音乐照常播放。回到前台后渲染循环恢复但它上一帧记录的状态还是切走之前的位置。因为我们是每帧从音频时钟重算位置的这个问题实际上会自动消失——恢复后第一帧就会跳到正确位置。但视觉上会有一个突兀的跳变。我的处理办法是监听visibilitychange切回前台时先做一次追赶判断如果时间差超过一秒就跳过中间的动画直接定位同时给画面加一个短暂的淡入让跳变看起来不那么硬。顺带说一个相关的坑有些浏览器在后台会限制定时器频率到每秒一次这会让前瞻调度器的检查间隔从 25 毫秒变成 1000 毫秒。好在预排是 120 毫秒撑不住这个间隔音乐会断。对策是把预排时间在检测到后台时临时拉长到两秒以上或者干脆用AudioWorklet里的时钟做调度。Euphony 目前用的是前者实现简单代价是回到前台后需要重建一次调度状态。5.3 音色方案的现实选择发声这一层的方案我反复权衡过最后做了三档实现并存方案实现方式优点缺点适合场景振荡器合成OscillatorNode 增益包络零加载、体积小音色电子味重快速预览、调试采样音源每个音高一个短音频文件音色真实加载体积大正式播放波形采样 变速每个乐器几段采样靠播放速率移调体积和音质平衡音域边缘会有失真通用方案我最后默认走的是第三档。每个乐器准备三到四段采样覆盖低、中、高音区中间的半音靠playbackRate调整。移调范围控制在 ±3 个半音以内超出就换到相邻采样。这样钢琴、弦乐、贝斯听起来都还算自然鼓组则必须用独立采样因为鼓的音高变化和音色变化不是一回事强行移调会让军鼓变成怪声。包络的写法上用exponentialRampToValueAtTime会比线性斜坡自然得多但要注意它的目标值不能是 0必须给一个很小的正数function playNote(freq, when, dur, velocity) { const osc ctx.createOscillator(); const gain ctx.createGain(); osc.type triangle; osc.frequency.setValueAtTime(freq, when); const peak (velocity / 127) * 0.25; const attack 0.005; const release Math.min(0.3, dur * 0.6); gain.gain.setValueAtTime(0.0001, when); gain.gain.exponentialRampToValueAtTime(peak, when attack); gain.gain.setTargetAtTime(0.0001, when dur, release / 3); osc.connect(gain).connect(masterGain); osc.start(when); osc.stop(when dur release 0.1); }0.0001这个几乎为零但不为零的值是必须的exponentialRampToValueAtTime不接受 0会直接抛异常。至于衰减为什么用setTargetAtTime而不是exponentialRampToValueAtTime是因为前者是指数趋近天然带有尾巴听起来更像真实乐器的自然衰减后者是精确到达反而僵硬。第三个参数是时间常数实际听感上大约是它的三倍时间衰减到可忽略的程度所以取release / 3。5.4 移动端与低端设备的适配移动端的问题集中在三处。第一是延迟。安卓上的音频输出延迟普遍比桌面高尤其是通过蓝牙时更明显。如果用户要用 Euphony 跟着弹琴必须提供延迟校准功能——让用户听着节拍点几下算出偏移量并全局补上。我给的可调范围是 -300 到 300 毫秒。第二是并发节点数。移动端创建振荡器节点的成本明显更高复音上限要降到 16 或 12。同时避免每帧创建和销毁节点能复用就复用。第三是省电。可视化的连续动画在手机上耗电很快因为频繁触发合成和绘制。我的做法是加一个省电模式把渲染帧率降到 30频谱图改成每两帧更新一次静止时停止渲染循环。实测下来耗电差异挺明显。还有一个小细节移动端浏览器在来电、切到其他应用之后AudioContext可能进入interrupted状态。监听到statechange时应该主动尝试resume()并且把播放位置续上而不是让整个播放卡死。6. 从能听到好用Euphony 后续可以怎么长6.1 轨道级的静音、独奏与音量Euphony 一开始只能整体播放用起来很别扭。加上轨道控制之后练习场景就成立了——把伴奏轨保留、旋律轨静音跟着弹。实现上要注意的是轨道控制不应该影响已经排入音频线程的音符。我的做法是让调度器在排音符时检查当前的静音状态而不是在渲染层做视觉隐藏。因为静音是一票否决的排进去再撤销代价太高。音量和声像则接在每条轨道的 GainNode 和 StereoPannerNode 上实时调整不会打断播放。还有一个细节轨道通道和轨道索引不是一回事。MIDI 格式 1 里一个轨道可以包含多个通道的事件多个轨道也可能共用同一个通道。做静音时我按轨道索引过滤做乐器选择时按通道区分两者分开处理才不会有歧义。6.2 键盘映射与练习模式可视化做出来之后最自然的延伸就是练习模式。做法是监听键盘事件把按键映射到音高然后和正在播放的 MIDI 做比对。命中就高亮偏差超过阈值就标出来。音高的判定我用了 ±50 音分也就是半个半音的容差超过就认为是错音。时间上给 ±150 毫秒的宽容度因为人手按键本身就不精确卡太紧会让人很挫败。这些数值纯属体感经验不同曲子差异很大所以我在界面里留了可调滑块。练习模式还带来一个额外的好处它会逼着你检查音高和按键的对应关系。我实现的时候才发现MIDI 音高 69 是标准音 A4440Hz换算公式是freq 440 * Math.pow(2, (midi - 69) / 12)。这个公式看起来简单但很多人会写成 440 乘以 2 的 midi/12 次方结果整首曲子移了几个八度。6.3 导出与分享最后一个方向是导出。可以导出成 WAV用OfflineAudioContext离线渲染——它和实时 AudioContext 的 API 几乎一样但会以最快速度跑完不受实时限制const offline new OfflineAudioContext(2, sampleRate * duration, sampleRate); // 在 offline 上重建同样的节点图按同样的时间排音符 const buffer await offline.startRendering();要注意的是离线渲染必须重新构建一遍节点图不能复用实时上下文里的节点。所以合理的架构是把构建节点图抽象成一个纯函数实时和离线两条路径都调用它。我重构过一次才把这个抽干净之前是两套代码改了一处忘了另一处导出结果和听到的完全不一样。导出音频还涉及编码问题。WAV 是无损的但文件大要压成别的格式得靠编码器这块我目前只做了 WAV够用。我自己在实际使用中体会最深的一点是这个项目的难点从来不在某一个技术点上而在几根时间轴的对齐上。二进制里的 tick、音频线程的采样时钟、屏幕的刷新周期三套节奏完全不同。只要坚持一条原则——所有涉及此刻是几点的判断都以AudioContext.currentTime为准其余的都只是它的投影——大部分看起来莫名其妙的同步问题都会自动消失。至于音色和界面那些是可以慢慢磨的东西真正决定体验好坏的是当你按下播放键的那一瞬间第一个音符有没有准时落在它该在的位置上。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →