Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 339

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 392

Deprecated: parse_url(): Passing null to parameter #1 ($url) of type string is deprecated in /www/wwwroot/blog_qqvbc_com/usr/plugins/Access/Access_Core.php on line 394
Joyber 的博客

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>后端MIDI时序 - 前端鼓播放器</title>
<style>
*{box-sizing:border-box;font-family:system-ui}
body{max-width:820px;margin:24px auto;padding:0 12px}
#log{height:260px;overflow:auto;border:1px solid #aaa;padding:10px;margin:12px 0;font-family:monospace;font-size:12px}
button{padding:9px 18px;margin:5px;cursor:pointer;font-size:14px}
#tip{color:#666;margin:8px 0}
#progressWrap{width:100%;height:24px;border:1px solid #777;margin:10px 0;position:relative;overflow:hidden}
#progressBar{height:100%;width:0%;background:#4488dd;transition:none}
#visualWrap{width:100%;height:140px;border:1px solid #777;margin:10px 0;position:relative;background:#f7f7f7;overflow:hidden}
.drumMarker{position:absolute;width:8px;height:20px;background:#222;top:60px}
#playHead{position:absolute;width:2px;height:100%;background:#ff3333;top:0;left:0px}
</style>
</head>
<body>
<h2>后端获取MIDI鼓时序 · 前端播放</h2>
<div>
    <button id="btnFetch">【1】从后端加载鼓时序JSON</button>
    <button id="btnPlay" disabled>【2】播放</button>
    <button id="btnStop" disabled>停止</button>
</div>
<div id="tip">
说明:默认先模拟后端数据;把fetch里url改成你的php接口地址,即可读取PHP解码MIDI输出的鼓点。
</div>

<div id="progressWrap">
    <div id="progressBar"></div>
</div>
<div id="visualWrap">
    <div id="playHead"></div>
</div>

<div id="log"></div>

<script>
const logEl = document.getElementById('log');
const btnFetch = document.getElementById('btnFetch');
const btnPlay = document.getElementById('btnPlay');
const btnStop = document.getElementById('btnStop');
const progressBar = document.getElementById('progressBar');
const visualWrap = document.getElementById('visualWrap');
const playHead = document.getElementById('playHead');

let audioCtx = null;
let drumList = [];
let isLoaded = false;
let totalDuration = 0;
let animationFrameId = null;
let playStartAudioTime = 0;
let isPlaying = false;

function appendLog(text){
    logEl.innerHTML += text + "\n";
    logEl.scrollTop = logEl.scrollHeight;
}

function clearVisualMarkers() {
    const markers = visualWrap.querySelectorAll('.drumMarker');
    markers.forEach(m=>m.remove());
}

function renderDrumMarkers() {
    clearVisualMarkers();
    if(totalDuration <= 0) return;
    const wrapWidth = visualWrap.clientWidth;
    drumList.forEach(ev=>{
        const marker = document.createElement('div');
        marker.className = 'drumMarker';
        const leftPercent = (ev.time / totalDuration) * 100;
        marker.style.left = leftPercent + "%";
        if(ev.note === 36) marker.style.background="#222222";
        else if(ev.note ===38) marker.style.background="#555555";
        else if(ev.note ===42) marker.style.background="#884422";
        visualWrap.appendChild(marker);
    })
}

function updateProgress() {
    if(!isPlaying || !audioCtx) return;
    const currentAudioTime = audioCtx.currentTime;
    const elapsed = currentAudioTime - playStartAudioTime;
    let percent = 0;
    if(totalDuration>0){
        percent = Math.min(100, (elapsed / totalDuration)*100);
    }
    progressBar.style.width = percent + "%";
    playHead.style.left = percent + "%";

    if(elapsed >= totalDuration){
        stopPlayback();
        return;
    }
    animationFrameId = requestAnimationFrame(updateProgress);
}

function playDrum(ctx, note, vel, startTime) {
    const gainVal = vel / 127;
    const mainGain = ctx.createGain();
    mainGain.gain.value = 0;

    if(note === 36) {
        const osc = ctx.createOscillator();
        osc.type = 'sine';
        osc.frequency.setValueAtTime(55, startTime);
        osc.frequency.exponentialRampToValueAtTime(18, startTime + 0.32);
        mainGain.gain.setValueAtTime(gainVal * 0.9, startTime);
        mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.32);
        osc.connect(mainGain);
        mainGain.connect(ctx.destination);
        osc.start(startTime);
        osc.stop(startTime + 0.32);
    } else if(note === 38) {
        const osc = ctx.createOscillator();
        osc.type = 'triangle';
        osc.frequency.setValueAtTime(170, startTime);
        mainGain.gain.setValueAtTime(gainVal * 0.65, startTime);
        mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.22);
        osc.connect(mainGain);

        const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.2, ctx.sampleRate);
        const data = noiseBuf.getChannelData(0);
        for(let i=0;i<data.length;i++) data[i] = Math.random()*2-1;
        const noiseSrc = ctx.createBufferSource();
        noiseSrc.buffer = noiseBuf;
        const filter = ctx.createBiquadFilter();
        filter.type = "highpass";
        filter.frequency.value = 4500;
        noiseSrc.connect(filter);
        filter.connect(mainGain);
        noiseSrc.start(startTime);

        mainGain.connect(ctx.destination);
        osc.start(startTime);
        osc.stop(startTime + 0.22);
    } else if(note === 42) {
        const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.14, ctx.sampleRate);
        const data = noiseBuf.getChannelData(0);
        for(let i=0;i<data.length;i++) data[i] = Math.random()*2-1;
        const noiseSrc = ctx.createBufferSource();
        noiseSrc.buffer = noiseBuf;
        const filter = ctx.createBiquadFilter();
        filter.type = "highpass";
        filter.frequency.value = 7500;
        noiseSrc.connect(filter);
        filter.connect(mainGain);
        mainGain.gain.setValueAtTime(gainVal * 0.45, startTime);
        mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.14);
        noiseSrc.start(startTime);
        mainGain.connect(ctx.destination);
    }
}

