@tencent-connect/openclaw-qqbot 1.7.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +25 -8
  2. package/README.zh.md +24 -8
  3. package/bin/qqbot-cli.js +5 -2
  4. package/dist/index.cjs +15549 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +890 -0
  7. package/index.ts +56 -19
  8. package/openclaw.plugin.json +19 -3
  9. package/package.json +14 -23
  10. package/preload.cjs +17 -14
  11. package/scripts/link-sdk-core.cjs +133 -50
  12. package/scripts/upgrade-via-npm.sh +42 -11
  13. package/scripts/upgrade-via-source.sh +4 -4
  14. package/skills/qqbot-channel/SKILL.md +34 -12
  15. package/src/adapter/contract.ts +69 -0
  16. package/src/adapter/gateway.ts +61 -0
  17. package/src/adapter/index.ts +15 -0
  18. package/src/adapter/lint.ts +144 -0
  19. package/src/adapter/media.ts +112 -0
  20. package/src/adapter/pairing.ts +99 -0
  21. package/src/adapter/resolve.ts +333 -0
  22. package/src/adapter/setup.ts +55 -0
  23. package/src/adapter/webhook.ts +248 -0
  24. package/src/adapter/workspace.ts +29 -0
  25. package/src/bot-instance.ts +60 -0
  26. package/src/channel.ts +202 -413
  27. package/src/commands/bot-approve.ts +143 -0
  28. package/src/commands/bot-clear-storage.ts +114 -0
  29. package/src/commands/bot-group-always.ts +62 -0
  30. package/src/commands/bot-help.ts +40 -0
  31. package/src/commands/bot-logs.ts +248 -0
  32. package/src/commands/bot-me.ts +18 -0
  33. package/src/commands/bot-pairing.ts +57 -0
  34. package/src/commands/bot-ping.ts +33 -0
  35. package/src/commands/bot-streaming.ts +55 -0
  36. package/src/commands/bot-upgrade.ts +56 -0
  37. package/src/commands/bot-version.ts +41 -0
  38. package/src/commands/config-util.ts +96 -0
  39. package/src/commands/index.ts +49 -0
  40. package/src/config.ts +46 -19
  41. package/src/dispatch/body-assembler.ts +356 -0
  42. package/src/dispatch/ctx-builder.ts +110 -0
  43. package/src/dispatch/dispatch.ts +352 -0
  44. package/src/dispatch/envelope-builder.ts +112 -0
  45. package/src/dispatch/index.ts +2 -0
  46. package/src/{approval-handler.ts → features/approval-handler.ts} +40 -94
  47. package/src/features/approval-utils.ts +41 -0
  48. package/src/features/credential-backup.ts +82 -0
  49. package/src/features/history-store.ts +19 -0
  50. package/src/features/msgid-cache.ts +55 -0
  51. package/src/features/onboarding.ts +38 -0
  52. package/src/{proactive.ts → features/proactive.ts} +68 -49
  53. package/src/features/ref-index-store.ts +282 -0
  54. package/src/{update-checker.ts → features/update-checker.ts} +15 -35
  55. package/src/gateway/event-handlers.ts +241 -0
  56. package/src/gateway/index.ts +3 -0
  57. package/src/gateway/lifecycle.ts +221 -0
  58. package/src/gateway/middleware-setup.ts +139 -0
  59. package/src/gateway/qqbot-gateway.ts +342 -0
  60. package/src/middleware/access-control.ts +130 -0
  61. package/src/middleware/attachment.ts +314 -0
  62. package/src/middleware/policy-injector.ts +66 -0
  63. package/src/openclaw-plugin-sdk.d.ts +81 -10
  64. package/src/outbound/cron-scheduler.ts +130 -0
  65. package/src/outbound/debounce.ts +102 -0
  66. package/src/outbound/deliver-pipeline.ts +235 -0
  67. package/src/outbound/image-size.ts +123 -0
  68. package/src/outbound/index.ts +3 -0
  69. package/src/outbound/local-file-router.ts +145 -0
  70. package/src/outbound/media-send.ts +377 -0
  71. package/src/outbound/outbound-service.ts +222 -0
  72. package/src/outbound/reply-limiter.ts +128 -0
  73. package/src/outbound/sanitize.ts +32 -0
  74. package/src/outbound/streaming-controller.ts +228 -0
  75. package/src/outbound/target.ts +63 -0
  76. package/src/outbound/tts-provider.ts +84 -0
  77. package/src/request-context.ts +9 -8
  78. package/src/runtime.ts +31 -6
  79. package/src/setup/account-key.ts +41 -0
  80. package/src/setup/finalize.ts +110 -0
  81. package/src/setup/login.ts +197 -0
  82. package/src/setup/surface.ts +40 -0
  83. package/src/tools/platform.ts +149 -0
  84. package/src/tools/remind.ts +2 -0
  85. package/src/types-augment.d.ts +54 -0
  86. package/src/types.ts +51 -9
  87. package/src/utils/mention.ts +52 -0
  88. package/src/utils/pkg-version.ts +84 -47
  89. package/src/utils/platform.ts +31 -7
  90. package/src/utils/plugin-logger.ts +104 -0
  91. package/src/utils/ssrf-guard.ts +32 -2
  92. package/src/utils/stt.ts +137 -0
  93. package/src/utils/voice-text.ts +61 -0
  94. package/tsconfig.json +2 -1
  95. package/tsup.config.ts +57 -0
  96. package/dist/index.d.ts +0 -18
  97. package/dist/index.js +0 -27
  98. package/dist/src/admin-resolver.d.ts +0 -33
  99. package/dist/src/admin-resolver.js +0 -157
  100. package/dist/src/api.d.ts +0 -309
  101. package/dist/src/api.js +0 -923
  102. package/dist/src/approval-handler.d.ts +0 -47
  103. package/dist/src/approval-handler.js +0 -372
  104. package/dist/src/channel.d.ts +0 -29
  105. package/dist/src/channel.js +0 -528
  106. package/dist/src/config.d.ts +0 -60
  107. package/dist/src/config.js +0 -288
  108. package/dist/src/credential-backup.d.ts +0 -31
  109. package/dist/src/credential-backup.js +0 -66
  110. package/dist/src/deliver-debounce.d.ts +0 -74
  111. package/dist/src/deliver-debounce.js +0 -174
  112. package/dist/src/gateway.d.ts +0 -18
  113. package/dist/src/gateway.js +0 -2025
  114. package/dist/src/group-history.d.ts +0 -136
  115. package/dist/src/group-history.js +0 -226
  116. package/dist/src/image-server.d.ts +0 -87
  117. package/dist/src/image-server.js +0 -570
  118. package/dist/src/inbound-attachments.d.ts +0 -60
  119. package/dist/src/inbound-attachments.js +0 -248
  120. package/dist/src/known-users.d.ts +0 -100
  121. package/dist/src/known-users.js +0 -263
  122. package/dist/src/message-gating.d.ts +0 -53
  123. package/dist/src/message-gating.js +0 -107
  124. package/dist/src/message-queue.d.ts +0 -91
  125. package/dist/src/message-queue.js +0 -257
  126. package/dist/src/onboarding.d.ts +0 -10
  127. package/dist/src/onboarding.js +0 -203
  128. package/dist/src/outbound-deliver.d.ts +0 -48
  129. package/dist/src/outbound-deliver.js +0 -392
  130. package/dist/src/outbound.d.ts +0 -220
  131. package/dist/src/outbound.js +0 -975
  132. package/dist/src/proactive.d.ts +0 -170
  133. package/dist/src/proactive.js +0 -399
  134. package/dist/src/ref-index-store.d.ts +0 -101
  135. package/dist/src/ref-index-store.js +0 -298
  136. package/dist/src/reply-dispatcher.d.ts +0 -35
  137. package/dist/src/reply-dispatcher.js +0 -311
  138. package/dist/src/request-context.d.ts +0 -25
  139. package/dist/src/request-context.js +0 -37
  140. package/dist/src/runtime.d.ts +0 -3
  141. package/dist/src/runtime.js +0 -13
  142. package/dist/src/session-store.d.ts +0 -52
  143. package/dist/src/session-store.js +0 -254
  144. package/dist/src/slash-commands.d.ts +0 -82
  145. package/dist/src/slash-commands.js +0 -2233
  146. package/dist/src/startup-greeting.d.ts +0 -30
  147. package/dist/src/startup-greeting.js +0 -97
  148. package/dist/src/streaming.d.ts +0 -247
  149. package/dist/src/streaming.js +0 -899
  150. package/dist/src/stt.d.ts +0 -21
  151. package/dist/src/stt.js +0 -70
  152. package/dist/src/tools/channel.d.ts +0 -16
  153. package/dist/src/tools/channel.js +0 -231
  154. package/dist/src/tools/remind.d.ts +0 -2
  155. package/dist/src/tools/remind.js +0 -255
  156. package/dist/src/transport/index.d.ts +0 -10
  157. package/dist/src/transport/index.js +0 -9
  158. package/dist/src/transport/webhook-transport.d.ts +0 -67
  159. package/dist/src/transport/webhook-transport.js +0 -245
  160. package/dist/src/transport/webhook-verify.d.ts +0 -48
  161. package/dist/src/transport/webhook-verify.js +0 -98
  162. package/dist/src/types.d.ts +0 -462
  163. package/dist/src/types.js +0 -22
  164. package/dist/src/typing-keepalive.d.ts +0 -27
  165. package/dist/src/typing-keepalive.js +0 -64
  166. package/dist/src/update-checker.d.ts +0 -36
  167. package/dist/src/update-checker.js +0 -171
  168. package/dist/src/utils/audio-convert.d.ts +0 -98
  169. package/dist/src/utils/audio-convert.js +0 -783
  170. package/dist/src/utils/chunked-upload.d.ts +0 -68
  171. package/dist/src/utils/chunked-upload.js +0 -341
  172. package/dist/src/utils/file-utils.d.ts +0 -61
  173. package/dist/src/utils/file-utils.js +0 -172
  174. package/dist/src/utils/image-size.d.ts +0 -51
  175. package/dist/src/utils/image-size.js +0 -234
  176. package/dist/src/utils/media-send.d.ts +0 -158
  177. package/dist/src/utils/media-send.js +0 -502
  178. package/dist/src/utils/media-tags.d.ts +0 -14
  179. package/dist/src/utils/media-tags.js +0 -165
  180. package/dist/src/utils/payload.d.ts +0 -112
  181. package/dist/src/utils/payload.js +0 -186
  182. package/dist/src/utils/pkg-version.d.ts +0 -5
  183. package/dist/src/utils/pkg-version.js +0 -61
  184. package/dist/src/utils/platform.d.ts +0 -137
  185. package/dist/src/utils/platform.js +0 -390
  186. package/dist/src/utils/ssrf-guard.d.ts +0 -25
  187. package/dist/src/utils/ssrf-guard.js +0 -91
  188. package/dist/src/utils/text-parsing.d.ts +0 -31
  189. package/dist/src/utils/text-parsing.js +0 -74
  190. package/dist/src/utils/upload-cache.d.ts +0 -34
  191. package/dist/src/utils/upload-cache.js +0 -93
  192. package/node_modules/@eshaz/web-worker/LICENSE +0 -201
  193. package/node_modules/@eshaz/web-worker/README.md +0 -134
  194. package/node_modules/@eshaz/web-worker/browser.js +0 -17
  195. package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
  196. package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
  197. package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
  198. package/node_modules/@eshaz/web-worker/node.js +0 -223
  199. package/node_modules/@eshaz/web-worker/package.json +0 -54
  200. package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
  201. package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
  202. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
  203. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
  204. package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
  205. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
  206. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
  207. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
  208. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
  209. package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
  210. package/node_modules/mpg123-decoder/README.md +0 -265
  211. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
  212. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
  213. package/node_modules/mpg123-decoder/index.js +0 -8
  214. package/node_modules/mpg123-decoder/package.json +0 -58
  215. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
  216. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
  217. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
  218. package/node_modules/mpg123-decoder/types.d.ts +0 -30
  219. package/node_modules/silk-wasm/LICENSE +0 -21
  220. package/node_modules/silk-wasm/README.md +0 -85
  221. package/node_modules/silk-wasm/lib/index.cjs +0 -16
  222. package/node_modules/silk-wasm/lib/index.d.ts +0 -70
  223. package/node_modules/silk-wasm/lib/index.mjs +0 -16
  224. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  225. package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
  226. package/node_modules/silk-wasm/package.json +0 -39
  227. package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
  228. package/node_modules/simple-yenc/.prettierignore +0 -1
  229. package/node_modules/simple-yenc/LICENSE +0 -7
  230. package/node_modules/simple-yenc/README.md +0 -163
  231. package/node_modules/simple-yenc/dist/esm.js +0 -1
  232. package/node_modules/simple-yenc/dist/index.js +0 -1
  233. package/node_modules/simple-yenc/package.json +0 -50
  234. package/node_modules/simple-yenc/rollup.config.js +0 -27
  235. package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
  236. package/node_modules/ws/LICENSE +0 -20
  237. package/node_modules/ws/README.md +0 -548
  238. package/node_modules/ws/browser.js +0 -8
  239. package/node_modules/ws/index.js +0 -13
  240. package/node_modules/ws/lib/buffer-util.js +0 -131
  241. package/node_modules/ws/lib/constants.js +0 -19
  242. package/node_modules/ws/lib/event-target.js +0 -292
  243. package/node_modules/ws/lib/extension.js +0 -203
  244. package/node_modules/ws/lib/limiter.js +0 -55
  245. package/node_modules/ws/lib/permessage-deflate.js +0 -528
  246. package/node_modules/ws/lib/receiver.js +0 -706
  247. package/node_modules/ws/lib/sender.js +0 -602
  248. package/node_modules/ws/lib/stream.js +0 -161
  249. package/node_modules/ws/lib/subprotocol.js +0 -62
  250. package/node_modules/ws/lib/validation.js +0 -152
  251. package/node_modules/ws/lib/websocket-server.js +0 -554
  252. package/node_modules/ws/lib/websocket.js +0 -1393
  253. package/node_modules/ws/package.json +0 -69
  254. package/node_modules/ws/wrapper.mjs +0 -8
  255. package/scripts/postinstall-link-sdk.js +0 -170
  256. package/skills/qqbot-media/SKILL.md +0 -60
  257. package/src/admin-resolver.ts +0 -181
  258. package/src/api.ts +0 -1333
  259. package/src/credential-backup.ts +0 -72
  260. package/src/deliver-debounce.ts +0 -229
  261. package/src/gateway.ts +0 -2272
  262. package/src/group-history.ts +0 -328
  263. package/src/image-server.ts +0 -675
  264. package/src/inbound-attachments.ts +0 -321
  265. package/src/known-users.ts +0 -353
  266. package/src/message-gating.ts +0 -190
  267. package/src/message-queue.ts +0 -354
  268. package/src/onboarding.ts +0 -282
  269. package/src/outbound-deliver.ts +0 -486
  270. package/src/outbound.ts +0 -1179
  271. package/src/ref-index-store.ts +0 -412
  272. package/src/reply-dispatcher.ts +0 -334
  273. package/src/session-store.ts +0 -303
  274. package/src/slash-commands.ts +0 -2431
  275. package/src/startup-greeting.ts +0 -120
  276. package/src/streaming.ts +0 -1077
  277. package/src/stt.ts +0 -86
  278. package/src/tools/channel.ts +0 -275
  279. package/src/transport/index.ts +0 -11
  280. package/src/transport/webhook-transport.ts +0 -332
  281. package/src/transport/webhook-verify.ts +0 -119
  282. package/src/typing-keepalive.ts +0 -59
  283. package/src/utils/audio-convert.ts +0 -887
  284. package/src/utils/chunked-upload.ts +0 -483
  285. package/src/utils/file-utils.ts +0 -193
  286. package/src/utils/image-size.ts +0 -266
  287. package/src/utils/media-send.ts +0 -636
  288. package/src/utils/media-tags.ts +0 -183
  289. package/src/utils/payload.ts +0 -265
  290. package/src/utils/text-parsing.ts +0 -85
  291. package/src/utils/upload-cache.ts +0 -128
