把音乐播放器做成一件有质感的事
为博客从零造一个本地音乐播放器的全过程——分层架构、Web Audio 链路、高潮检测、风铃歌词、以及所有细腻交互背后的设计哲学。
我不想做一个”能用的”播放器。
“能用”太容易了——一个 <audio> 标签,几个按钮,加上 localStorage 存音量,一天就能写完。我想做的是那种打开的时候会有一秒钟停顿、然后觉得”这东西有点意思”的播放器。
这篇文章记录这个过程里最值得说的部分。
分层,然后彻底执行
整个模块有一条贯穿始终的设计原则:每一层只做一件事,层与层之间只有一条通道。
最底层是 audioEngine,一个单例,封装 HTMLAudioElement 和 Web Audio 链路。它不知道”播放列表”是什么,不知道”当前歌曲”是什么,只知道 load / play / pause / seek,以及往外发射 timeupdate / ended / error / canplay 四个事件。
中间层是 musicStore,一个 Svelte 5 runes class。它持有所有响应式状态——队列、历史、播放模式、当前位置。它监听 engine 的事件,把时间轴同步进来:
onTimeUpdate(ms: number): void {
this.currentPositionMs = ms;
if (!this._historyAdded && ms >= 5000) {
this._addToHistory(this.currentQueue[this.currentIndex]);
this._historyAdded = true;
}
} UI 层只从 store 取数据,只调 store 的方法。整个代码库里找不到一个 UI 组件直接调 audio.play()。
这条规则执行彻底之后,带来的不只是”代码整洁”。它意味着任何一层都可以独立替换,任何一层的 bug 都有明确的归属,任何新功能都知道该往哪里加。
Web Audio:一条可以热插拔的链路
最初只用 HTMLAudioElement,后来要加 EQ,要加混响,要给可视化提供频谱数据。这时候就需要接入 Web Audio API,把音频流从 <audio> 引出来,过一遍处理链路,再送到扬声器。
链路长这样:
MediaElementSource
→ inputGain
→ BiquadFilter × 10 (10段参数均衡器)
→ effectBusIn
↓
[preset 子图] (混响 / 压缩 / 声场效果)
↓
effectBusOut
→ AnalyserNode (频谱数据,供可视化读取)
→ volumeGain
→ destination 其中 effectBus 是整个链路里最重要的设计。effectBusIn 默认直接连到 effectBusOut(bypass),加载效果预设时断开 bypass、把 preset 子图插进去,切换或关闭时再拆掉、重连 bypass。整条链路其余部分感知不到这个切换,没有中断,没有杂音。
每个预设都是一段独立的子图构建函数。比如”3D 声场”:
build(ctx) {
const panner = ctx.createStereoPanner();
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
lfo.type = 'sine';
lfo.frequency.value = 0.2; // 0.2Hz,约5秒一个来回
lfoGain.gain.value = 0.55;
lfo.connect(lfoGain);
lfoGain.connect(panner.pan);
lfo.start();
return { input: panner, output: panner, oscillators: [lfo], all: [panner, lfoGain] };
} 用一个低频振荡器(LFO)驱动声像位置,声音就会以 0.2Hz 的频率在左右之间缓缓漂移。听感上像是声音在头顶绕圈。
混响的 IR 用程序生成,不依赖任何外部音频文件:
private _generateIR(durationSec: number, decay: number): AudioBuffer {
const len = Math.floor(durationSec * ctx.sampleRate);
const buf = ctx.createBuffer(2, len, ctx.sampleRate);
for (let c = 0; c < 2; c++) {
const d = buf.getChannelData(c);
for (let i = 0; i < len; i++) {
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
return buf;
} 白噪声乘上指数衰减曲线,模拟声音在空间里反弹、衰减的过程。三种尺寸(0.5s / 1.5s / 3s),对应小房间、中型空间、音乐厅。
高潮检测:让页面知道音乐到了哪里
可视化模块每帧从 AnalyserNode 读取频谱数据,计算低频、中频、高频的平均能量,以及整体 RMS。这些数值直接驱动页面底部的律动条和封面的呼吸动画。
但我还想做一件更有意思的事:让页面在副歌来临时有所感知。
检测逻辑分两步。首先用谱通量(spectral flux)检测节拍——计算每帧频谱相对上一帧的正向变化总量,如果当前帧的 flux 显著高于近30帧的均值,认为是一个节拍:
let flux = 0;
for (let i = 0; i < binCount; i++) {
const curr = this.freqArr[i] / 255;
const d = curr - this._prevFreq[i];
if (d > 0) flux += d; // 只取正向变化
this._prevFreq[i] = curr;
}
if (flux > avgFlux * 1.5 && flux > 0.03) {
this.beat = true;
this._beatFrames = 6; // 维持约100ms
} 然后用滚动 RMS 历史的百分位数检测高潮段落——取过去10秒的 RMS 序列,如果当前值超过第80百分位的1.5倍,认为进入高潮。加了滞后(hysteresis)防止在边界附近反复抖动:
private _detectClimax(rawRms: number): void {
if (hist.length < 300) return; // 需要足够的历史才能判断
const p80 = sorted[Math.floor(sorted.length * 0.8)];
const p50 = sorted[Math.floor(sorted.length * 0.5)];
if (!this.isClimax) {
if (p80 > 0.04 && rawRms > p80 * 1.5) this.isClimax = true;
} else {
if (rawRms < p50 * 1.1) this.isClimax = false; // 降到中位数才退出
}
} 高潮状态变化时会发一个自定义事件,背景雨景、封面动画都监听这个事件做响应。
随机播放的正确实现
随机播放最常见的错误实现是每次 next() 时随机取一首,结果是有些歌反复出现,有些歌永远听不到,而且没法真正回到”上一首”。
正确的做法是一次性洗牌,在序列上移动指针:
function fisherYates(arr: TrackId[]): TrackId[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
} 洗牌后把当前正在播的歌移到序列头部,这样切换到随机模式不会打断当前播放。走完一轮重新洗牌,开始下一轮。prev() 只是把指针往回移一格,真正意义上的”上一首”。
歌词是一件值得认真对待的事
歌词渲染有两个层次。
第一层是滚动对齐——当前行高亮,容器随歌词推进自动滚动,前后各保留两行上下文。这部分是常规实现。
第二层是当前行的动画——每个字符拆开,各自以顶端为支点悬挂,入场时像风铃一样飘落,然后持续轻轻摇摆,相邻字符之间有交错的延迟,形成一道波浪:
.chime-char {
transform-origin: 50% 0; /* 顶端悬挂点 */
animation:
chime-drop 0.5s cubic-bezier(0.22, 0.9, 0.27, 1) both,
chime-sway 2.8s ease-in-out infinite;
animation-delay: var(--di), calc(0.5s + var(--si));
}
@keyframes chime-drop {
from { opacity: 0; transform: translateY(-0.7em) rotate(-14deg); }
to { opacity: 1; transform: translateY(0) rotate(0); }
}
@keyframes chime-sway {
0%, 100% { transform: rotate(-4.5deg); }
50% { transform: rotate(4.5deg); }
} 每当歌词切换到下一行,新行的字符重新触发这个动画。看起来像是每句歌词都是新挂上去的风铃。
歌词来源有两个:随音频文件导入的 .lrc,以及通过 LRCLIB API 在线获取。LRC 解析处理了几个边缘情况——同一行多个时间戳(合唱段落常见)、两位或三位毫秒、metadata 标签需要跳过。拿到的歌词会持久化到服务端的 metadata.json,下次不再请求网络。
进度条的微交互
进度条是一个高频交互区域,值得花时间做细。
静止时它非常克制:一根1px的细线,几乎不存在。鼠标靠近时,它在150ms内平滑加粗到3px,同时出现一个12px的拖动圆点,左侧滑入一个播放/暂停小按钮,右侧滑入一个歌词开关。
const barH = $derived(active ? 3 : 1);
const dotD = $derived(active ? 12 : 2);
const active = $derived(hovered || seeking); 拖动时圆点跟手指实时移动,松手的瞬间才 seek。拖动过程中用 setPointerCapture 捕获指针,这样手指滑出进度条区域也不会丢失拖动状态。
hover 时还有一个小细节:进度圆点从”用时间驱动的平滑过渡”切换到”跟随鼠标的即时响应”——因为拖动时需要跟手,不能有延迟:
const dotLeftTrans = $derived(
(barHovered && !seeking) || seeking
? 'left 0ms' // 跟手,无过渡
: 'left 180ms ease-out' // 时间推进,平滑
); 播放器的三种形态
播放器有三种展示形态,通过 barMode 切换:
dot 模式:只有底部那根进度条,hover 时浮出曲名气泡。存在感最低,适合在看文章时作为背景。
mini 模式:一条 h-14 的紧凑行,封面缩略图 + 曲名 + 三个控制按钮。和博客导航栏同高,在视觉上像是导航的延伸。
full 模式:完整播放器,加上音量、播放模式、队列面板。
三种形态之间用 grid-template-rows 做展开收折动画,不用 height——因为 height: auto 不能过渡,而 grid-template-rows: 0fr → 1fr 可以。
全屏播放页(NowPlayingOverlay)是一个模态覆盖层,用了 macOS Genie 效果:从底部播放条的位置”吸出来”,关闭时”吸回去”。用 clip-path: inset() 实现:
function genieIn(_node: Element) {
return {
duration: 640,
easing: backOut,
css: (t: number) => {
const u = 1 - t;
const topInset = u * 92;
const sideInset = u * u * 26;
return `clip-path: inset(${topInset}% ${sideInset}% 0% ${sideInset}% round ${u * 22}px)`;
}
};
} topInset 从 92% 线性收到 0,sideInset 用平方曲线,两侧先快速收拢、再慢慢展开,这个节奏最接近真实的 Genie 效果。
服务端:一条静默的流水线
音乐文件不只是存在浏览器里。服务端有一套 ingest 流水线:上传音频文件后,Node.js 用 music-metadata 解析 EXIF(标题、艺人、专辑、时长、内嵌封面),如果没有随附的 .lrc 就去 LRCLIB 抓歌词,最后把结构化元数据写入 static/music/metadata.json。
这个 JSON 是博客在 Vercel 上运行时的唯一数据源。元数据在构建时就固定了,不需要运行时数据库。
移动端上传通过二维码:后端生成一个短期 token,手机扫码打开上传页,直接 POST 到同一个 token 接口。这样站在电脑前,掏出手机扫一下,就能把手机里的音乐文件上传到服务器。
元数据解析放在 Web Worker 里跑,不阻塞主线程:
function getWorker(): Worker {
if (!_worker) {
_worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
_worker.addEventListener('message', (e) => {
const cb = pending.get(e.data.reqId);
if (cb) { pending.delete(e.data.reqId); cb(e.data); }
});
}
return _worker;
} 用 reqId 匹配请求和回调,这样可以并发发出多个解析任务,Worker 处理完哪个就回哪个,不需要排队等待。
做完这些之后,我打开播放器,放了一首歌,看着歌词一行一行像风铃一样飘落,底部的律动条跟着节拍跳动,副歌来的时候背景雨景加强了一下——
觉得值了。