btnFetch.onclick = async ()=>{
    appendLog("请求后端鼓时序数据...");
    try{
        // const res = await fetch("./midi_decode.php");
        // drumList = await res.json();

        drumList = [
            {time:0.0, note:36, vel:100},
            {time:0.5, note:42, vel:80},
            {time:1.0, note:38, vel:95},
            {time:1.5, note:42, vel:80},
            {time:2.0, note:36, vel:100},
            {time:2.5, note:42, vel:80},
            {time:3.0, note:38, vel:95},
            {time:3.5, note:42, vel:80},
            {time:4.0, note:36, vel:100},
        ];

        if(drumList.length>0){
            totalDuration = drumList.reduce((max,item)=> Math.max(max, item.time),0);
        }else{
            totalDuration = 0;
        }
        isLoaded = true;
        btnPlay.disabled = false;
        appendLog("成功拿到时序,共"+drumList.length+"个鼓点,总时长:"+totalDuration.toFixed(2)+"s");
        renderDrumMarkers();
        drumList.forEach(e=>{
            appendLog(" > t:"+e.time.toFixed(2)+"s | note:"+e.note+" vel:"+e.vel);
        })
    }catch(err){
        appendLog("获取后端数据失败:" + err.message);
        console.error(err);
    }
};

function stopPlayback(){
    isPlaying = false;
    if(animationFrameId){
        cancelAnimationFrame(animationFrameId);
        animationFrameId = null;
    }
    progressBar.style.width = "0%";
    playHead.style.left = "0%";
    appendLog("播放停止\n");
}

btnPlay.onclick = ()=>{
    if(!isLoaded) return;
    if(isPlaying) return;
    if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    isPlaying = true;
    playStartAudioTime = audioCtx.currentTime;
    appendLog("\n===== 开始播放 =====");
    drumList.forEach(ev=>{
        const playAt = playStartAudioTime + ev.time;
        playDrum(audioCtx, ev.note, ev.vel, playAt);
    })
    updateProgress();
};

btnStop.onclick = ()=>{
    stopPlayback();
    if(audioCtx){
        audioCtx.close();
        audioCtx = null;
    }
}
</script>
</body>
</html>

两个关键点说明

  1. 刚才那个原生WebAudio版本不是真实采样音源,是实时算法合成声音(振荡器+白噪声模拟鼓,所以不用加载外部音频文件,离线也能用),音质偏简易,适合验证时序逻辑。
  2. Magenta那个midi-player没看到播放按钮:<midi-player> 自定义组件,部分浏览器需要加载成功后才渲染播放控件,而且它是完整MIDI播放器,和你「后端返回时序JSON,前端播放」的目标不完全匹配。

