# 基于 MIDI 的音乐播放

> [基于 MIDI 的音乐播放](https://www.ftls.xyz/posts/2026-05-24-midi-music/)
> Penned by [恐咖兵糖](https://www.ftls.xyz/) on 2026-05-24


试着加了一个 MIDI 播放器

<!--more-->

## 起因

用了 Swup.js 和 Alpine.js 。想做一个简单的音乐播放器来着。以前搞 Hexo Next 的时候，就搞过全站音乐，pjax 支持下，换页音乐不断。

正好电脑上有很多 MIDI 文件，MIDI 文件很小。一个音乐几 KB。用来做博客的背景音乐刚好合适。不过缺点也很明显，MIDI 文件相当于谱子，不是音频文件，不支持人声，播放效果依赖音源库。

> MIDI 是一种很老的技术了，不过现在一些领域还是在用。文件体积可以做到非常小——一首几分钟的曲子通常只有几十 KB，甚至几 KB。因为它存储的不是音频波形，而是音符、力度、音色这些演奏指令。缺点就是播放效果依赖音源库，同样的 MIDI 文件在不同设备上听起来可能不太一样。
> 实际数据大概是 30MB 125h

## 实现

MIDI 播放器放哪是个小问题。带有音乐播放器的博客一般放在左下角。我这直接模仿手机电脑搞了一个控制中心，放里面了。

使用了 MIDIjs - https://www.midijs.net/ 这是一个纯 JavaScript 的 MIDI 播放器，不需要任何插件，浏览器原生就能跑。

MIDI.js 有好几个同名的库，我也没弄明白哪个是哪个，能用就行。音源也不知道哪来的。好像是这个 https://github.com/babelsberg/babelsberg-js/blob/master/midijs/ 里的。

写起来不麻烦。很快写了个 shortcode 验证一下。

### shortcode

这个 Hugo shortcode `{{</* midi */>}}`，用在文章里可以直接嵌入一个曲目列表供读者点播。样式是简单的链接列表，点击曲目名称就开始播放。

这个 shortcode 的核心 Alpine.js 组件和控制中心大同小异。播放界面比较简洁（简陋），显示当前曲目、当前时间和总时长。

这个 shortcode 会自动加载 MIDIjs，MIDIjs 会先加载一个 3.5MB 的 `midijs/lib/pat/arachno-127.pat` ，加载完成后就可以播放那些只使用钢琴的 `.mid` 文件了。然后有其他乐器再加载其他的 `.pat` 文件。
 
```html
<div x-data="{playing:'Nothing',time:0,length:0,loop:false,async init(){typeof MIDIjs==='undefined'&&await Somnia.loadResource({href:'//www.midijs.net/lib/midi.js',dataSomnia:'midi.js'});MIDIjs.player_callback=(msg)=>{this.time=parseInt(msg.time);if(this.loop&&msg.status==='finished'){this.play(this.playing)}}},play(path){this.playing=path;MIDIjs.play(path);MIDIjs.get_duration(path,(duration)=>{this.length=duration})},stop(){MIDIjs.stop()},pause(){console.log(this.time);MIDIjs.pause()},resume(){MIDIjs.resume()}}" class="flex flex-col p-4 midi-player">
    <div class="pb-4">
        Playing: <span x-text="playing"></span>
        <span x-text="time"></span>s
        <span x-text="length"></span>s
        <br>
        <a @click="stop();">Stop</a>
        <a @click="pause();">Pause</a>
        <a @click="resume();">Resume</a>
        <a @click="loop = !loop">Loop <span x-text="loop ? 'On' : 'Off'"></span></a>
    </div>
    <a @click="play('/midi/midi_某科学的超电磁炮 - only my railgun.mid');">Play midi_某科学的超电磁炮 - only my
        railgun.mid</a>
    <a @click="play('/midi/未闻花名secret-base.mid');">Play 未闻花名secret-base.mid</a>
    <a @click="play('/midi/卡农.mid');">Play 卡农.mid</a>
    <a @click="play('/midi/崩坏3「最后一课」插曲-Night Glow.mid');">Play 崩坏3「最后一课」插曲-Night Glow.mid</a>
    <a @click="play('/midi/团子大家族.mid');">Play 团子大家族.mid</a>
    <a @click="play('/midi/midi_久石让 - 天空之城.mid');">Play midi_久石让 - 天空之城.mid</a>
    <a @click="play('/midi/原神-我们终将重逢.mid');">Play 原神-我们终将重逢.mid</a>
    <a @click="play('/midi/千与千寻的神隐 - あの日の川「铃声版」.mid');">Play 千与千寻的神隐 - あの日の川「铃声版」.mid</a>
    <a @click="play('/midi/[67018]【缘之空】Old Memory（完整版）.midi');">Play [67018]【缘之空】Old
        Memory（完整版）.midi</a>
    <a @click="play('/midi/midi_崩坏3rd - Rubia.mid');">Play midi_崩坏3rd - Rubia.mid</a>
    <a @click="play('/midi/昔涟.mid');">Play 昔涟.mid</a>
</div>
<style>.midi-player a {cursor: pointer;}</style>
<!--
<script>
    function midiComponent() {
        return {
            playing: 'Nothing',
            time: 0,
            length: 0,
            // 循环
            loop: false,
            async init() {
                // https://www.midijs.net/
                typeof MIDIjs === 'undefined' && await Somnia.loadResource({ href: '//www.midijs.net/lib/midi.js', dataSomnia: 'midi.js' });
                MIDIjs.player_callback = (msg) => {
                    // console.log(msg);
                    this.time = parseInt(msg.time);
                    if(this.loop && msg.status === 'finished') {
                        this.play(this.playing);
                    }
                };
            },
            play(path) {
                this.playing = path;
                MIDIjs.play(path);
                MIDIjs.get_duration(path, (duration) => {
                    this.length = duration;
                })
            },
            stop() {
                MIDIjs.stop();
            },
            pause() {
                console.log(this.time);
                MIDIjs.pause();
            },
            resume() {
                MIDIjs.resume();
            }
        }
    }
</script>
-->
```

### 控制中心版

控制中心里的实现大概是这样：

- 在控制中心面板的 music 区域绑了一个 `@mousemove="initMusic()"`，鼠标移过去才开始加载，避免不必要的请求。
- 曲目列表存在 `/midi/midi.txt` 里，按行分隔。播放时从列表里随机选一首。如果开启了循环模式，当前曲目播完后会重新播放同一首，否则自动随机切下一首。

这个版本读取的 txt 做音乐列表。随时可以加新的，往 `/content/midi/` 目录下丢个 `.mid` 文件，在 `midi.txt` 里加一行就行。

```html
<div x-data="controlCenterComponent()" x-ignore id="control-center-container">
    <button
        class="group/dark box-content size-5 rounded-md border p-1.5 transition-colors hover:bg-border sm:group-[.not-top]:rounded-xl"
        @click="btnClick()" x-ref="controlCenterBtn">
        <!-- https://www.svgrepo.com/svg/332223/appstore COLLECTION: Ant Design Outlined Icons LICENSE: MIT License AUTHOR: Ant Design-->
        <svg fill="currentColor" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class="icon">
            <g id="SVGRepo_bgCarrier" stroke-width="0"></g>
            <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
            <g id="SVGRepo_iconCarrier">
                <path
                    d="M464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H212V212h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm-52 268H612V212h200v200zM464 544H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H212V612h200v200zm452-268H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zm-52 268H612V612h200v200z">
                </path>
            </g>
        </svg>
    </button>
    <template x-teleport="#main-container">
        <div class="w-full" x-show="controlCenterOpen" id="control-center" x-transition>
            {{/* <div class="fixed left-0 w-full h-full cc-bg" style="top:0rem;z-index: 25;"></div> */}}
            <div class="fixed p-2 rounded-xl cc-bg border-b border-l" @click.outside="controlCenterOpen = false"
                x-ref="controlCenter">
                <div class="animate flex flex-col gap-y-3 this w-full p-2"
                    style="overflow-y: scroll;height: calc(100vh - 7rem);">
                    <div class="rounded-2xl p-2 border">
                        <a class="text-sm mb-3 px-1 text-muted-foreground" href='{{"about"|relURL}}'>恐咖兵糖</a>
                    </div>
                    <div class="rounded-2xl p-4 border" @mousemove="initMusic()">
                        <!-- Music -->
                        <h3 class="text-sm mb-3 px-1 text-muted-foreground">Music <span x-text="musicInfo"></span></h3>
                        <div class="px-1">
                            Playing: <span x-text="playing"></span>
                            <span x-text="time"></span>s
                            <span x-text="length"></span>s
                            <br>
                            <a @click="play()" class="cursor-pointer">Random</a>
                            <a @click="stop()" class="cursor-pointer">Stop</a>
                            <a @click="pause()" class="cursor-pointer">Pause</a>
                            <a @click="resume()" class="cursor-pointer">Resume</a>
                            <a @click="loop = !loop" class="cursor-pointer" :class="loop ? 'text-primary' : ''">Loop</a>
                        </div>
                    </div>

                    <div class="rounded-2xl p-4 border">
                        <h3 class="text-sm mb-3 px-1 text-muted-foreground">Background</h3>
                        <div class="flex gap-x-2 px-1">
                            <button @click="setBackground('#203880')">Default</button>
                            <button @click="setBackground('#fff')">Light</button>
                            <button @click="setBackground('#000')">Dark</button>
                            <button @click="setBackground()">Random</button>
                        </div>
                    </div>

                    <div class="rounded-2xl p-4 border">
                        <h3 class="text-sm mb-3 px-1 text-muted-foreground">
                            <a href='{{"posts"|relURL}}'>Recent</a>
                        </h3>
                        <div class="flex flex-col gap-y-2 px-1">
                            {{ $posts := where site.RegularPages "Type" "posts" }}
                            {{ range first 3 $posts }}
                            <a href='{{.Permalink}}'>{{.Title}}</a>
                            {{ end }}
                        </div>
                    </div>

                    <div class="rounded-2xl p-4 border">
                        <h3 class="text-sm mb-3 px-1 text-muted-foreground"><a href='{{"whispers"|relURL}}'>Whispers</a>
                        </h3>
                        <a x-html="whisper" class="px-1" href='{{"whispers"|relURL}}'
                            style="white-space:pre-wrap;max-height: 10px"></a>
                    </div>
                </div>
            </div>
        </div>
    </template>
</div>
<script>
    function controlCenterComponent() {
        return {
            controlCenterOpen: false,
            observer: null,
            whisper: '我们的征途是星辰大海！',
            playing: '',
            musicInit: false,
            musicInfo: '',
            time: 0,
            length: 0,
            loop: false,
            musicList: ['昔涟.mid'],
            init() {
                // console.log("[Somnia] [ControlCenter] Loaded");
                this.observer = new ResizeObserver(e => {
                    if (this.controlCenterOpen) {
                        this.controlCenterOpen = false;
                    }
                })
                this.observer.observe(document.body);
                this.fetchWhisper();
            },
            // Music
            async initMusic() {
                if (!this.musicInit && typeof MIDIjs === 'undefined') {
                    this.musicInit = true;
                    await Somnia.loadResource({ href: '//www.midijs.net/lib/midi.js', dataSomnia: 'midi.js' });
                    MIDIjs.player_callback = (msg) => {
                        this.time = parseInt(msg.time);
                        if (this.loop && msg.status === 'finished') {
                            this.play(this.playing);
                        } else if (msg.status === 'finished') {
                            this.play();
                        }
                    };
                    fetch('/midi/midi.txt?v=' + VERSION)
                        .then(res => res.text())
                        .then(text => {
                            this.musicList = text.trim().split('\n');
                        })
                }

            },
            sleep: (delay) => new Promise((resolve) => setTimeout(resolve, delay)),
            retry: 0,
            async play(next = null) {
                this.musicInfo = '';
                try {
                    this.playing = next || this.musicList[Math.floor(Math.random() * this.musicList.length)];
                    console.log("[Somnia] [Music]", this.playing);
                    MIDIjs.play('/midi/' + this.playing);
                    MIDIjs.get_duration('/midi/' + this.playing, (duration) => {
                        this.length = duration;
                    })
                } catch (e) {
                    console.error(e);
                    this.musicInfo = 'Not Ready';
                    await this.sleep(1000);
                    if (this.retry < 5) {
                        this.retry++;
                        this.play();
                    } else {
                        this.musicInfo = 'Load Failed';
                    }
                }
            },
            stop() {
                MIDIjs.stop();
            },
            pause() {
                MIDIjs.pause();
            },
            resume() {
                MIDIjs.resume();
            },
            fetchWhisper() {
                const date = `${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}`;
                fetch(`https://note.ftls.xyz/microblog/whispers1.json?v=${date}`)
                    .then(res => res.json())
                    .then(obj => {
                        this.whisper = this.mdToHtml(obj.whispers[0].content);
                    });
            },
            mdToHtml(md) {
                let html = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="" loading="lazy">');
                html = html.replace(/\[([^\]]*)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
                return html;
            },
            setBackground(color) {
                if (color) {
                    document.documentElement.style.setProperty('--highlightColor', color)
                    return;
                }
                const r = Math.round(Math.random() * 255);
                const g = Math.round(Math.random() * 255);
                const b = Math.round(Math.random() * 255);
                document.documentElement.style.setProperty('--highlightColor', `rgb(${r}, ${g}, ${b})`);
            },
            btnClick() {
                const rect = this.$refs.controlCenterBtn.getBoundingClientRect();
                const panel = this.$refs.controlCenter;
                // 判断屏幕 >=640
                if (window.innerWidth >= 640) {
                    panel.style.top = rect.bottom + 16 + 'px';
                    panel.style.left = rect.left - 384 + 30 + 'px';
                    // panel.style.width = '384px';
                    panel.style.width = '384px';
                    panel.style.height = 'auto';
                    panel.style.paddingTop = 0;
                    panel.style.zIndex = 50;
                } else {
                    panel.style.top = 0;
                    panel.style.left = 0;
                    panel.style.width = '100%';
                    panel.style.height = '100%';
                    panel.style.paddingTop = 5 + 'rem';
                    panel.style.zIndex = 25;
                }
                this.controlCenterOpen = !this.controlCenterOpen;

            },
            destroy() {
                this.observer?.disconnect();
                this.observer = null;
            }
        }
    }
