给博客装上一台黑胶唱机:从伴奏播放到人声混音

给博客装上一台黑胶唱机:从伴奏播放到人声混音

最初,我只是想让访客在阅读博客时,可以顺手听一些伴奏。

后来,这个想法慢慢变成了一套完整的音乐功能:导航栏里的黑胶唱片、跨页续播、同步歌词、后台批量导入,以及通过一个滑块调节人声和伴奏的比例。

这篇文章记录其中几个值得展开的设计和实现。

从阅读场景出发设计交互

博客的主要内容依然是文章,所以播放器需要容易找到,也要足够安静。

桌面端的入口放在导航栏中,用一张小黑胶唱片展示当前歌曲的封面。播放时唱片旋转,唱针落下;暂停时停止转动。点击唱片展开面板,用户可以在里面播放、选歌和调整进度。

手机端则在开始播放后显示底部迷你条,方便阅读过程中暂停或重新打开面板。

面板里,我把最常用的两个操作放在同一行:

伴奏 / 混合 / 人声  歌词 / 选歌

左边控制听到的内容,右边控制看到的内容。两组控件采用相同的胶囊样式,颜色跟随博客主题。

歌词和歌曲列表共用中间区域,底部的进度、播放和音量控件保持固定。切换视图时,只改变内容,不改变卡片高度,也不影响播放。

让播放状态独立于界面

实现跨页续播,首先要明确音频由谁管理。

如果每个文章页面都创建自己的音频实例,切换路由时页面被卸载,音乐也会随之中断。因此,播放器放在共享布局中,播放状态集中交给 Pinia 管理。

状态分为两类:

状态 作用
当前歌曲、播放时间、音量、播放模式 决定实际播放行为
面板是否展开、显示歌词还是选歌 决定界面展示

这样,关闭面板或切换“歌词 / 选歌”,都不会触碰音源。

歌词面板首次打开后会保留挂载,站内页面切换也不会重新创建播放器。歌曲、进度、音量和混音比例会保存到本地,刷新后可以恢复这些设置;是否继续出声仍由用户主动操作。

选歌、上一首、下一首和随机播放也共用同一个队列。随机模式维护待播放歌曲集合与实际播放历史,一轮内减少重复,“上一首”则返回刚才真正听过的歌曲。

用一个滑块调节伴奏与人声

我希望用户只需要表达一个简单的偏好:

伴奏多一点,还是人声多一点?

因此,混音控制最终收敛成一个可以连续拖动的分段滑块:

滑块位置 伴奏增益 人声增益
最左侧 100% 0%
左侧四分之一 100% 50%
中间 100% 100%
右侧四分之三 50% 100%
最右侧 0% 100%

将滑块位置记为 balance,范围为 0~1,计算方式很简单:

TS
const instrumentalLevel = Math.min(1, (1 - balance) * 2)
const vocalLevel = Math.min(1, balance * 2)

它是分段线性调节:左半段保持伴奏,逐渐加入人声;右半段保持人声,逐渐降低伴奏。

这里的百分比指增益系数,不代表人耳感受到的响度百分比。整体听感还受到原始音轨响度和底部总音量的影响。

中间位置也称为“混合”,而不是“原曲”。分离得到的音轨可能带有残留、损失和编码差异,重新叠加能形成完整的听感,但不能保证恢复成原始文件。

浏览器里的两路音频如何混合

混音使用两路音频:一路伴奏,一路人声。

浏览器负责流式读取文件,Web Audio 负责增益处理。两路音频分别经过自己的 GainNode,汇合后再经过总音量与动态压缩节点,最后输出。

连接媒体元素与 Web Audio,使用的是 MediaElementAudioSourceNode。这样可以继续利用媒体元素的加载、缓冲和跳转能力,无需先把整首歌曲解码成内存里的音频缓冲区。

拖动滑块时,只更新两路增益,不重新设置音源,因此播放进度和总音量都能保持。

增益变化也做了短暂的平滑处理:

TS
gainNode.gain.setTargetAtTime(
  targetGain,
  audioContext.currentTime,
  0.02
)

其中 0.02 是时间常数,用来控制逐渐接近目标值的速度,并不表示固定在 20 毫秒后完成变化。这个方法适合让音量变化更平滑。MDN 文档

两路音频还需要共同处理播放、暂停、拖动进度和缓冲。当前实现以伴奏为参考时钟,检查人声是否出现明显偏移,必要时进行校正;一方缓冲时,两路一起等待。

这种方案服务于博客里的流式试听。它没有采样级同步保证,对音轨版本也有要求:人声与伴奏需要来自同一版本,保持一致的起点和速度。