下面给你最终目标版本:
后端PHP输出 [{"time":秒, "note":音符号, "vel":力度},...] JSON时序
前端ajax请求后端拿到时序列表
WebAudio原生合成鼓(底鼓/军鼓/闭踩镲),无外部音源依赖,离线可用
带播放、停止按钮,日志输出
预留替换接口:以后可以把简易合成换成真实采样音源

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>后端MIDI时序 - 前端鼓播放器</title>
<style>
*{box-sizing:border-box;font-family:system-ui}
body{max-width:820px;margin:24px auto;padding:0 12px}
#log{height:320px;overflow:auto;border:1px solid #aaa;padding:10px;margin:12px 0;font-family:monospace;font-size:12px}
button{padding:9px 18px;margin:5px;cursor:pointer;font-size:14px}
#tip{color:#666;margin:8px 0}
</style>
</head>
<body>
<h2>后端获取MIDI鼓时序 · 前端播放</h2>
<div>
    <button id="btnFetch">【1】从后端加载鼓时序JSON</button>
    <button id="btnPlay" disabled>【2】播放</button>
    <button id="btnStop" disabled>停止</button>
</div>
<div id="tip">
说明:默认先模拟后端数据;把fetch里url改成你的php接口地址,即可读取PHP解码MIDI输出的鼓点。
</div>
<div id="log"></div>

<script>
const logEl = document.getElementById('log');
const btnFetch = document.getElementById('btnFetch');
const btnPlay = document.getElementById('btnPlay');
const btnStop = document.getElementById('btnStop');

let audioCtx = null;
let drumList = []; // 存放后端返回时序数据
let isLoaded = false;

function appendLog(text){
    logEl.innerHTML += text + "\n";
    logEl.scrollTop = logEl.scrollHeight;
}

// ======================
// WebAudio 合成鼓发生器
// note: GM鼓编号 36底鼓,38军鼓,42闭踩镲
// ======================
function playDrum(ctx, note, vel, startTime) {
    const gainVal = vel / 127;
    const mainGain = ctx.createGain();
    mainGain.gain.value = 0;

    if(note === 36) {
        // 底鼓 Kick
        const osc = ctx.createOscillator();
        osc.type = 'sine';
        osc.frequency.setValueAtTime(55, startTime);
        osc.frequency.exponentialRampToValueAtTime(18, startTime + 0.32);
        mainGain.gain.setValueAtTime(gainVal * 0.9, startTime);
        mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.32);
        osc.connect(mainGain);
        mainGain.connect(ctx.destination);
        osc.start(startTime);
        osc.stop(startTime + 0.32);
    } else if(note === 38) {
        // 军鼓 Snare
        const osc = ctx.createOscillator();
        osc.type = 'triangle';
        osc.frequency.setValueAtTime(170, startTime);
        mainGain.gain.setValueAtTime(gainVal * 0.65, startTime);
        mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.22);
        osc.connect(mainGain);

        // 军鼓叠加白噪声
        const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.2, ctx.sampleRate);
        const data = noiseBuf.getChannelData(0);
        for(let i=0;i<data.length;i++) data[i] = Math.random()*2-1;
        const noiseSrc = ctx.createBufferSource();
        noiseSrc.buffer = noiseBuf;
        const filter = ctx.createBiquadFilter();
        filter.type = "highpass";
        filter.frequency.value = 4500;
        noiseSrc.connect(filter);
        filter.connect(mainGain);
        noiseSrc.start(startTime);

        mainGain.connect(ctx.destination);
        osc.start(startTime);
        osc.stop(startTime + 0.22);
    } else if(note === 42) {
        // 闭踩镲 HH Closed
        const noiseBuf = ctx.createBuffer(1, ctx.sampleRate * 0.14, ctx.sampleRate);
        const data = noiseBuf.getChannelData(0);
        for(let i=0;i<data.length;i++) data[i] = Math.random()*2-1;
        const noiseSrc = ctx.createBufferSource();
        noiseSrc.buffer = noiseBuf;
        const filter = ctx.createBiquadFilter();
        filter.type = "highpass";
        filter.frequency.value = 7500;
        noiseSrc.connect(filter);
        filter.connect(mainGain);
        mainGain.gain.setValueAtTime(gainVal * 0.45, startTime);
        mainGain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.14);
        noiseSrc.start(startTime);
        mainGain.connect(ctx.destination);
    }
}

