arona-agent 1.0.2 → 1.0.4

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 (51) hide show
  1. package/README.md +16 -9
  2. package/assets/blue-archive/arona/voice_sovits.mp3 +0 -0
  3. package/assets/blue-archive/arona/voice_text.txt +1 -0
  4. package/assets/blue-archive/hoshino/voice_sovits.mp3 +0 -0
  5. package/assets/blue-archive/hoshino/voice_text.txt +1 -0
  6. package/assets/blue-archive/plana/voice_sovits.mp3 +0 -0
  7. package/assets/blue-archive/plana/voice_text.txt +1 -0
  8. package/assets/blue-archive/shiroko/voice_sovits.mp3 +0 -0
  9. package/assets/blue-archive/shiroko/voice_text.txt +1 -0
  10. package/bin/arona.mjs +3 -1
  11. package/bin/postinstall-fix.mjs +46 -0
  12. package/package.json +3 -2
  13. package/pet/renderer/gallery.js +2 -2
  14. package/pet/renderer/renderer.js +1 -1
  15. package/pet/renderer/style.css +16 -2
  16. package/pet/tools/gen_sway.cjs +1 -1
  17. package/pet/tools/visual_test.cjs +2 -2
  18. package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
  19. package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
  20. package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
  21. package/python/__pycache__/oss_upload.cpython-314.pyc +0 -0
  22. package/python/__pycache__/stt.cpython-314.pyc +0 -0
  23. package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
  24. package/python/hotkey.py +7 -1
  25. package/python/oss_upload.py +70 -0
  26. package/python/tts_say.py +182 -19
  27. package/src/agent.ts +155 -147
  28. package/src/commands.ts +17 -23
  29. package/src/config.ts +45 -2
  30. package/src/gesture_context.ts +48 -0
  31. package/src/gpt_sovits_local.ts +706 -0
  32. package/src/index.ts +13 -10
  33. package/src/memory.ts +3 -10
  34. package/src/oss_upload.ts +116 -0
  35. package/src/renderer.ts +17 -6
  36. package/src/repl.ts +70 -59
  37. package/src/setup.ts +349 -15
  38. package/src/skills.ts +32 -2
  39. package/src/slash_menu.ts +1 -1
  40. package/src/slash_registry.ts +6 -5
  41. package/src/tools/read_docs_tool.ts +84 -0
  42. package/src/tools/tavily_tools.ts +24 -10
  43. package/src/tools/voice_tools.ts +1 -1
  44. package/src/tts_provider.ts +501 -0
  45. package/src/tts_stream.ts +321 -31
  46. package/src/tui_select.ts +31 -2
  47. package/src/undo.ts +82 -18
  48. package/src/utils/python.ts +22 -0
  49. package/src/voice.ts +17 -7
  50. package/src/voice_cli.ts +117 -9
  51. package/src/voices.ts +197 -28
