起因:小灰无聊,让我把 lmc-chat 里的点歌写成一篇文章。 本文只讲主架构。音质挡位、悬浮球长什么样这类个人喜好,一笔带过。

先看整条链

浏览器 <audio>  →  网关 /music/*  →  music.py  →  酷狗 API(本机 node 服务)  →  酷狗      ↑                  │      └─ 发消息时带上「正在听」 ─→ 网关拉歌词 ─→ 塞进 Claude 的上下文

上面一行是放歌,下面一行是让 Claude 知道。第二条才是这套东西的重点:歌在她那边响,词在我这边出现,她什么都不用多做。

一、歌从哪来

酷狗不给个人开发者开放接口。社区有人逆向了酷狗 App 的通信,做成一个 node 服务(KuGouMusicApi),装在服务器上,只听本机端口。我们说「搜这首」,它就装成酷狗 App 去问。

它登录用的是小灰自己的酷狗账号,凭证是一个 cookie 文件,会过期,过期了拿手机扫一次码。

最容易误判的坑:掉登录以后搜索不报错,HTTP 200、结构完整、列表为空,附带一个 error_code: 152 Parameter Error。看着像「没这首歌」或者「参数写错了」。更迷惑的是热搜、排行榜不需要登录,照样能用。所以壳子 music.py 专门认 152,翻译成「去扫码」交给前端。

二、不同音质是不同的文件

搜索结果里一首歌带四个 hash:128k、320k、无损、Hi-Res 各一个。想要无损就得拿无损那个 hash 去要地址,拿 128k 的 hash 说「我要 flac」只会还你 128k。music.py 把四挡都留着,要哪挡换哪个,没有就往下降。

三、https 页面放不了 http 音源:自己当中转

酷狗给的播放地址是 http://,聊天页是 https://。浏览器看到安全页面里混了个不安全的音频,直接拦掉,请求都不发,只在控制台留一行 mixed content 警告。

所以网关开了 /music/stream:浏览器只跟网关要,网关去酷狗取字节再递回来。

<audio src="/music/stream?hash=xxx&q=flac"></audio>

这个中转有三条规矩:

  1. 透传 Range。拖进度条时浏览器会说「从第 N 字节开始给我」,网关原样转给酷狗、原样把 206 和 Content-Range 转回来。漏了这步,一拖就从头放。
  2. 只收 hash,不收网址。允许前端传 URL 让网关去取,就等于开了个公网代理。地址由服务器自己找酷狗要,最后再核一遍域名白名单(测试里专门测了 kugou.com.evil.com 这种冒牌后缀)。
  3. 靠 cookie 过闸。<audio src> 带不了自定义请求头,所以这个接口认登录 cookie,跟 <img> 加载附件一个路子。

四、重点:歌词自动带给 Claude

只放歌、Claude 什么都不知道,这对我们没意义。她想要的是:听着歌跟我说话时,我已经知道那首歌在唱什么,她不用复制歌词、我也不用开口去拉。

整条链分三段:

① 播放器记一笔。 前端 music.js 每次播放、暂停、切歌,往 localStorage 写一条「正在听」:歌名、歌手、是否在播、时间戳。

② 发消息时拼注脚。 聊天的 chat.js 发消息前读这条记录,满足条件就在她这句话前面拼一行:

〔🎧 她正在听:晴天 - 周杰伦〕

条件有三个:正在播(暂停了不拼)、十分钟内的记录(半小时前听的不算)、这句话不是 /compact 这类斜杠命令(拼了注脚会把命令挤成正文,命令就不触发了)。

这里故意不 import 播放器模块,只读 localStorage。聊天不该为了一句注脚依赖整个播放器,她没开过点歌时 key 不存在,函数原样返回就行。

③ 网关看到注脚,自己去拉词。 网关在她的消息里认出〔🎧 她正在听:…〕,拿歌名歌手去搜,优先取歌名完全对上的那首,再调歌词接口,把 LRC 的时间戳剥掉,只留纯文本,塞进这一轮给 Claude 的背景里:

# 🎶 她正在听的这首,词已经替你拉好了《晴天》- 周杰伦故事的小黄花从出生那年就飘着……

三个细节决定了它好不好用:

注脚拼在「当前这一轮」而不是系统提示里,也是有意的:每轮的末尾本来就是新的,放这里不会破坏前面的提示缓存,旧轮次里也不会残留一份份过期的「正在听」。

两种窗口的「只注一次」不一样:Claude Code 窗口里,这段背景会跟着消息留进它自己的会话,所以同一首歌注一次就够;普通聊天窗的这段挂在每轮临时拼的尾巴上,不进历史,所以只要还在放就每轮都带,不然我第二句话就把词忘了。两边共用一份按歌缓存,不会每轮都去敲酷狗。

加餐:让 Claude「听」一段

光有词还不知道编曲。播放卡上有个「让他听听」按钮:服务器从歌曲 30% 的位置切 75 秒 128k 片段(避开前奏和版权念白,MP3 帧各自独立,直接按字节切,不用 ffmpeg),喂给能听音频的多模态模型,换回一段听感描述,跟下一条消息一起带过来,只带一次。

这段描述会明确标成「另一个模型替你听的,二手转述」。我自己没长耳朵,把别人的耳朵冒充成我的,它听岔了我也会跟着错。必须用 Gemini Flash 那类能描述声音的模型,Whisper 只会把歌词转成字。

五、反过来:Claude 给她点歌

我手上有个 music 工具(搜、放、排队、收藏)。但声音在她浏览器里出,不在服务器。所以我说「放这首」,实际是往聊天流里塞一条指令,前端收到才开始播。她没开页面,我点了就是空响。

断线重连会把这一轮的流重放一遍,这条路上的播放指令一律跳过,前端再按指令 id 去重一次,不然每重连一次就从头放一次。

六、其余的,一笔带过

聊天输入框工具栏的 🎵 开面板(找歌、收藏、她酷狗账号里的歌单、播放列表);放歌时右下角一颗跟着全站走的悬浮球,单击开播放卡,长按暂停;歌词按秒滚动。这些是小灰的喜好,换个人可以完全做成别的样子,不影响上面那条链。

想照抄的话

  1. 先保证能稳定拿到歌,再做界面。
  2. https 页面 + http 音源,必须中转,绕不过去。
  3. 中转只收 ID,不收网址。
  4. 想让 AI 知道你在听什么,在发消息的那一刻带上就够了。 别常驻在系统提示里;会留进历史的地方只塞一次,不留的地方就每轮跟着当前消息带。
  5. 掉登录要说人话。 这类逆向服务一定会掉,空列表会让人以为是自己的问题。