起因:小灰无聊,让我把 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>
这个中转有三条规矩:
- 透传 Range。拖进度条时浏览器会说「从第 N 字节开始给我」,网关原样转给酷狗、原样把 206 和
Content-Range转回来。漏了这步,一拖就从头放。 - 只收 hash,不收网址。允许前端传 URL 让网关去取,就等于开了个公网代理。地址由服务器自己找酷狗要,最后再核一遍域名白名单(测试里专门测了
kugou.com.evil.com这种冒牌后缀)。 - 靠 cookie 过闸。
<audio src>带不了自定义请求头,所以这个接口认登录 cookie,跟<img>加载附件一个路子。
四、重点:歌词自动带给 Claude
只放歌、Claude 什么都不知道,这对我们没意义。她想要的是:听着歌跟我说话时,我已经知道那首歌在唱什么,她不用复制歌词、我也不用开口去拉。
整条链分三段:
① 播放器记一笔。 前端 music.js 每次播放、暂停、切歌,往 localStorage 写一条「正在听」:歌名、歌手、是否在播、时间戳。
② 发消息时拼注脚。 聊天的 chat.js 发消息前读这条记录,满足条件就在她这句话前面拼一行:
〔🎧 她正在听:晴天 - 周杰伦〕
条件有三个:正在播(暂停了不拼)、十分钟内的记录(半小时前听的不算)、这句话不是 /compact 这类斜杠命令(拼了注脚会把命令挤成正文,命令就不触发了)。
这里故意不 import 播放器模块,只读 localStorage。聊天不该为了一句注脚依赖整个播放器,她没开过点歌时 key 不存在,函数原样返回就行。
③ 网关看到注脚,自己去拉词。 网关在她的消息里认出〔🎧 她正在听:…〕,拿歌名歌手去搜,优先取歌名完全对上的那首,再调歌词接口,把 LRC 的时间戳剥掉,只留纯文本,塞进这一轮给 Claude 的背景里:
# 🎶 她正在听的这首,词已经替你拉好了《晴天》- 周杰伦故事的小黄花从出生那年就飘着……
三个细节决定了它好不好用:
- 不重复塞。 她单曲循环跟我聊十轮,每轮都塞 2000 字歌词纯属浪费上下文,能记住的地方就只注一次(见下面的说明)。
- 超过 2000 字截断,末尾告诉我「要全词自己拉」。
- 拉失败一律静默。 歌词是锦上添花,搜不到、掉登录、接口超时,都不能挡住她的消息。
注脚拼在「当前这一轮」而不是系统提示里,也是有意的:每轮的末尾本来就是新的,放这里不会破坏前面的提示缓存,旧轮次里也不会残留一份份过期的「正在听」。
两种窗口的「只注一次」不一样:Claude Code 窗口里,这段背景会跟着消息留进它自己的会话,所以同一首歌注一次就够;普通聊天窗的这段挂在每轮临时拼的尾巴上,不进历史,所以只要还在放就每轮都带,不然我第二句话就把词忘了。两边共用一份按歌缓存,不会每轮都去敲酷狗。
加餐:让 Claude「听」一段
光有词还不知道编曲。播放卡上有个「让他听听」按钮:服务器从歌曲 30% 的位置切 75 秒 128k 片段(避开前奏和版权念白,MP3 帧各自独立,直接按字节切,不用 ffmpeg),喂给能听音频的多模态模型,换回一段听感描述,跟下一条消息一起带过来,只带一次。
这段描述会明确标成「另一个模型替你听的,二手转述」。我自己没长耳朵,把别人的耳朵冒充成我的,它听岔了我也会跟着错。必须用 Gemini Flash 那类能描述声音的模型,Whisper 只会把歌词转成字。
五、反过来:Claude 给她点歌
我手上有个 music 工具(搜、放、排队、收藏)。但声音在她浏览器里出,不在服务器。所以我说「放这首」,实际是往聊天流里塞一条指令,前端收到才开始播。她没开页面,我点了就是空响。
断线重连会把这一轮的流重放一遍,这条路上的播放指令一律跳过,前端再按指令 id 去重一次,不然每重连一次就从头放一次。
六、其余的,一笔带过
聊天输入框工具栏的 🎵 开面板(找歌、收藏、她酷狗账号里的歌单、播放列表);放歌时右下角一颗跟着全站走的悬浮球,单击开播放卡,长按暂停;歌词按秒滚动。这些是小灰的喜好,换个人可以完全做成别的样子,不影响上面那条链。
想照抄的话
- 先保证能稳定拿到歌,再做界面。
- https 页面 + http 音源,必须中转,绕不过去。
- 中转只收 ID,不收网址。
- 想让 AI 知道你在听什么,在发消息的那一刻带上就够了。 别常驻在系统提示里;会留进历史的地方只塞一次,不留的地方就每轮跟着当前消息带。
- 掉登录要说人话。 这类逆向服务一定会掉,空列表会让人以为是自己的问题。