最初,我只是想让访客在阅读博客时,可以顺手听一些伴奏。
后来,这个想法慢慢变成了一套完整的音乐功能:导航栏里的黑胶唱片、跨页续播、同步歌词、后台批量导入,以及通过一个滑块调节人声和伴奏的比例。
这篇文章记录其中几个值得展开的设计和实现。
从阅读场景出发设计交互
博客的主要内容依然是文章,所以播放器需要容易找到,也要足够安静。
桌面端的入口放在导航栏中,用一张小黑胶唱片展示当前歌曲的封面。播放时唱片旋转,唱针落下;暂停时停止转动。点击唱片展开面板,用户可以在里面播放、选歌和调整进度。
手机端则在开始播放后显示底部迷你条,方便阅读过程中暂停或重新打开面板。
面板里,我把最常用的两个操作放在同一行:
伴奏 / 混合 / 人声 歌词 / 选歌
左边控制听到的内容,右边控制看到的内容。两组控件采用相同的胶囊样式,颜色跟随博客主题。
歌词和歌曲列表共用中间区域,底部的进度、播放和音量控件保持固定。切换视图时,只改变内容,不改变卡片高度,也不影响播放。
让播放状态独立于界面
实现跨页续播,首先要明确音频由谁管理。
如果每个文章页面都创建自己的音频实例,切换路由时页面被卸载,音乐也会随之中断。因此,播放器放在共享布局中,播放状态集中交给 Pinia 管理。
状态分为两类:
| 状态 | 作用 |
|---|---|
| 当前歌曲、播放时间、音量、播放模式 | 决定实际播放行为 |
| 面板是否展开、显示歌词还是选歌 | 决定界面展示 |
这样,关闭面板或切换“歌词 / 选歌”,都不会触碰音源。
歌词面板首次打开后会保留挂载,站内页面切换也不会重新创建播放器。歌曲、进度、音量和混音比例会保存到本地,刷新后可以恢复这些设置;是否继续出声仍由用户主动操作。
选歌、上一首、下一首和随机播放也共用同一个队列。随机模式维护待播放歌曲集合与实际播放历史,一轮内减少重复,“上一首”则返回刚才真正听过的歌曲。
用一个滑块调节伴奏与人声
我希望用户只需要表达一个简单的偏好:
伴奏多一点,还是人声多一点?
因此,混音控制最终收敛成一个可以连续拖动的分段滑块:
| 滑块位置 | 伴奏增益 | 人声增益 |
|---|---|---|
| 最左侧 | 100% | 0% |
| 左侧四分之一 | 100% | 50% |
| 中间 | 100% | 100% |
| 右侧四分之三 | 50% | 100% |
| 最右侧 | 0% | 100% |
将滑块位置记为 balance,范围为 0~1,计算方式很简单:
const instrumentalLevel = Math.min(1, (1 - balance) * 2)
const vocalLevel = Math.min(1, balance * 2)
它是分段线性调节:左半段保持伴奏,逐渐加入人声;右半段保持人声,逐渐降低伴奏。
这里的百分比指增益系数,不代表人耳感受到的响度百分比。整体听感还受到原始音轨响度和底部总音量的影响。
中间位置也称为“混合”,而不是“原曲”。分离得到的音轨可能带有残留、损失和编码差异,重新叠加能形成完整的听感,但不能保证恢复成原始文件。
浏览器里的两路音频如何混合
混音使用两路音频:一路伴奏,一路人声。
浏览器负责流式读取文件,Web Audio 负责增益处理。两路音频分别经过自己的 GainNode,汇合后再经过总音量与动态压缩节点,最后输出。
连接媒体元素与 Web Audio,使用的是 MediaElementAudioSourceNode。这样可以继续利用媒体元素的加载、缓冲和跳转能力,无需先把整首歌曲解码成内存里的音频缓冲区。
拖动滑块时,只更新两路增益,不重新设置音源,因此播放进度和总音量都能保持。
增益变化也做了短暂的平滑处理:
gainNode.gain.setTargetAtTime(
targetGain,
audioContext.currentTime,
0.02
)
其中 0.02 是时间常数,用来控制逐渐接近目标值的速度,并不表示固定在 20 毫秒后完成变化。这个方法适合让音量变化更平滑。MDN 文档
两路音频还需要共同处理播放、暂停、拖动进度和缓冲。当前实现以伴奏为参考时钟,检查人声是否出现明显偏移,必要时进行校正;一方缓冲时,两路一起等待。
这种方案服务于博客里的流式试听。它没有采样级同步保证,对音轨版本也有要求:人声与伴奏需要来自同一版本,保持一致的起点和速度。
歌词同步,关键在时间轴
“MP3 里有歌词”与“歌词能够跟随播放进度”是两回事。
纯文本歌词只有内容,没有每句开始的时间。要实现高亮、同步滚动和点击跳转,需要逐句时间戳。
后台按以下顺序寻找时间轴:
- 优先读取 MP3 内嵌的同步歌词。
- 没有时间戳时,匹配原曲 LRC。
- 仍未匹配到时,使用对应人声和已有歌词生成时间轴。
- 无法生成时保留纯文本,允许后台导入和校准。
第三步使用的是面向歌声的强制对齐工具 SOFA。它接收人声和对应文本,寻找文本在音频中的位置。因此,准确的歌词和对应版本的人声都是必要输入。SOFA 项目说明
生成的结果仍需要试听检查,尤其是重复副歌、说唱和发音复杂的片段。
时间轴准备好后,前台根据当前播放时间定位歌词句子。点击某句歌词,就跳转到它对应的时间。
后台还保存一个独立的偏移量:正值让歌词延后,负值让歌词提前。这样遇到整首歌词统一快半拍或慢半拍时,可以直接校准,无需修改 MP3。
Go 后端负责保存结果和调度任务
歌单保存歌曲信息与音源地址,完整歌词则放在独立的 music_lyrics 表中,避免每次读取公共设置都携带大量歌词。
核心字段可以简化为:
type MusicLyric struct {
TrackID string
PlainText string
LRC string
OffsetMS int
Source string
Status string
}
实际模型还记录音源指纹、任务标识和更新时间,用来判断歌词是否仍属于当前歌曲版本。
歌词匹配和人声对齐都放到后台执行。访客打开播放器时,只读取已经保存的结果,不触发模型推理。
任务调度也做了边界控制:
- 普通读取、在线匹配限制并发数。
- 人声对齐任务串行执行,并设置超时。
- 修改排序或封面,不重复生成歌词。
- 任务写回时检查歌曲指纹和任务标识,避免旧结果覆盖新配置。
这次上线保留了人声对齐的接入能力,但生产服务器尚未启用模型运行环境。现有时间轴预先生成并保存,前台可以直接使用。
后台批量导入与音源归组
歌曲多起来以后,逐首填写信息会很繁琐,所以后台支持一次选择多个 MP3。
导入时先读取标签里的歌名、歌手和内嵌封面;缺少信息时,再从文件名补充。封面提取后单独压缩,MP3 原文件保持不变。
同一首歌的不同音源会归到同一条记录中。例如:
晴天_原曲.mp3晴天_伴奏.mp3晴天_人声.mp3
归组依据是规范化后的歌名与歌手。已经存在的音源版本直接跳过,缺少的版本则补充进去。歌手信息不完整、同名候选不唯一时,不盲目合并。
封面虽然可以内嵌在 MP3 中,网页展示仍然使用提取后的图片。这样,歌曲列表显示缩略图时,无需反复读取音频标签。
后台还提供伴奏下载开关。前台据此显示下载入口,下载接口也会重新检查当前配置,而不只依赖按钮是否隐藏。
如何控制加载与运行开销
音乐功能确实会增加资源消耗,尤其是混音需要同时读取和解码两路音频。
因此,实现时把这些成本尽量放到用户明确需要它们的时候:
- 导航栏入口保持轻量,完整面板首次打开时异步加载。
- 用户主动播放后才开始读取音频。
- 首次混音播放时才创建音频处理环境。
- 歌词按当前歌曲读取。
- 在线匹配和模型计算在后台完成。
- 音频采用流式播放,避免整首解码进内存。
本次上线验收中,初始页面没有发起音乐音频请求,播放器面板也尚未挂载。拖动混音滑块时,已加载的两路音频没有重新加载。
文章仍沿用原有的服务端渲染、正文和页面元信息。这些设计减少了播放器对阅读页面的干扰,但不能据此宣称 SEO 或性能完全不受影响;实际体验仍需要结合设备、网络和长期监测判断。
上线验收时,歌单共 99 首,均已关联伴奏与人声,其中 98 首具备同步歌词。
现在,阅读时可以把滑块拉向伴奏,想听演唱时再慢慢加入人声。播放器收起后,导航栏里只留下一张转动的唱片,文章和音乐各自继续。

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