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
@@ -157,15 +157,17 @@ function errorText(status: number, detail: string): string {
157
157
  /** 限流重试 5 次仍失败 → 给 Agent 的可操作建议。 */
158
158
  function rateLimitExhaustedText(): string {
159
159
  return t(
160
- "Tavily 触发限流,自动重试 5 次仍失败。建议:1) 在 ~/.arona/settings.json 配置 tavilyApiKey 以提升额度(免费计划每月 1000 credits,无需信用卡);2) 稍后重试。",
161
- "Tavily is rate-limiting; 5 auto-retries failed. Suggest: 1) configure tavilyApiKey in ~/.arona/settings.json for higher quota (free plan: 1000 credits/month, no credit card); 2) retry later.",
160
+ "Tavily 触发限流,自动重试 5 次仍失败。建议:1) 在 ~/.arona/settings.json 配置 tavilyApiKey;2) 稍后重试。",
161
+ "Tavily is rate-limiting; 5 auto-retries failed. Suggest: 1) configure tavilyApiKey in ~/.arona/settings.json; 2) retry later.",
162
162
  );
163
163
  }
164
164
 
165
165
  /** 统一失败文案:重试耗尽 → 限流建议;否则 → 通用错误。 */
166
166
  function failText(toolName: string, res: TavilyResponse<unknown>): string {
167
- if (res.retries >= RATE_LIMIT_RETRIES && res.ok === false) return `${toolName} 失败:${rateLimitExhaustedText()}`;
168
- return `${toolName} 失败:${errorText(res.status, res.error || "")}`;
167
+ if (res.retries >= RATE_LIMIT_RETRIES && res.ok === false) {
168
+ return t(`${toolName} 失败:${rateLimitExhaustedText()}`, `${toolName} failed: ${rateLimitExhaustedText()}`);
169
+ }
170
+ return t(`${toolName} 失败:${errorText(res.status, res.error || "")}`, `${toolName} failed: ${errorText(res.status, res.error || "")}`);
169
171
  }
170
172
 
171
173
  // ============================================================
@@ -249,9 +251,12 @@ export const webExtractTool = defineTool({
249
251
 
250
252
  const truncated =
251
253
  text.length > EXTRACT_MAX_CHARS
252
- ? `${text.slice(0, EXTRACT_MAX_CHARS)}\n\n…(已截断,全文共 ${text.length} 字符,仅显示前 ${EXTRACT_MAX_CHARS})`
254
+ ? `${text.slice(0, EXTRACT_MAX_CHARS)}\n\n${t(
255
+ `…(已截断,全文共 ${text.length} 字符,仅显示前 ${EXTRACT_MAX_CHARS})`,
256
+ `… (truncated; ${text.length} chars total, showing first ${EXTRACT_MAX_CHARS})`,
257
+ )}`
253
258
  : text;
254
- return textResult(`来源: ${result.url}\n\n${truncated}`);
259
+ return textResult(t(`来源: ${result.url}\n\n${truncated}`, `Source: ${result.url}\n\n${truncated}`));
255
260
  },
256
261
  });
257
262
 
@@ -308,7 +313,10 @@ export const webCrawlTool = defineTool({
308
313
  const body = (r.raw_content || "").trim();
309
314
  const truncated =
310
315
  body.length > CRAWL_PAGE_MAX_CHARS
311
- ? `${body.slice(0, CRAWL_PAGE_MAX_CHARS)}\n…(已截断,该页共 ${body.length} 字符)`
316
+ ? `${body.slice(0, CRAWL_PAGE_MAX_CHARS)}\n${t(
317
+ `…(已截断,该页共 ${body.length} 字符)`,
318
+ `… (truncated; this page has ${body.length} chars)`,
319
+ )}`
312
320
  : body;
313
321
  return `[${i + 1}] ${r.url}\n${truncated}`;
314
322
  });
@@ -345,7 +353,9 @@ export const webMapTool = defineTool({
345
353
  }
346
354
  const shown = urls.slice(0, 100);
347
355
  const lines = shown.map((u, i) => `- ${u}`).join("\n");
348
- const more = urls.length > shown.length ? `\n…(共 ${urls.length} 个,仅显示前 ${shown.length} 个)` : "";
356
+ const more = urls.length > shown.length
357
+ ? `\n${t(`…(共 ${urls.length} 个,仅显示前 ${shown.length} 个)`, `… (${urls.length} total, showing first ${shown.length})`)}`
358
+ : "";
349
359
  return textResult(t(`站点结构(${urls.length} 个页面):\n`, `Site map (${urls.length} pages):\n`) + lines + more);
350
360
  },