</script>
<style>
    #main-container .cc-bg {
        backdrop-filter: saturate(130%) blur(64px);
        -webkit-backdrop-filter: blur(64px);
        transform: translateZ(0);
    }
</style>
```

写完发现几个小问题，源码 5kb 有点大。一个 404 页面总共才 不到 10kb。所以我搞成了单独的 HTML ，js fetch 加载。所以初次进入博客的时候会闪一下。

然后就是由于放的位置比较靠后，元素聚焦顺序靠后，键盘导航体验不是很好。比较反直觉。再就是比较经典的遮蔽罩滚动问题。不过没再优化。

搞成一个 HTML 文件，放在对应位置，然后 js 加载一下 `/control-center/index.html`里的 js 就行了。

```js
// 全局变量
const VERSION = 'v{{ now.Format "2006-0102-1504" }}';
// ......
function initControlCenter() {
    fetch('/control-center/index.html?v=' + VERSION)
        .then(response => response.text())
        .then(html => {
            const container = document.createElement('div');
            container.innerHTML = html;
            document.getElementById('toggleMenu').after(container);
            const jsElement = document.getElementById('control-center-container').nextElementSibling;
            if (jsElement.tagName.toLowerCase() === 'script') {
                const newScript = document.createElement('script');
                newScript.innerHTML = jsElement.innerHTML;
                document.head.appendChild(newScript);
                jsElement.remove();
                document.getElementById('control-center-container').removeAttribute('x-ignore');
            }
        })
        .catch(error => {
            console.error('[Somnia] 加载控制中心失败:', error);
        });
}

initControlCenter();
```



## 最后

有兴趣的可以点开控制中心试试。
