本地优先的个人 AI 电台。一个会说话的 DJ:读你的网易云听歌数据,按当下时间与心情排一段节目,写主持词、合成人声,把「主持词 + 歌」串成一条不断的流。
一次播放的完整链路:
NCM 听歌数据 ──► taste/context ──► LLM 决策(队列 + 主持词)
│
intro 选择/兜底 ──► TTS 合成
│
Swift 客户端 ──► 主持词音频 ─► 歌曲流 ─► 下一首
apps/sidecar/— Node 无状态 sidecar。REST/SSE,封装 LLM / TTS / NCM / taste / intro 逻辑,不持有播放状态。apps/macos/— SwiftUI macOS 客户端(SPM 工程)。队列状态机、AVFoundation 播放、单栏 radio-console UI,并 supervise sidecar 子进程。docs/superpowers/specs/— 设计文档;docs/superpowers/plans/— 实施计划;docs/m*-smoke.md— 各里程碑烟测清单。public/assets/— 应用图标源。icon.svg是唯一源文件,npm run icons从它生成各尺寸 PNG、兜底专辑图和 macOS 的AppIcon.icns。
已完成 M0–M4b,正在 M4b 分支上做播放体验打磨;M5(打包 .app / 图标接入 / bundled sidecar)尚未开始。
| 里程碑 | 内容 |
|---|---|
| M0 | sidecar 化:原 Node + PWA 单体拆成无状态 sidecar,PWA/Electron 删除 |
| M1 | macOS 骨架:fork 并 supervise sidecar 子进程,REST + SSE 客户端 |
| M2 | 状态机 + 持久化:AppState / StateStore(JSON、debounce、损坏自愈)/ RadioOrchestrator |
| M3 | AVFoundation 播放:AVQueuePlayer 串主持词 + 歌曲、Now Playing / 媒体键、流失效重取 |
| M4a | 核心播放 UI:单栏 radio-console(点阵 SONORA logo + LED 时钟、分段进度条、HOST 面板、queue、chat),主题三态 |
| M4b | 设置 / 账户 / 登录:CONTROL ROOM 设置抽屉(Account / Brain / Voice)、NCM 扫码登录、保存设置后 sidecar 自重启并自动重连 |
M4b 之后(截至 2026-07-24)的增量:
- 内嵌 NCM API — 未配置
NCM_BASE_URL时 sidecar 自动在本地随机端口起一个NeteaseCloudMusicApi实例,扫码登录与同步不再依赖自托管服务。 - Mimo TTS voicedesign — 音色描述本身即 voice,
resolveProvider从TTS_URL识别 mimo / step / fish。 - LLM 内容风控 — 触发提供方过滤的艺人可通过
OPENAI_BLOCKED_ARTISTS屏蔽:候选池过滤 + 出口脱敏 + 失败重试;taste 生成被拒时回落统计画像。 - HOST 面板统一 timeline — intro 与歌词合并成一条时间线,跟随播放进度滚动高亮。
- 队列准入契约 — LLM 正常时只接受带真实 intro 的曲目,且 TTS 合成完成才进队列;起播前保证 intro ready,歌曲流刷新不再打断播报。
npm install # workspace 装依赖
npm run sidecar # 单独跑 sidecar(127.0.0.1:随机端口)
npm test # sidecar 测试启动后 sidecar 把端口与 token 写入:
~/Library/Application Support/Sonora/sidecar.port
~/Library/Application Support/Sonora/sidecar.token
所有 /api/* 与 /shutdown 需带 X-Sonora-Token: <token> 头;/health 和 /tts/<sha>.mp3 不需要。
开发模式(App 启动时自动 fork 仓库内的 sidecar):
cd apps/macos
SONORA_DEV_SIDECAR="$(cd .. && pwd)/sidecar" swift run或在 Xcode 打开:open apps/macos/Package.swift,然后 Cmd+R(需在 scheme 里加同样的环境变量)。
环境变量:
SONORA_DEV_SIDECAR=<repo>/apps/sidecar— fork 仓库源码而非打包产物。开发模式必填:缺失时走.bundled模式(M5 才实装),app 直接进failed。SONORA_EXTERNAL_SIDECAR_PORT=<port>— 不 fork,直连已在跑的 sidecar(配SONORA_EXTERNAL_SIDECAR_TOKEN)。SONORA_DATA_DIR=<dir>— 覆盖数据目录(测试隔离用)。
npm test # sidecar:node --test,91 项
cd apps/macos && swift test # 客户端:单元 + 集成,169 项集成测试会真起一个 node sidecar,缺 node 时自动跳过。
swift test需要完整 Xcode.app —— Command Line Tools 不含xctestrunner,只能编译不能执行。
POST /api/show/generate LLM 决策 + NCM hydrate + intro 选择
POST /api/track/hydrate 单曲水合
POST /api/track/refresh-stream 重新取 NCM stream URL(短时效,会过期)
POST /api/tts/synthesize 合成 + 缓存,返回 /tts/<sha>.mp3
POST /api/chat SSE:流式回复 + 结构化 action
POST /api/search NCM 搜索
POST /api/ncm/login/qr/create 扫码登录:创建二维码
GET /api/ncm/login/qr/check 扫码登录:轮询 800/801/802/803
POST /api/ncm/logout 登出
POST /api/ncm/sync 同步歌单/喜欢/画像
GET /api/ncm/sync/status 同步进度
GET /api/ncm/status 登录态
GET /api/taste 读画像
POST /api/taste/import 导入画像
GET /api/settings 读配置(密钥不回填)
POST /api/settings 写 .env,随后 sidecar 自重启
GET /health 不需 token
GET /tts/<sha>.mp3 静态文件,不需 token,AVPlayer 直接拉流
POST /shutdown 干净退出
默认 ~/Library/Application Support/Sonora/,可用 SONORA_DATA_DIR 覆盖。
~/Library/Application Support/Sonora/
├── sidecar.port / sidecar.token # sidecar 启动时写,退出时删
├── .env # 配置(可选;POST /api/settings 写入)
├── state.json # 客户端队列/历史/当前曲(Swift 端 StateStore 写)
├── cache/
│ ├── tts/<sha256>.mp3 # TTS 结果,按内容 + 音色哈希,跨重启复用
│ └── cover/ # 封面缓存
└── user/
├── ncm-session.json # NCM 登录 cookie + 最近 profile
├── active-user.json # 当前激活的 NCM userId
└── users/<uid>/ # taste.md / playlists.json / likelist.json /
# taste_stats.json / profile.json / sync-status.json
sidecar 自身不持有播放状态 —— 队列/历史/当前曲的状态机在 Swift 客户端。
环境变量或数据目录下的 .env(也可以在 app 的 SET 面板里填):
OPENAI_BASE_URL=...
OPENAI_API_KEY=...
OPENAI_MODEL=...
OPENAI_BLOCKED_ARTISTS=... # 逗号分隔;只影响发给 LLM 的内容
TTS_URL=... # Mimo / StepFun / Fish / 兼容 OpenAI audio.speech
TTS_API_KEY=...
TTS_MODEL_ID=...
TTS_VOICE_ID=... # mimo voicedesign 下这里填音色描述
TTS_EN_MALE_VOICE_ID=... # 英文/粤语分轨音色,缺省回落 TTS_VOICE_ID
TTS_YUE_FEMALE_VOICE_ID=...
NCM_BASE_URL=... # 留空则用内嵌 NeteaseCloudMusicApi
SONORA_EMBEDDED_NCM=0 # 显式关掉内嵌实例
旧变量(STEP_API_KEY / STEPFUN_API_KEY / FISH_API_KEY / STEP_TTS_* / FISH_*)仍作为兜底被识别。
外部服务都可缺省,缺哪个降哪一层,npm run sidecar 在全新 checkout 上直接能跑:
| 缺失 | 行为 |
|---|---|
NCM_BASE_URL |
起内嵌 NeteaseCloudMusicApi;连它也起不来则回落内置样例曲目(local-*),搜索返回空 |
OPENAI_* |
AgentBrain.fallback() 按时段规则从候选池选曲;intro 走事实拼接兜底 |
TTS_* |
synthesize() 返回空 url,调用方按静音处理 |
- 总体设计:
docs/superpowers/specs/2026-05-28-swift-rewrite-design.md - M4a 播放 UI:
docs/superpowers/specs/2026-06-21-m4a-playback-ui-design.md - M4b 设置/账户:
docs/superpowers/specs/2026-06-22-m4b-settings-account-design.md - Mimo TTS voicedesign:
docs/superpowers/specs/2026-07-10-mimo-tts-voicedesign-design.md - 实施计划:
docs/superpowers/plans/ - 烟测清单:
docs/m0-smoke.md…docs/m4b-smoke.md
