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
package/src/tts_stream.ts CHANGED
@@ -5,9 +5,16 @@ import { t } from "./locale.ts";
5
5
  import { spawnCompat, stripProxyEnv } from "./utils/spawn.ts";
6
6
  import { stripMarkdown } from "./voice.ts";
7
7
  import { getMainAgent, type AgentId } from "./agent_registry.ts";
8
- import { getVoiceId } from "./voices.ts";
8
+ import { getTtsProvider, type TtsCommand } from "./tts_provider.ts";
9
9
  import { splitStreamedText, countTextUnits } from "./text_split.ts";
10
10
 
11
+ /** --verbose 日志用:掩码载荷里的 apiKey(避免密钥进日志)。 */
12
+ function redactPayload(payload: Record<string, unknown>): Record<string, unknown> {
13
+ const out = { ...payload };
14
+ if (typeof out.apiKey === "string" && out.apiKey) out.apiKey = "***";
15
+ return out;
16
+ }
17
+
11
18
  /**
12
19
  * 非流式 TTS 管道(阿里云百炼"非实时语音合成",整句一次 HTTP 合成)。
13
20
  *
@@ -21,13 +28,27 @@ import { splitStreamedText, countTextUnits } from "./text_split.ts";
21
28
  * 过程性发言不朗读)——避免被 change_emotion 等工具调用切成多段的句子被拆读
22
29
  * - 整段字数判断:countTextUnits(整段) >= MAX_TURN_LEN 则整段跳过不朗读(长回复静音)
23
30
  * - 围栏代码块(```...```)整块剔除;句内 markdown 成句后剔除
24
- * - 句子进入严格串行队列:合成(HTTP+下载)→ 播放 → 下一句
25
- * - 音色每句动态取 getVoiceId(currentAgent),setVoice 仅切换当前角色、无需重启进程
26
- * - 覆盖主 + 子 Agent:同一实例通过 setVoice 切角色
31
+ * - 句子进入串行播放队列 + 单槽预合成流水线:上一句播放期间预合成下一句(synth_only 写临时
32
+ * wav),句间只付一次无 HTTP 的 play 进程启动,消除句间合成停顿;预合成失败回退合成+播放
33
+ * - 音色按句入队时固化发音人 {text, agent},spawn 用该角色 voice_id(不受后续 setVoice 影响)
34
+ * - 每句带 TTS_SENTENCE_TIMEOUT_MS 保险丝:卡死的句子被跳过,busy 必然复位,整体不静音
35
+ * - 覆盖主 + 子 Agent:同一实例通过 setVoice 切当前角色判断开关,队列句音色已各自绑定
27
36
  */
28
37
  // 整段字数阈值(countTextUnits 口径):>= 50 的回复不朗读
29
38
  const MAX_SENTENCE_LEN = 50;
30
39
  const TTS_FORCE_SPLIT_LEN = 9999; // 仅标点切句、不强切
40
+ // 每句合成+播放的时长保险丝:到点仍未 play_end/close 则主动杀进程、按"正常结束"推进下一句。
41
+ // 防止 busy 被卡死的句子永久钉死整条队列(多 Agent 紧邻回合会把单句故障放大成整体静音)。
42
+ const TTS_SENTENCE_TIMEOUT_MS = 30_000;
43
+
44
+ /** 队列项:入队即固化发音人,之后任何 setVoice/cancel 都不影响本句音色选择。
45
+ audioPath:undefined=未尝试预合成;null=预合成失败(回退合成+播放);string=预合成好的 wav 路径 */
46
+ interface PendingSentence {
47
+ text: string;
48
+ agent: AgentId;
49
+ audioPath?: string | null;
50
+ prefetchPromise?: Promise<void>;
51
+ }
31
52
 
32
53
  /** 剔除整段文本中的 ``` 代码围栏(文本已完整,无需跨 delta 状态机)。 */
