我朋友在娱乐公司上班,用ai做了一个读取歌词的工具,但是没法做到实时歌声进度同步
这篇文章记录一下整个过程:从 Electron 包结构、source map、GetTrackV2 接口,到 Frida 抓包、TTNet 签名、KRC 解析,最后接进自己的 Python 项目。
目标版本:汽水音乐
目标接口:POST https://api.qishui.com/luna/pc/track_v2
目标结果:根据 SMTC 的歌曲信息和播放进度,实时显示汽水原生 KRC 歌词
一、先看它到底是个什么应用
汽水音乐 PC 端安装在:
1
| D:\Program Files (x86)\Soda Music(我的安装位置)
|
目录结构是典型的 Electron 应用:
1 2 3 4 5 6 7 8 9 10
| Soda Music/ ├── SodaMusic.exe ├── resources/ │ ├── app.asar │ ├── main.asar │ └── app.asar.unpacked/ │ ├── ttnet.node │ ├── sscronet.dll │ └── metasecml.dll └── ...
|
其中:
app.asar:渲染进程 / 页面代码
main.asar:主进程 / 服务代码
desktopLyrics.asar:桌面歌词窗口
ttnet.node + sscronet.dll:字节系 TTNet 网络栈和 native 请求签名
metasecml.dll:Metasec 安全相关 native 库
先不要急着上调试器,Electron 应用最舒服的入口还是 ASAR 和 source map。
二、静态分析:从 ASAR 到 GetTrackV2
1. 解包 ASAR
用 @electron/asar 把几个包解出来:
1 2 3
| npx --yes @electron/asar extract app.asar app npx --yes @electron/asar extract main.asar main npx --yes @electron/asar extract desktopLyrics.asar desktopLyrics
|
有些版本的 app.asar 里有 unpacked 文件,解包时可能报完整性校验失败,可以改用 --unpack 或者把 app.asar.unpacked 一起保留。
2. 从 source map 还原 TypeScript
汽水很多 bundle 都带了 .map,直接用 Node 解析 source map,把 sourcesContent 还原回 .ts:
1 2 3 4 5 6 7 8 9 10
| const fs = require('fs') const map = JSON.parse(fs.readFileSync('player-BVSvMO7D.js.map', 'utf8'))
map.sources.forEach((name, i) => { const content = map.sourcesContent?.[i] if (!content) return const out = name.replace(/^webpack:\/\/\//, '').replace(/\?.*$/, '') fs.mkdirSync(require('path').dirname(out), { recursive: true }) fs.writeFileSync(out, content) })
|
还原后重点看这几个目录:
1 2 3 4 5 6 7
| src_maps/ ├── services/ │ ├── player/v4/model.ts │ └── request/request.ts ├── idl/goapi/main.ts └── desktopLyrics/ └── Lyrics.vue
|
3. 找到 GetTrackV2
在 services/player/v4/model.ts 里能看到播放详情请求:
1 2 3 4 5 6 7
| GetTrackV2({ media_type: 'track', track_id: trackId, queue_type: queueType, scene_name: sceneName, limited_free_param: limitedFreeParam, })
|
再在 idl/goapi/main.ts 里找到实际调用位置,确认它的返回结构里包含:
1 2 3 4 5 6 7 8 9
| MediaDetail ├── track ├── track_player └── lyric ├── content ├── type ├── hide_request_lyrics ├── lyric_contributor └── translation_contributor
|
lyric.content 就是我们要的歌词正文。
4. 看桌面歌词怎么解析
desktopLyrics 里的 Lyrics.vue 是最重要的参考,因为它就是汽水自己渲染桌面歌词的逻辑。
它判断 KRC 的方式很直接:
如果是 KRC,就按行拆:
1
| [行开始毫秒,行时长毫秒]<字偏移毫秒,字时长毫秒,0>字...
|
例如:
1
| [470,4170]<0,410,0>听<440,410,0>戏<1200,410,0>人<1840,410,0>叹
|
解析规则:
[start, duration] 是整行的时间轴;
- 每个
<offset, duration, 0> 后面跟一个或多个字;
- 字的实际开始时间 =
行开始 + offset;
- 如果一段文字对应一个 tag,则字时长按字数均分;
- 去掉
<...> 标签之后就是显示文本。
这个解析逻辑比猜 LRC 靠谱得多,直接照抄即可。
三、运行时:把真正的请求抓出来
静态代码只能看到“它要调 GetTrackV2”,但请求头里的 X-Helios、X-Medusa 这些动态签名,代码里是看不到的。
1. Hook Cronet
汽水底层走的是 TTNet / Cronet。用 Frida attach 到主进程,hook:
1
| sscronet.dll!Cronet_UrlRequest_InitWithParams
|
Frida 脚本的核心逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| const mod = Process.getModuleByName('sscronet.dll') const init = mod.getExportByName('Cronet_UrlRequest_InitWithParams')
Interceptor.attach(init, { onEnter(args) { const url = args[2].readUtf8String() if (!url || !url.includes('/luna/pc/track_v2')) return
const size = new NativeFunction( mod.getExportByName('Cronet_UrlRequestParams_request_headers_size'), 'int', ['pointer'] )(args[3])
const at = new NativeFunction( mod.getExportByName('Cronet_UrlRequestParams_request_headers_at'), 'pointer', ['pointer', 'int'] ) const name = new NativeFunction( mod.getExportByName('Cronet_HttpHeader_name_get'), 'pointer', ['pointer'] ) const value = new NativeFunction( mod.getExportByName('Cronet_HttpHeader_value_get'), 'pointer', ['pointer'] )
const headers = {} for (let i = 0; i < size; i++) { const h = at(args[3], i) headers[name(h).readUtf8String().toLowerCase()] = value(h).readUtf8String() }
send({ url, headers }) } })
|
抓到之后,实际请求长这样(敏感值已替换):
1 2 3 4 5 6 7 8 9 10
| POST https://api.qishui.com/luna/pc/track_v2?aid=386088&app_name=luna_pc®ion=cn&geo_region=cn&os_region=cn&sim_region=&device_id=<DEVICE_ID>&cdid=&iid=<IID>&version_name=3.8.0&version_code=30080000&channel=official&build_mode=master&network_carrier=&ac=wifi&tz_name=Asia%2FShanghai&resolution=&device_platform=windows&device_type=Windows&os_version=Windows+11+Home+China&fp=<DEVICE_ID> content-type: application/json; charset=utf-8 user-agent: LunaPC/3.8.0(467160162) x-ss-stub: <BODY_MD5_UPPER> x-helios: <DYNAMIC_SIGN> x-medusa: <DYNAMIC_SIGN> cookie: <COOKIE> x-luna-background-type: foreground x-luna-is-background-req: 0 x-luna-is-local-user: 0
|
2. 请求 Body
普通队列的 Body:
1 2 3 4 5 6
| { "track_id": "<TRACK_ID>", "media_type": "track", "queue_type": "daily_mix", "scene_name": "track_reco" }
|
VIP / 限制免费歌曲会多一个 limited_free_param:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| { "track_id": "<TRACK_ID>", "media_type": "track", "queue_type": "daily_mix", "limited_free_param": { "from_other_queue": false, "is_login_support": true, "is_logout_support": false, "queue_types": null, "limited_free": true, "expire_time": 1793973946, "sign": "<SERVER_SIGN>", "sign_version": "2.0", "limited_free_type": "", "rewind_prev_intercept_type": "", "intercept_type": "", "track_value": "" }, "scene_name": "track_reco" }
|
3. X-SS-STUB 是什么
x-ss-stub 就是 Body 的 MD5,大写:
1 2
| const crypto = require('crypto') const stub = crypto.createHash('md5').update(body).digest('hex').toUpperCase()
|
这个头少了会直接报:
1
| net::ERR_TTNET_POST_MD5_HEADER_NOT_FOUND
|
4. 拿真实请求重放
把抓到的完整 URL、Headers、Body 拼回去重放,正常情况下会返回 200 和完整 JSON:
1 2 3 4 5 6 7 8 9 10 11 12
| { "status_info": { "log_id": "...", "now": 1793804162 }, "lyric": { "content": "[25380,7430]<0,350,0>爱<400,350,0>像<840,350,0>是...", "hide_request_lyrics": false, "type": "krc", "lyric_contributor": { "filter_reason": "not_luna_user" }, "translation_contributor": {} }, "track": { "id": "...", "name": "...", "artists": [...] }, "track_player": { ... } }
|
但这里有个坑:直接拿 Python / curl 裸请求,Body 和 Cookie 都对了,也可能返回 200 空 body。原因是缺少 x-helios / x-medusa,这两个不是普通 JS 能生成的,而是 TTNet / Metasec 在 native 层做掉的。
四、绕过签名:把 ttnet.node 变成独立客户端
一开始想直接逆 metasecml.dll,但它是个很大的混淆 native 库,成本不低。后来换了个思路:
汽水自己就带了 ttnet.node,为什么不让它帮我发请求?
ttnet.node 的导出很少:
1 2 3 4 5 6
| hello initTTNet shutdownTTNet triggerGetDomain request cancel
|
1. 在普通 Node 里加载
ttnet.node 可以在系统 Node 里加载,但初始化时要注意:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| const ttnet = require('D:/Program Files (x86)/Soda Music/resources/app.asar.unpacked/ttnet.node')
ttnet.initTTNet({ storagePath: '...', appId: '386088', appName: 'luna_pc', deviceId: '<DEVICE_ID>', versionCode: 30080000, deviceType: 'Windows', channel: 'official', devicePlatform: 'windows', uuid: '<DEVICE_ID>', updateVersionCode: 30080000, tncHostFirst: 'tnc3-bjlgy.zijieapi.com', tncHostSecond: 'tnc3-alisc1.zijieapi.com', tncHostThird: 'tnc3-aliec2.zijieapi.com', isMainProcess: '1', httpDns: 'dig.bdurl.net', domainNetlog: 'crash.snssdk.com', domainBoe: '.boe-gateway.byted.org', domainDefaultJson: '{}', tncHeaders: [], tncQueries: { iid: '<IID>' }, tncLoadFlags: 0, httpDnsLoadFlags: 0, useDomesticStoreRegion: true, useInjectMode: false, })
|
关键点是 useInjectMode: false。在 Electron 里它用的是 Chromium 的 global context;普通 Node 没有这个上下文,用 true 会直接报:
2. request 的回调不是 onResponse
ttnet.request 的第二个参数不是 onResponse/onData,而是单个 onEvent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ttnet.request({ url, method: 'POST', headers, body: Buffer.from(body), requestTimeout: 15000, }, { onEvent(event) { if (event.type === 'response') { console.log(event.statusCode, event.headers) } else if (event.type === 'data') { chunks.push(event.data) } else if (event.type === 'end') { const json = JSON.parse(Buffer.concat(chunks).toString('utf8')) console.log(json.lyric.content) } else if (event.type === 'error') { console.error(event.error) } } })
|
3. 请求里只需要补 X-SS-STUB
ttnet.node 会负责:
- 生成
X-Helios / X-Medusa
- 走 TTNet 的 DNS / 连接 / 压缩
- 处理响应流
我们只需要补:
1 2 3 4 5 6
| content-type user-agent x-ss-stub x-luna-background-type x-luna-is-background-req x-luna-is-local-user
|
实测免费曲库的歌不带 Cookie 也能拿到歌词(本文测试的几首都是),所以独立客户端不需要去解 Chromium 的 Cookies;但 VIP / 受限歌曲大概率依赖登录态,没验证过就别当通用结论。
最后封装成了 soda_client.js:
1
| node soda_client.js <TRACK_ID>
|
输出一行完整 track_v2 JSON,Python 端直接 json.loads。
五、接进自己的项目
1. 从 QueueCache 找 track_id
SMTC 只给歌名、歌手、专辑、播放进度,不给汽水的 track_id。
汽水的本地队列缓存在:
1
| %APPDATA%\SodaMusic\LunaStorage\QueueCache
|
文件是 LUNA 头 + gzip JSON:
1 2 3 4 5
| with open(path, 'rb') as f: raw = f.read() if raw[:4] == b'LUNA': raw = gzip.decompress(raw[4:]) data = json.loads(raw.decode('utf-8'))
|
遍历 queue['playables'],用 SMTC 的歌名/歌手匹配,就能拿到 track_id。
2. KRC 解析
server.py 里增加了 parse_krc()(下面省略了署名行过滤 CREDIT_RE、垃圾行过滤 JUNK_RE 和 (t, text) 去重,完整版看仓库;不滤掉”作词/作曲”行的话它们会混进歌词里):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| def parse_krc(text): rows = [] for raw in text.splitlines(): m = re.match(r'^\[(\d+),(\d+)\](.*)$', raw.strip()) if not m: continue start_ms = int(m.group(1)) content = m.group(3) chars = [] pending = None for part in re.split(r'(<[^>]*>)', content): if not part: continue if part.startswith('<') and part.endswith('>'): tm = re.match(r'<(\d+),(\d+)(?:,[^>]*)?>', part) if tm: pending = (int(tm.group(1)), int(tm.group(2))) continue part = part.replace('\u3000', ' ') if pending is None: for ch in part: chars.append({ 't': round(start_ms / 1000.0, 3), 'd': 0.0, 'c': ch, }) continue offset_ms, dur_ms = pending n = len(part) per = float(dur_ms) / n if n else 0.0 for idx, ch in enumerate(part): chars.append({ 't': round((start_ms + offset_ms + idx * per) / 1000.0, 3), 'd': round(per / 1000.0, 3), 'c': ch, }) pending = None plain = ''.join(c['c'] for c in chars).strip() if plain: rows.append({ 't': round(start_ms / 1000.0, 2), 'text': plain, 'chars': chars, }) return rows
|
保留 chars 的原因不是行级歌词,而是为了后面的逐字进度效果。
3. 最终调用链
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Windows SMTC ├── 歌名 / 歌手 / 播放进度 ↓ soda_queue_track() ├── 从 QueueCache 匹配 track_id ↓ soda_fetch_lyrics() ├── 调用 soda_client.js ├── ttnet.node 动态签名 ↓ track_v2 响应 ├── lyric.content (KRC) ↓ parse_krc() ├── 行 + 逐字时间 ↓ 前端 Canvas 歌词层
|
六、SMTC 的 timeline 是冻结的
项目做好后遇到一个很典型的问题:
汽水音乐已经在播放时,启动项目,歌词进度会从 0 秒开始;切一首歌就正常了。
原因是汽水只在切歌、暂停、拖动这些事件时上报一次 SMTC timeline,之后 position 一直冻结在歌曲开始时的位置。
用 Windows API 读出来是这样:
1 2 3
| position = 0.061s last_updated_time = 132 秒前 playing = true
|
也就是说:
1 2 3
| 最后一次快照位置 = 0.061s 距离快照时间已经过了 132s 真实播放位置 ≈ 0.061 + 132 = 132.061s
|
原来的代码第一次读取时只取了 position,所以从 0 秒开始;切歌后汽水推了新的 timeline,才恢复正常。
第一次读取的校正:
但只修这个还不够。上线后很快又踩了第二个坑:
暂停汽水音乐,歌词还在继续滚。
原因是汽水暂停时经常不推送新的 timeline 快照——系统侧只把播放状态改成 paused,position 还停在旧位置。如果”是否在推进”这个标志只在收到新快照时更新,那暂停后它还停留在”播放中”,本地时钟就会一直把进度往前推。
所以完整的修复是一个三分支状态机,每次读取(0.3s 一次)都走一遍:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| fresh = (0 <= gap < 2.5) if self.frozen_wall == 0.0: ... elif fresh or abs(pos - self.frozen_pos) > 0.4: back = gap if fresh else 0.0 self.frozen_pos = pos self.frozen_wall = wall_now - back self.frozen_playing = playing cur = pos elif self.frozen_playing != playing: est = self.frozen_pos + (wall_now - self.frozen_wall if self.frozen_playing else 0.0) self.frozen_pos = est self.frozen_wall = wall_now self.frozen_playing = playing cur = est else: cur = self.frozen_pos + (wall_now - self.frozen_wall if self.frozen_playing else 0.0)
|
其中分支 3 是”暂停不停”的直接解药;分支 2 里回拨 gap 秒还有个附带好处:快照其实是几秒前生成的,不回拨的话进度会永远慢一拍。
实测采样(3 秒间隔):
1 2 3 4
| T+0s: status=playing cur=51.35 T+3s: status=playing cur=54.37 (Δ=3.01) T+6s: status=playing cur=57.38 (Δ=3.01) T+9s: status=playing cur=60.40 (Δ=3.01)
|
暂停后连续采样 Δ=0.00,完全冻结;恢复播放从冻结位置无缝续推。
七、验证结果
验证项:
| 验证项 |
结果 |
真实签名的 track_v2 请求重放 |
200,返回完整 JSON |
lyric.type |
krc |
| 《恋人》KRC |
36 行,逐字时间正常 |
| 《第57次取消发送》 |
可正常拉取 |
| 启动时汽水已在播放 |
通过 last_updated_time 校正到真实进度 |
| 播放中持续采样(3s 间隔) |
Δ=3.01s,误差 0.3% |
| 暂停后连续采样 |
Δ=0.00,进度完全冻结 |
| 歌词条 Canvas 绘制 |
/bar 与 /lyric 均正常 |
| 汽水卸载/数据目录缺失 |
静默降级到 QQ/网易云 LRC(无界面提示,见第八节) |
这是《恋人》返回的歌词开头:
1
| [25380,7430]<0,350,0>爱<400,350,0>像<840,350,0>是<1280,350,0>一<1720,350,0>场<2080,350,0>小<2840,350,0>雨...
|