// ======================
// 【获取后端时序接口】
// ======================
btnFetch.onclick = async ()=>{
    appendLog("请求后端鼓时序数据...");
    try{
        // ========== 这里改成你的PHP接口地址 ==========
        // const res = await fetch("./midi_decode.php");
        // drumList = await res.json();

        // 模拟后端返回JSON(测试用,上线注释掉上面模拟,启用fetch)
        drumList = [
            {time:0.0, note:36, vel:100},
            {time:0.5, note:42, vel:80},
            {time:1.0, note:38, vel:95},
            {time:1.5, note:42, vel:80},
            {time:2.0, note:36, vel:100},
            {time:2.5, note:42, vel:80},
            {time:3.0, note:38, vel:95},
            {time:3.5, note:42, vel:80},
            {time:4.0, note:36, vel:100},
        ];
        // ===========================================

        isLoaded = true;
        btnPlay.disabled = false;
        appendLog(` 成功拿到时序,共${drumList.length}个鼓点`);
        drumList.forEach(e=>{
            appendLog(` > t:${e.time.toFixed(2)}s | note:${e.note} vel:${e.vel}`);
        })
    }catch(err){
        appendLog(" 获取后端数据失败:" + err.message);
        console.error(err);
    }
};

// 播放
btnPlay.onclick = ()=>{
    if(!isLoaded) return;
    if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    const startTime = audioCtx.currentTime;
    appendLog("\n===== 开始播放 =====");
    drumList.forEach(ev=>{
        const playAt = startTime + ev.time;
        playDrum(audioCtx, ev.note, ev.vel, playAt);
    })
};

// 停止
btnStop.onclick = ()=>{
    if(audioCtx){
        audioCtx.close();
        audioCtx = null;
    }
    appendLog(" 播放停止\n");
}
</script>
</body>
</html>

PHP后端改造:输出JSON时序

修改你之前的midi_decode.php,增加一个接口函数,只输出鼓通道ch=9的时序数组:

// 在MidiDecoder类里面新增这个函数
public function getDrumJson():array
{
    $out = [];
    foreach ($this->tracks as $events) {
        foreach ($events as $e) {
            if ($e['type'] === 'note_on' && $e['ch'] === 9) {
                // 过滤 vel=0 的NoteOn(MIDI标准等价NoteOff,忽略)
                if($e['vel'] === 0) continue;
                $sec = $this->tickToSeconds($e['tick']);
                $out[] = [
                    'time' => $sec,
                    'note' => $e['note'],
                    'vel' => $e['vel']
                ];
            }
        }
    }
    return $out;
}

// 文件末尾,接口模式:直接输出JSON
// $decoder = new MidiDecoder();
// $midFile = './test.mid';
// if ($decoder->loadFile($midFile)) {
//     header("Content-Type: application/json");
//     echo json_encode($decoder->getDrumJson(), JSON_UNESCAPED_UNICODE);
//     exit;
// }
当访问 midi_decode.php,后端直接返回JSON数组,前端fetch拿到,完美对接。

扩展规划(后续可选)

  1. 增加更多鼓音色:吊镲、叮叮镲、踩镲CC控制(开踩镲)
  2. 增加播放进度条,可视化动态鼓谱(DTX风格)
  3. 替换简易算法合成 → 加载真实鼓采样wav,音质升级

使用步骤

  1. 保存html,浏览器打开
  2. 点【从后端加载鼓时序JSON】,加载模拟数据
  3. 点【播放】,听到鼓声
  4. 测试没问题后,把前端fetch注释掉模拟数据,换成请求php接口

用 Web Audio 原生 API,不引入外部 soundfont 库,用内置正弦波简单模拟鼓声,完全不依赖外网音源,不会报undefined。

这个原生版本优势

