汽水音乐歌词协议逆向:从 Electron ASAR 到 KRC 实时同步

我朋友在娱乐公司上班,用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 的方式很直接:

1
/^\[\d+,/.test(line)

如果是 KRC,就按行拆:

1
[行开始毫秒,行时长毫秒]<字偏移毫秒,字时长毫秒,0>字...

例如:

1
[470,4170]<0,410,0>听<440,410,0>戏<1200,410,0>人<1840,410,0>叹

解析规则:

  1. [start, duration] 是整行的时间轴;
  2. 每个 <offset, duration, 0> 后面跟一个或多个字;
  3. 字的实际开始时间 = 行开始 + offset;
  4. 如果一段文字对应一个 tag,则字时长按字数均分;
  5. 去掉 <...> 标签之后就是显示文本。

这个解析逻辑比猜 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&region=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 会直接报:

1
NO global context

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:
# 分支 1:首次观察,用 pos + gap 校正到真实进度(上面那段)
...
elif fresh or abs(pos - self.frozen_pos) > 0.4:
# 分支 2:拿到新快照(切歌、拖动)
# 快照是 gap 秒前的真实时刻,基准墙钟回拨 gap,消除轮询滞后
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:
# 分支 3:状态切换但没有新快照(暂停/恢复的常态)
# 暂停 -> 冻结在当前推算位置;恢复 -> 从当前推算位置继续
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:
# 分支 4:timeline 冻结中,本地单调时钟推进,封顶不超曲长
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>雨...

汽水音乐歌词协议逆向:从 Electron ASAR 到 KRC 实时同步
https://blog.newpon.top/2026/11/07/汽水音乐歌词协议逆向与实时同步/
作者
Niezidong
发布于
2026年11月7日
许可协议