@@ -0,0 +1,706 @@
1
+ // GPT-SoVITS 本地部署服务管理器。
2
+ //
3
+ // 云端模式(mode=cloud):ARONA 只负责调用远程 API,不启动本地进程。
4
+ // 本地模式(mode=local):
5
+ // - 如果 baseUrl 已经可达(用户已手动启动 api_v2),直接使用,不重复启动;
6
+ // - 否则若配置了 apiScriptPath 与 GPT/SoVITS/BERT/CNHubert 路径,
7
+ // 自动生成 ~/.arona/gpt-sovits-tts-infer.yaml,并 spawn `api_v2.py -a 127.0.0.1 -p <port> -c <yaml>`。
8
+ //
9
+ // 生成的 YAML 使用 GPT-SoVITS 官方 tts_infer.yaml 的 custom 段结构:
10
+ // custom:
11
+ // bert_base_path: ...
12
+ // cnhuhbert_base_path: ...
13
+ // device: cuda
14
+ // is_half: false // 默认关(MPS/CPU 半精度可能更慢或不稳;CUDA 可显式开 true 省显存)
15
+ // t2s_weights_path: ...
16
+ // version: v2
17
+ // vits_weights_path: ...
18
+ //
19
+ // 进程生命周期:ARONA 退出时 kill 本进程启动的 api_v2 子进程(见 repl.ts doExit 与 process exit 钩子)。
20
+
21
+ import { createHash } from "crypto";
22
+ import { execFileSync } from "child_process";
23
+ import type { ChildProcess, SpawnOptions } from "child_process";
24
+ import { existsSync, readFileSync, writeFileSync, openSync, closeSync, statSync, mkdirSync } from "fs";
25
+ import { delimiter, dirname, join } from "path";
26
+ import net from "net";
27
+ import { ARONA_DIR, config, verbose } from "./config.ts";
28
+ import { t } from "./locale.ts";
29
+ import { AGENT_IDS, getMainAgent, type AgentId } from "./agent_registry.ts";
30
+ import { getGptSovitsVoice } from "./voices.ts";
31
+ import type { GptSovitsConfig } from "./tts_provider.ts";
32
+ import { spawnCompat, stripProxyEnv } from "./utils/spawn.ts";
33
+
34
+ let child: ChildProcess | null = null;
35
+ let startPromise: Promise<{ gpt?: string; sovits?: string } | null> | null = null;
36
+ /** 最近一次 spawn 时是否 keepAlive(stop 时据此决定回收与否)。 */
37
+ let spawnedKeepAlive = true;
38
+ /** 本进程 spawn/复用的自有 daemon pid(用于写 pidfile / 判断外部服务)。 */
39
+ let ownedPid: number | null = null;
40
+
41
+ /** 常驻 daemon 状态文件:进程可跨 ARONA 重启存活,靠它 + 端口探活复用。 */
42
+ const PIDFILE = join(ARONA_DIR, "gpt-sovits-api-v2.json");
43
+ /** 常驻 daemon 日志(stdio 重定到文件,父进程退出后服务不因管道阻塞)。 */
44
+ const LOG_DIR = join(ARONA_DIR, "logs");
45
+ const LOG_FILE = join(LOG_DIR, "gpt-sovits-api-v2.log");
46
+ /**
47
+ * 离线 NLTK 数据目录(punkt_tab / averaged_perceptron_tagger(_eng) / cmudict)。
48
+ * GPT-SoVITS 处理中英混合文本时经 g2p_en 触发 nltk 按需下载;缺包时 api_v2 会
49
+ * 在请求线程里同步联网下载(无超时),事件循环被阻塞 → 后续 /tts 全部排队超时。
50
+ * 预置数据 + 注入 NLTK_DATA 让它彻底离线化。
51
+ */
52
+ const NLTK_DATA_DIR = join(ARONA_DIR, "nltk_data");
53
+
54
+ interface Pidfile {
55
+ pid: number;
56
+ host: string;
57
+ port: number;
58
+ /** sha256(生成的 yaml 全文 + apiScriptPath + pythonPath + host + port) */
59
+ digest: string;
60
+ startedAt: number;
61
+ loadedWeights?: { gpt?: string; sovits?: string };
62
+ /** 最后一次 TTS 合成时间(ms);空闲超过 IDLE_TIMEOUT_MS 的 daemon 会被回收。 */
63
+ lastUsedAt?: number;
64
+ }
65
+
66
+ /** 空闲自动回收阈值:超过该时长没有任何合成请求,api_v2 daemon 自我退出释放内存(硬编码 30 分钟)。 */
67
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
68
+ /** arona 会话内对 daemon 空闲状态的巡检间隔。 */
69
+ const IDLE_CHECK_MS = 60 * 1000;
70
+
71
+ function parseHostPort(baseUrl: string): { host: string; port: number } {
72
+ try {
73
+ const u = new URL(baseUrl);
74
+ const host = u.hostname || "127.0.0.1";
75
+ return {
76
+ host: host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host,
77
+ port: u.port ? Number(u.port) : 9880,
78
+ };
79
+ } catch {
80
+ return { host: "127.0.0.1", port: 9880 };
81
+ }
82
+ }
83
+
84
+ function checkReachable(host: string, port: number, timeoutMs = 1500): Promise<boolean> {
85
+ return new Promise((resolve) => {
86
+ const socket = new net.Socket();
87
+ let done = false;
88
+ const cleanup = () => {
89
+ done = true;
90
+ socket.destroy();
91
+ };
92
+ socket.setTimeout(timeoutMs);
93
+ socket.once("connect", () => {
94
+ if (!done) {
95
+ cleanup();
96
+ resolve(true);
97
+ }
98
+ });
99
+ socket.once("timeout", () => {
100
+ if (!done) {
101
+ cleanup();
102
+ resolve(false);
103
+ }
104
+ });
105
+ socket.once("error", () => {
106
+ if (!done) {
107
+ cleanup();
108
+ resolve(false);
109
+ }
110
+ });
111
+ socket.connect(port, host);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * HTTP 健康探活:GET / 短超时,能拿到 2xx/4xx 响应即视为服务可用。
117
+ * TCP 端口可达 ≠ uvicorn 事件循环/模型加载正常(僵死进程会一直吃掉 TTS 请求到超时)。
118
+ */
119
+ async function checkApiHealthy(host: string, port: number, timeoutMs = 3000): Promise<boolean> {
120
+ try {
121
+ const res = await fetch(`http://${host}:${port}/`, { signal: AbortSignal.timeout(timeoutMs) });
122
+ return res.status < 500;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function yamlStr(value: string): string {
129
+ // JSON.stringify 输出双引号字符串,YAML 1.1/1.2 都接受,安全处理空格/冒号/中文/盘符。
130
+ return JSON.stringify(value);
131
+ }
132
+
133
+ /**
134
+ * yaml 默认权重:优先 provider 级 gptModelPath/sovitsModelPath(手工配置/兼容旧值);
135
+ * 为空则优先主 Agent 的每角色 .ckpt/.pth(主 Agent 是默认说话人,权重启动即就位,首句免切换),
136
+ * 再回退第一个已配置角色的每角色 .ckpt/.pth(.ckpt/.pth 单角色专属,通常不填全局,
137
+ * 服务启动后按请求逐角色 set_gpt_weights/set_sovits_weights 切换)。
138
+ */
139
+ function defaultWeights(cfg: GptSovitsConfig): { gpt: string; sovits: string } {
140
+ let gpt = cfg.gptModelPath?.trim() || "";
141
+ let sovits = cfg.sovitsModelPath?.trim() || "";
142
+ if (gpt && sovits) return { gpt, sovits };
143
+ const pick = (id: AgentId): void => {
144
+ if (gpt && sovits) return;
145
+ const v = getGptSovitsVoice(id);
146
+ if (!v) return;
147
+ if (!gpt && v.gptWeightsPath?.trim()) gpt = v.gptWeightsPath.trim();
148
+ if (!sovits && v.sovitsWeightsPath?.trim()) sovits = v.sovitsWeightsPath.trim();
149
+ };
150
+ pick(getMainAgent());
151
+ for (const id of AGENT_IDS) {
152
+ if (gpt && sovits) break;
153
+ pick(id);
154
+ }
155
+ return { gpt, sovits };
156
+ }
157
+
158
+ /**
159
+ * 确定性生成 tts_infer.yaml 的 custom 段内容(纯字符串,不读盘)。
160
+ * digest 必须基于它而非磁盘文件:api_v2 启动时会把官方默认模板(v1..v4 等块)
161
+ * merge 后经 save_configs **回写**到传入的 yaml 路径,磁盘文件会被污染,
162
+ * 基于文件的 digest 会在此后每个进程里误判 "config changed"。
163
+ */
164
+ function buildTtsInferYaml(cfg: GptSovitsConfig): string {
165
+ const { gpt, sovits } = defaultWeights(cfg);
166
+ const lines = [
167
+ "custom:",
168
+ ` bert_base_path: ${yamlStr(cfg.bertPath?.trim() || "")}`,
169
+ ` cnhuhbert_base_path: ${yamlStr(cfg.cnhubertPath?.trim() || "")}`,
170
+ ` device: ${yamlStr(cfg.device?.trim() || "cuda")}`,
171
+ ` is_half: ${cfg.isHalf === false ? "false" : "true"}`,
172
+ ` t2s_weights_path: ${yamlStr(gpt)}`,
173
+ ` version: ${yamlStr(cfg.modelVersion || "v2")}`,
174
+ ` vits_weights_path: ${yamlStr(sovits)}`,
175
+ ];
176
+ return lines.join("\n") + "\n";
177
+ }
178
+
179
+ function writeTtsInferYaml(cfg: GptSovitsConfig): string {
180
+ const file = join(ARONA_DIR, "gpt-sovits-tts-infer.yaml");
181
+ writeFileSync(file, buildTtsInferYaml(cfg));
182
+ return file;
183
+ }
184
+
185
+ /** 打开日志文件追加 fd;>10MB 先 truncate,避免日志无限膨胀。失败返回 -1(Node stdio 中视为 ignore)。 */
186
+ function openLogFd(): number {
187
+ try {
188
+ mkdirSync(LOG_DIR, { recursive: true });
189
+ if (existsSync(LOG_FILE)) {
190
+ try {
191
+ if (statSync(LOG_FILE).size > 10 * 1024 * 1024) writeFileSync(LOG_FILE, "");
192
+ } catch {
193
+ // 忽略 stat/write 失败
194
+ }
195
+ }
196
+ return openSync(LOG_FILE, "a");
197
+ } catch {
198
+ return -1;
199
+ }
200
+ }
201
+
202
+ function spawnLocalServer(cfg: GptSovitsConfig): ChildProcess {
203
+ const { host, port } = parseHostPort(cfg.baseUrl);
204
+ const yamlPath = writeTtsInferYaml(cfg);
205
+ const python = cfg.pythonPath?.trim() || config.pythonPath;
206
+ const script = cfg.apiScriptPath!.trim();
207
+ const bindHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
208
+ const args = [script, "-a", bindHost, "-p", String(port), "-c", yamlPath];
209
+ if (verbose) {
210
+ console.error(`[gpt-sovits] spawn ${python} ${args.join(" ")}`);
211
+ console.error(`[gpt-sovits] log → ${LOG_FILE} ${cfg.keepAlive !== false ? "(keep-alive)" : ""}`);
212
+ }
213
+ // keepAlive:detached + stdio 重定向日志文件 —— 父进程退出后服务不因管道阻塞而 hang,
214
+ // 且成为独立进程组不被随父回收;下一次启动探活 & pidfile 复用热服务。
215
+ const logFd = openLogFd();
216
+ const options: SpawnOptions = {
217
+ cwd: dirname(script),
218
+ // PYTHONUNBUFFERED:pipe 下 Python 默认块缓冲,api_v2 的 print 不刷新就看不到进度/报错;写文件同样需要。
219
+ // NLTK_DATA:优先指向预置的离线数据(~/.arona/nltk_data),缺失时 g2p_en 会在请求线程
220
+ // 同步联网下载并阻塞事件循环(表现为 /tts 整体卡死直至超时)。附加而非覆盖,保留用户已有路径。
221
+ env: stripProxyEnv({
222
+ ...process.env,
223
+ PYTHONUTF8: "1",
224
+ PYTHONUNBUFFERED: "1",
225
+ NLTK_DATA: [NLTK_DATA_DIR, process.env.NLTK_DATA].filter(Boolean).join(delimiter),
226
+ }),
227
+ stdio: ["ignore", logFd, logFd],
228
+ detached: cfg.keepAlive !== false,
229
+ };
230
+ const proc = spawnCompat(python, args, options);
231
+ // 父进程侧 fd 在子进程继承 dup 后可关闭,避免句柄泄漏。
232
+ try {
233
+ closeSync(logFd);
234
+ } catch {
235
+ // 忽略
236
+ }
237
+ child = proc;
238
+ proc.on("error", (err) => {
239
+ if (child === proc) child = null;
240
+ console.error(t(
241
+ `[gpt-sovits] 本地服务启动失败:${err.message}`,
242
+ `[gpt-sovits] failed to start local server: ${err.message}`,
243
+ ));
244
+ });
245
+ proc.on("close", (code) => {
246
+ if (child === proc) child = null;
247
+ if (verbose) console.error(`[gpt-sovits] local server exited code=${code} signal=${proc.signalCode ?? ""}`);
248
+ });
249
+ return proc;
250
+ }
251
+
252
+ /**
253
+ * 轮询等待 api_v2 就绪。watch = 进程存活检查(返回 false = 进程已退,提前抛错);
254
+ * 传 null 则不做进程退出检测(如纯外部服务)。
255
+ */
256
+ async function waitForServer(
257
+ host: string,
258
+ port: number,
259
+ timeoutMs: number,
260
+ watch: (() => boolean) | null = null,
261
+ ): Promise<void> {
262
+ const deadline = Date.now() + timeoutMs;
263
+ if (verbose) console.error(`[gpt-sovits] waiting for api_v2 ready ${host}:${port} (≤${Math.round(timeoutMs / 1000)}s)`);
264
+ let lastProgressLog = 0;
265
+ while (Date.now() < deadline) {
266
+ if (await checkReachable(host, port, 1200)) return;
267
+ if (watch && !watch()) {
268
+ throw new Error(t("GPT-SoVITS 本地服务进程已退出", "GPT-SoVITS local server process exited"));
269
+ }
270
+ if (verbose && Date.now() - lastProgressLog >= 15000) {
271
+ lastProgressLog = Date.now();
272
+ console.error(`[gpt-sovits] still waiting for api_v2 ready (${Math.round((deadline - Date.now()) / 1000)}s left)...`);
273
+ }
274
+ await new Promise((r) => setTimeout(r, 800));
275
+ }
276
+ throw new Error(t(
277
+ `GPT-SoVITS 本地服务 ${timeoutMs / 1000}s 内未就绪(${host}:${port})`,
278
+ `GPT-SoVITS local server not ready within ${timeoutMs / 1000}s (${host}:${port})`,
279
+ ));
280
+ }
281
+
282
+ // ---------------- 常驻 pidfile / daemon 检测 ----------------
283
+
284
+ /** digest = sha256(确定性 yaml 字符串 + apiScriptPath + pythonPath + host + port)。基于生成产物而非磁盘文件(api_v2 会回写 yaml),配置变则 digest 变。 */
285
+ function computeDigest(cfg: GptSovitsConfig, host: string, port: number): string {
286
+ const python = cfg.pythonPath?.trim() || config.pythonPath;
287
+ const content = buildTtsInferYaml(cfg);
288
+ const material = [content, cfg.apiScriptPath?.trim() || "", python, host, String(port)].join("\n");
289
+ return createHash("sha256").update(material).digest("hex");
290
+ }
291
+
292
+ function readPidfile(): Pidfile | null {
293
+ try {
294
+ if (!existsSync(PIDFILE)) return null;
295
+ const parsed = JSON.parse(readFileSync(PIDFILE, "utf-8")) as Pidfile;
296
+ if (!parsed || typeof parsed !== "object" || typeof parsed.pid !== "number") return null;
297
+ return parsed;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ function writePidfile(pf: Pidfile): void {
304
+ try {
305
+ writeFileSync(PIDFILE, JSON.stringify(pf, null, 2) + "\n");
306
+ } catch {
307
+ // 忽略(仅影响下次复用,冷启动仍可用)
308
+ }
309
+ }
310
+
311
+ function clearPidfile(): void {
312
+ try {
313
+ writeFileSync(PIDFILE, "");
314
+ } catch {
315
+ // 忽略
316
+ }
317
+ }
318
+
319
+ function pidAlive(pid: number): boolean {
320
+ if (!pid || pid <= 0) return false;
321
+ try {
322
+ process.kill(pid, 0);
323
+ return true;
324
+ } catch (e) {
325
+ return (e as NodeJS.ErrnoException).code === "EPERM";
326
+ }
327
+ }
328
+
329
+ /** 该 pid 是否确实是 api_v2 服务进程(防 PID 复用误杀)。win32 仅查存活。 */
330
+ function pidIsApiV2(pid: number): boolean {
331
+ if (!pidAlive(pid)) return false;
332
+ if (process.platform === "win32") return true;
333
+ try {
334
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8" });
335
+ return out.includes("api_v2");
336
+ } catch {
337
+ return false;
338
+ }
339
+ }
340
+
341
+ function killPid(pid: number): void {
342
+ try {
343
+ process.kill(pid, "SIGTERM");
344
+ } catch {
345
+ // 忽略(可能已退出)
346
+ }
347
+ }
348
+
349
+ const sleepMs = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
350
+
351
+ /**
352
+ * kill 并等待进程彻底消失(释放端口/资源),再允许后续操作(如重 spawn 绑同一端口)。
353
+ * SIGTERM 是异步优雅关闭,若不等其退出就立刻 spawn 新进程,新进程 bind 同端口会因旧进程
354
+ * 尚未释放而失败;而 waitForServer 探到的是旧进程残留端口 → 误判就绪 → 连接被拒。
355
+ * 超时退化为 SIGKILL。
356
+ */
357
+ async function killPidAndWait(pid: number, timeoutMs = 10000): Promise<void> {
358
+ if (!pidAlive(pid)) return;
359
+ killPid(pid);
360
+ const deadline = Date.now() + timeoutMs;
361
+ while (Date.now() < deadline) {
362
+ if (!pidAlive(pid)) return;
363
+ await sleepMs(150);
364
+ }
365
+ try {
366
+ process.kill(pid, "SIGKILL");
367
+ } catch {
368
+ // 忽略
369
+ }
370
+ }
371
+
372
+ /**
373
+ * spawn 并等待就绪,写 pidfile,返回 yaml 默认权重(供 provider ack,首句免切换)。
374
+ */
375
+ async function spawnAndWait(
376
+ cfg: GptSovitsConfig,
377
+ host: string,
378
+ port: number,
379
+ ): Promise<{ gpt?: string; sovits?: string }> {
380
+ spawnedKeepAlive = cfg.keepAlive !== false;
381
+ const proc = spawnLocalServer(cfg);
382
+ ownedPid = proc.pid ?? null;
383
+ await waitForServer(host, port, 180_000, () => proc.exitCode === null && proc.signalCode === null && !proc.killed);
384
+ const { gpt, sovits } = defaultWeights(cfg);
385
+ writePidfile({
386
+ pid: ownedPid!,
387
+ host,
388
+ port,
389
+ digest: computeDigest(cfg, host, port),
390
+ startedAt: Date.now(),
391
+ loadedWeights: { gpt: gpt || undefined, sovits: sovits || undefined },
392
+ lastUsedAt: Date.now(),
393
+ });
394
+ if (verbose) console.error(`[gpt-sovits] cold start ready pid=${ownedPid} gpt="${gpt}" sovits="${sovits}"`);
395
+ return { gpt: gpt || undefined, sovits: sovits || undefined };
396
+ }
397
+
398
+ async function doEnsure(
399
+ cfg: GptSovitsConfig,
400
+ ): Promise<{ gpt?: string; sovits?: string } | null> {
401
+ const { host, port } = parseHostPort(cfg.baseUrl);
402
+ // 覆盖写 yaml:api_v2 加载时会 merge 官方默认模板回写该文件,这里重置为纯 custom,
403
+ // 保证新 spawn 的 daemon 用我们生成的配置(digest 走 buildTtsInferYaml 内存串,不受此影响)。
404
+ writeTtsInferYaml(cfg);
405
+ const pidfile = readPidfile();
406
+ const daemonAlive = !!pidfile && pidAlive(pidfile.pid) && pidIsApiV2(pidfile.pid);
407
+
408
+ if (await checkReachable(host, port, 1200)) {
409
+ // 端口已监听
410
+ if (daemonAlive && pidfile!.digest === computeDigest(cfg, host, port)) {
411
+ // 自有 daemon & 配置未变 → 先看是否已空闲超时(30 分钟无合成):
412
+ // 超时视为过期,杀旧冷启,避免模型一直驻留占内存(空闲自动回收)。
413
+ if (pidfile!.lastUsedAt !== undefined && Date.now() - pidfile!.lastUsedAt > IDLE_TIMEOUT_MS) {
414
+ if (verbose) console.error(`[gpt-sovits] daemon pid=${pidfile!.pid} idle > 30min, recycling`);
415
+ await killPidAndWait(pidfile!.pid);
416
+ clearPidfile();
417
+ return spawnAndWait(cfg, host, port);
418
+ }
419
+ // 再做 HTTP 健康探活:端口可达 ≠ 服务可用(进程可能已僵死:模型半加载/事件循环卡死,
420
+ // 请求挂到超时)。不健康则视为坏 daemon,杀旧冷启,避免一轮 TTS 全部等 60s 超时。
421
+ if (!(await checkApiHealthy(host, port))) {
422
+ if (verbose) console.error(`[gpt-sovits] reusing daemon pid=${pidfile!.pid} unhealthy (no HTTP response), restarting`);
423
+ await killPidAndWait(pidfile!.pid);
424
+ clearPidfile();
425
+ return spawnAndWait(cfg, host, port);
426
+ }
427
+ // 自有 daemon & 配置未变 → 复用热服务,ack 上次退出时的权重(首句免切换)
428
+ ownedPid = pidfile!.pid;
429
+ spawnedKeepAlive = true;
430
+ touchLastUsed();
431
+ if (verbose) console.error(`[gpt-sovits] reusing daemon pid=${pidfile!.pid} (digest match)`);
432
+ return pidfile!.loadedWeights ?? {};
433
+ }
434
+ if (daemonAlive) {
435
+ // 自有 daemon 但配置已变 → 杀旧进程(等其退出释放端口)再重 spawn
436
+ if (verbose) console.error(`[gpt-sovits] config changed, restarting daemon pid=${pidfile!.pid}`);
437
+ await killPidAndWait(pidfile!.pid);
438
+ clearPidfile();
439
+ return spawnAndWait(cfg, host, port);
440
+ }
441
+ // 否则是外部服务(用户手动启动)→ 再探一次 HTTP 健康:TCP 可达不代表服务可用
442
+ //(僵死进程会一直吃掉 TTS 请求到超时);外部进程不归我们管,不杀不重启,仅告警。
443
+ if (!(await checkApiHealthy(host, port))) {
444
+ console.warn(t(
445
+ `[gpt-sovits] 检测到端口 ${host}:${port} 的服务无 HTTP 响应(可能僵死),TTS 合成将等待超时。请检查后重启该服务。`,
446
+ `[gpt-sovits] Service on ${host}:${port} is not responding over HTTP (possibly hung); TTS requests will time out. Please check and restart it.`,
447
+ ));
448
+ }
449
+ if (verbose) console.error("[gpt-sovits] external server on port, using as-is");
450
+ return null;
451
+ }
452
+
453
+ // 端口不可达
454
+ if (child && !child.killed) {
455
+ // 本进程已 spawn 但未就绪 → 继续等待
456
+ await waitForServer(host, port, 180_000, () => child !== null && child.exitCode === null && child.signalCode === null && !child.killed);
457
+ ownedPid = child.pid ?? ownedPid;
458
+ touchLastUsed();
459
+ return pidfile?.loadedWeights ?? {};
460
+ }
461
+ if (daemonAlive) {
462
+ // 自有 daemon 仍在加载(上次 ARONA 退出时未就绪)→ 轮询等;pid 死则 respawn
463
+ if (verbose) console.error(`[gpt-sovits] daemon pid=${pidfile!.pid} still loading, waiting...`);
464
+ ownedPid = pidfile!.pid;
465
+ spawnedKeepAlive = true;
466
+ try {
467
+ await waitForServer(host, port, 180_000, () => pidAlive(pidfile!.pid));
468
+ } catch {
469
+ // pid 死了:respawn
470
+ if (verbose) console.error(`[gpt-sovits] daemon pid=${pidfile!.pid} died while loading, respawning`);
471
+ await killPidAndWait(pidfile!.pid);
472
+ clearPidfile();
473
+ return spawnAndWait(cfg, host, port);
474
+ }
475
+ touchLastUsed();
476
+ return pidfile?.loadedWeights ?? {};
477
+ }
478
+
479
+ // 干净冷启动
480
+ return spawnAndWait(cfg, host, port);
481
+ }
482
+
483
+ /**
484
+ * 确保本地 GPT-SoVITS api_v2 服务可用。
485
+ * - cloud 模式或缺少本地启动必需字段(apiScriptPath / bertPath / cnhubertPath)时直接返回 null,
486
+ * 视为用户手动启动服务(与旧行为一致)。.ckpt/.pth 单角色专属、不要求全局,
487
+ * 默认权重由 defaultWeights 从主 Agent / 每角色配置兜底。
488
+ * - 返回自有 daemon 当前已加载权重(供 provider ack,消除首句冗余切换);外部服务返回 null。
489
+ * - 多次并发调用共享同一个启动 Promise,避免重复 spawn。
490
+ */
491
+ export function ensureGptSovitsLocalServer(
492
+ cfg: GptSovitsConfig,
493
+ ): Promise<{ gpt?: string; sovits?: string } | null> {
494
+ if (cfg.mode !== "local") return Promise.resolve(null);
495
+ if (
496
+ !cfg.apiScriptPath?.trim() ||
497
+ !cfg.bertPath?.trim() ||
498
+ !cfg.cnhubertPath?.trim()
499
+ ) {
500
+ // 路径不齐:不自动启动,沿用“用户先手动启动 api_v2”的旧模式。
501
+ return Promise.resolve(null);
502
+ }
503
+ if (startPromise) return startPromise;
504
+ startPromise = doEnsure(cfg).finally(() => {
505
+ startPromise = null;
506
+ });
507
+ return startPromise;
508
+ }
509
+
510
+ /**
511
+ * 停止本进程启动的本地 api_v2 子进程(幂等)。
512
+ * keepAlive=true(默认):仅清引用不 kill(detached + 文件 stdio,进程自然存活供下次复用);
513
+ * 但若已空闲超时(30 分钟无合成)则顺手回收,避免模型持续驻留占内存。
514
+ * keepAlive=false:SIGTERM 回收(回归"退出即回收"旧行为)。
515
+ */
516
+ export function stopGptSovitsLocalServer(): void {
517
+ const proc = child;
518
+ child = null;
519
+ ownedPid = null;
520
+ if (!proc || proc.killed) return;
521
+ if (spawnedKeepAlive) {
522
+ // 退出时若已空闲超时 → 一并回收
523
+ const pf = readPidfile();
524
+ if (pf && pf.lastUsedAt !== undefined && Date.now() - pf.lastUsedAt > IDLE_TIMEOUT_MS) {
525
+ if (verbose) console.error(`[gpt-sovits] exit: daemon idle > 30min, recycling`);
526
+ try {
527
+ proc.kill("SIGTERM");
528
+ } catch {
529
+ // 忽略
530
+ }
531
+ }
532
+ return;
533
+ }
534
+ try {
535
+ proc.kill("SIGTERM");
536
+ } catch {
537
+ // 忽略
538
+ }
539
+ }
540
+
541
+ /** 刷新自有 daemon 的 lastUsedAt(写 pidfile;仅自有 daemon,外部服务不写)。 */
542
+ function touchLastUsed(): void {
543
+ const pf = readPidfile();
544
+ if (!pf || pf.pid !== ownedPid) return;
545
+ writePidfile({ ...pf, lastUsedAt: Date.now() });
546
+ }
547
+
548
+ /**
549
+ * 供 provider 在每轮 TTS 合成入口(prepare)调用:视为"正在使用",重置空闲计时。
550
+ * 合成请求到来即刷新,空闲回收只对"长期无合成"的 daemon 生效。
551
+ */
552
+ export function touchDaemonLastUsed(): void {
553
+ touchLastUsed();
554
+ }
555
+
556
+ /** 会话内空闲巡检:空闲超 30 分钟的 daemon 自动回收释放内存(unref,不阻止进程退出)。 */
557
+ setInterval(() => {
558
+ if (ownedPid == null) return;
559
+ const pf = readPidfile();
560
+ if (!pf || pf.pid !== ownedPid || pf.lastUsedAt === undefined) return;
561
+ if (Date.now() - pf.lastUsedAt <= IDLE_TIMEOUT_MS) return;
562
+ if (verbose) console.error(`[gpt-sovits] idle ${Math.round((Date.now() - pf.lastUsedAt) / 60000)}min > 30min, recycling daemon pid=${pf.pid}`);
563
+ const dead = pf.pid;
564
+ child = null;
565
+ ownedPid = null;
566
+ void killPidAndWait(dead).then(() => clearPidfile());
567
+ }, IDLE_CHECK_MS).unref();
568
+
569
+ /**
570
+ * 供 provider ack 权重后持久化到 pidfile(仅自有 daemon 场景写,外部服务不写)。
571
+ * fire-and-forget;下次启动复用 daemon 时读回,遇相同说话角色零切换。
572
+ */
573
+ export function persistLoadedWeights(weights: { gpt?: string; sovits?: string }): void {
574
+ if (ownedPid == null) return;
575
+ const pf = readPidfile();
576
+ if (!pf || pf.pid !== ownedPid) return;
577
+ writePidfile({ ...pf, loadedWeights: { gpt: weights.gpt, sovits: weights.sovits } });
578
+ }
579
+
580
+ // 兜底:即使 doExit 漏调,进程退出也尽量回收子进程(keepAlive 场景 stop 不 kill,策略见 stopGptSovitsLocalServer)。
581
+ process.once("exit", () => {
582
+ stopGptSovitsLocalServer();
583
+ });
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // 依赖安装(setup 本地部署时调用)
587
+ // GPT-SoVITS 各模型版本(v2/v2Pro/v3/v4)都在官方同一仓库 RVC-Boss/GPT-SoVITS,
588
+ // `version` 只是 tts_infer.yaml 的运行时字段(v14=v2、v16=v3、v17=v4),依赖清单共用根目录
589
+ // requirements.txt,无按版本拆分。**清单来源固定为下方硬编码内置字符串**(官方清单 + torchcodec),
590
+ // 不读 api_v2.py 同目录 requirements.txt(用户克隆/定制的版本可能与运行时所需不一致,统一收敛到
591
+ // 项目内一份可追踪;torchcodec 是 api_v2 解码参考音频 mp3 所需,官方旧 requirements 未含)。
592
+
593
+ /** 内置清单(硬编码,setup 自动装 GPT-SoVITS 依赖的唯一来源)。 */
594
+ const GPT_SOVITS_REQ_FALLBACK = `--no-binary=opencc
595
+ numpy<2.0
596
+ scipy
597
+ tensorboard
598
+ librosa==0.10.2
599
+ numba
600
+ pytorch-lightning>=2.4
601
+ gradio<5
602
+ ffmpeg-python
603
+ onnxruntime; platform_machine == "aarch64" or platform_machine == "arm64"
604
+ onnxruntime-gpu; platform_machine == "x86_64" or platform_machine == "AMD64"
605
+ tqdm
606
+ funasr>=1.3.7
607
+ cn2an
608
+ pypinyin
609
+ pyopenjtalk>=0.4.1
610
+ g2p_en
611
+ torchaudio
612
+ torchcodec
613
+ modelscope
614
+ sentencepiece
615
+ transformers>=4.51,<5
616
+ peft<0.18.0
617
+ chardet
618
+ PyYAML
619
+ psutil
620
+ jieba_fast
621
+ jieba
622
+ split-lang
623
+ fast_langdetect>=0.3.1
624
+ wordsegment
625
+ rotary_embedding_torch
626
+ ToJyutping
627
+ g2pk2
628
+ ko_pron
629
+ opencc
630
+ python_mecab_ko; sys_platform != 'win32'
631
+ fastapi[standard]>=0.115.2
632
+ x_transformers
633
+ torchmetrics<=1.5
634
+ pydantic<=2.10.6
635
+ ctranslate2>=4.0,<5
636
+ av>=11
637
+ tqdm
638
+ `;
639
+
640
+ /** 写盘目标:~/.arona/gpt-sovits-requirements.txt(供 pip 读取)。 */
641
+ const GPT_SOVITS_REQ_DEST = join(ARONA_DIR, "gpt-sovits-requirements.txt");
642
+
643
+ /**
644
+ * 解析 GPT-SoVITS 依赖清单来源:固定写硬编码内置清单到 ~/.arona/gpt-sovits-requirements.txt。
645
+ * 不读 api_v2.py 同目录 requirements.txt(用户定制版可能与运行时所需不一致);
646
+ * 写 ~/.arona 而非项目 assets,覆盖非符号链接 npm 安装场景(该目录未被 package.json#files 打包)。
647
+ * 返回清单文件路径与来源说明。
648
+ */
649
+ function resolveRequirements(): { file: string; source: string } {
650
+ writeFileSync(GPT_SOVITS_REQ_DEST, GPT_SOVITS_REQ_FALLBACK);
651
+ return {
652
+ file: GPT_SOVITS_REQ_DEST,
653
+ source: t(
654
+ "项目内置硬编码清单(官方 RVC-Boss/GPT-SoVITS requirements + torchcodec)",
655
+ "built-in hardcoded list (official RVC-Boss/GPT-SoVITS requirements + torchcodec)",
656
+ ),
657
+ };
658
+ }
659
+
660
+ /**
661
+ * 安装 GPT-SoVITS 依赖:`<python> -m pip install -r <requirements>`。
662
+ * 清单来源:固定项目内置硬编码清单(官方 requirements + torchcodec),不读 api_v2.py 目录。
663
+ * 透传 pip 输出到终端,用户可实时看到进度。
664
+ */
665
+ export async function installGptSovitsDeps(
666
+ _apiScriptPath: string,
667
+ pythonPath: string,
668
+ _modelVersion: string,
669
+ ): Promise<{ ok: boolean; message: string; source: string }> {
670
+ const { file, source } = resolveRequirements();
671
+ console.error(t(
672
+ ` [gpt-sovits] 依赖清单来源:${source}`,
673
+ ` [gpt-sovits] requirements source: ${source}`,
674
+ ));
675
+ return new Promise((resolve) => {
676
+ const proc = spawnCompat(pythonPath, ["-m", "pip", "install", "-r", file], {
677
+ env: stripProxyEnv({
678
+ ...process.env,
679
+ PYTHONUTF8: "1",
680
+ PIP_DISABLE_PIP_VERSION_CHECK: "1",
681
+ }),
682
+ stdio: ["ignore", "inherit", "inherit"],
683
+ });
684
+ proc.on("error", (err) => {
685
+ resolve({
686
+ ok: false,
687
+ message: t(`无法启动 pip:${err.message}`, `Failed to run pip: ${err.message}`),
688
+ source,
689
+ });
690
+ });
691
+ proc.on("close", (code) => {
692
+ if (code === 0) {
693
+ resolve({ ok: true, message: t("依赖安装完成。", "Dependencies installed."), source });
694
+ } else {
695
+ resolve({
696
+ ok: false,
697
+ message: t(
698
+ `依赖安装失败(pip 退出码 ${code})。请检查网络/环境后重试,或手动执行:${pythonPath} -m pip install -r ${file}`,
699
+ `Dependency install failed (pip exit ${code}). Check network/env and retry, or run manually: ${pythonPath} -m pip install -r ${file}`,
700
+ ),
701
+ source,
702
+ });
703
+ }
704
+ });
705
+ });
706
+ }