歌词同步,关键在时间轴

“MP3 里有歌词”与“歌词能够跟随播放进度”是两回事。

纯文本歌词只有内容,没有每句开始的时间。要实现高亮、同步滚动和点击跳转,需要逐句时间戳。

后台按以下顺序寻找时间轴:

  1. 优先读取 MP3 内嵌的同步歌词。
  2. 没有时间戳时,匹配原曲 LRC。
  3. 仍未匹配到时,使用对应人声和已有歌词生成时间轴。
  4. 无法生成时保留纯文本,允许后台导入和校准。

第三步使用的是面向歌声的强制对齐工具 SOFA。它接收人声和对应文本,寻找文本在音频中的位置。因此,准确的歌词和对应版本的人声都是必要输入。SOFA 项目说明

生成的结果仍需要试听检查,尤其是重复副歌、说唱和发音复杂的片段。

时间轴准备好后,前台根据当前播放时间定位歌词句子。点击某句歌词,就跳转到它对应的时间。

后台还保存一个独立的偏移量:正值让歌词延后,负值让歌词提前。这样遇到整首歌词统一快半拍或慢半拍时,可以直接校准,无需修改 MP3。

Go 后端负责保存结果和调度任务

歌单保存歌曲信息与音源地址,完整歌词则放在独立的 music_lyrics 表中,避免每次读取公共设置都携带大量歌词。

核心字段可以简化为:

GO
type MusicLyric struct {
    TrackID   string
    PlainText string
    LRC       string
    OffsetMS  int
    Source    string
    Status    string
}

实际模型还记录音源指纹、任务标识和更新时间,用来判断歌词是否仍属于当前歌曲版本。

歌词匹配和人声对齐都放到后台执行。访客打开播放器时,只读取已经保存的结果,不触发模型推理。

任务调度也做了边界控制:

  • 普通读取、在线匹配限制并发数。
  • 人声对齐任务串行执行,并设置超时。
  • 修改排序或封面,不重复生成歌词。
  • 任务写回时检查歌曲指纹和任务标识,避免旧结果覆盖新配置。

这次上线保留了人声对齐的接入能力,但生产服务器尚未启用模型运行环境。现有时间轴预先生成并保存,前台可以直接使用。

后台批量导入与音源归组

歌曲多起来以后,逐首填写信息会很繁琐,所以后台支持一次选择多个 MP3。

导入时先读取标签里的歌名、歌手和内嵌封面;缺少信息时,再从文件名补充。封面提取后单独压缩,MP3 原文件保持不变。

同一首歌的不同音源会归到同一条记录中。例如:

  • 晴天_原曲.mp3
  • 晴天_伴奏.mp3
  • 晴天_人声.mp3

归组依据是规范化后的歌名与歌手。已经存在的音源版本直接跳过,缺少的版本则补充进去。歌手信息不完整、同名候选不唯一时,不盲目合并。

封面虽然可以内嵌在 MP3 中,网页展示仍然使用提取后的图片。这样,歌曲列表显示缩略图时,无需反复读取音频标签。

后台还提供伴奏下载开关。前台据此显示下载入口,下载接口也会重新检查当前配置,而不只依赖按钮是否隐藏。

如何控制加载与运行开销

音乐功能确实会增加资源消耗,尤其是混音需要同时读取和解码两路音频。

因此,实现时把这些成本尽量放到用户明确需要它们的时候:

  • 导航栏入口保持轻量,完整面板首次打开时异步加载。
  • 用户主动播放后才开始读取音频。
  • 首次混音播放时才创建音频处理环境。
  • 歌词按当前歌曲读取。
  • 在线匹配和模型计算在后台完成。
  • 音频采用流式播放,避免整首解码进内存。

本次上线验收中,初始页面没有发起音乐音频请求,播放器面板也尚未挂载。拖动混音滑块时,已加载的两路音频没有重新加载。

文章仍沿用原有的服务端渲染、正文和页面元信息。这些设计减少了播放器对阅读页面的干扰,但不能据此宣称 SEO 或性能完全不受影响;实际体验仍需要结合设备、网络和长期监测判断。

上线验收时,歌单共 99 首,均已关联伴奏与人声,其中 98 首具备同步歌词。

现在,阅读时可以把滑块拉向伴奏,想听演唱时再慢慢加入人声。播放器收起后,导航栏里只留下一张转动的唱片,文章和音乐各自继续。

评论

(0)

适用于本次会话的图片上传。PNG、WebP 和动图默认保留原文件;JPEG 文字截图建议勾选。

还没有评论,来留下第一条评论吧。