351
361
  });
@@ -410,10 +420,14 @@ export const webResearchTool = defineTool({
410
420
  }
411
421
  const truncated =
412
422
  content.length > RESEARCH_MAX_CHARS
413
- ? `${content.slice(0, RESEARCH_MAX_CHARS)}\n\n…(报告过长,已截断,全文共 ${content.length} 字符)`
423
+ ? `${content.slice(0, RESEARCH_MAX_CHARS)}\n\n${t(
424
+ `…(报告过长,已截断,全文共 ${content.length} 字符)`,
425
+ `… (report too long; truncated; ${content.length} chars total)`,
426
+ )}`
414
427
  : content;
415
428
  const sourceLines = sources.length > 0
416
- ? `\n\n来源(${sources.length}):\n` + sources.map((s) => `- ${s.title || ""}: ${s.url || ""}`).join("\n")
429
+ ? `\n\n${t(`来源(${sources.length}):\n`, `Sources (${sources.length}):\n`)}` +
430
+ sources.map((s) => `- ${s.title || ""}: ${s.url || ""}`).join("\n")
417
431
  : "";
418
432
  return textResult(truncated + sourceLines);
419
433
  }
@@ -7,7 +7,7 @@ import { listen } from "../voice.ts";
7
7
  *
8
8
  * 设计原则:TTS 完全由程序在 Agent 回复时自动触发(renderer text_delta → TtsStream 实时流式),
9
9
  * 语气风格由 TTS 引擎自身决定,无需 Tool 介入。
10
- * STT 在用户长按右 Cmd ≥2秒时自动 listen(见 repl.ts),也无需 Tool 介入。
10
+ * STT 在用户长按全局录音热键(macOS 右 Cmd / 其他右 Ctrl)≥2秒时自动 listen(见 repl.ts),也无需 Tool 介入。
11
11
  *
12
12
  * 保留 transcribe Tool 仅作为手动触发 STT 的兜底入口(auto STT 关时仍可工作)。
13
13
  */
