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.
- package/README.md +16 -9
- package/assets/blue-archive/arona/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/arona/voice_text.txt +1 -0
- package/assets/blue-archive/hoshino/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/hoshino/voice_text.txt +1 -0
- package/assets/blue-archive/plana/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/plana/voice_text.txt +1 -0
- package/assets/blue-archive/shiroko/voice_sovits.mp3 +0 -0
- package/assets/blue-archive/shiroko/voice_text.txt +1 -0
- package/bin/arona.mjs +3 -1
- package/bin/postinstall-fix.mjs +46 -0
- package/package.json +3 -2
- package/pet/renderer/gallery.js +2 -2
- package/pet/renderer/renderer.js +1 -1
- package/pet/renderer/style.css +16 -2
- package/pet/tools/gen_sway.cjs +1 -1
- package/pet/tools/visual_test.cjs +2 -2
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/oss_upload.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
- package/python/hotkey.py +7 -1
- package/python/oss_upload.py +70 -0
- package/python/tts_say.py +182 -19
- package/src/agent.ts +155 -147
- package/src/commands.ts +17 -23
- package/src/config.ts +45 -2
- package/src/gesture_context.ts +48 -0
- package/src/gpt_sovits_local.ts +706 -0
- package/src/index.ts +13 -10
- package/src/memory.ts +3 -10
- package/src/oss_upload.ts +116 -0
- package/src/renderer.ts +17 -6
- package/src/repl.ts +70 -59
- package/src/setup.ts +349 -15
- package/src/skills.ts +32 -2
- package/src/slash_menu.ts +1 -1
- package/src/slash_registry.ts +6 -5
- package/src/tools/read_docs_tool.ts +84 -0
- package/src/tools/tavily_tools.ts +24 -10
- package/src/tools/voice_tools.ts +1 -1
- package/src/tts_provider.ts +501 -0
- package/src/tts_stream.ts +321 -31
- package/src/tui_select.ts +31 -2
- package/src/undo.ts +82 -18
- package/src/utils/python.ts +22 -0
- package/src/voice.ts +17 -7
- package/src/voice_cli.ts +117 -9
- 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 {
|
|
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
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
* -
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/** 串行排空队列:合成 +
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
509
|
+
doResolve(this._killRequested ? "cancel" : "ok");
|
|
233
510
|
});
|
|
234
511
|
proc.on("error", () => {
|
|
235
512
|
if (this.proc === proc) this.proc = null;
|
|
236
|
-
doResolve(
|
|
513
|
+
doResolve(this._killRequested ? "cancel" : "ok");
|
|
237
514
|
});
|
|
238
515
|
|
|
239
516
|
try {
|
|
240
|
-
|
|
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 (
|
|
117
|
-
|
|
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") {
|