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 的博客

文章:https://juejin.cn/post/7114869372144844814
一个演示站:https://kingrayhan.github.io/javascript-drum-kit/

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JS Drum kit</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.min.css">
    <link rel="stylesheet" href="css/style.css">

    <!--Open graph meta-->
    <meta property="og:url"                content="" />
    <meta property="og:type"               content="article" />
    <meta property="og:title"              content="Javascript Drum Kit" />
    <meta property="og:description"        content="Play Drum by keyboard" />
    <meta property="og:image"              content="images/og.jpg" />

</head>
<body>
<h1 class="title">JavaScript Drum Kit</h1>
<div class="keys">
    <p class="note">Use keyboard to play drum</p>
    <div data-key="65" class="key">
        <kbd>A</kbd>
        <span class="sound">clap</span>
    </div>
    <div data-key="83" class="key">
        <kbd>S</kbd>
        <span class="sound">hihat</span>
    </div>
    <div data-key="68" class="key">
        <kbd>D</kbd>
        <span class="sound">kick</span>
    </div>
    <div data-key="70" class="key">
        <kbd>F</kbd>
        <span class="sound">openhat</span>
    </div>
    <div data-key="71" class="key">
        <kbd>G</kbd>
        <span class="sound">boom</span>
    </div>
    <div data-key="72" class="key">
        <kbd>H</kbd>
        <span class="sound">ride</span>
    </div>
    <div data-key="74" class="key">
        <kbd>J</kbd>
        <span class="sound">snare</span>
    </div>
    <div data-key="75" class="key">
        <kbd>K</kbd>
        <span class="sound">tom</span>
    </div>
    <div data-key="76" class="key">
        <kbd>L</kbd>
        <span class="sound">tink</span>
    </div>
</div>
<audio data-key="65" src="sounds/clap.wav"></audio>
<audio data-key="83" src="sounds/hihat.wav"></audio>
<audio data-key="68" src="sounds/kick.wav"></audio>
<audio data-key="70" src="sounds/openhat.wav"></audio>
<audio data-key="71" src="sounds/boom.wav"></audio>
<audio data-key="72" src="sounds/ride.wav"></audio>
<audio data-key="74" src="sounds/snare.wav"></audio>
<audio data-key="75" src="sounds/tom.wav"></audio>
<audio data-key="76" src="sounds/tink.wav"></audio>
<script src="app.js"></script>
</body>
</html>
// Play drum by keyboard press
window.addEventListener('keydown' , playDrum);
function playDrum(e) {
    const audio = document.querySelector(`audio[data-key="${e.keyCode}"]`);
    if(!audio) return;
    audio.currentTime = 0;
    audio.play();
    const key = document.querySelector(`.key[data-key="${e.keyCode}"]`);
    key.classList.add('playing');
};

// Remove .playing class after transition action over
const keys = document.querySelectorAll('.key');
keys.forEach( key => key.addEventListener('transitionend' , removePlayingClass ) );
function removePlayingClass(e)
{
    //if( e.propertyName != 'transform' ) return;
    this.classList.remove('playing');
}


// Play music by mouse click
keys.forEach( key => key.addEventListener('click' , playByMouse ) );
function playByMouse(e)
{
    const keyCode = this.getAttribute('data-key');
    const audio = document.querySelector(`audio[data-key="${keyCode}"]`);
    audio.currentTime = 0;
    audio.play();
    const key = document.querySelector(`.key[data-key="${keyCode}"]`);
    key.classList.add('playing');
}

<!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本身不带任何声音采样,没有音源就发不出声音。