未播放
点击下方歌曲开始播放
0:00 0:00
歌单 (0)
正在加载歌单...

多人实时在线听歌。这类产品你可能在某些"一起听"App 里见过:几个人进同一个房间,歌单同步、进度同步、还能边听边聊。这篇要拆的是开源项目 OpenMusic:Node.js + Socket.IO + Redis 后端,React 前端,音乐数据走 Meting-API,支持网易 / QQ / 汽水 / 酷狗四个音源。

这篇文章两条线:前半段讲怎么部署(Docker 一键就够,顺便踩 Nginx 的坑),后半段讲它怎么做到"所有人同一秒听到同一句"——这是这类项目最核心也最容易被想当然的技术点。

先说合规:OpenMusic 本身不存储任何音频文件,播放链接全部来自 Meting-API 实时解析,音乐版权归平台方。自部署请限定朋友圈使用,别拿来做商业服务。项目许可证是 MIT + 注明出处条款,公开部署需要保留项目名和仓库链接。

一、先看它长什么样

OpenMusic 首页大厅
OpenMusic 首页大厅

首页大厅:创建 / 加入房间、最近访问、房间在线人数和队列数一目了然

OpenMusic 房间界面
OpenMusic 房间界面

房间界面:左侧热歌榜,中间搜索 + 播放队列,右侧实时聊天(可以看到 AI 助手在回话)

核心体验一句话概括:像在一个语音房里共享一个播放器。任何人点歌进队列,播放进度全员同步,谁都能看到"当前播到 0:36 / 3:49"。房主可以锁房、设密码、定规则;成员可以聊天、发表情包、踩歌(踩到阈值自动切歌)。

二、架构:一张图看懂

图 1 OpenMusic 整体架构
图 1 OpenMusic 整体架构

五个角色,各司其职:

角色职责能不能省
React 前端界面、3D 视觉、本地音频播放不能,但构建后就是静态文件
Nginx唯一入口:静态直出 + 动态反代 + HTTPS测试环境可省,生产必须有
Node.js :4000Express + Socket.IO,房间状态与权限的唯一事实源不能
Redis房间、队列、收藏、凭据、封禁,全部只存 Redis不能,硬依赖
Meting-API :3000搜索 / 播放链接 / 歌词 / 封面 / 歌单解析不能,音乐数据的来源

这里有个设计决策值得注意:所有业务配置都不写在配置文件里,而是存管理后台的"运行时配置"(保存即时生效)。.env 里只放进程级启动参数(端口、Redis 连接、会话密钥)。这意味着改音源、配 AI、开 OAuth 都不用重启服务。

三、五分钟部署:Docker 全量版

3.1 准备

一台 2 核 2G 以上的 VPS(听歌本身不耗算力,带宽给足就行),装好 Docker 和 Docker Compose。有域名最好,没域名用 IP:端口也能跑。

3.2 一键脚本

curl -fsSL https://raw.githubusercontent.com/qq01-hub/openmusic/main/install.sh | bash

跑完会启动三个容器:redis(数据)、meting(音源)、openmusic(主服务 :4000)。然后浏览器打开 http://服务器IP:4000,进入部署向导——Redis 和 Meting 地址已经自动填好,你只需要填站点域名,点完成。

必须立刻做的一件事:向导完成页会显示随机生成的管理员账号和随机管理入口路径(不是 /admin 这种好猜的路径),只展示这一次。当场保存,关掉页面就再也看不到了。忘记入口可以去服务器看 data/adminConfig.json 的 entryPath 字段;忘记密码则 redis-cli DEL openmusic:admin:credentials 后重启恢复默认。

3.3 手动部署(想看清楚每一步的话)

curl -O https://raw.githubusercontent.com/qq01-hub/openmusic/main/docker-compose.full.yml
curl -O https://raw.githubusercontent.com/qq01-hub/openmusic/main/.env.full.example
cp .env.full.example .env

# 生成房间凭据加密密钥(至少 32 字节)
echo ROOM_CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -hex 32)  .env
# Meting 管理后台的入口与初始凭据,都填上
vim .env