33
54
  function stripCodeBlocks(s: string): string {
@@ -35,13 +56,25 @@ function stripCodeBlocks(s: string): string {
35
56
  return s.replace(/```[\s\S]*?```/g, " ").replace(/```/g, " ");
36
57
  }
37
58
 
59
+ /**
60
+ * 判断 GPT-SoVITS 报错是否与参考音频相关("发现无效→重新上传再试"的依据)。
61
+ * 参考音频是云端通过 OSS URL 引用的,URL 失效时服务端报错文案多样,这里做宽松匹配。
62
+ * 误判只多一次重试(无害);漏判则跳过本句、下轮 prepare 重新校验。
63
+ */
64
+ function isRefAudioError(msg: string | undefined): boolean {
65
+ if (!msg) return false;
66
+ return /(参考音频|ref_?audio|ref.?wav|refer|示例音频|prompt|无法打开|not found|404|下载失败|download.*fail|cannot.*(?:audio|wav)|invalid.*(?:audio|wav))/i.test(msg);
67
+ }
68
+
38
69
  export class TtsStream {
39
70
  private proc: ChildProcessWithoutNullStreams | null = null;
40
71
  private pendingCount = 0; // 活跃播放段数(play_start +1 / play_end -1)
41
- private queue: string[] = []; // 待合成句子队列
72
+ private queue: PendingSentence[] = []; // 待合成句子队列(按句绑定发音人)
42
73
  private busy = false; // 当前是否有句子在合成/播放
43
74
  private shuttingDown = false;
44
75
  private _killRequested = false; // 本次是否因 cancel 主动杀进程(区分正常结束与打断)
76
+ private generation = 0; // 打断纪元:cancel 自增以作废在途 drain,杜绝双 drain 竞态
77
+ private prefetchProc: ChildProcessWithoutNullStreams | null = null; // 在途预合成进程(cancel 时一并杀)
45
78
  private currentAgent: AgentId = getMainAgent();
46
79
 
47
80
  constructor(
@@ -87,18 +120,33 @@ export class TtsStream {
87
120
  return;
88
121
  }
89
122
  const { sentences, rest } = splitStreamedText(full, TTS_FORCE_SPLIT_LEN);
90
- for (const s of sentences) this.emitSentence(s);
123
+ // 碎句合并:切句器把「…」当句末(人设常用结巴省略号),会产生 1 字碎句(如「嗯……」
124
+ // 被切成「嗯…」+「好呀。」)。GPT-SoVITS 对 1 字超短输入的合成又快又怪(语速快/吞字感),
125
+ // 这里把 countTextUnits ≤ 1 的碎句并入相邻句,仅影响 TTS,不影响气泡渲染。
126
+ const merged: string[] = [];
127
+ for (const s of sentences) {
128
+ if (countTextUnits(stripMarkdown(s)) <= 1 && merged.length > 0) {
129
+ merged[merged.length - 1] += s;
130
+ } else {
131
+ merged.push(s);
132
+ }
133
+ }
134
+ for (const s of merged) this.emitSentence(s, this.currentAgent);
91
135
  const tail = rest.trim();
92
- if (tail) this.emitSentence(tail);
136
+ if (tail) this.emitSentence(tail, this.currentAgent);
93
137
  void this.drain();
94
138
  }
95
139
 
96
140
  /** 打断当前合成与队列(新输入 / Esc / Ctrl+C / STT 触发时调用)。 */
97
141
  cancel(): void {
142
+ // 自增纪元作废任何在途 drain,并复位 busy:即使被杀进程不 close,下一回合也不会被残余 busy 吞掉
143
+ this.generation++;
144
+ this.busy = false;
145
+ if (verbose) console.error("[tts] cancel (generation++ queue cleared busy=0)");
98
146
  this.queue = [];
99
147
  this.pendingCount = 0;
100
148
  this.killProc();
101
- if (verbose) console.error("[tts] cancel (queue cleared)");
149
+ this.killPrefetchProc();
102
150
  }
103
151
 
104
152
  /** 切换角色后清空残余(非流式音色动态取,无需杀进程重拉)。 */
@@ -121,7 +169,7 @@ export class TtsStream {
121
169
  * 符号不计(countTextUnits);字数为 0(纯符号)的句子不朗读。
122
170
  * (整段已 < MAX_SENTENCE_LEN,单句必然 < 阈值,此处过滤为纯防御)
123
171
  */
124
- private emitSentence(sentence: string): void {
172
+ private emitSentence(sentence: string, agent: AgentId): void {
125
173
  const clean = stripMarkdown(sentence).trim();
126
174
  if (!clean) return;
127
175
  const units = countTextUnits(clean);
@@ -129,63 +177,286 @@ export class TtsStream {
129
177
  if (verbose) console.error(`[tts] skip sentence units=${units} "${clean.slice(0, 40)}"`);
130
178
  return;
131
179
  }
132
- if (verbose) console.error(`[tts] queue push units=${units} "${clean.slice(0, 40)}"`);
133
- this.queue.push(clean);
180
+ if (verbose) console.error(`[tts] queue push agent=${agent} units=${units} "${clean.slice(0, 40)}"`);
181
+ // 入队即固化发音人:之后任何 setVoice/cancel 都不影响本句音色选择
182
+ this.queue.push({ text: clean, agent });
134
183
  void this.drain();
135
184
  }
136
185
 
137
- /** 串行排空队列:合成 + 播放一句再下一句。全部播完且未被打断时触发 onIdle。 */
186
+ /** 串行排空队列:合成 + 播放一句再下一句;下一句在上一句播放期间预合成(单槽流水线)。
187
+ 全部播完且未被打断时触发 onIdle。 */
138
188
  private async drain(): Promise<void> {
139
189
  if (this.busy || this.shuttingDown) return;
140
190
  if (this.queue.length === 0) return;
191
+ const gen = this.generation;
141
192
  this.busy = true;
142
193
  let aborted = false;
143
194
  try {
195
+ // 合成前准备:本地 GPT-SoVITS 确保 api_v2 已启动/可达;云端模式预上传并校验 OSS 参考音频。
196
+ const provider = getTtsProvider();
197
+ if (provider.prepare) {
198
+ try {
199
+ await provider.prepare();
200
+ } catch (err) {
201
+ console.warn(t(
202
+ `GPT-SoVITS 本地服务准备失败:${err instanceof Error ? err.message : String(err)}`,
203
+ `GPT-SoVITS local server prepare failed: ${err instanceof Error ? err.message : String(err)}`,
204
+ ));
205
+ }
206
+ if (this.generation !== gen || this.shuttingDown) return;
207
+ }
144
208
  while (this.queue.length > 0 && !this.shuttingDown) {
145
- const sentence = this.queue.shift()!;
146
- if (verbose) console.error(`[tts] speakOne start "${sentence.slice(0, 40)}"`);
147
- const ok = await this.speakOne(sentence);
148
- if (verbose) console.error(`[tts] speakOne end ok=${ok} remaining=${this.queue.length}`);
209
+ if (this.generation !== gen) return; // 已被 cancel 作废,停止处理
210
+ const item = this.queue.shift()!;
211
+ const next = this.queue[0];
212
+ // 单槽预合成:仅当本句走 play 模式(已合成 wav,只播本地不碰 GPT-SoVITS 服务器)时提前启动。
213
+ // 本句走合成+播放(audioPath 为空/失败)时若提前 prefetch(next),两个 python 进程会并发
214
+ // set_gpt_weights 争抢同一 api_v2 的全局权重 → 首句 /tts 可能落入 next 权重(群聊音色错乱)。
215
+ // 因此仅 play 模式预取;合成模式下 defer 到本句 play_start(音频已到手、权重可切换)后再预取。
216
+ let prefetchStarted = false;
217
+ const startPrefetch = () => {
218
+ if (prefetchStarted) return;
219
+ if (this.generation !== gen) return;
220
+ if (!next || next.audioPath !== undefined || next.prefetchPromise) return;
221
+ prefetchStarted = true;
222
+ next.prefetchPromise = this.prefetch(next).finally(() => {
223
+ next.prefetchPromise = undefined;
224
+ });
225
+ };
226
+ if (typeof item.audioPath === "string") startPrefetch();
227
+ // 短句兜底:上一句太短时预合成可能未完成,等其收尾
228
+ if (item.prefetchPromise) {
229
+ if (verbose) console.error(`[tts] await prefetch "${item.text.slice(0, 40)}"`);
230
+ await item.prefetchPromise;
231
+ if (this.generation !== gen) return; // 等待期间被 cancel 作废
232
+ }
233
+ if (verbose) console.error(`[tts] speakOne start agent=${item.agent} "${item.text.slice(0, 40)}"`);
234
+ const ok = await this.speakOne(item, typeof item.audioPath === "string" ? undefined : startPrefetch);
235
+ if (verbose) console.error(`[tts] speakOne end agent=${item.agent} ok=${ok} remaining=${this.queue.length}`);
149
236
  if (!ok) {
150
237
  aborted = true;
151
238
  break;
152
239
  }
153
240
  }
154
241
  } finally {
155
- this.busy = false;
242
+ // 仅最年轻的 drain 复位 busy;被 cancel 作废的旧 drain 不再动它,避免清掉新一轮 drain 的 busy
243
+ if (this.generation === gen && this.busy) this.busy = false;
156
244
  }
157
- if (!aborted && !this.shuttingDown && this.pendingCount === 0) {
245
+ if (this.generation === gen && !aborted && !this.shuttingDown && this.pendingCount === 0) {
158
246
  this.onIdle?.();
159
247
  }
160
248
  }
161
249
 
250
+ /**
251
+ * 预合成下一句:spawn 一次性 synth_only 进程,把 wav 写到临时文件,供播放时直接读(无 HTTP)。
252
+ * 成功(synth_done)→ item.audioPath = 路径(仅当 generation 未变);失败/超时/被杀 → null(回退合成+播放)。
253
+ * 在上一句播放期间调用,消除句间 HTTP 合成停顿。带 TTS_SENTENCE_TIMEOUT_MS 保险丝。
254
+ * 参考音频相关错误 → 通知 provider 作废缓存(本句回退合成+播放,speakOne 会重传重试)。
255
+ */
256
+ private async prefetch(item: PendingSentence): Promise<void> {
257
+ if (verbose) console.error(`[tts] prefetch start agent=${item.agent} "${item.text.slice(0, 40)}"`);
258
+ const gen = this.generation;
259
+ const provider = getTtsProvider();
260
+ let cmd: TtsCommand;
261
+ try {
262
+ cmd = await provider.buildCommand(item.agent, item.text, "synth_only");
263
+ } catch (err) {
264
+ console.warn(t(`TTS: ${err instanceof Error ? err.message : String(err)}`, `TTS: ${err instanceof Error ? err.message : String(err)}`));
265
+ if (this.generation === gen) item.audioPath = null;
266
+ return;
267
+ }
268
+ if (this.generation !== gen) return;
269
+ return new Promise((resolve) => {
270
+ let settled = false;
271
+ let timer: NodeJS.Timeout | undefined;
272
+ const doResolve = () => {
273
+ if (settled) return;
274
+ if (timer) clearTimeout(timer);
275
+ settled = true;
276
+ resolve();
277
+ };
278
+ const fuseMs = provider.sentenceTimeoutMs();
279
+ let stdoutBuffer = "";
280
+ const proc = spawnCompat(config.pythonPath, ["-u", join(PYTHON_DIR, "tts_say.py")], {
281
+ env: stripProxyEnv({
282
+ ...process.env,
283
+ PYTHONUTF8: "1",
284
+ ...cmd.env,
285
+ }),
286
+ stdio: ["pipe", "pipe", "pipe"],
287
+ });
288
+ this.prefetchProc = proc;
289
+
290
+ // 保险丝:预合成最多等 fuseMs(provider 自定义,aliyun 30s / gpt-sovits timeoutMs),
291
+ // 超时杀进程 + null 回退(busy 必然复位、整体不静音)
292
+ timer = setTimeout(() => {
293
+ if (settled) return;
294
+ if (verbose) console.error(`[tts] prefetch TIMEOUT (${fuseMs}ms) agent=${item.agent} "${item.text.slice(0, 40)}"`);
295
+ if (this.prefetchProc === proc) this.prefetchProc = null;
296
+ if (!proc.killed) {
297
+ try {
298
+ proc.kill("SIGTERM");
299
+ } catch {
300
+ // 忽略
301
+ }
302
+ }
303
+ if (this.generation === gen) item.audioPath = null;
304
+ doResolve();
305
+ }, fuseMs);
306
+
307
+ proc.stdout.on("data", (data) => {
308
+ stdoutBuffer += data.toString();
309
+ const lines = stdoutBuffer.split("\n");
310
+ stdoutBuffer = lines.pop() || "";
311
+ for (const line of lines) {
312
+ const trimmed = line.trim();
313
+ if (!trimmed) continue;
314
+ let evt: { event?: string; message?: string; path?: string; gpt?: string; sovits?: string };
315
+ try {
316
+ evt = JSON.parse(trimmed);
317
+ } catch {
318
+ continue;
319
+ }
320
+ if (evt.event === "synth_done" && evt.path) {
321
+ if (verbose) console.error(`[tts] prefetch synth_done agent=${item.agent} "${item.text.slice(0, 40)}"`);
322
+ if (this.generation === gen) item.audioPath = evt.path;
323
+ doResolve();
324
+ } else if (evt.event === "weights_loaded") {
325
+ if (verbose) console.error(`[tts] prefetch weights_loaded agent=${item.agent} gpt=${evt.gpt ?? ""} sovits=${evt.sovits ?? ""}`);
326
+ provider.ackWeights?.({ gpt: evt.gpt, sovits: evt.sovits });
327
+ } else if (evt.event === "error") {
328
+ console.warn(t(`TTS: ${evt.message ?? "unknown error"}`, `TTS: ${evt.message ?? "unknown error"}`));
329
+ if (verbose) console.error(`[tts] prefetch error: ${evt.message}`);
330
+ if (isRefAudioError(evt.message)) provider.markRefInvalid?.(item.agent);
331
+ if (this.generation === gen) item.audioPath = null;
332
+ doResolve();
333
+ }
334
+ }
335
+ });
336
+
337
+ proc.stderr.on("data", (data) => {
338
+ if (!verbose) return;
339
+ const msg = data.toString().trim();
340
+ if (msg) console.error(`[python:tts_say]`, msg);
341
+ });
342
+
343
+ proc.on("close", () => {
344
+ if (this.prefetchProc === proc) this.prefetchProc = null;
345
+ if (!settled) {
346
+ // 进程退出但没等到 synth_done(被杀 / 异常):按失败回退
347
+ if (this.generation === gen) item.audioPath = null;
348
+ doResolve();
349
+ }
350
+ });
351
+ proc.on("error", () => {
352
+ if (this.prefetchProc === proc) this.prefetchProc = null;
353
+ if (!settled) {
354
+ if (this.generation === gen) item.audioPath = null;
355
+ doResolve();
356
+ }
357
+ });
358
+
359
+ try {
360
+ if (verbose) console.error(`[tts] prefetch payload ${JSON.stringify(redactPayload(cmd.payload))}`);
361
+ proc.stdin.write(JSON.stringify(cmd.payload) + "\n");
362
+ proc.stdin.end();
363
+ } catch {
364
+ // 忽略
365
+ }
366
+ });
367
+ }
368
+
162
369
  /**
163
370
  * 合成并播放一句话:spawn 一次性 python 进程,等待其播完(play_end)或退出。
164
- * @returns true = 正常结束(含合成失败,继续下一句);false = 被 cancel 打断
371
+ * 已预合成(item.audioPath 为 string)走 play 模式(无 HTTP,直接播临时 wav);
372
+ * 未预合成(含预合成失败回退)走默认合成+播放模式。
373
+ * 音色按句固定:环境变量与 stdin 都用该句所属角色的 voice_id,不受后续 setVoice 影响。
374
+ * 带 TTS_SENTENCE_TIMEOUT_MS 保险丝:到点仍未 play_end/close → 杀进程 + 按正常结束继续下一句,
375
+ * 保证 drain 必然推进、busy 必然复位,杜绝"卡死一句 → 整条 TTS 永久静音"。
376
+ * 参考音频相关错误(云端 OSS URL 失效等)→ markRefInvalid 作废缓存并【重新上传再试一次】。
377
+ * @param onPlayStart 合成+播放模式下,本句音频已到手(play_start)时回调:此刻 GPT-SoVITS
378
+ * 服务器权重可安全切换,由调用方在此启动下一句预合成(避免权重并发竞态)。
379
+ * @returns true = 正常结束(含合成失败/超时,继续下一句);false = 被 cancel 打断
380
+ */
381
+ private async speakOne(item: PendingSentence, onPlayStart?: () => void): Promise<boolean> {
382
+ const { text, agent } = item;
383
+ const provider = getTtsProvider();
384
+ const gen = this.generation;
385
+ this._killRequested = false;
386
+ let retried = false;
387
+ for (;;) {
388
+ if (this.generation !== gen) return false;
389
+ let cmd: TtsCommand;
390
+ try {
391
+ cmd = await provider.buildCommand(agent, text, "synth_play");
392
+ } catch (err) {
393
+ console.warn(t(`TTS: ${err instanceof Error ? err.message : String(err)}`, `TTS: ${err instanceof Error ? err.message : String(err)}`));
394
+ return true; // 音色解析失败:跳过本句继续
395
+ }
396
+ if (this.generation !== gen) return false;
397
+ const payload =
398
+ typeof item.audioPath === "string"
399
+ ? { mode: "play", path: item.audioPath }
400
+ : cmd.payload;
401
+ const result = await this.runSentence(cmd.env, payload, agent, text, provider.sentenceTimeoutMs(), onPlayStart);
402
+ if (result === "retry" && !retried) {
403
+ retried = true;
404
+ if (verbose) console.error(`[tts] 参考音频无效,重新上传后重试 agent=${agent} "${text.slice(0, 40)}"`);
405
+ provider.markRefInvalid?.(agent);
406
+ item.audioPath = null; // 丢弃可能已预合成的旧 wav,走合成+播放
407
+ continue;
408
+ }
409
+ return result === "cancel" ? false : true;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * 合成+播放一句(spawn 一次性 python 进程):play 模式只播已合成 wav;否则合成+播放。
415
+ * 带 fuseMs 时长保险丝:超时杀进程并按"正常结束"返回,保证整条 TTS 不静音。
416
+ * @param onPlayStart 合成模式收到 play_start 时调用一次(在此启动下一句预合成)
417
+ * @returns "ok"=正常结束(继续下一句);"cancel"=被 cancel 打断;"retry"=参考音频相关错误(可重试)
165
418
  */
166
- private speakOne(text: string): Promise<boolean> {
419
+ private runSentence(
420
+ env: Record<string, string>,
421
+ payload: Record<string, unknown>,
422
+ agent: AgentId,
423
+ text: string,
424
+ fuseMs: number,
425
+ onPlayStart?: () => void,
426
+ ): Promise<"ok" | "cancel" | "retry"> {
167
427
  return new Promise((resolve) => {
168
428
  this._killRequested = false;
169
429
  let settled = false;
170
- const doResolve = (val: boolean) => {
430
+ let timer: NodeJS.Timeout | undefined; // 时长保险丝句柄(TDZ 安全:先声明,后赋值)
431
+ const doResolve = (val: "ok" | "cancel" | "retry") => {
171
432
  if (settled) return;
433
+ if (timer) clearTimeout(timer);
434
+ // 无论正常 play_end、异常退出、超时还是取消,本句结束后都不允许残留 pendingCount,
435
+ // 否则 onIdle/isPending 会被卡死(play_start 后进程异常退出/超时是主要泄漏路径)。
436
+ this.pendingCount = 0;
172
437
  settled = true;
173
438
  resolve(val);
174
439
  };
440
+ const provider = getTtsProvider();
175
441
  let stdoutBuffer = "";
176
442
  const proc = spawnCompat(config.pythonPath, ["-u", join(PYTHON_DIR, "tts_say.py")], {
177
443
  env: stripProxyEnv({
178
444
  ...process.env,
179
445
  PYTHONUTF8: "1",
180
- QWEN_WORKSPACE_ID: config.workspaceId,
181
- QWEN_TTS_API_KEY: config.ttsApiKey,
182
- QWEN_TTS_MODEL: config.ttsModel,
183
- QWEN_TTS_VOICE: getVoiceId(this.currentAgent),
446
+ ...env,
184
447
  }),
185
448
  stdio: ["pipe", "pipe", "pipe"],
186
449
  });
187
450
  this.proc = proc;
188
451
 
452
+ // 时长保险丝:一句合成+播放最多等 fuseMs(provider 自定义),超时主动杀进程并推进一步
453
+ timer = setTimeout(() => {
454
+ if (settled) return;
455
+ if (verbose) console.error(`[tts] speakOne TIMEOUT (${fuseMs}ms) agent=${agent} "${text.slice(0, 40)}"`);
456
+ if (this.proc === proc) this.killProc();
457
+ doResolve("ok");
458
+ }, fuseMs);
459
+
189
460
  proc.stdout.on("data", (data) => {
190
461
  stdoutBuffer += data.toString();
191
462
  const lines = stdoutBuffer.split("\n");
@@ -193,7 +464,7 @@ export class TtsStream {
193
464
  for (const line of lines) {
194
465
  const trimmed = line.trim();
195
466
  if (!trimmed) continue;
196
- let evt: { event?: string; message?: string };
467
+ let evt: { event?: string; message?: string; gpt?: string; sovits?: string };
197
468
  try {
198
469
  evt = JSON.parse(trimmed);
199
470
  } catch {
@@ -202,17 +473,23 @@ export class TtsStream {
202
473
  switch (evt.event) {
203
474
  case "play_start":
204
475
  this.pendingCount++;
476
+ onPlayStart?.(); // 音频已到手、服务器权重可切换 → 启动下一句预合成
205
477
  if (verbose) console.error(`[tts] play_start pending=${this.pendingCount}`);
206
478
  break;
207
479
  case "play_end":
208
480
  if (this.pendingCount > 0) this.pendingCount--;
209
481
  if (verbose) console.error(`[tts] play_end pending=${this.pendingCount}`);
210
- doResolve(true); // 播放完成
482
+ doResolve("ok"); // 播放完成
483
+ break;
484
+ case "weights_loaded":
485
+ if (verbose) console.error(`[tts] speakOne weights_loaded agent=${agent} gpt=${evt.gpt ?? ""} sovits=${evt.sovits ?? ""}`);
486
+ provider.ackWeights?.({ gpt: evt.gpt, sovits: evt.sovits });
211
487
  break;
212
488
  case "error":
213
489
  console.warn(t(`TTS: ${evt.message ?? "unknown error"}`, `TTS: ${evt.message ?? "unknown error"}`));
214
490
  if (verbose) console.error(`[tts] error: ${evt.message}`);
215
- // 合成失败不中断队列,靠 close 再 resolve(true) 继续下一句
491
+ // 参考音频相关错误(OSS URL 失效等)→ 返回 retry 让上层重传重试;其它错误靠 close 继续下一句
492
+ if (isRefAudioError(evt.message)) doResolve("retry");
216
493
  break;
217
494
  default:
218
495
  break;
@@ -229,15 +506,16 @@ export class TtsStream {
229
506
  proc.on("close", () => {
230
507
  if (this.proc === proc) this.proc = null;
231
508
  // 若已通过 play_end 结算,则 close 不再改写结果;否则按是否被 kill 决定
232
- doResolve(!this._killRequested);
509
+ doResolve(this._killRequested ? "cancel" : "ok");
233
510
  });
234
511
  proc.on("error", () => {
235
512
  if (this.proc === proc) this.proc = null;
236
- doResolve(!this._killRequested);
513
+ doResolve(this._killRequested ? "cancel" : "ok");
237
514
  });
238
515
 
239
516
  try {
240
- proc.stdin.write(JSON.stringify({ text, voice: getVoiceId(this.currentAgent) }) + "\n");
517
+ if (verbose) console.error(`[tts] speakOne payload ${JSON.stringify(redactPayload(payload))}`);
518
+ proc.stdin.write(JSON.stringify(payload) + "\n");
241
519
  proc.stdin.end();
242
520
  } catch {
243
521
  // 忽略
@@ -245,6 +523,18 @@ export class TtsStream {
245
523
  });
246
524
  }
247
525
 
526
+ private killPrefetchProc(): void {
527
+ const proc = this.prefetchProc;
528
+ this.prefetchProc = null;
529
+ if (proc && !proc.killed) {
530
+ try {
531
+ proc.kill("SIGTERM");
532
+ } catch {
533
+ // 忽略
534
+ }
535
+ }
536
+ }
537
+
248
538
  private killProc(): void {
249
539
  this._killRequested = true;
250
540
  const proc = this.proc;
package/src/tui_select.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // 复用 commands.ts 的可见宽度计算与行截断思路,避免 CJK 折行导致重绘叠字。
4
4
 
5
5
  import chalk from "chalk";
6
+ import { t } from "./locale.ts";
6
7
 
7
8
  export interface SelectOption {
8
9
  id: string;
@@ -54,6 +55,7 @@ function truncateStyled(text: string, maxW: number, style: (s: string) => string
54
55
  * 多选 TUI。返回选中的 id 集合;用户取消(Esc/q/Ctrl+C)返回 null。
55
56
  *
56
57
  * 交互:↑/↓ 循环移动光标,空格切换 [*]/[ ],回车确认。
58
+ * single=true 时单选:空格把当前项设为唯一选中(再按空格取消不选)。
57
59
  * 调用方需保证进入前 stdin 未被 readline 占用(setup 在调用前 rl.close())。
58
60
  */
59
61
  export async function multiSelect(
@@ -61,6 +63,7 @@ export async function multiSelect(
61
63
  options: SelectOption[],
62
64
  initiallySelected: Set<string>,
63
65
  hint?: string,
66
+ single = false,
64
67
  ): Promise<Set<string> | null> {
65
68
  if (options.length === 0) return new Set<string>();
66
69
 
@@ -74,6 +77,8 @@ export async function multiSelect(
74
77
  const selected = new Set<string>(initiallySelected);
75
78
  let cursor = 0;
76
79
  let drawnScreenLines = 0;
80
+ /** 临时提示(单选未选择就按回车时显示,下次按键清除) */
81
+ let notice: string | null = null;
77
82
  const cols = process.stdout.columns ?? 80;
78
83
 
79
84
  const render = () => {
@@ -96,6 +101,7 @@ export async function multiSelect(
96
101
  out.push(styled);
97
102
  });
98
103
  if (hint) out.push(truncateStyled(hint, maxW, (x) => chalk.cyan(x)));
104
+ if (notice) out.push(truncateStyled(notice, maxW, (x) => chalk.yellow(x)));
99
105
  process.stdout.write(out.join("\r\n") + "\r\n");
100
106
  drawnScreenLines = out.length;
101
107
  };
@@ -105,18 +111,41 @@ export async function multiSelect(
105
111
  let resolveFn: (v: Set<string> | null) => void = () => {};
106
112
  const onData = (key: string) => {
107
113
  if (key === "\x1b[A") {
114
+ notice = null;
108
115
  cursor = (cursor - 1 + options.length) % options.length;
109
116
  render();
110
117
  } else if (key === "\x1b[B") {
118
+ notice = null;
111
119
  cursor = (cursor + 1) % options.length;
112
120
  render();
113
121
  } else if (key === " ") {
122
+ notice = null;
114
123
  const o = options[cursor];
115
124
  if (o.locked) return; // 锁定项:空格无效,保持 [*]
116
- if (selected.has(o.id)) selected.delete(o.id);
117
- else selected.add(o.id);
125
+ if (single) {
126
+ // 单选:空格把当前项设为唯一选中;已选中时按空格取消(可 Enter 由调用方回退默认)
127
+ if (selected.has(o.id)) selected.delete(o.id);
128
+ else {
129
+ selected.clear();
130
+ selected.add(o.id);
131
+ }
132
+ } else if (selected.has(o.id)) {
133
+ selected.delete(o.id);
134
+ } else {
135
+ selected.add(o.id);
136
+ }
118
137
  render();
119
138
  } else if (key === "\r" || key === "\n") {
139
+ // 单选未选中任何项时回车不生效:不静默回落默认项,
140
+ // 提示后保持菜单,等待用户明确选择
141
+ if (single && selected.size === 0) {
142
+ notice = t(
143
+ "请先按空格选择一项,再回车确认",
144
+ "Press Space to select an item first, then Enter",
145
+ );
146
+ render();
147
+ return;
148
+ }
120
149
  cleanup();
121
150
  resolveFn(selected);
122
151
  } else if (key === "\x1b" || key === "\x1b\x1b" || key === "q" || key === "\x03") {