完全不需要外部 CDN 音源,不会报 xxx is not defined
浏览器直接打开,离线可用
用 WebAudio 原生振荡器 + 噪声模拟底鼓、军鼓、踩镲
可以直接对接 PHP 输出的 JSON 鼓点数组,替换 drumEvents 即可

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>鼓点时序演示(原生WebAudio,无外部依赖)</title>
<style>
*{box-sizing:border-box;font-family:system-ui}
body{max-width:800px;margin:20px auto;padding:0 10px}
#log{height:300px;overflow:auto;border:1px solid #ccc;padding:8px;margin:10px 0;font-family:monospace;font-size:12px}
button{padding:8px 16px;margin:4px;cursor:pointer}
</style>
</head>
<body>
<h2>原生WebAudio 鼓点演示(无外部CDN音源)</h2>
<div>
<button id="btnPlay">播放鼓序列</button>
<button id="btnStop">停止</button>
</div>
<div id="log"></div>

<script>
const logEl = document.getElementById('log');
const btnPlay = document.getElementById('btnPlay');
const btnStop = document.getElementById('btnStop');

let audioCtx = null;
let stopFlag = false;

// 鼓点时序,和PHP输出JSON格式一致
const drumEvents = [
    {time:0.0, note:36, vel:100},
    {time:0.5, note:42, vel:80},
    {time:1.0, note:38, vel:95},
    {time:1.5, note:42, vel:80},
    {time:2.0, note:36, vel:100},
    {time:2.5, note:42, vel:80},
    {time:3.0, note:38, vel:95},
    {time:3.5, note:42, vel:80},
    {time:4.0, note:36, vel:100},
];

function appendLog(text){
    logEl.innerHTML += text + "\n";
    logEl.scrollTop = logEl.scrollHeight;
}

// 简易合成鼓音色
function playDrum(ctx, note, vel, startTime) {
    const gain = vel / 127;
    const osc = ctx.createOscillator();
    const gainNode = ctx.createGain();
    osc.connect(gainNode);
    gainNode.connect(ctx.destination);

    if(note === 36){
        // 底鼓
        osc.type = 'sine';
        osc.frequency.setValueAtTime(60, startTime);
        osc.frequency.exponentialRampToValueAtTime(20, startTime + 0.25);
        gainNode.gain.setValueAtTime(gain*0.8, startTime);
        gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + 0.3);
        osc.start(startTime);
        osc.stop(startTime + 0.3);
    }else if(note === 38){
        // 军鼓
        osc.type = 'triangle';
        osc.frequency.setValueAtTime(180, startTime);
        gainNode.gain.setValueAtTime(gain*0.6, startTime);
        gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + 0.2);
        osc.start(startTime);
        osc.stop(startTime + 0.2);
    }else if(note === 42){
        // 闭踩镲(白噪声模拟)
        const bufferSize = ctx.sampleRate * 0.15;
        const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
        const data = buffer.getChannelData(0);
        for (let i=0;i<bufferSize;i++) data[i] = Math.random()*2-1;
        const noise = ctx.createBufferSource();
        noise.buffer = buffer;
        const filter = ctx.createBiquadFilter();
        filter.type = "highpass";
        filter.frequency.value = 7000;
        noise.connect(filter);
        filter.connect(gainNode);
        gainNode.gain.setValueAtTime(gain*0.4, startTime);
        gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + 0.12);
        noise.start(startTime);
    }
}