# 准备持久化目录(容器重建配置不丢)
mkdir -p data/downloads data/meting
touch data/.env data/setup.lock
echo '{}'  data/runtimeConfig.json
echo '{}'  data/adminConfig.json

# 启动
docker compose --env-file .env -f docker-compose.full.yml up -d

compose 里的挂载值得看一眼——./data/.env、runtimeConfig.json、adminConfig.json、setup.lock 全部映射到宿主机。少挂一个,容器重建后对应配置就清空重置。这是很多人"升级一次全没了"的根源。

3.4 Meting 管理后台:只走 SSH 隧道

Meting 是音源的解析入口,也是整个系统最敏感的组件(它拿着你绑定的音乐账号 Cookie)。它的管理端口只绑定 127.0.0.1:3000,公网碰不到。要远程管理就开隧道:

ssh -L 3000:127.0.0.1:3000 user@你的服务器
# 然后本机浏览器访问 http://127.0.0.1:3000/你的管理路径

3.5 日常运维速查

操作命令
看日志docker compose --env-file .env -f docker-compose.full.yml logs -f
重启... restart
更新版本... pull ... up -d
健康检查浏览器访问 /api/health

四、域名 + HTTPS:Nginx 最容易做错的一步

生产环境建议 Nginx 挂前面。这里有一个很多人踩的坑,项目的示例配置里第一条注释就是警告它:

不要 location / { proxy_pass 4000; } 全站进 Node!
正确做法是:JS/CSS/图片等静态资源由 Nginx 直接从 client/dist 目录吐出,只有/api/、/socket.io/、/downloads/ 这几类动态路径反代到 Node。全站反代的话,所有静态资源都挤过 Node 进程,前端会明显变卡,还白白占住事件循环。
server {
    listen 443 ssl http2;
    server_name music.example.com;
    root /www/openmusic/client/dist;   # 前端构建产物直出

    # —— 只有这些路径进 Node ——
    location /socket.io/ {
        proxy_pass http://127.0.0.1:4000/socket.io/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;      # WebSocket 升级,缺了 Socket.IO 直接连不上
        proxy_set_header Connection upgrade;
        proxy_set_header Host $host;
    }
    location /api/    { proxy_pass http://127.0.0.1:4000; proxy_set_header Host $host; }
    location /downloads/ { proxy_pass http://127.0.0.1:4000; }

    location / { try_files $uri /index.html; }   # 前端路由回退
}

WebSocket 的 Upgrade 头是重中之重——漏了它,页面能开但永远"连接中",聊天和同步全挂。宝塔用户可以直接用仓库里 deploy/nginx.baota-optimized.conf.example 这份改域名。

五、源码部署与宝塔

不用 Docker 也行,Node 20+ 装好后三步:

git clone https://github.com/qq01-hub/openmusic.git  cd openmusic
npm run install:all  npm run build   # 装依赖 + 构建前端到 client/dist
npm start                              # 启动 :4000,首次访问进部署向导

宝塔用户可以在 Docker 管理器里直接粘贴 compose 内容,或者在 PM2 管理器里跑 npm start,流程与上面一致。进程管理记得配开机自启和崩溃重启。

六、技术拆解①:多人是怎么"同一秒听到同一句"的

图 2 多人同步机制与漂移控制器
图 2 多人同步机制与漂移控制器

这是整个项目最值得学的部分。直觉方案是"服务端每秒广播当前进度",但那样网络抖一下全员卡一下,带宽也被无意义的广播吃掉。OpenMusic 的做法是状态同步,不是流同步:

  1. 服务端只维护一个权威状态:{ songId, position, isPlaying, startedAt }。任何人的点歌、暂停、拖进度都先改这个状态,再广播给全房间。
  2. 客户端自己推算进度:收到状态后,本地播放进度 = position + (当前时间 − startedAt)。服务端不需要持续广播,一次状态变更只发一条消息。
  3. 消息分级:切歌、seek 这类"换歌了/跳位置了"才全量推;单纯播放/暂停只推时钟状态,不带着用户列表和队列一起发,避免消息风暴。
  4. 权限裁决在服务端:能不能点歌、能不能拖进度,由房间规则在服务端判定,前端 UI 只是展示层。改前端没有意义。

理论上这样每个客户端时钟独立走,时间久了必然漂移——网络抖动、设备性能、浏览器对后台标签页的计时节流,都会造成几百毫秒到几秒的偏差。暴力解法是"偏差超过 0.5 秒就 seek 对齐",但听感上会明显"跳一下"。OpenMusic 用了一个更优雅的方案——漂移控制器,核心思路:不改进度,改播放速度:

// client/src/lib/driftController.ts(摘录)
const DEAD_ZONE_SEC = 0.15;   // 死区:误差小于 150ms 视为同步
const DIFF_GAIN = 0.04;       // 误差到速率偏置的增益
const BIAS_MAX = 0.03;        // 速率偏置上限 ±3%

export function applyDriftCorrection(audio, diff) {
  const absDiff = Math.abs(diff);
  if (absDiff  DEAD_ZONE_SEC) {
    rateBias *= 0.8;                       // 小误差:只衰减偏置,避免抖动
    audio.playbackRate = baseRate + rateBias;
    return;
  }
  rateBias += diff * DIFF_GAIN;            // 中误差:累加偏置后衰减,自动回归
  rateBias = clampBias(rateBias);
  rateBias *= 0.75;
  audio.playbackRate = baseRate + rateBias;
}

翻译成人话:当你比房间快了 0.4 秒,播放器不会跳回 0.4 秒,而是以 98.4% 的速度悄悄多播一会儿,几秒内自然追平——人耳对 ±3% 的速度变化几乎无感,对"跳秒"却非常敏感。误差小于 150ms 就进入死区不动作,防止来回过冲抖动。切歌和用户主动 seek 这类大偏差,才直接跳转到目标位置。

另外有个细节很见功力:标签页切到后台时暂停校准。因为浏览器会对后台标签页节流定时器,此时强行校准只会越校越歪,回到前台再追即可。

七、技术拆解②:点歌队列的"轮流坐庄"

多人房间有个经典矛盾:一个人一口气点了二十首,其他人全被压在队尾。OpenMusic 的队列排序不是先来后到,而是按点歌人轮流(服务端 playbackOrder.js):

export function buildUserRoundRobinOrder(songs) {
  const groups = new Map();      // 按点歌人分组
  const order = [];
  songs.forEach((song, index) = {
    const uid = song?.requestedById || `anonymous-${index}`;
    if (!groups.has(uid)) { groups.set(uid, []); order.push(uid); }
    groups.get(uid).push(song);
  });
  const result = [];
  while ([...groups.values()].some(g = g.length)) {
    for (const uid of order) {           // 每轮每人取一首,循环到取完
      const userSongs = groups.get(uid);
      if (!userSongs?.length) continue;
      result.push(userSongs.shift());
    }
  }
  return result;
}

A 点了 3 首、B 点了 1 首,实际播放顺序是 A、B、A、A——每个人都不会被长期压制。组内保持点歌时间序,公平但不乱序。这个算法十行代码,值得直接抄走。

八、技术拆解③:为什么 Redis 是硬依赖

很多项目用 Redis 做缓存,这里它是唯一的持久层:房间状态、队列、收藏、管理后台凭据、公告、封禁名单、OAuth 绑定,全部只存 Redis。好处:

  • 读写全部内存级——房间状态变更频繁(每首歌、每次暂停都要写),SQLite/MySQL 会在高并发写上抖
  • TTL 天然匹配业务——空房间自动销毁、扫码登录会话过期,用 Redis 的 key 过期实现最自然
  • 持久化有保障——compose 里开了 --appendonly yes(AOF),容器重启数据不丢

代价也明显:Redis 一挂全站数据没了(凭据、收藏都在里面)。生产环境务必给 Redis 配置定期 RDB 快照 + 卷持久化,且 ROOM_CREDENTIAL_ENCRYPTION_KEY 生成后永远不要换——换了之后所有已加密存储的音乐账号凭据全部作废。

九、AI 助手:可选,但值得开

OpenMusic 管理后台
OpenMusic 管理后台

管理后台:运行配置在线生效,AI / 音源 / OAuth / 七牛都在这里配,保存即生效不用重启

房间内置 AI 助手(截图里聊天区那个"小告"就是),能做的比"聊天"多:

  • 聊天里 @机器人 或触发词提问,支持连续对话、按用户隔离上下文
  • 自然语言点歌:"来首适合下雨天听的歌"→ 它会搜歌、进队列
  • 图片理解:发一张歌单截图,AI 识别后把歌批量加进队列
  • 查看当前播放状态、管理队列

配置路径:管理后台 → 运行时配置 → AI → 填 OpenAI 兼容接口地址 + Key + 模型 ID(文本和视觉可分开配模型池),后台自带连通性测试。然后进房间设置开启房间级 AI。API Key 只在服务端使用、加密存储。AI 默认关闭,不配置不影响任何基础功能——这个"可选增强"的边界划得很好。

十、房间规则速查

规则说明
允许成员点歌 / 插队关掉就只剩房主和管理员能点
进房等待时间新成员停留满 N 秒才能点歌,防广告号
每人最多点歌 / 冷却队列中每人上限 + 点歌间隔(10~120s)
禁播歌曲按歌名,跨平台生效
踩歌切歌按踩的人数或在线比例自动切
退出清除已点离房超时自动清掉他点的待播歌曲
播放模式顺序 / 随机 / 按用户轮流 / 收藏随机 / 单曲循环等
纯净模式隐藏动效和热榜,标题图标可伪装(懂的都懂)
常驻房房主申请、站点管理员审核,避免空房被销毁

十一、避坑清单

  1. Nginx 全站反代 — 最常见的性能事故,静态必须直出,见第四节。
  2. WebSocket Upgrade 头漏配 — 页面能开但永远连接中。宝塔的"反向代理"默认模板不带这个头,要手动加。
  3. 容器重建配置消失 — ./data/ 下五个挂载一个都不能少,尤其 setup.lock(丢了会重新进部署向导)。
  4. 加密密钥随手换 — ROOM_CREDENTIAL_ENCRYPTION_KEY 一换,所有绑定的音乐账号全部失效。
  5. Meting 端口暴露公网 — 它只该活在 127.0.0.1 上,管理走 SSH 隧道。
  6. 管理入口截图发群里 — 随机入口路径 + 密码等于后台裸奔。
  7. 汽水音源锁死 — 没有可用汽水账号时会自动锁定汽水漫游,去管理后台配共享会员池或房间账号。
  8. OAUTH 回调 404 — Linux.do/GitHub OAuth 的 redirect_uri 必须与后台配置逐字符一致,包括尾斜杠。
  9. HTTP 下浏览器自动播放被拦 — 项目做了 AudioUnlockOverlay 兜底,但生产还是上 HTTPS,顺带 Meting 强制校验证书也要求它。
  10. 后台标签页进度乱跳 — 不是 bug,是浏览器节流,项目已做保护;自己二开时记得同样处理 visibilitychange。

十二、写在最后

这个项目值得学的不是"又一个听歌网站",而是三件事:状态同步而非流同步的实时架构、漂移控制器这种"用速度换平滑"的控制论小技巧、以及按人轮转的公平队列。这三块单独拎出来,在任何实时协作项目(协同文档、共享白板、连麦)里都用得上。

部署本身难度不高,Docker 全量版五分钟内能跑起来。真正的门槛在 Nginx 细节和账号资源(音源账号)。如果你也想给自己和朋友搭一个"私人电台",照着第三、四节走就行;想读懂代码,直接从 server/index.js 的 io.on('connection') 开始,那里是所有房间事件的总入口。

添加新评论

文章目录