@@ -1,887 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { execFile } from "node:child_process";
4
- import { detectFfmpeg, isWindows } from "./platform.js";
5
-
6
- // silk-wasm 动态加载(可选依赖,未安装时降级为不支持语音编解码)
7
- let silkWasm: { decode: typeof import("silk-wasm").decode; encode: typeof import("silk-wasm").encode; isSilk: typeof import("silk-wasm").isSilk } | null = null;
8
- let silkWasmLoaded = false;
9
-
10
- async function loadSilkWasm() {
11
- if (silkWasmLoaded) return silkWasm;
12
- silkWasmLoaded = true;
13
- try {
14
- silkWasm = await import("silk-wasm");
15
- } catch {
16
- console.warn("[audio-convert] silk-wasm not available, voice encoding/decoding disabled");
17
- silkWasm = null;
18
- }
19
- return silkWasm;
20
- }
21
-
22
- // 同步版本的 isSilk(用于不方便 await 的场景),首次调用前需先 loadSilkWasm
23
- function isSilkSync(data: Uint8Array): boolean {
24
- if (!silkWasm) return false;
25
- return silkWasm.isSilk(data);
26
- }
27
-
28
- /**
29
- * 检查文件是否为 SILK 格式(QQ/微信语音常用格式)
30
- * QQ 语音文件通常以 .amr 扩展名保存,但实际编码可能是 SILK v3
31
- * SILK 文件头部标识: 0x02 "#!SILK_V3"
32
- */
33
- function isSilkFile(filePath: string): boolean {
34
- try {
35
- const buf = fs.readFileSync(filePath);
36
- return isSilkSync(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
37
- } catch {
38
- return false;
39
- }
40
- }
41
-
42
- /**
43
- * 将 PCM (s16le) 数据封装为 WAV 文件格式
44
- * WAV = 44 字节 RIFF 头 + PCM 原始数据
45
- */
46
- function pcmToWav(pcmData: Uint8Array, sampleRate: number, channels: number = 1, bitsPerSample: number = 16): Buffer {
47
- const byteRate = sampleRate * channels * (bitsPerSample / 8);
48
- const blockAlign = channels * (bitsPerSample / 8);
49
- const dataSize = pcmData.length;
50
- const headerSize = 44;
51
- const fileSize = headerSize + dataSize;
52
-
53
- const buffer = Buffer.alloc(fileSize);
54
-
55
- // RIFF header
56
- buffer.write("RIFF", 0);
57
- buffer.writeUInt32LE(fileSize - 8, 4);
58
- buffer.write("WAVE", 8);
59
-
60
- // fmt sub-chunk
61
- buffer.write("fmt ", 12);
62
- buffer.writeUInt32LE(16, 16); // sub-chunk size
63
- buffer.writeUInt16LE(1, 20); // PCM format
64
- buffer.writeUInt16LE(channels, 22);
65
- buffer.writeUInt32LE(sampleRate, 24);
66
- buffer.writeUInt32LE(byteRate, 28);
67
- buffer.writeUInt16LE(blockAlign, 32);
68
- buffer.writeUInt16LE(bitsPerSample, 34);
69
-
70
- // data sub-chunk
71
- buffer.write("data", 36);
72
- buffer.writeUInt32LE(dataSize, 40);
73
- Buffer.from(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength).copy(buffer, headerSize);
74
-
75
- return buffer;
76
- }
77
-
78
- /**
79
- * 去除 QQ 语音文件的 AMR 头(如果存在)
80
- * QQ 的 .amr 文件可能在 SILK 数据前有 "#!AMR\n" 头(6 字节)
81
- * 需要去除后才能被 silk-wasm 正确解码
82
- */
83
- function stripAmrHeader(buf: Buffer): Buffer {
84
- const AMR_HEADER = Buffer.from("#!AMR\n");
85
- if (buf.length > 6 && buf.subarray(0, 6).equals(AMR_HEADER)) {
86
- return buf.subarray(6);
87
- }
88
- return buf;
89
- }
90
-
91
- /**
92
- * 将 SILK/AMR 语音文件转换为 WAV 格式
93
- *
94
- * @param inputPath 输入文件路径(.amr / .silk / .slk)
95
- * @param outputDir 输出目录(默认与输入文件同目录)
96
- * @returns 转换后的 WAV 文件路径,失败返回 null
97
- */
98
- export async function convertSilkToWav(
99
- inputPath: string,
100
- outputDir?: string,
101
- ): Promise<{ wavPath: string; duration: number } | null> {
102
- if (!fs.existsSync(inputPath)) {
103
- return null;
104
- }
105
-
106
- const fileBuf = fs.readFileSync(inputPath);
107
-
108
- // 去除可能的 AMR 头
109
- const strippedBuf = stripAmrHeader(fileBuf);
110
-
111
- // 转为 Uint8Array 以兼容 silk-wasm 类型要求
112
- const rawData = new Uint8Array(strippedBuf.buffer, strippedBuf.byteOffset, strippedBuf.byteLength);
113
-
114
- // 验证是否为 SILK 格式
115
- const silk = await loadSilkWasm();
116
- if (!silk || !silk.isSilk(rawData)) {
117
- return null;
118
- }
119
-
120
- // SILK 解码为 PCM (s16le)
121
- // QQ 语音通常采样率为 24000Hz
122
- const sampleRate = 24000;
123
- const result = await silk.decode(rawData, sampleRate);
124
-
125
- // PCM → WAV
126
- const wavBuffer = pcmToWav(result.data, sampleRate);
127
-
128
- // 写入 WAV 文件
129
- const dir = outputDir || path.dirname(inputPath);
130
- if (!fs.existsSync(dir)) {
131
- fs.mkdirSync(dir, { recursive: true });
132
- }
133
- const baseName = path.basename(inputPath, path.extname(inputPath));
134
- const wavPath = path.join(dir, `${baseName}.wav`);
135
- fs.writeFileSync(wavPath, wavBuffer);
136
-
137
- return { wavPath, duration: result.duration };
138
- }
139
-
140
- /**
141
- * 判断是否为语音附件(根据 content_type 或文件扩展名)
142
- */
143
- export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean {
144
- if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) {
145
- return true;
146
- }
147
- const ext = att.filename ? path.extname(att.filename).toLowerCase() : "";
148
- return [".amr", ".silk", ".slk", ".slac"].includes(ext);
149
- }
150
-
151
- /**
152
- * 格式化语音时长为可读字符串
153
- */
154
- export function formatDuration(durationMs: number): string {
155
- const seconds = Math.round(durationMs / 1000);
156
- if (seconds < 60) {
157
- return `${seconds}秒`;
158
- }
159
- const minutes = Math.floor(seconds / 60);
160
- const remainSeconds = seconds % 60;
161
- return remainSeconds > 0 ? `${minutes}分${remainSeconds}秒` : `${minutes}分钟`;
162
- }
163
-
164
- export function isAudioFile(filePath: string, mimeType?: string): boolean {
165
- // MIME 优先判断(解决无扩展名或扩展名不匹配的问题)
166
- if (mimeType) {
167
- if (mimeType === "voice" || mimeType.startsWith("audio/")) return true;
168
- }
169
- const ext = path.extname(filePath).toLowerCase();
170
- return [".silk", ".slk", ".amr", ".wav", ".mp3", ".ogg", ".opus", ".aac", ".flac", ".m4a", ".wma", ".pcm"].includes(ext);
171
- }
172
-
173
- /** QQ 平台原生支持的语音 MIME 类型(不需要转码) */
174
- const QQ_NATIVE_VOICE_MIMES = new Set([
175
- "audio/silk", "audio/amr", "audio/wav", "audio/wave",
176
- "audio/x-wav", "audio/mpeg", "audio/mp3",
177
- ]);
178
-
179
- /** QQ 平台原生支持的语音扩展名(不需要转码) */
180
- const QQ_NATIVE_VOICE_EXTS = new Set([
181
- ".silk", ".slk", ".amr", ".wav", ".mp3",
182
- ]);
183
-
184
- /**
185
- * 判断语音是否需要转码(参考企微 wecom-app 的 shouldTranscodeWecomVoice)
186
- *
187
- * QQ Bot API 原生支持 WAV/MP3/SILK 三种格式,其他格式需要先转码。
188
- * 使用 MIME + 扩展名双重判断,避免仅靠扩展名导致误判。
189
- *
190
- * @param filePath 音频文件路径
191
- * @param mimeType 可选的 MIME 类型
192
- * @returns true 表示需要转码,false 表示可以直传
193
- */
194
- export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean {
195
- // MIME 优先:如果 MIME 是 QQ 原生支持的格式,不需要转码
196
- if (mimeType && QQ_NATIVE_VOICE_MIMES.has(mimeType.toLowerCase())) {
197
- return false;
198
- }
199
- // 扩展名判断
200
- const ext = path.extname(filePath).toLowerCase();
201
- if (QQ_NATIVE_VOICE_EXTS.has(ext)) {
202
- return false;
203
- }
204
- // 是音频但不是原生格式 → 需要转码
205
- return isAudioFile(filePath, mimeType);
206
- }
207
-
208
- // ============ TTS(文字转语音)============
209
-
210
- export interface TTSConfig {
211
- baseUrl: string;
212
- apiKey: string;
213
- model: string;
214
- voice: string;
215
- /** Azure OpenAI 风格:使用 api-key header 而非 Bearer token */
216
- authStyle?: "bearer" | "api-key";
217
- /** 附加在 URL 后的查询参数,如 Azure 的 api-version */
218
- queryParams?: Record<string, string>;
219
- /** 自定义速度(默认不传) */
220
- speed?: number;
221
- }
222
-
223
- function resolveTTSFromBlock(
224
- block: Record<string, any>,
225
- providerCfg: Record<string, any> | undefined,
226
- ): TTSConfig | null {
227
- const baseUrl: string | undefined = block?.baseUrl || providerCfg?.baseUrl;
228
- const apiKey: string | undefined = block?.apiKey || providerCfg?.apiKey;
229
- const model: string = block?.model || "tts-1";
230
- const voice: string = block?.voice || "alloy";
231
- if (!baseUrl || !apiKey) return null;
232
-
233
- const authStyle = (block?.authStyle || providerCfg?.authStyle) === "api-key" ? "api-key" as const : "bearer" as const;
234
- const queryParams: Record<string, string> = { ...(providerCfg?.queryParams ?? {}), ...(block?.queryParams ?? {}) };
235
- const speed: number | undefined = block?.speed;
236
-
237
- return {
238
- baseUrl: baseUrl.replace(/\/+$/, ""),
239
- apiKey,
240
- model,
241
- voice,
242
- authStyle,
243
- ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
244
- ...(speed !== undefined ? { speed } : {}),
245
- };
246
- }
247
-
248
- export function resolveTTSConfig(cfg: Record<string, unknown>): TTSConfig | null {
249
- const c = cfg as any;
250
-
251
- // 优先使用 channels.qqbot.tts(插件专属配置)
252
- const channelTts = c?.channels?.qqbot?.tts;
253
- if (channelTts && channelTts.enabled !== false) {
254
- const providerId: string = channelTts?.provider || "openai";
255
- const providerCfg = c?.models?.providers?.[providerId];
256
- const result = resolveTTSFromBlock(channelTts, providerCfg);
257
- if (result) return result;
258
- }
259
-
260
- // 回退到 messages.tts(openclaw 框架级 TTS 配置)
261
- const msgTts = c?.messages?.tts;
262
- if (msgTts && msgTts.auto !== "disabled") {
263
- const providerId: string = msgTts?.provider || "openai";
264
- const providerBlock = msgTts?.[providerId];
265
- const providerCfg = c?.models?.providers?.[providerId];
266
- const result = resolveTTSFromBlock(providerBlock ?? {}, providerCfg);
267
- if (result) return result;
268
- }
269
-
270
- return null;
271
- }
272
-
273
- /**
274
- * 构建 TTS 请求 URL 和 Headers
275
- * 支持 OpenAI 标准和 Azure OpenAI 两种风格
276
- */
277
- function buildTTSRequest(ttsCfg: TTSConfig): { url: string; headers: Record<string, string> } {
278
- // 构建 URL:baseUrl + /audio/speech + 可选 queryParams
279
- let url = `${ttsCfg.baseUrl}/audio/speech`;
280
- if (ttsCfg.queryParams && Object.keys(ttsCfg.queryParams).length > 0) {
281
- const qs = new URLSearchParams(ttsCfg.queryParams).toString();
282
- url += `?${qs}`;
283
- }
284
-
285
- // 构建认证 Header
286
- const headers: Record<string, string> = { "Content-Type": "application/json" };
287
- if (ttsCfg.authStyle === "api-key") {
288
- headers["api-key"] = ttsCfg.apiKey;
289
- } else {
290
- headers["Authorization"] = `Bearer ${ttsCfg.apiKey}`;
291
- }
292
-
293
- return { url, headers };
294
- }
295
-
296
- export async function textToSpeechPCM(
297
- text: string,
298
- ttsCfg: TTSConfig,
299
- ): Promise<{ pcmBuffer: Buffer; sampleRate: number }> {
300
- const sampleRate = 24000;
301
- const { url, headers } = buildTTSRequest(ttsCfg);
302
-
303
- console.log(`[tts] Request: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, url=${url}`);
304
- console.log(`[tts] Input text (${text.length} chars): "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`);
305
-
306
- // 先尝试 PCM 格式(最高质量,无需二次转码)
307
- const formats: Array<{ format: string; needsDecode: boolean }> = [
308
- { format: "pcm", needsDecode: false },
309
- { format: "mp3", needsDecode: true },
310
- ];
311
-
312
- let lastError: Error | null = null;
313
- const startTime = Date.now();
314
-
315
- for (const { format, needsDecode } of formats) {
316
- const controller = new AbortController();
317
- const ttsTimeout = setTimeout(() => controller.abort(), 120000);
318
-
319
- try {
320
- const body: Record<string, unknown> = {
321
- model: ttsCfg.model,
322
- input: text,
323
- voice: ttsCfg.voice,
324
- response_format: format,
325
- ...(format === "pcm" ? { sample_rate: sampleRate } : {}),
326
- ...(ttsCfg.speed !== undefined ? { speed: ttsCfg.speed } : {}),
327
- };
328
-
329
- console.log(`[tts] Trying format=${format}...`);
330
- const fetchStart = Date.now();
331
- const resp = await fetch(url, {
332
- method: "POST",
333
- headers,
334
- body: JSON.stringify(body),
335
- signal: controller.signal,
336
- }).finally(() => clearTimeout(ttsTimeout));
337
-
338
- const fetchMs = Date.now() - fetchStart;
339
-
340
- if (!resp.ok) {
341
- const detail = await resp.text().catch(() => "");
342
- console.log(`[tts] HTTP ${resp.status} for format=${format} (${fetchMs}ms): ${detail.slice(0, 200)}`);
343
- // 如果 PCM 不支持(Azure 等),回退到 mp3
344
- if (format === "pcm" && (resp.status === 400 || resp.status === 422)) {
345
- console.log(`[tts] PCM format not supported, falling back to mp3`);
346
- lastError = new Error(`TTS PCM not supported: ${detail.slice(0, 200)}`);
347
- continue;
348
- }
349
- throw new Error(`TTS failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
350
- }
351
-
352
- const arrayBuffer = await resp.arrayBuffer();
353
- const rawBuffer = Buffer.from(arrayBuffer);
354
- console.log(`[tts] Response OK: format=${format}, size=${rawBuffer.length} bytes, latency=${fetchMs}ms`);
355
-
356
- if (!needsDecode) {
357
- console.log(`[tts] Done: PCM direct, ${rawBuffer.length} bytes, total=${Date.now() - startTime}ms`);
358
- return { pcmBuffer: rawBuffer, sampleRate };
359
- }
360
-
361
- // mp3 需要解码为 PCM
362
- console.log(`[tts] Decoding mp3 response (${rawBuffer.length} bytes) to PCM...`);
363
- const tmpDir = path.join(fs.mkdtempSync(path.join(require("node:os").tmpdir(), "tts-")));
364
- const tmpMp3 = path.join(tmpDir, "tts.mp3");
365
- fs.writeFileSync(tmpMp3, rawBuffer);
366
-
367
- try {
368
- // 优先用 ffmpeg
369
- const ffmpegCmd = await checkFfmpeg();
370
- if (ffmpegCmd) {
371
- const pcmBuf = await ffmpegToPCM(ffmpegCmd, tmpMp3, sampleRate);
372
- console.log(`[tts] Done: mp3→PCM (ffmpeg), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
373
- return { pcmBuffer: pcmBuf, sampleRate };
374
- }
375
- // WASM fallback
376
- const pcmBuf = await wasmDecodeMp3ToPCM(rawBuffer, sampleRate);
377
- if (pcmBuf) {
378
- console.log(`[tts] Done: mp3→PCM (wasm), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`);
379
- return { pcmBuffer: pcmBuf, sampleRate };
380
- }
381
- throw new Error("No decoder available for mp3 (install ffmpeg for best compatibility)");
382
- } finally {
383
- try { fs.unlinkSync(tmpMp3); fs.rmdirSync(tmpDir); } catch {}
384
- }
385
- } catch (err) {
386
- clearTimeout(ttsTimeout);
387
- lastError = err instanceof Error ? err : new Error(String(err));
388
- console.log(`[tts] Error for format=${format}: ${lastError.message.slice(0, 200)}`);
389
- if (format === "pcm") {
390
- // PCM 失败时不立即抛出,尝试 mp3
391
- continue;
392
- }
393
- throw lastError;
394
- }
395
- }
396
-
397
- console.log(`[tts] All formats exhausted after ${Date.now() - startTime}ms`);
398
- throw lastError ?? new Error("TTS failed: all formats exhausted");
399
- }
400
-
401
- export async function pcmToSilk(
402
- pcmBuffer: Buffer,
403
- sampleRate: number,
404
- ): Promise<{ silkBuffer: Buffer; duration: number }> {
405
- const silk = await loadSilkWasm();
406
- if (!silk) {
407
- throw new Error("silk-wasm not available, cannot encode PCM to SILK. Install silk-wasm to enable voice sending.");
408
- }
409
- const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength);
410
- const result = await silk.encode(pcmData, sampleRate);
411
- return {
412
- silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength),
413
- duration: result.duration,
414
- };
415
- }
416
-
417
- export async function textToSilk(
418
- text: string,
419
- ttsCfg: TTSConfig,
420
- outputDir: string,
421
- ): Promise<{ silkPath: string; silkBase64: string; duration: number }> {
422
- const { pcmBuffer, sampleRate } = await textToSpeechPCM(text, ttsCfg);
423
- const { silkBuffer, duration } = await pcmToSilk(pcmBuffer, sampleRate);
424
-
425
- if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
426
- const silkPath = path.join(outputDir, `tts-${Date.now()}.silk`);
427
- fs.writeFileSync(silkPath, silkBuffer);
428
-
429
- return { silkPath, silkBase64: silkBuffer.toString("base64"), duration };
430
- }
431
-
432
- // ============ 核心:任意音频 → SILK Base64 ============
433
-
434
- /** QQ Bot API 原生支持上传的音频格式(无需转换为 SILK) */
435
- const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"];
436
-
437
- /**
438
- * 将本地音频文件转换为 QQ Bot 可上传的 Base64
439
- *
440
- * QQ Bot API 支持直传 WAV、MP3、SILK 三种格式,其他格式仍需转换。
441
- * 转换策略:
442
- *
443
- * 1. WAV / MP3 / SILK → 直传(跳过转换)
444
- * 2. 有 ffmpeg → ffmpeg 万能解码为 PCM → silk-wasm 编码
445
- * 支持: ogg, opus, aac, flac, wma, m4a, pcm 等所有 ffmpeg 支持的格式
446
- * 3. 无 ffmpeg → WASM fallback(仅支持 pcm, wav)
447
- *
448
- * @param directUploadFormats - 自定义直传格式列表,覆盖默认值。传 undefined 使用 QQ_NATIVE_UPLOAD_FORMATS
449
- */
450
- export async function audioFileToSilkBase64(filePath: string, directUploadFormats?: string[]): Promise<string | null> {
451
- if (!fs.existsSync(filePath)) return null;
452
-
453
- const buf = fs.readFileSync(filePath);
454
- if (buf.length === 0) {
455
- console.error(`[audio-convert] file is empty: ${filePath}`);
456
- return null;
457
- }
458
-
459
- const ext = path.extname(filePath).toLowerCase();
460
-
461
- // 0. 直传判断:QQ Bot API 原生支持 WAV/MP3/SILK,可通过配置覆盖
462
- const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS;
463
- if (uploadFormats.includes(ext)) {
464
- console.log(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
465
- return buf.toString("base64");
466
- }
467
-
468
- // 1. .slk / .amr 扩展名 → 检测 SILK 魔数,是 SILK 则直传
469
- const silk = await loadSilkWasm();
470
- if ([".slk", ".slac"].includes(ext)) {
471
- const stripped = stripAmrHeader(buf);
472
- const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
473
- if (silk?.isSilk(raw)) {
474
- console.log(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
475
- return buf.toString("base64");
476
- }
477
- }
478
-
479
- // 按文件头检测 SILK(不依赖扩展名)
480
- const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
481
- const strippedCheck = stripAmrHeader(buf);
482
- const strippedRaw = new Uint8Array(strippedCheck.buffer, strippedCheck.byteOffset, strippedCheck.byteLength);
483
- if (silk?.isSilk(rawCheck) || silk?.isSilk(strippedRaw)) {
484
- console.log(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
485
- return buf.toString("base64");
486
- }
487
-
488
- const targetRate = 24000;
489
-
490
- // 2. 优先使用 ffmpeg(业界标准做法,跨平台检测)
491
- const ffmpegCmd = await checkFfmpeg();
492
- if (ffmpegCmd) {
493
- try {
494
- console.log(`[audio-convert] ffmpeg (${ffmpegCmd}): converting ${ext} (${buf.length} bytes) → PCM s16le ${targetRate}Hz`);
495
- const pcmBuf = await ffmpegToPCM(ffmpegCmd, filePath, targetRate);
496
- if (pcmBuf.length === 0) {
497
- console.error(`[audio-convert] ffmpeg produced empty PCM output`);
498
- return null;
499
- }
500
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
501
- console.log(`[audio-convert] ffmpeg: ${ext} → SILK done (${silkBuffer.length} bytes)`);
502
- return silkBuffer.toString("base64");
503
- } catch (err) {
504
- console.error(`[audio-convert] ffmpeg conversion failed: ${err instanceof Error ? err.message : String(err)}`);
505
- // ffmpeg 失败后不 return,继续尝试 WASM fallback
506
- }
507
- }
508
-
509
- // 3. WASM fallback(无 ffmpeg 时的降级方案)
510
- console.log(`[audio-convert] fallback: trying WASM decoders for ${ext}`);
511
-
512
- // 3a. PCM:视为 s16le 24000Hz 单声道
513
- if (ext === ".pcm") {
514
- const pcmBuf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
515
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
516
- return silkBuffer.toString("base64");
517
- }
518
-
519
- // 3b. WAV:手动解析(仅支持标准 PCM WAV)
520
- if (ext === ".wav" || (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF")) {
521
- const wavInfo = parseWavFallback(buf);
522
- if (wavInfo) {
523
- const { silkBuffer } = await pcmToSilk(wavInfo, targetRate);
524
- return silkBuffer.toString("base64");
525
- }
526
- }
527
-
528
- // 3c. MP3:WASM 解码
529
- if (ext === ".mp3" || ext === ".mpeg") {
530
- const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate);
531
- if (pcmBuf) {
532
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
533
- console.log(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`);
534
- return silkBuffer.toString("base64");
535
- }
536
- }
537
-
538
- const installHint = isWindows()
539
- ? "安装方式: choco install ffmpeg 或 scoop install ffmpeg 或从 https://ffmpeg.org 下载"
540
- : process.platform === "darwin"
541
- ? "安装方式: brew install ffmpeg"
542
- : "安装方式: sudo apt install ffmpeg 或 sudo yum install ffmpeg";
543
- console.error(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`);
544
- return null;
545
- }
546
-
547
- /**
548
- * 将音频文件转码为 SILK,**输出到临时文件**(供分片上传使用)。
549
- *
550
- * 如果文件已经是 QQ 原生格式(WAV/MP3/SILK)或已经是 SILK 编码,
551
- * 则直接返回原文件路径(不需要转码)。
552
- *
553
- * @returns 转码后的文件路径,或 null 表示转码失败
554
- */
555
- export async function audioFileToSilkFile(filePath: string, directUploadFormats?: string[]): Promise<string | null> {
556
- if (!fs.existsSync(filePath)) return null;
557
-
558
- const buf = fs.readFileSync(filePath);
559
- if (buf.length === 0) {
560
- console.error(`[audio-convert] file is empty: ${filePath}`);
561
- return null;
562
- }
563
-
564
- const ext = path.extname(filePath).toLowerCase();
565
-
566
- // 0. 直传格式 → 直接返回原文件
567
- const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS;
568
- if (uploadFormats.includes(ext)) {
569
- console.log(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
570
- return filePath;
571
- }
572
-
573
- // 1. 已经是 SILK 编码 → 直接返回原文件
574
- const silk = await loadSilkWasm();
575
- if ([".slk", ".slac"].includes(ext)) {
576
- const stripped = stripAmrHeader(buf);
577
- const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
578
- if (silk?.isSilk(raw)) {
579
- console.log(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
580
- return filePath;
581
- }
582
- }
583
- const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
584
- const strippedCheck = stripAmrHeader(buf);
585
- const strippedRaw = new Uint8Array(strippedCheck.buffer, strippedCheck.byteOffset, strippedCheck.byteLength);
586
- if (silk?.isSilk(rawCheck) || silk?.isSilk(strippedRaw)) {
587
- console.log(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
588
- return filePath;
589
- }
590
-
591
- // 需要转码 → 调用 audioFileToSilkBase64 获取结果,写入临时文件
592
- const silkBase64 = await audioFileToSilkBase64(filePath, directUploadFormats);
593
- if (!silkBase64) return null;
594
-
595
- const silkBuffer = Buffer.from(silkBase64, "base64");
596
- const os = await import("node:os");
597
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "voice-silk-"));
598
- const tmpFile = path.join(tmpDir, `voice${Date.now()}.silk`);
599
- fs.writeFileSync(tmpFile, silkBuffer);
600
- console.log(`[audio-convert] SILK written to temp file: ${tmpFile} (${silkBuffer.length} bytes)`);
601
- return tmpFile;
602
- }
603
-
604
- /**
605
- * 等待文件就绪(轮询直到文件出现且大小稳定)
606
- * 用于 TTS 生成后等待文件写入完成
607
- *
608
- * 优化策略:
609
- * - 文件出现后如果持续 0 字节超过 emptyGiveUpMs(默认 10s),快速失败
610
- * - 文件未出现超过 noFileGiveUpMs(默认 15s),快速失败
611
- * - 整体超时 timeoutMs 作为最终兜底
612
- *
613
- * @param filePath 文件路径
614
- * @param timeoutMs 最大等待时间(默认 30 秒)
615
- * @param pollMs 轮询间隔(默认 500ms)
616
- * @returns 文件大小(字节),超时或文件始终为空返回 0
617
- */
618
- export async function waitForFile(
619
- filePath: string,
620
- timeoutMs: number = 30000,
621
- pollMs: number = 500,
622
- ): Promise<number> {
623
- const start = Date.now();
624
- let lastSize = -1;
625
- let stableCount = 0;
626
- let fileExists = false;
627
- let fileAppearedAt = 0; // 文件首次出现时间
628
- let pollCount = 0;
629
-
630
- // 0 字节文件放弃等待阈值:文件出现后持续空文件超过此时间则快速失败
631
- const emptyGiveUpMs = 10000;
632
- // 文件始终不出现的放弃阈值
633
- const noFileGiveUpMs = 15000;
634
-
635
- while (Date.now() - start < timeoutMs) {
636
- pollCount++;
637
- try {
638
- const stat = fs.statSync(filePath);
639
- if (!fileExists) {
640
- fileExists = true;
641
- fileAppearedAt = Date.now();
642
- console.log(`[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`);
643
- }
644
- if (stat.size > 0) {
645
- if (stat.size === lastSize) {
646
- stableCount++;
647
- if (stableCount >= 2) {
648
- console.log(`[audio-convert] waitForFile: ready (${stat.size} bytes, waited ${Date.now() - start}ms, polls=${pollCount})`);
649
- return stat.size;
650
- }
651
- } else {
652
- stableCount = 0;
653
- }
654
- lastSize = stat.size;
655
- } else {
656
- // 文件存在但 0 字节:检查是否已超过空文件等待阈值
657
- if (Date.now() - fileAppearedAt > emptyGiveUpMs) {
658
- console.error(`[audio-convert] waitForFile: file still empty after ${emptyGiveUpMs}ms, giving up: ${path.basename(filePath)}`);
659
- return 0;
660
- }
661
- }
662
- } catch {
663
- // 文件不存在:检查是否已超过无文件等待阈值
664
- if (!fileExists && Date.now() - start > noFileGiveUpMs) {
665
- console.error(`[audio-convert] waitForFile: file never appeared after ${noFileGiveUpMs}ms, giving up: ${path.basename(filePath)}`);
666
- return 0;
667
- }
668
- }
669
- await new Promise((r) => setTimeout(r, pollMs));
670
- }
671
-
672
- // 超时后最后检查一次
673
- try {
674
- const finalStat = fs.statSync(filePath);
675
- if (finalStat.size > 0) {
676
- console.warn(`[audio-convert] waitForFile: timeout but file has data (${finalStat.size} bytes), using it`);
677
- return finalStat.size;
678
- }
679
- console.error(`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file exists but empty (0 bytes): ${path.basename(filePath)}`);
680
- } catch {
681
- console.error(`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file never appeared: ${path.basename(filePath)}`);
682
- }
683
- return 0;
684
- }
685
-
686
- // ============ ffmpeg 跨平台调用 ============
687
-
688
- /**
689
- * 检测 ffmpeg 是否可用(委托给 platform.ts 跨平台检测)
690
- * @returns ffmpeg 可执行路径或 null
691
- */
692
- async function checkFfmpeg(): Promise<string | null> {
693
- return detectFfmpeg();
694
- }
695
-
696
- /**
697
- * 使用 ffmpeg 将任意音频文件转换为 PCM s16le 单声道 24kHz
698
- *
699
- * 跨平台注意:
700
- * - Windows 上 pipe:1 需要 encoding: "buffer" 防止 BOM 问题
701
- * - 使用 detectFfmpeg() 返回的完整路径,兼容非 PATH 安装
702
- */
703
- function ffmpegToPCM(ffmpegCmd: string, inputPath: string, sampleRate: number = 24000): Promise<Buffer> {
704
- return new Promise((resolve, reject) => {
705
- const args = [
706
- "-i", inputPath,
707
- "-f", "s16le",
708
- "-ar", String(sampleRate),
709
- "-ac", "1",
710
- "-acodec", "pcm_s16le",
711
- "-v", "error",
712
- "pipe:1",
713
- ];
714
- execFile(ffmpegCmd, args, {
715
- maxBuffer: 50 * 1024 * 1024,
716
- encoding: "buffer",
717
- // Windows: 隐藏弹出的 cmd 窗口
718
- ...(isWindows() ? { windowsHide: true } : {}),
719
- }, (err, stdout) => {
720
- if (err) {
721
- reject(new Error(`ffmpeg failed: ${err.message}`));
722
- return;
723
- }
724
- resolve(stdout as unknown as Buffer);
725
- });
726
- });
727
- }
728
-
729
- // ============ WASM fallback: MP3 解码 ============
730
-
731
- /**
732
- * 使用 mpg123-decoder (WASM) 解码 MP3 为 PCM
733
- * 仅在 ffmpeg 不可用时作为 fallback
734
- */
735
- async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise<Buffer | null> {
736
- try {
737
- const { MPEGDecoder } = await import("mpg123-decoder");
738
- console.log(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`);
739
- const decoder = new MPEGDecoder();
740
- await decoder.ready;
741
-
742
- const decoded = decoder.decode(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
743
- decoder.free();
744
-
745
- if (decoded.samplesDecoded === 0 || decoded.channelData.length === 0) {
746
- console.error(`[audio-convert] WASM MP3 decode: no samples (samplesDecoded=${decoded.samplesDecoded})`);
747
- return null;
748
- }
749
-
750
- console.log(`[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`);
751
-
752
- // Float32 多声道混缩为单声道
753
- let floatMono: Float32Array;
754
- if (decoded.channelData.length === 1) {
755
- floatMono = decoded.channelData[0];
756
- } else {
757
- floatMono = new Float32Array(decoded.samplesDecoded);
758
- const channels = decoded.channelData.length;
759
- for (let i = 0; i < decoded.samplesDecoded; i++) {
760
- let sum = 0;
761
- for (let ch = 0; ch < channels; ch++) {
762
- sum += decoded.channelData[ch][i];
763
- }
764
- floatMono[i] = sum / channels;
765
- }
766
- }
767
-
768
- // Float32 → s16le
769
- const s16 = new Uint8Array(floatMono.length * 2);
770
- const view = new DataView(s16.buffer);
771
- for (let i = 0; i < floatMono.length; i++) {
772
- const clamped = Math.max(-1, Math.min(1, floatMono[i]));
773
- const val = clamped < 0 ? clamped * 32768 : clamped * 32767;
774
- view.setInt16(i * 2, Math.round(val), true);
775
- }
776
-
777
- // 简单线性插值重采样
778
- let pcm: Uint8Array = s16;
779
- if (decoded.sampleRate !== targetRate) {
780
- const inputSamples = s16.length / 2;
781
- const outputSamples = Math.round(inputSamples * targetRate / decoded.sampleRate);
782
- const output = new Uint8Array(outputSamples * 2);
783
- const inView = new DataView(s16.buffer, s16.byteOffset, s16.byteLength);
784
- const outView = new DataView(output.buffer, output.byteOffset, output.byteLength);
785
- for (let i = 0; i < outputSamples; i++) {
786
- const srcIdx = i * decoded.sampleRate / targetRate;
787
- const idx0 = Math.floor(srcIdx);
788
- const idx1 = Math.min(idx0 + 1, inputSamples - 1);
789
- const frac = srcIdx - idx0;
790
- const s0 = inView.getInt16(idx0 * 2, true);
791
- const s1 = inView.getInt16(idx1 * 2, true);
792
- const sample = Math.round(s0 + (s1 - s0) * frac);
793
- outView.setInt16(i * 2, Math.max(-32768, Math.min(32767, sample)), true);
794
- }
795
- pcm = output;
796
- }
797
-
798
- return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
799
- } catch (err) {
800
- console.error(`[audio-convert] WASM MP3 decode failed: ${err instanceof Error ? err.message : String(err)}`);
801
- if (err instanceof Error && err.stack) {
802
- console.error(`[audio-convert] stack: ${err.stack}`);
803
- }
804
- return null;
805
- }
806
- }
807
-
808
- /**
809
- * 规范化格式列表(确保以 . 开头,小写)
810
- */
811
- function normalizeFormats(formats: string[]): string[] {
812
- return formats.map((f) => {
813
- const lower = f.toLowerCase().trim();
814
- return lower.startsWith(".") ? lower : `.${lower}`;
815
- });
816
- }
817
-
818
- /**
819
- * WAV fallback 解析(无 ffmpeg 时使用)
820
- * 仅支持标准 PCM WAV (format=1, 16bit)
821
- */
822
- function parseWavFallback(buf: Buffer): Buffer | null {
823
- if (buf.length < 44) return null;
824
- if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
825
- if (buf.toString("ascii", 8, 12) !== "WAVE") return null;
826
- if (buf.toString("ascii", 12, 16) !== "fmt ") return null;
827
-
828
- const audioFormat = buf.readUInt16LE(20);
829
- if (audioFormat !== 1) return null;
830
-
831
- const channels = buf.readUInt16LE(22);
832
- const sampleRate = buf.readUInt32LE(24);
833
- const bitsPerSample = buf.readUInt16LE(34);
834
- if (bitsPerSample !== 16) return null;
835
-
836
- // 找 data chunk
837
- let offset = 36;
838
- while (offset < buf.length - 8) {
839
- const chunkId = buf.toString("ascii", offset, offset + 4);
840
- const chunkSize = buf.readUInt32LE(offset + 4);
841
- if (chunkId === "data") {
842
- const dataStart = offset + 8;
843
- const dataEnd = Math.min(dataStart + chunkSize, buf.length);
844
- let pcm = new Uint8Array(buf.buffer, buf.byteOffset + dataStart, dataEnd - dataStart);
845
-
846
- // 多声道混缩
847
- if (channels > 1) {
848
- const samplesPerCh = pcm.length / (2 * channels);
849
- const mono = new Uint8Array(samplesPerCh * 2);
850
- const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
851
- const outV = new DataView(mono.buffer, mono.byteOffset, mono.byteLength);
852
- for (let i = 0; i < samplesPerCh; i++) {
853
- let sum = 0;
854
- for (let ch = 0; ch < channels; ch++) sum += inV.getInt16((i * channels + ch) * 2, true);
855
- outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(sum / channels))), true);
856
- }
857
- pcm = mono;
858
- }
859
-
860
- // 简单线性插值重采样
861
- const targetRate = 24000;
862
- if (sampleRate !== targetRate) {
863
- const inSamples = pcm.length / 2;
864
- const outSamples = Math.round(inSamples * targetRate / sampleRate);
865
- const out = new Uint8Array(outSamples * 2);
866
- const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
867
- const outV = new DataView(out.buffer, out.byteOffset, out.byteLength);
868
- for (let i = 0; i < outSamples; i++) {
869
- const src = i * sampleRate / targetRate;
870
- const i0 = Math.floor(src);
871
- const i1 = Math.min(i0 + 1, inSamples - 1);
872
- const f = src - i0;
873
- const s0 = inV.getInt16(i0 * 2, true);
874
- const s1 = inV.getInt16(i1 * 2, true);
875
- outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(s0 + (s1 - s0) * f))), true);
876
- }
877
- pcm = out;
878
- }
879
-
880
- return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
881
- }
882
- offset += 8 + chunkSize;
883
- }
884
-
885
- return null;
886
- }
887
-