btnPlay.onclick = async ()=>{
    if(!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    stopFlag = false;
    const start = audioCtx.currentTime;
    appendLog("=== 开始播放鼓序列 ===");
    drumEvents.forEach(ev=>{
        const t = start + ev.time;
        playDrum(audioCtx, ev.note, ev.vel, t);
        appendLog(`预定: ${ev.time.toFixed(2)}s | note:${ev.note} vel:${ev.vel}`);
    })
};
btnStop.onclick = ()=>{
    if(audioCtx) audioCtx.close();
    audioCtx = null;
    appendLog(" 停止");
}
</script>
</body>
</html>

核心结论

  1. MIDI会记录乐器标识(Program Change,音色号),但不是全部乐器;打击乐通道(ch9,MIDI通道10)是特殊规则,不用Program Change。
  2. 理论上:解码MIDI,在对应时间触发对应音符+对应音色,就能播放出整首曲子;
  3. 但是:MIDI本身不带音频采样,音色好坏、能不能发出声音,完全依赖「音源」。

一、乐器标识怎么记录的

MIDI通道 0~15:

  • 普通旋律通道(ch0~ch8, ch11~ch15):用 Program Change(程序改变事件),一个0~127数字。
    这个数字对应GM标准音色库:

    • 0=钢琴,24=原声吉他,40=小提琴,等等。

      比如事件:PROGRAM ch0 prog 0 → 0号通道切换为钢琴。
      之后这个通道的NoteOn音符,就用钢琴音色发声。
  • 打击乐通道 ch9(MIDI标准叫通道10)
    这个通道不使用Program Change!
    这个通道里音符编号本身就代表不同鼓/镲。
    note36=底鼓,note38军鼓,note42闭踩镲……
    不管你发什么Program Change,GM音源都会忽略,永远按打击乐表映射。

注意:

  1. Program Change只是选择音色编号,不是保存声音;
  2. 标准是 GM / GM2 / GS / XG。如果MIDI是GS/XG扩展音色,普通GM音源播放会音色错乱。

二、是不是解码+按时触发音符就能播放?

原理上是的,完整流程:

  1. 解析MIDI,得到所有事件:时间点、通道、音色号、音符、力度。
  2. 准备一个音源(SoundFont / 硬件音源):里面存放所有乐器的音频采样(wav采样)。
  3. 按计算出来的时间,在对应时刻通知音源:

    通道X,音色N,音符Y,力度V → 播放这个采样;NoteOff就停止/释放采样。

两个关键坑(非常重要)

坑1:音源不一样,听感完全不一样

MIDI只写「要用0号钢琴」,不写钢琴的音频。

  • 用简陋GM软音源:钢琴听起来像电子玩具;
  • 用高质量SoundFont(比如大钢琴sf2):听起来很真实;

    同一个MIDI文件,换音源,整首歌质感天差地别。

坑2:还有很多附加控制事件,漏掉会很难听

MIDI不止音符,还有:

  • CC控制器:音量、声像、踩镲开闭、延音踏板、表情;
  • 弯音Pitch Wheel:滑音;
    如果你的播放器只处理NoteOn/NoteOff,忽略CC事件:
  • 踩镲不会开合;
  • 延音踏板失效;
  • 音量没有强弱起伏;
    曲子会变得干巴巴,甚至鼓组踩镲声音错乱(DTX鼓谱非常依赖CC踩镲信号)。

三、结合你PHP解码器来说

你现在PHP代码只能做到解析读取MIDI事件,PHP本身不能直接播放声音。
两种方案实现播放:

  1. 方案A:PHP解析MIDI → 提取事件+时间轴 → 交给前端JS音源(比如WebMIDI、SoundFont.js)在浏览器播放。
  2. 方案B:PHP解析,导出事件列表 → 送入后端软音源(如fluidsynth)渲染导出wav音频文件。
适合你的电鼓场景:
解析ch9打击乐通道,提取 [time, note, velocity] 列表,就可以用来做DTX谱、动态鼓谱,不一定需要渲染完整音乐。

四、一句话总结

MIDI记录了用哪个音色、什么时候按下哪个音符、力度多大、各种控制器参数;
只要有音源(乐器采样库),并且按时序执行全部事件(音符+CC),就可以还原整首乐曲;
MIDI本身不带任何声音采样,没有音源就发不出声音。

<?php
class MidiDecoder
{
    public string $raw;
    public int $ptr = 0;
    public array $header = [];
    public array $tracks = [];

    // 新增:全局时间参数
    public int $tpqn;
    public float $microsecondsPerQuarter = 500000; // 默认 120BPM: 500000微秒/四分音符
    public float $bpm = 120.0;
    public array $tempoEvents = []; // 保存所有tempo变更点 delta => microseconds
    public array $allTrackEvents = []; // 合并全部事件,用于时间计算

    public function loadFile(string $filePath): bool
    {
        if (!is_file($filePath)) return false;
        $this->raw = file_get_contents($filePath);
        $this->ptr = 0;
        $this->tempoEvents = [];
        $this->allTrackEvents = [];
        return $this->parseHeader();
    }

    // 读取N字节,大端序
    protected function readBig(int $bytes): int
    {
        $bin = substr($this->raw, $this->ptr, $bytes);
        $this->ptr += $bytes;
        $unpack = unpack('N', str_pad($bin, 4, chr(0), STR_PAD_LEFT));
        return $unpack[1];
    }

    // VLQ变长整数解码
    protected function readVLQ(): int
    {
        $val = 0;
        while (true) {
            $b = ord($this->raw[$this->ptr++]);
            $val = ($val << 7) | ($b & 0x7F);
            if (($b & 0x80) === 0) break;
        }
        return $val;
    }

    protected function parseHeader(): bool
    {
        $id = substr($this->raw, $this->ptr, 4);
        $this->ptr += 4;
        if ($id !== 'MThd') return false;

        $len = $this->readBig(4);
        $fmt = $this->readBig(2);
        $trackCount = $this->readBig(2);
        $tpqn = $this->readBig(2);

        $this->header = [
            'format' => $fmt,
            'track_count' => $trackCount,
            'tpqn' => $tpqn
        ];
        $this->tpqn = $tpqn;

        for ($t = 0; $t < $trackCount; $t++) {
            $this->parseTrack($t);
        }
        return true;
    }

    protected function parseTrack(int $trackIndex)
    {
        $id = substr($this->raw, $this->ptr, 4);
        $this->ptr +=4;
        $trackLen = $this->readBig(4);
        $trackEnd = $this->ptr + $trackLen;

        $events = [];
        $runningStatus = 0;
        $tickTotal = 0;

        while ($this->ptr < $trackEnd) {
            $delta = $this->readVLQ();
            $tickTotal += $delta;

            $byte = ord($this->raw[$this->ptr]);
            if (($byte & 0x80) === 0) {
                $status = $runningStatus;
            } else {
                $status = $byte;
                $runningStatus = $byte;
                $this->ptr++;
            }

            $type = ($status >> 4) & 0x0F;
            $ch = $status & 0x0F;

            $ev = [
                'delta' => $delta,
                'tick' => $tickTotal,
                'track' => $trackIndex
            ];

            if ($status === 0xFF) {
                $metaType = ord($this->raw[$this->ptr++]);
                $metaLen = $this->readVLQ();
                $metaData = substr($this->raw, $this->ptr, $metaLen);
                $this->ptr += $metaLen;
                $ev['type'] = 'meta';
                $ev['meta_type'] = $metaType;
                $ev['data'] = $metaData;

                // BPM Tempo元事件 FF 51 03
                if ($metaType === 0x51 && $metaLen === 3) {
                    $ms = unpack('N', chr(0) . $metaData);
                    $this->tempoEvents[$tickTotal] = $ms[1];
                }
            } elseif ($status === 0xF0 || $status === 0xF7) {
                $sysexLen = $this->readVLQ();
                $data = substr($this->raw, $this->ptr, $sysexLen);
                $this->ptr += $sysexLen;
                $ev['type'] = 'sysex';
                $ev['status'] = $status;
                $ev['data'] = $data;
            } else {
                switch ($type) {
                    case 0x9:
                        $note = ord($this->raw[$this->ptr++]);
                        $vel = ord($this->raw[$this->ptr++]);
                        $ev['type'] = 'note_on';
                        $ev['ch'] = $ch;
                        $ev['note'] = $note;
                        $ev['vel'] = $vel;
                        break;
                    case 0x8:
                        $note = ord($this->raw[$this->ptr++]);
                        $vel = ord($this->raw[$this->ptr++]);
                        $ev['type'] = 'note_off';
                        $ev['ch'] = $ch;
                        $ev['note'] = $note;
                        $ev['vel'] = $vel;
                        break;
                    case 0xB:
                        $ccNum = ord($this->raw[$this->ptr++]);
                        $val = ord($this->raw[$this->ptr++]);
                        $ev['type'] = 'cc';
                        $ev['ch'] = $ch;
                        $ev['cc'] = $ccNum;
                        $ev['value'] = $val;
                        break;
                    case 0xC:
                        $prog = ord($this->raw[$this->ptr++]);
                        $ev['type'] = 'program';
                        $ev['ch'] = $ch;
                        $ev['program'] = $prog;
                        break;
                    case 0xE:
                        $b1 = ord($this->raw[$this->ptr++]);
                        $b2 = ord($this->raw[$this->ptr++]);
                        $pitch = ($b2 <<7) | $b1;
                        $ev['type'] = 'pitch';
                        $ev['ch'] = $ch;
                        $ev['value'] = $pitch;
                        break;
                    default:
                        $ev['type'] = 'unknown';
                        $ev['status'] = $status;
                        break;
                }
            }
            $events[] = $ev;
            $this->allTrackEvents[] = $ev;
        }
        $this->tracks[] = $events;
    }

    /**
     * 根据tick计算真实时间(秒),支持多段BPM变速
     */
    public function tickToSeconds(int $targetTick): float
    {
        ksort($this->tempoEvents);
        $lastTick = 0;
        $lastMicro = 500000;
        $totalSec = 0.0;

        foreach ($this->tempoEvents as $tick => $micro) {
            if ($targetTick <= $lastTick) break;
            $useTick = min($tick, $targetTick);
            $deltaTick = $useTick - $lastTick;
            $totalSec += $deltaTick * $lastMicro / $this->tpqn / 1000000;
            $lastTick = $tick;
            $lastMicro = $micro;
        }
        // 剩余tick
        if ($targetTick > $lastTick) {
            $deltaTick = $targetTick - $lastTick;
            $totalSec += $deltaTick * $lastMicro / $this->tpqn / 1000000;
        }
        return $totalSec;
    }

    // 打印全部事件,附带时间,增加过滤打击乐通道ch=9
    public function dumpWithTime(bool $onlyDrumChannel = false)
    {
        echo "==== MIDI HEADER ====\n";
        print_r($this->header);
        echo "\n==== TPQN: {$this->tpqn} ====\n";

        foreach ($this->tracks as $tkIdx => $events) {
            echo "\n--- Track #$tkIdx , total events: ".count($events)."\n";
            foreach ($events as $e) {
                $sec = $this->tickToSeconds($e['tick']);
                if ($onlyDrumChannel) {
                    if (!isset($e['ch']) || $e['ch'] !== 9) continue;
                }
                echo sprintf("tick:%6d | time:%.3fs | ", $e['tick'], $sec);
                echo match($e['type']) {
                    'note_on' => "NOTE_ON ch{$e['ch']} note{$e['note']} vel{$e['vel']}",
                    'note_off' => "NOTE_OFF ch{$e['ch']} note{$e['note']} vel{$e['vel']}",
                    'cc' => "CC ch{$e['ch']} cc{$e['cc']} val{$e['value']}",
                    'program' => "PROGRAM ch{$e['ch']} prog{$e['program']}",
                    'meta' => "META type{$e['meta_type']} len=".strlen($e['data']),
                    'sysex' => "SYSEX",
                    default => "UNKNOWN"
                };
                echo "\n";
            }
        }
    }
}

// ========= 使用示例 =========
$decoder = new MidiDecoder();
$midFile = './test.mid';
if ($decoder->loadFile($midFile)) {
    // 第二个参数true:只输出打击乐通道 ch=9(电鼓音符)
    $decoder->dumpWithTime(true);
} else {
    echo "解析失败,文件不存在或者不是合法MIDI";
}
?>

核心逻辑说明

  1. 收集所有FF 51 03 tempo事件,记录在哪个tick点变速
  2. tickToSeconds() 分段累加时间:
    [
    \text{秒数} = \frac{\text{tick数量} \times \text{每四分音符微秒数}}{\text{TPQN}} \div 1000000
    ]

    • microsecondsPerQuarter:一个四分音符占用多少微秒
    • BPM = 60000000 / microsecondsPerQuarter
    • 例:500000微秒/四分音符 → BPM=120
  3. 新增开关 $onlyDrumChannel = true,直接过滤只输出鼓通道ch=9,专门用于DTX/手卷鼓MIDI分析

用法

  • 只看鼓点并附带时间:$decoder->dumpWithTime(true);
  • 看全部音轨所有事件:$decoder->dumpWithTime(false);

扩展可选

如果你后续需要,我可以再加:

  1. 导出鼓点数组 [['time'=>0.5, 'note'=>36, 'vel'=>90],...] 直接存入数据库
  2. 过滤掉 vel=0 的NoteOn(MIDI标准里vel=0等价NoteOff,很多谱会这么写)
  3. 输出JSON