@@ -0,0 +1,501 @@
1
+ // TTS Provider 抽象:不同后端实现同一接口,Node 侧据此构造 python/tts_say.py 的一次性合成命令。
2
+ //
3
+ // 已实现:
4
+ // - aliyun 阿里云百炼非流式 TTS(默认;角色音色 = voices.json#aliyun 克隆 voice_id)
5
+ // - gpt-sovits 云端 API / 本地 GPT-SoVITS api_v2 服务(角色音色 = voices.json#gpt-sovits 里配置的
6
+ // .ckpt/.pth/.refAudioPath/promptText;local 未提供示例音频时回退角色素材 voice.mp3,
7
+ // ref_text 用同目录 voice_text.txt 顶替;cloud 不回退本地素材)
8
+ //
9
+ // 命令协议:TtsCommand.env 合并进子进程环境;TtsCommand.payload 作为 stdin 单行 JSON 交给
10
+ // python/tts_say.py。play 模式不经过 provider(直接播已合成的临时 wav)。
11
+
12
+ import { existsSync, readFileSync } from "fs";
13
+ import { dirname, join } from "path";
14
+ import { config, verbose, type TtsProvider as TtsProviderId } from "./config.ts";
15
+ import { t } from "./locale.ts";
16
+ import { AGENT_IDS, type AgentId } from "./agent_registry.ts";
17
+ import {
18
+ VOICE_SOVITS_AUDIO,
19
+ getVoiceId,
20
+ getGptSovitsVoice,
21
+ type GptSovitsVoiceConfig,
22
+ } from "./voices.ts";
23
+ import { ensureGptSovitsLocalServer, persistLoadedWeights, touchDaemonLastUsed } from "./gpt_sovits_local.ts";
24
+ import {
25
+ ensureOssUrlAlive,
26
+ fetchTextFromUrl,
27
+ invalidateOssUrl,
28
+ isHttpUrl,
29
+ joinPromptLines,
30
+ uploadToOss,
31
+ } from "./oss_upload.ts";
32
+
33
+ /** GPT-SoVITS 模型版本(tts_infer.yaml 的 custom.version 取值)。 */
34
+ export type GptSovitsModelVersion = "v2" | "v2Pro" | "v3" | "v4";
35
+
36
+ // GptSovitsVoiceConfig 类型定义在 voices.ts(避免循环依赖),此处 re-export 供调用方从本模块导入。
37
+ export type { GptSovitsVoiceConfig } from "./voices.ts";
38
+
39
+ /** ttsConfig["gpt-sovits"]:GPT-SoVITS provider 专属配置。 */
40
+ export interface GptSovitsConfig {
41
+ /**
42
+ * cloud = 只连接远程 API(无需本地模型路径);
43
+ * local = 本地部署:可自动启动 api_v2(需 apiScriptPath + 模型路径),或连接用户手动启动的本地服务。
44
+ */
45
+ mode: "cloud" | "local";
46
+ /** api_v2 服务地址,默认 http://127.0.0.1:9880 */
47
+ baseUrl: string;
48
+ /** 云端 API 可选鉴权 Key(有则请求带 Authorization: Bearer) */
49
+ apiKey?: string;
50
+ /** text_lang,默认 auto */
51
+ textLang: string;
52
+ /** prompt_lang,默认 zh */
53
+ promptLang: string;
54
+ /** 每句合成保险丝(毫秒),默认 60000 */
55
+ timeoutMs: number;
56
+ // ---- 本地部署专用 ----
57
+ /** GPT-SoVITS 仓库根目录下的 api_v2.py 绝对路径(用于自动启动本地服务) */
58
+ apiScriptPath?: string;
59
+ /** 启动 api_v2 使用的 Python 可执行文件;空 = ARONA 的 pythonPath */
60
+ pythonPath?: string;
61
+ /** 模型版本,默认 v2 */
62
+ modelVersion?: GptSovitsModelVersion;
63
+ /** GPT 权重 .ckpt 路径 */
64
+ gptModelPath?: string;
65
+ /** SoVITS 权重 .pth 路径 */
66
+ sovitsModelPath?: string;
67
+ /** BERT 模型目录(chinese-roberta-wwm-ext-large) */
68
+ bertPath?: string;
69
+ /** CNHubert 模型目录(chinese-hubert-base) */
70
+ cnhubertPath?: string;
71
+ /** 推理设备,默认 cuda(可 cuda/cpu/mps) */
72
+ device?: string;
73
+ /** 是否半精度,默认 true */
74
+ isHalf?: boolean;
75
+ /**
76
+ * api_v2 是否常驻后台(默认 true):ARONA 退出不杀进程,下次启动秒连热服务。
77
+ * 设为 false 恢复"退出即回收"。常驻由 detached + 日志文件 + pidfile 支撑。
78
+ */
79
+ keepAlive?: boolean;
80
+ /** @deprecated 每角色音色已迁移到 voices.json#gpt-sovits;此处仅兼容读取 settings.json 旧配置(迁移后为空) */
81
+ voices?: Partial<Record<AgentId, GptSovitsVoiceConfig>>;
82
+ }
83
+
84
+ /** 解析回退后的完整发声参数(发送给 python 前已定型)。 */
85
+ export interface ResolvedGptSovitsVoice {
86
+ gptWeightsPath: string;
87
+ sovitsWeightsPath: string;
88
+ refAudioPath: string;
89
+ promptText: string;
90
+ promptLang: string;
91
+ textLang: string;
92
+ }
93
+
94
+ /** 交给 python/tts_say.py 的一次性合成命令。 */
95
+ export interface TtsCommand {
96
+ env: Record<string, string>;
97
+ payload: Record<string, unknown>;
98
+ }
99
+
100
+ export interface TtsProvider {
101
+ readonly id: TtsProviderId;
102
+ label(): string;
103
+ /** 该角色在当前 provider 下是否具备可合成音色(无音色 → 强制静音)。 */
104
+ hasVoice(agent: AgentId): boolean;
105
+ /** 构造合成命令("synth_play" 默认合成+播放;"synth_only" 预合成写临时 wav)。
106
+ 异步:GPT-SoVITS 的参考音频本地文件需先上传 OSS、文字文件/URL 需先解析。 */
107
+ buildCommand(agent: AgentId, text: string, mode: "synth_play" | "synth_only"): Promise<TtsCommand>;
108
+ /** 每句合成+播放的时长保险丝(毫秒)。 */
109
+ sentenceTimeoutMs(): number;
110
+ /** GPT-SoVITS 专用:python 侧成功切换权重后回调,更新服务端已加载权重状态。 */
111
+ ackWeights?(evt: { gpt?: string; sovits?: string }): void;
112
+ /** 可选:合成前准备(如自动启动本地 GPT-SoVITS 服务、预上传并校验 OSS 参考音频)。 */
113
+ prepare?(): Promise<void>;
114
+ /** 可选:作废某角色参考音频缓存(参考音频相关错误时由调用方触发,下次合成重新上传)。 */
115
+ markRefInvalid?(agent: AgentId): void;
116
+ }
117
+
118
+ // ------------------------------------------------------------
119
+ // GPT-SoVITS
120
+ // ------------------------------------------------------------
121
+
122
+ /** 归并 ttsConfig["gpt-sovits"]:缺省值 + 兼容读取旧 settings voices(已迁移到 voices.json#gpt-sovits)。 */
123
+ export function normalizeGptSovitsConfig(raw: unknown): GptSovitsConfig {
124
+ const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
125
+ const voicesRaw =
126
+ obj.voices && typeof obj.voices === "object" && !Array.isArray(obj.voices)
127
+ ? (obj.voices as Record<string, unknown>)
128
+ : {};
129
+ const voices: Partial<Record<AgentId, GptSovitsVoiceConfig>> = {};
130
+ for (const id of AGENT_IDS) {
131
+ const v = voicesRaw[id];
132
+ if (v && typeof v === "object" && !Array.isArray(v)) {
133
+ const vv = v as Record<string, unknown>;
134
+ voices[id] = {
135
+ gptWeightsPath: typeof vv.gptWeightsPath === "string" ? vv.gptWeightsPath : "",
136
+ sovitsWeightsPath: typeof vv.sovitsWeightsPath === "string" ? vv.sovitsWeightsPath : "",
137
+ refAudioPath: typeof vv.refAudioPath === "string" ? vv.refAudioPath : "",
138
+ promptText: typeof vv.promptText === "string" ? vv.promptText : "",
139
+ };
140
+ }
141
+ }
142
+ const modelVersionRaw = typeof obj.modelVersion === "string" ? obj.modelVersion : "";
143
+ const modelVersion: GptSovitsModelVersion =
144
+ modelVersionRaw === "v2Pro" || modelVersionRaw === "v3" || modelVersionRaw === "v4" || modelVersionRaw === "v2"
145
+ ? modelVersionRaw
146
+ : "v2";
147
+ return {
148
+ mode: obj.mode === "cloud" ? "cloud" : "local",
149
+ baseUrl: typeof obj.baseUrl === "string" && obj.baseUrl ? obj.baseUrl : "http://127.0.0.1:9880",
150
+ apiKey: typeof obj.apiKey === "string" ? obj.apiKey : "",
151
+ textLang: typeof obj.textLang === "string" && obj.textLang ? obj.textLang : "auto",
152
+ promptLang: typeof obj.promptLang === "string" && obj.promptLang ? obj.promptLang : "zh",
153
+ timeoutMs: typeof obj.timeoutMs === "number" && obj.timeoutMs > 0 ? obj.timeoutMs : 60000,
154
+ apiScriptPath: typeof obj.apiScriptPath === "string" ? obj.apiScriptPath : "",
155
+ pythonPath: typeof obj.pythonPath === "string" ? obj.pythonPath : "",
156
+ modelVersion,
157
+ gptModelPath: typeof obj.gptModelPath === "string" ? obj.gptModelPath : "",
158
+ sovitsModelPath: typeof obj.sovitsModelPath === "string" ? obj.sovitsModelPath : "",
159
+ bertPath: typeof obj.bertPath === "string" ? obj.bertPath : "",
160
+ cnhubertPath: typeof obj.cnhubertPath === "string" ? obj.cnhubertPath : "",
161
+ device: typeof obj.device === "string" && obj.device ? obj.device : "cuda",
162
+ isHalf: typeof obj.isHalf === "boolean" ? obj.isHalf : false,
163
+ keepAlive: typeof obj.keepAlive === "boolean" ? obj.keepAlive : true,
164
+ voices,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * 参考音频语言(prompt_lang)自动判断:依据文字内容字符集。
170
+ * 走默认素材(voice.mp3 + voice_text.txt)时固定 zh;自定义文字则按内容判断。
171
+ * 空/无法识别 → zh(GPT-SoVITS 默认参考语言)。
172
+ */
173
+ export function detectPromptLang(text: string): string {
174
+ const s = (text || "").trim();
175
+ if (!s) return "zh";
176
+ if (/[\u3040-\u30ff]/.test(s)) return "ja"; // 日文假名(先于汉字判定,日语含假名)
177
+ if (/[\uac00-\ud7af]/.test(s)) return "ko"; // 韩文谚文
178
+ if (/[嘅唔喺啲嗰乜嘢嚟咗哋冇畀]/.test(s)) return "yue"; // 粤语特征字
179
+ if (/[\u4e00-\u9fff]/.test(s)) return "zh"; // 汉字
180
+ return "en"; // 其余(拉丁为主)
181
+ }
182
+
183
+ class GptSovitsTtsProvider implements TtsProvider {
184
+ readonly id = "gpt-sovits" as const;
185
+
186
+ /** 服务端当前已加载的权重;null = 未知(首次遇到配置权重的角色必须切换一次)。 */
187
+ private loadedGpt: string | null = null;
188
+ private loadedSovits: string | null = null;
189
+
190
+ /** 每角色解析结果缓存(ref/prompt 已按 路径/URL 解析定型;URL 长期有效,会话内复用)。 */
191
+ private resolved = new Map<AgentId, ResolvedGptSovitsVoice>();
192
+ /** ref 来源元信息:仅记录"本地文件已上传 OSS"的角色与其源路径(校验/重传用)。 */
193
+ private refOssSource = new Map<AgentId, { sourcePath: string }>();
194
+
195
+ constructor(private cfg: GptSovitsConfig) {}
196
+
197
+ label(): string {
198
+ return "GPT-SoVITS";
199
+ }
200
+
201
+ /** 该角色 GPT-SoVITS 音色配置:优先 voices.json#gpt-sovits(已迁移的权威来源),回退 settings 旧 voices。 */
202
+ private voiceFor(agent: AgentId): GptSovitsVoiceConfig | undefined {
203
+ return getGptSovitsVoice(agent) ?? this.cfg.voices?.[agent];
204
+ }
205
+
206
+ hasVoice(agent: AgentId): boolean {
207
+ const v = this.voiceFor(agent);
208
+ if (!v) return false;
209
+ if (this.cfg.mode === "cloud") {
210
+ // 云端:本地路径(将上传 OSS 换 URL)或 URL 都视为有音色,仅空串无音色
211
+ return !!v.refAudioPath?.trim();
212
+ }
213
+ const explicitRef = v.refAudioPath?.trim() || "";
214
+ if (explicitRef && existsSync(explicitRef)) return true;
215
+ // local 未配置/配置缺失 → 一律用角色素材 voice_sovits.mp3(各角色素材均已内置,无需判断存在)
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * 解析参考音频与对应文字(async:云端本地 ref 需上传 OSS、文字文件/URL 需读取/抓取)。
221
+ * 每次请求时解析并缓存(用户之后手动补 voice_text.txt / 换文件无需重启即生效)。
222
+ */
223
+ private async resolveVoice(agent: AgentId): Promise<ResolvedGptSovitsVoice> {
224
+ const v = this.voiceFor(agent);
225
+ const explicitRef = v?.refAudioPath?.trim() || "";
226
+ const bundledAudio = VOICE_SOVITS_AUDIO[agent];
227
+ let refAudioPath: string;
228
+ let usingBundled = false;
229
+ let ossSource: string | null = null; // 仅"本地文件已上传 OSS"时非空(校验/重传用)
230
+
231
+ if (this.cfg.mode === "cloud") {
232
+ if (isHttpUrl(explicitRef)) {
233
+ // 已是远端 URL:云端服务端可直接引用
234
+ refAudioPath = explicitRef;
235
+ } else if (existsSync(explicitRef)) {
236
+ // 本地文件 → 上传阿里云 OSS 换取长期 URL(会话内缓存,失效时重传)
237
+ ossSource = explicitRef;
238
+ refAudioPath = await uploadToOss(explicitRef, config.ttsApiKey);
239
+ } else if (!explicitRef) {
240
+ // 云端不回退本地素材(与 hasVoice 判定一致:无 ref 即无音色,正常不会走到这里)
241
+ refAudioPath = "";
242
+ } else {
243
+ throw new Error(t(
244
+ `GPT-SoVITS 参考音频不存在: ${explicitRef}`,
245
+ `GPT-SoVITS ref audio not found: ${explicitRef}`,
246
+ ));
247
+ }
248
+ } else {
249
+ if (explicitRef && !existsSync(explicitRef) && !isHttpUrl(explicitRef)) {
250
+ // 用户填的本地 ref 不存在:回退角色素材(与 hasVoice 判定一致)
251
+ refAudioPath = bundledAudio || explicitRef;
252
+ usingBundled = !!bundledAudio;
253
+ } else if (explicitRef) {
254
+ // 本地路径或 URL 均透传(本地服务端自行解析)
255
+ refAudioPath = explicitRef;
256
+ } else {
257
+ refAudioPath = bundledAudio;
258
+ usingBundled = !!bundledAudio;
259
+ }
260
+ }
261
+ if (ossSource) this.refOssSource.set(agent, { sourcePath: ossSource });
262
+ else this.refOssSource.delete(agent);
263
+
264
+ let promptText = v?.promptText?.trim() || "";
265
+ if (usingBundled && !promptText) {
266
+ const infoPath = join(dirname(VOICE_SOVITS_AUDIO[agent]), "voice_text.txt");
267
+ if (existsSync(infoPath)) {
268
+ try {
269
+ promptText = readFileSync(infoPath, "utf-8").trim();
270
+ } catch {
271
+ promptText = "";
272
+ }
273
+ }
274
+ } else if (promptText) {
275
+ if (isHttpUrl(promptText)) {
276
+ promptText = await fetchTextFromUrl(promptText);
277
+ } else if (existsSync(promptText)) {
278
+ try {
279
+ promptText = readFileSync(promptText, "utf-8");
280
+ } catch {
281
+ promptText = "";
282
+ }
283
+ }
284
+ // 其余视为字面量文字
285
+ }
286
+ // 多行合并为一行:行尾缺句末标点按中英文智能补「。」/「.」(单行输入原样返回)
287
+ promptText = joinPromptLines(promptText);
288
+
289
+ return {
290
+ gptWeightsPath: v?.gptWeightsPath?.trim() || "",
291
+ sovitsWeightsPath: v?.sovitsWeightsPath?.trim() || "",
292
+ refAudioPath,
293
+ promptText,
294
+ // 参考语言:走默认素材固定 zh;自定义文字按内容自动判断(空→zh)
295
+ promptLang: usingBundled ? "zh" : detectPromptLang(promptText),
296
+ textLang: this.cfg.textLang,
297
+ };
298
+ }
299
+
300
+ /** 解析并缓存(幂等);buildCommand / prepare 共用,返回的 voice 对象可被调用方原地改 URL。 */
301
+ private async ensureResolved(agent: AgentId): Promise<ResolvedGptSovitsVoice> {
302
+ const cached = this.resolved.get(agent);
303
+ if (cached) return cached;
304
+ const voice = await this.resolveVoice(agent);
305
+ this.resolved.set(agent, voice);
306
+ return voice;
307
+ }
308
+
309
+ /** 会话内校验一次 OSS URL;失效则重传并刷新缓存("发现无效→重新走上传流程")。 */
310
+ private async verifyResolvedRefs(): Promise<void> {
311
+ for (const agent of AGENT_IDS) {
312
+ const src = this.refOssSource.get(agent);
313
+ if (!src) continue;
314
+ const voice = this.resolved.get(agent);
315
+ if (!voice?.refAudioPath) continue;
316
+ try {
317
+ const fresh = await ensureOssUrlAlive(src.sourcePath, voice.refAudioPath, config.ttsApiKey);
318
+ if (fresh !== voice.refAudioPath) voice.refAudioPath = fresh;
319
+ } catch (err) {
320
+ console.warn(t(
321
+ `GPT-SoVITS 参考音频上传/校验失败: ${err instanceof Error ? err.message : String(err)}`,
322
+ `GPT-SoVITS ref audio upload/verify failed: ${err instanceof Error ? err.message : String(err)}`,
323
+ ));
324
+ // 清缓存:下次 buildCommand 重新上传
325
+ this.resolved.delete(agent);
326
+ this.refOssSource.delete(agent);
327
+ }
328
+ }
329
+ }
330
+
331
+ async buildCommand(agent: AgentId, text: string, mode: "synth_play" | "synth_only"): Promise<TtsCommand> {
332
+ const voice = await this.ensureResolved(agent);
333
+ const switchGpt = !!voice.gptWeightsPath && voice.gptWeightsPath !== this.loadedGpt;
334
+ const switchSovits = !!voice.sovitsWeightsPath && voice.sovitsWeightsPath !== this.loadedSovits;
335
+ const payload: Record<string, unknown> = {
336
+ provider: "gpt-sovits",
337
+ text,
338
+ baseUrl: this.cfg.baseUrl,
339
+ apiKey: this.cfg.apiKey || "",
340
+ refAudioPath: voice.refAudioPath,
341
+ promptText: voice.promptText,
342
+ promptLang: voice.promptLang,
343
+ textLang: voice.textLang,
344
+ timeoutMs: this.cfg.timeoutMs,
345
+ gptWeightsPath: voice.gptWeightsPath,
346
+ sovitsWeightsPath: voice.sovitsWeightsPath,
347
+ switchGpt,
348
+ switchSovits,
349
+ };
350
+ if (mode === "synth_only") payload.mode = "synth_only";
351
+ return { env: {}, payload };
352
+ }
353
+
354
+ sentenceTimeoutMs(): number {
355
+ return this.cfg.timeoutMs;
356
+ }
357
+
358
+ ackWeights(evt: { gpt?: string; sovits?: string }): void {
359
+ if (typeof evt.gpt === "string") this.loadedGpt = evt.gpt;
360
+ if (typeof evt.sovits === "string") this.loadedSovits = evt.sovits;
361
+ // 持久化到 pidfile(仅自有 daemon 场景生效,外部服务不写):
362
+ // 下次复用 daemon 时读回,遇相同说话角色零切换。
363
+ persistLoadedWeights({ gpt: this.loadedGpt ?? undefined, sovits: this.loadedSovits ?? undefined });
364
+ }
365
+
366
+ async prepare(): Promise<void> {
367
+ // 确保本地服务就绪;自有 daemon(digest 匹配被复用/冷启动)返回当前已加载权重 → ack,首句免切换;
368
+ // 外部服务返回 null → 不 ack(防止拿错权重致音色错乱,宁可首句多切换一次)。
369
+ const w = await ensureGptSovitsLocalServer(this.cfg);
370
+ if (w) this.ackWeights(w);
371
+ // 每轮合成入口即视为"正在使用":重置 daemon 空闲计时(空闲 30 分钟未合成才会被回收)
372
+ touchDaemonLastUsed();
373
+ if (this.cfg.mode !== "cloud") return;
374
+ // 云端:预解析所有已配置音色角色(本地 ref 上传 OSS),再会话内校验一次 OSS URL。
375
+ // 上传/校验失败只告警不阻塞队列,后续 buildCommand 失败时由 markRefInvalid 重传。
376
+ for (const agent of AGENT_IDS) {
377
+ if (!this.hasVoice(agent)) continue;
378
+ try {
379
+ await this.ensureResolved(agent);
380
+ } catch (err) {
381
+ console.warn(t(
382
+ `GPT-SoVITS 参考音频准备失败: ${err instanceof Error ? err.message : String(err)}`,
383
+ `GPT-SoVITS ref audio prepare failed: ${err instanceof Error ? err.message : String(err)}`,
384
+ ));
385
+ this.resolved.delete(agent);
386
+ this.refOssSource.delete(agent);
387
+ }
388
+ }
389
+ await this.verifyResolvedRefs();
390
+ }
391
+
392
+ /** 作废某角色参考音频缓存(合成报参考音频相关错误时由 tts_stream 触发,下次合成重新上传)。 */
393
+ markRefInvalid(agent: AgentId): void {
394
+ const src = this.refOssSource.get(agent);
395
+ this.resolved.delete(agent);
396
+ this.refOssSource.delete(agent);
397
+ if (src) invalidateOssUrl(src.sourcePath);
398
+ }
399
+ }
400
+
401
+ // ------------------------------------------------------------
402
+ // Aliyun(百炼)
403
+ // ------------------------------------------------------------
404
+
405
+ class AliyunTtsProvider implements TtsProvider {
406
+ readonly id = "aliyun" as const;
407
+
408
+ label(): string {
409
+ return t("阿里云百炼", "Aliyun Bailian");
410
+ }
411
+
412
+ hasVoice(agent: AgentId): boolean {
413
+ return getVoiceId(agent) !== "";
414
+ }
415
+
416
+ async buildCommand(agent: AgentId, text: string, mode: "synth_play" | "synth_only"): Promise<TtsCommand> {
417
+ const voiceId = getVoiceId(agent);
418
+ const payload: Record<string, unknown> = {
419
+ provider: "aliyun",
420
+ text,
421
+ voice: voiceId,
422
+ };
423
+ if (mode === "synth_only") payload.mode = "synth_only";
424
+ return {
425
+ env: {
426
+ QWEN_WORKSPACE_ID: config.workspaceId,
427
+ QWEN_TTS_API_KEY: config.ttsApiKey,
428
+ QWEN_TTS_MODEL: config.ttsModel,
429
+ QWEN_TTS_VOICE: voiceId,
430
+ },
431
+ payload,
432
+ };
433
+ }
434
+
435
+ sentenceTimeoutMs(): number {
436
+ return 30_000;
437
+ }
438
+
439
+ ackWeights(): void {
440
+ // 百炼无需切换权重
441
+ }
442
+ }
443
+
444
+ // ------------------------------------------------------------
445
+ // Factory
446
+ // ------------------------------------------------------------
447
+
448
+ let providerInstance: TtsProvider | undefined;
449
+
450
+ export function getTtsProvider(): TtsProvider {
451
+ if (!providerInstance) {
452
+ providerInstance =
453
+ config.ttsProvider === "gpt-sovits"
454
+ ? new GptSovitsTtsProvider(normalizeGptSovitsConfig(config.ttsConfig["gpt-sovits"]))
455
+ : new AliyunTtsProvider();
456
+ if (verbose) {
457
+ console.error(`[tts] provider active = ${providerInstance.id}`);
458
+ if (providerInstance.id === "gpt-sovits") {
459
+ console.error(`[tts] gpt-sovits mode=${(config.ttsConfig?.["gpt-sovits"] as { mode?: string } | undefined)?.mode ?? "?"} baseUrl=${(config.ttsConfig?.["gpt-sovits"] as { baseUrl?: string } | undefined)?.baseUrl ?? "?"}`);
460
+ }
461
+ }
462
+ }
463
+ return providerInstance;
464
+ }
465
+
466
+ /**
467
+ * 启动预热:本地 GPT-SoVITS 在程序启动时后台 spawn api_v2 并加载模型(fire-and-forget,不阻塞 CLI)。
468
+ * 首次 TTS 时服务通常已就绪,避免"没声音/卡 180s"式的冷启动等待。
469
+ * 条件:provider=gpt-sovits、local 模式、非 --no-voice、存在可加载的默认权重(全局或每角色)。
470
+ * 失败仅打日志,不影响主流程(首次 TTS 的 drain prepare 仍会按原路径再尝试并给出明确报错)。
471
+ */
472
+ export function preloadGptSovitsLocal(): void {
473
+ if (config.ttsProvider !== "gpt-sovits" || config.noVoice) return;
474
+ const cfg = normalizeGptSovitsConfig(config.ttsConfig["gpt-sovits"]);
475
+ if (cfg.mode !== "local") return;
476
+ const hasGpt =
477
+ !!cfg.gptModelPath?.trim() ||
478
+ AGENT_IDS.some((id) => !!getGptSovitsVoice(id)?.gptWeightsPath?.trim());
479
+ const hasSovits =
480
+ !!cfg.sovitsModelPath?.trim() ||
481
+ AGENT_IDS.some((id) => !!getGptSovitsVoice(id)?.sovitsWeightsPath?.trim());
482
+ if (!hasGpt || !hasSovits) {
483
+ if (verbose) console.error("[tts] preload gpt-sovits skipped: 无可用默认权重");
484
+ return;
485
+ }
486
+ if (verbose) console.error(`[tts] preload gpt-sovits local (${cfg.baseUrl})`);
487
+ void ensureGptSovitsLocalServer(cfg)
488
+ .then((w) => {
489
+ if (w) getTtsProvider().ackWeights?.(w); // 复用/冷启动时 ack 已加载权重,首句免切换
490
+ })
491
+ .catch((err) => {
492
+ if (verbose) console.error(`[tts] preload gpt-sovits failed: ${err instanceof Error ? err.message : String(err)}`);
493
+ });
494
+ // Mac 上用 cpu 推理明显慢于 mps:提示但不自动改配置(尊重显式选择;老 Mac 可能不支持 MPS)。
495
+ if (process.platform === "darwin" && cfg.device === "cpu") {
496
+ console.error(t(
497
+ "提示:GPT-SoVITS 推理设备为 cpu,Mac 上改用 device 字段的 mps 可显著提升合成速度。",
498
+ "Hint: GPT-SoVITS device=cpu; on Mac switching device to mps speeds up synthesis significantly.",
499
+ ));
500
+ }
501
+ }