跳到正文

把音乐播放器做成一件有质感的事

1 分钟阅读

为博客从零造一个本地音乐播放器的全过程——分层架构、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 处理完哪个就回哪个,不需要排队等待。


做完这些之后,我打开播放器,放了一首歌,看着歌词一行一行像风铃一样飘落,底部的律动条跟着节拍跳动,副歌来的时候背景雨景加强了一下——

觉得值了。

© 2026 chenzylab