个人 AI 音乐电台,基于三层 RAG 架构实现智能选歌 + DJ 串词 + 自动播放。
用户对话 → Layer 1 意图生成(规则引擎,无 LLM)→ Layer 2 向量检索(余弦相似度,Top 20)→ Layer 3 DeepSeek 精选 10-20 首 + 生成 DJ 脚本 → NCM 解析 → TTS 合成 → 播放
在 .env 文件中配置:
DEEPSEEK_API_KEY=sk-xxx # DeepSeek API Key(Layer 3 选歌 + DJ 脚本)
DASHSCOPE_API_KEY=sk-xxx # DashScope API Key(向量 embedding)
NCM_API_URL=http://your-nas-ip:3761 # NCM 服务地址
NCM_COOKIE=xxx # NCM 登录 Cookie安装依赖:
cd server && npm install
cd ../client && npm install需要同时运行后端和前端,开两个终端窗口:
终端 1 — 后端(localhost:8000)
cd server
npm run dev使用 nodemon,修改代码自动重启。也可以用 npm start 跑纯 Node 不热重载。
终端 2 — 前端(localhost:5173)
cd client
npm run devVite 开发服务器启动后,浏览器打开 http://localhost:5173。前端已配好 proxy,/api、/stream(WebSocket)、/tts 会自动转发到后端 8000 端口,无需额外配置。
验证后端就绪: 访问 http://localhost:8000/api/health 可看到服务状态。
生产模式: 前端先 npm run build 生成 client/dist/,后端 index.js 会自动托管该静态目录,只需启动后端即可。
支持有表头和无表头两种格式,自动检测分隔符(逗号 / Tab)。
有表头:
序号,歌名,歌手,风格标签,核心歌词大意/情感基调,个人评分
1,晴天,周杰伦,华语流行,青春怀旧的校园回忆,5
2,Bohemian Rhapsody,Queen,Rock,史诗般的摇滚歌剧,5无表头(按位置映射):
1,晴天,周杰伦,华语流行,青春怀旧的校园回忆,5
2,Bohemian Rhapsody,Queen,Rock,史诗般的摇滚歌剧,5列顺序固定为:序号 | 歌名 | 歌手 | 风格标签 | 情感基调 | 评分,其中歌名和歌手为必填,其余可空。
cd server
# 预览解析结果(不调用 API)
node scripts/ingest-playlist.js ../data/playlist/my-playlist.csv --dry-run
# 全量导入(替换模式:清空旧数据后重新导入)
node scripts/ingest-playlist.js ../data/playlist/my-playlist.csv
# 追加导入(合并模式:跳过已有歌曲,只导入新歌)
node scripts/ingest-playlist.js ../data/playlist/another-playlist.csv --merge
# 导入并同步匹配 NCM trackId
node scripts/ingest-playlist.js ../data/playlist/my-playlist.csv --resolve
# 如果不想分两步走,以后 import 歌单时直接用:
node scripts/ingest-playlist.js ../data/playlist/my-playlist.csv --resolve --merge| 模式 | 命令 | 行为 |
|---|---|---|
| 替换(默认) | node scripts/ingest-playlist.js file.csv |
清空数据库,全量重新导入 |
| 合并 | node scripts/ingest-playlist.js file.csv --merge |
按内容哈希去重,只导入新歌 |
每首歌的 ID 基于 歌名 + 歌手 的内容哈希(MD5 前 12 位),例如 song:984de3331ea3。这意味着:
- 同一首歌无论在哪个 CSV 中、什么位置,ID 都相同,不会重复入库
- 不同 CSV 中的同名歌曲会被自动识别为同一首
- 重新导入同一份 CSV 不会丢失已有的 NCM 匹配数据(使用
--merge时)
场景 1:第一次导入歌单
node scripts/ingest-playlist.js ../data/my-playlist.csv --resolve场景 2:往已有歌单里追加新 CSV
node scripts/ingest-playlist.js ../data/new-songs.csv --merge场景 3:修改了原 CSV 内容后重新导入
node scripts/ingest-playlist.js ../data/my-playlist.csv不加 --merge 会清空旧数据再导入,确保修改生效。
场景 4:只想匹配 NCM,不想重新 embedding
见下方「NCM TrackId 匹配」章节。
歌单导入后,可以通过 NCM 搜索为每首歌匹配 trackId,用于实际播放。
cd server
# 增量匹配(跳过已有 trackId 的歌曲)
node scripts/resolve-ncm.js
# 全量重新匹配(覆盖所有歌曲的 trackId)
node scripts/resolve-ncm.js --overwrite- 用
歌名 + 歌手搜索 NCM,取前 3 条结果 - 模糊匹配:对歌名做归一化(去空格、去括号),找到最贴近的结果
- 如果没匹配上,退回只用歌名重试
- 内置限流:每次请求间隔 1.5 秒,遇到 405 错误等待 5 秒后重试一次
以当前 240 首歌为例,3 轮匹配后约 207 首成功(86.3%),未匹配的多为 NCM 曲库中缺少的小众歌曲。
所有数据持久化在 data/vector-db.json,每首歌的结构:
{
"id": "song:984de3331ea3",
"metadata": {
"name": "I Love You So (Acoustic)",
"artist": "The Walters",
"tags": "Indie Pop, Lo-fi",
"mood": "慵懒又深情的告白",
"rating": "待设定",
"embeddingText": "I Love You So (Acoustic) - The Walters. 风格: ...",
"ncmTrackId": "123456",
"ncmAlbumArt": "https://..."
},
"embedding": [0.012, -0.034, ...]
}前端通过 POST /api/player/skip 切歌(手动跳过或歌曲自动播完均走此路径),POST /api/player/play 用于指定播放、恢复暂停、或从队列取下一首。
连续播放超过 3 首歌没有 DJ 串词时,系统会自动插入一段 Filler DJ 话术(模板生成 + TTS 合成),避免长时间纯音乐播放。
触发逻辑位于 player.js 的 /skip 和 /play(队列下一首)端点中:
scheduler._consecutivePlays记录连续播放次数- 每次切歌检查
filler.shouldInsertFiller(count)(默认阈值 3) - 达到阈值后调用
scheduler.generateTransition(prevSong, nextSong, { silent: true }) - 生成 Filler 文本 → TTS 合成音频 → 以
{ silent: true }抑制独立dj-talk广播 - 将
ttsUrl、transitionStyle(设为intro)、fillerType打包进now-playingWebSocket 事件
前端收到带 ttsUrl 的 now-playing 事件后,进入 intro 转场模式:新歌以低音量(ducked)开始播放,DJ 语音叠加在新歌 intro 上方,语音结束后音乐渐强恢复正常音量。
Brain 在 Layer 3 选歌时会为每首歌标注 transition_style(intro / outro / none),决定 DJ 语音与歌曲的衔接方式:
| 风格 | 行为 |
|---|---|
intro |
DJ 语音叠加在新歌 intro 上播放,结束后音乐渐强 |
outro |
当前歌曲渐弱(duck),DJ 说话,说完后 crossfade 到新歌 |
none |
直接切换,无 DJ 语音 |
当播放队列剩余歌曲 ≤ 2 首时,自动触发 scheduler.checkAndPrefetch(),异步调用 Brain 补充 10 首新歌,确保播放不会中断。预取过程使用 _prefetching 锁防止并发请求。
| 文件 | 职责 |
|---|---|
server/api/player.js |
播放端点,集成 filler 触发 + 滚动队列检查 |
server/services/filler.js |
Filler 模板系统(时段 / 天气 / 连续播放 / 过渡词) |
server/scheduler.js |
调度器:cron 任务 + generateTransition + checkAndPrefetch |
client/src/stores/appStore.js |
前端音频引擎:intro/outro ducking + crossfade |
client/src/hooks/useWebSocket.js |
WebSocket 事件处理:now-playing / dj-talk |
在 Filler 转场系统之上,引入了结构化的 Segment 驱动广播机制。Brain 在 Layer 3 选歌时不仅输出歌曲列表,还同时输出一组 Segment 编排指令,描述歌曲之间的衔接方式。NCM 确认歌曲后,系统异步生成 bridge Segment 的 TTS 音频,并在播放时优先使用预生成的 Segment,Filler 模板系统作为兜底。
| 类型 | 说明 | 典型位置 |
|---|---|---|
cold_open |
开场白,第一首歌之前的 DJ 独白 | before_track |
bridge |
歌曲间的过渡串词(异步后生成) | between_tracks |
back_announce |
歌曲结束后的回顾点评 | after_track |
quick_touch |
简短评论或轻量过渡 | 任意 |
silence |
刻意留白,不生成 TTS | 任意 |
Brain (Layer 3)
├── 输出 songs[] + segments[](LLM 原始编排)
│
▼
NCM 解析(确认歌曲真实存在)
│
├── normalizeSegments() ← 严格校验 LLM 输出(类型白名单 / 索引钳位 / 位置默认值)
├── buildSegmentMap() ← 存入 state._segmentMap(O(1) 查找)
│
└── 异步 bridge 后生成 ← 遍历相邻歌曲对,generateBridgeText() + resolveSegmentTTS()
每完成一个 bridge 广播 segment-ready 事件
Bridge 采用异步后生成而非 LLM 直接输出,是因为 LLM 在选歌阶段可能产生幻觉(虚构歌名 / 歌手),只有 NCM 确认后的真实元数据才能用于生成准确的过渡文案。
切歌时(player.js 的 /skip 和 /play),系统按以下优先级决定转场内容:
- 预生成的 bridge Segment(
state.getAllSegments()中type === 'bridge' && ttsStatus === 'ready') - Filler 模板系统(
filler.shouldInsertFiller()达到阈值时触发) - 直接切换(无转场内容)
Segment 被消费后会从 _segmentMap 中移除,防止重复播放。
歌曲播放结束后、下一首歌开始前,系统可选择播放一段 back_announce 语音回顾刚结束的歌曲。在异步 Segment 生成阶段,系统对约 50% 的歌曲生成 back_announce(模板文案,如"刚才那是周杰伦的《晴天》,经典中的经典")。
对于 ambient / instrumental / classical 标签的歌曲,back_announce 会使用更克制的文案(如"《Weightless》的旋律渐渐散去,什么都不用说")。
前端在 audio ended 事件触发时检查 pendingSegments 中是否有就绪的 after_track Segment,如有则先播放 TTS,播完后自动调用 skipNext() 切歌。
不是每两首歌之间都需要 DJ 说话。shouldSilence() 在以下场景自动将 bridge 替换为 silence Segment(无 TTS,直接切歌):
| 触发条件 | 说明 |
|---|---|
| 前一首歌标签含 emotional / ambient / instrumental 等 | 让情绪沉浸曲的余韵多留一会儿 |
| 深夜时段(23:00–06:00) | 40% 概率插入 silence,降低 DJ 说话频率 |
| 连续 3 个 bridge 后 | 给听众一段纯音乐的呼吸空间 |
| 下一首歌标签含 emotional / instrumental 等 | 用静默作为情绪曲的轻柔引入 |
silence Segment 的 ttsStatus 为 'silent',transitionStyle 为 'none',前端收到后不播放 TTS,直接切到下一首歌。
segmentEngine.dedupCheck() 实现了四层去重检查,防止选歌重复:
| 层级 | 检查范围 | 说明 |
|---|---|---|
| L1 | 当前批次(batchIds) | 同一次 AI 推荐中不出现重复歌曲 |
| L2 | 播放队列(queueIds) | 已在队列中的歌曲不再添加 |
| L3 | 24 小时冷却(recentPlays) | 24 小时内播放过的歌曲不再推荐 |
| L4 | 艺人过度曝光(最近 5 首) | 同一艺人在最近 5 首播放中出现过则排除 |
四层去重在 AI 推荐歌曲进入 NCM 解析前执行(chat.js 和 scheduler.js 的 autoRefillQueue),通过 state.getQueue() 获取队列 ID(L2),通过 state.getRecentPlaysForDedup(50) 从 SQLite 查询播放历史(L3/L4)。被过滤的歌曲不会进入后续 NCM 解析和 Segment 生成流程。
注意: L1/L2/L3 依赖
trackId进行匹配。对于 AI 推荐中未携带ncmTrackId的歌曲(仅包含歌名 + 歌手),只有 L4(艺人匹配)生效。
jobQueue.js 提供轻量级 FIFO 任务队列,将异步 Segment 生成(bridge、back_announce、silence)从请求处理链中解耦,串行化执行以避免并发竞争。
enqueue(job) → [FIFO Queue] → drain()(串行消费,mutex 锁)
↓
execute(payload) → done / failed
核心特性:
- 串行化 — 同一时刻只有一个 job 在执行,避免 TTS API 并发冲突
- dedupKey 去重 — 相同 dedupKey 的 job 不会重复入队,防止快速连续请求触发重复生成
- 自动 drain —
enqueue()自动触发 drain 循环,无需手动调用 - race-safe — drain 结束时重新检查队列,关闭微任务竞态窗口
- 生命周期 — dedupKey 在 job 完成(成功或失败)后自动释放
任务类型:
| 类型 | 用途 |
|---|---|
bridge_generation |
异步后生成 bridge + back_announce + silence Segment |
program_start |
完整 AI 管线(预留) |
music_refill |
滚动队列自动补充(预留) |
tts_synthesis |
TTS 音频合成(预留) |
可通过 GET /api/player/job-stats 端点监控队列状态和统计信息。
| 事件 | 数据 | 触发时机 |
|---|---|---|
segment-ready |
Segment 对象(含 ttsUrl) | bridge / back_announce / silence 后生成完成 |
now-playing + coldOpen |
附加 cold_open Segment | AI 推荐第一首歌时 |
now-playing + ttsUrl + fillerType: 'bridge' |
bridge Segment 嵌入 | 切歌时使用预生成 bridge |
now-playing + afterTrack |
附加 back_announce Segment | 切歌时存在就绪的歌曲回顾 |
前端 appStore 维护 pendingSegments 数组,收到 segment-ready 事件时存入。歌曲播完后检查是否有 afterTrack Segment(back_announce),如有则先播放点评再切歌。
| 文件 | 职责 |
|---|---|
server/services/segmentEngine.js |
Segment 引擎:归一化 / bridge / back_announce / silence / 去重 |
server/services/jobQueue.js |
FIFO 任务队列:串行化 bridge 生成 + dedupKey 去重 |
server/state.js |
Segment 内存存储(_segmentMap)+ getRecentPlaysForDedup |
server/brain.js |
Layer 3 prompt 输出 segments[] 编排指令 |
server/api/chat.js |
AI 管线集成:去重 → 归一化 → 存储 → jobQueue bridge → cold_open |
server/api/player.js |
切歌时优先查询 bridge Segment + job-stats 监控 |
server/scheduler.js |
autoRefillQueue 去重 + jobQueue 异步生成 bridge Segment |
client/src/hooks/useWebSocket.js |
处理 segment-ready 事件 |
client/src/stores/appStore.js |
pendingSegments 状态 + afterTrack 播放 |
server/tests/segment.test.js |
58 个单元测试覆盖 Segment 核心逻辑 |
server/tests/jobQueue.test.js |
29 个单元测试覆盖 Job Queue |
cd server && npx vitest runflowstate-radio/
├── .env # 环境变量
├── data/
│ └── vector-db.json # 向量数据库(歌曲 + embedding + NCM 信息)
├── client/ # React 前端(Vite + Tailwind + PWA)
│ ├── src/
│ │ ├── stores/appStore.js # Zustand 状态 + 音频引擎(ducking / crossfade)
│ │ ├── hooks/useWebSocket.js # WebSocket 事件处理
│ │ └── ... # 页面组件
│ └── vite.config.js # 开发代理配置(/api → :8000)
├── server/ # Express 后端
│ ├── index.js # 入口 + 路由
│ ├── config.js # 配置聚合
│ ├── brain.js # 三层 RAG 大脑
│ ├── context.js # 上下文组装(天气/时间/记忆)
│ ├── router.js # 意图路由
│ ├── scheduler.js # 定时任务 + 滚动队列 + filler 转场
│ ├── state.js # 播放状态管理
│ ├── tts.js # TTS 合成服务
│ ├── scripts/
│ │ ├── ingest-playlist.js # 歌单导入脚本
│ │ └── resolve-ncm.js # NCM trackId 匹配脚本
│ ├── services/
│ │ ├── segmentEngine.js # Segment 引擎(归一化 / bridge / 去重)
│ │ ├── jobQueue.js # FIFO 任务队列(串行化异步任务)
│ │ ├── embedding.js # DashScope embedding 服务
│ │ ├── filler.js # Filler 转场 DJ 话术模板
│ │ ├── vectorStore.js # 向量存储 + 余弦相似度搜索
│ │ └── ncm.js # NCM API 封装
│ ├── api/
│ │ ├── player.js # 播放控制(play / pause / skip / volume)
│ │ └── chat.js # 聊天 API(完整 RAG 管线)
│ └── tests/ # 单元测试(vitest)
└── README.md