@kmchai/cortico-world-voice 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +79 -70
- package/describe.json +2 -27
- package/package.json +25 -4
- package/src/ENV_PROMPT.md +6 -7
- package/src/audio/queue.ts +1 -1
- package/src/config.ts +46 -108
- package/src/definition.ts +3 -4
- package/src/options.ts +29 -8
- package/src/service.ts +290 -0
- package/src/speech/style.ts +2 -14
- package/src/speech/tags.ts +4 -7
- package/src/speech/text.ts +1 -1
- package/src/tools.ts +1 -16
- package/src/types.ts +17 -51
- package/src/world.ts +144 -242
- package/src/backends/audiocpp.ts +0 -98
- package/src/backends/custom.ts +0 -132
- package/src/backends/http.ts +0 -91
- package/src/backends/index.ts +0 -21
- package/src/backends/openai.ts +0 -51
- package/src/http.ts +0 -205
- package/src/sing.ts +0 -73
package/src/world.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* 语音 World:把回复念出来。
|
|
3
3
|
*
|
|
4
4
|
* 触发层从主 session 输出流与事件库认出要念的话;文本层过滤与切句;
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* 合成层经 HTTP 调 Kmch AI · 服务;出口层本机播放、发事件或只返回附件。
|
|
6
|
+
* 这一层不解析任何 TTS 方言,也不监听端口。
|
|
7
7
|
*/
|
|
8
8
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
@@ -14,24 +14,26 @@ import type {
|
|
|
14
14
|
Logger, ToolDef, ToolOutcome, World, WorldHost, WorldLamp, WorldWebUiDecl,
|
|
15
15
|
} from 'cortico/core/types.ts';
|
|
16
16
|
import { nullLogger, nowIso } from 'cortico/core/util.ts';
|
|
17
|
-
import {
|
|
18
|
-
import { VOICE_DEFAULTS, VOICE_ID, VOICE_OPTIONS_KIND, voiceConfigGroups, voiceText } from './config.ts';
|
|
17
|
+
import { VOICE_DEFAULTS, VOICE_ID, VOICE_MODELS_KIND, VOICE_OPTIONS_KIND, voiceConfigGroups, voiceText } from './config.ts';
|
|
19
18
|
import type { AudioPlayer } from './audio/player.ts';
|
|
20
19
|
import { createPlayer } from './audio/player.ts';
|
|
21
20
|
import { SpeechQueue, type SpeechJob } from './audio/queue.ts';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { singHealth, singRemote, singRequestOf } from './sing.ts';
|
|
21
|
+
import { registerOptions, unregisterOptions } from './options.ts';
|
|
22
|
+
import { ServiceClient, type SpeechService } from './service.ts';
|
|
25
23
|
import { splitSpeech, truncateSpeech } from './speech/chunk.ts';
|
|
24
|
+
import { buildInstruct, resolveStyle } from './speech/style.ts';
|
|
26
25
|
import { filterSpeech } from './speech/text.ts';
|
|
27
26
|
import { EventPoller, eventTypes, hasKeyword, latestUserText, splitList, VoiceTap } from './triggers.ts';
|
|
28
27
|
import { VOICE_TOOL_DECLS } from './tools.ts';
|
|
29
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
ModelOption, ServiceHealth, TtsAudio, TtsRequest, VoiceConfigSection, VoiceOption,
|
|
30
|
+
} from './types.ts';
|
|
30
31
|
|
|
31
32
|
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
32
33
|
const DEDUPE_WINDOW_MS = 5000;
|
|
34
|
+
const HEALTH_INTERVAL_MS = 15_000;
|
|
33
35
|
const HEALTH_TTL_MS = 15_000;
|
|
34
|
-
const
|
|
36
|
+
const CATALOG_TTL_MS = 10 * 60_000;
|
|
35
37
|
|
|
36
38
|
export interface VoiceWorldDeps {
|
|
37
39
|
fetch: typeof fetch;
|
|
@@ -39,8 +41,8 @@ export interface VoiceWorldDeps {
|
|
|
39
41
|
rng(): number;
|
|
40
42
|
now(): number;
|
|
41
43
|
sleep(ms: number): Promise<void>;
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
+
/** 测试用:替换服务客户端。 */
|
|
45
|
+
service?: SpeechService;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export interface VoiceWorldOptions {
|
|
@@ -61,8 +63,12 @@ interface EnqueueOptions {
|
|
|
61
63
|
voice?: string;
|
|
62
64
|
speed?: number;
|
|
63
65
|
silent?: boolean;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ServiceState {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
detail?: string;
|
|
71
|
+
at: number;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
function defaultDeps(): VoiceWorldDeps {
|
|
@@ -84,6 +90,17 @@ function asNumber(value: unknown): number | undefined {
|
|
|
84
90
|
return Number.isFinite(n) ? n : undefined;
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
/** 徽标只显示主机与端口;地址不合法时退回原串。 */
|
|
94
|
+
function serviceHost(raw: string): string {
|
|
95
|
+
const value = raw.trim();
|
|
96
|
+
if (!value) return '—';
|
|
97
|
+
try {
|
|
98
|
+
return new URL(value).host || '—';
|
|
99
|
+
} catch {
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
/** 等一个 promise;超时返回 'timeout',不取消原任务。 */
|
|
88
105
|
async function within<T>(promise: Promise<T>, ms: number): Promise<T | 'timeout'> {
|
|
89
106
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -104,27 +121,36 @@ export class VoiceWorld implements World {
|
|
|
104
121
|
private readonly player: AudioPlayer | null;
|
|
105
122
|
private readonly queue: SpeechQueue;
|
|
106
123
|
private readonly tap: VoiceTap;
|
|
124
|
+
private readonly service: SpeechService;
|
|
107
125
|
private host: WorldHost | null = null;
|
|
108
|
-
private http: VoiceHttpHandle | null = null;
|
|
109
|
-
private httpError = '';
|
|
110
126
|
private poller: EventPoller | null = null;
|
|
111
127
|
private healthTimer: NodeJS.Timeout | null = null;
|
|
112
128
|
private started = false;
|
|
113
129
|
private tempDir = '';
|
|
114
130
|
private tempSeq = 0;
|
|
115
|
-
private
|
|
131
|
+
private serviceState: ServiceState | null = null;
|
|
116
132
|
private probing = false;
|
|
117
|
-
private singReady = false;
|
|
118
133
|
private voices: VoiceOption[] = [];
|
|
119
134
|
private voicesAt = 0;
|
|
120
135
|
private voicesSignature = '';
|
|
121
136
|
private voicesLoading = false;
|
|
137
|
+
private models: ModelOption[] = [];
|
|
138
|
+
private modelsAt = 0;
|
|
139
|
+
private modelsSignature = '';
|
|
140
|
+
private modelsLoading = false;
|
|
122
141
|
private readonly lastSpoken = new Map<string, number>();
|
|
123
142
|
private lastFailureAt = 0;
|
|
143
|
+
private readonly modelOptions = (): readonly ModelOption[] => this.models;
|
|
144
|
+
private readonly voiceOptions = (): readonly VoiceOption[] => this.voices;
|
|
124
145
|
|
|
125
146
|
constructor(private readonly opts: VoiceWorldOptions) {
|
|
126
147
|
this.log = opts.log ?? nullLogger();
|
|
127
148
|
this.deps = { ...defaultDeps(), ...opts.deps };
|
|
149
|
+
this.service = this.deps.service ?? new ServiceClient({
|
|
150
|
+
fetch: this.deps.fetch,
|
|
151
|
+
secret: (name) => this.opts.secret(name),
|
|
152
|
+
cfg: () => this.cfg,
|
|
153
|
+
});
|
|
128
154
|
this.player = this.deps.createPlayer(this.log);
|
|
129
155
|
this.queue = new SpeechQueue({
|
|
130
156
|
player: this.player,
|
|
@@ -141,7 +167,6 @@ export class VoiceWorld implements World {
|
|
|
141
167
|
onText: (text, source) => this.consider(text, source),
|
|
142
168
|
onAbort: () => this.queue.hush(),
|
|
143
169
|
});
|
|
144
|
-
registerVoiceOptions(() => this.voiceOptions());
|
|
145
170
|
}
|
|
146
171
|
|
|
147
172
|
private get cfg(): VoiceConfigSection {
|
|
@@ -152,11 +177,9 @@ export class VoiceWorld implements World {
|
|
|
152
177
|
const cfg = this.cfg;
|
|
153
178
|
return {
|
|
154
179
|
'voice.auto': cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off',
|
|
155
|
-
'voice.backend': cfg.backend,
|
|
156
180
|
'voice.voice': cfg.voice || 'default',
|
|
157
181
|
'voice.emotion': cfg.defaultEmotion || 'neutral',
|
|
158
182
|
'voice.instruct': cfg.defaultInstruct || 'none',
|
|
159
|
-
'voice.sing': cfg.singUrl ? 'available' : 'not configured',
|
|
160
183
|
};
|
|
161
184
|
}
|
|
162
185
|
|
|
@@ -168,7 +191,6 @@ export class VoiceWorld implements World {
|
|
|
168
191
|
});
|
|
169
192
|
return [
|
|
170
193
|
tool('voice_speak', (args) => this.handleSpeak(args)),
|
|
171
|
-
tool('voice_sing', (args) => this.handleSing(args)),
|
|
172
194
|
tool('voice_hush', async () => {
|
|
173
195
|
this.queue.hush();
|
|
174
196
|
return 'Voice stopped; queued lines were dropped.';
|
|
@@ -182,30 +204,28 @@ export class VoiceWorld implements World {
|
|
|
182
204
|
this.maybeRefresh();
|
|
183
205
|
const cfg = this.cfg;
|
|
184
206
|
const stats = this.queue.stats();
|
|
185
|
-
const
|
|
186
|
-
? { label: t.
|
|
187
|
-
: this.
|
|
188
|
-
? { label: t.
|
|
189
|
-
: { label: t.
|
|
190
|
-
const apiLamp: WorldLamp = !cfg.httpApi
|
|
191
|
-
? { label: t.lampApi, state: 'offline' }
|
|
192
|
-
: this.http
|
|
193
|
-
? { label: t.lampApi, state: 'online', hint: `127.0.0.1:${this.http.port}` }
|
|
194
|
-
: { label: t.lampApi, state: 'error', ...(this.httpError ? { hint: this.httpError } : {}) };
|
|
207
|
+
const serviceLamp: WorldLamp = this.serviceState === null
|
|
208
|
+
? { label: t.lampService, state: this.started ? 'loading' : 'offline' }
|
|
209
|
+
: this.serviceState.ok
|
|
210
|
+
? { label: t.lampService, state: 'online', ...(this.serviceState.detail ? { hint: this.serviceState.detail } : {}) }
|
|
211
|
+
: { label: t.lampService, state: 'error', ...(this.serviceState.detail ? { hint: this.serviceState.detail } : {}) };
|
|
195
212
|
return {
|
|
196
213
|
label: t.label,
|
|
197
214
|
lamps: [
|
|
198
|
-
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
215
|
+
serviceLamp,
|
|
216
|
+
{
|
|
217
|
+
label: t.lampQueue,
|
|
218
|
+
state: !this.started ? 'offline' : stats.speaking ? 'loading' : 'online',
|
|
219
|
+
hint: `${stats.queued}`,
|
|
220
|
+
},
|
|
202
221
|
],
|
|
203
222
|
badges: [
|
|
223
|
+
{ label: 'service', value: serviceHost(cfg.serviceUrl), tone: 'plain' },
|
|
224
|
+
{ label: 'model', value: cfg.model || '—', tone: 'plain' },
|
|
204
225
|
{ label: 'voice', value: cfg.voice || '—', tone: 'plain' },
|
|
205
|
-
{ label: 'emotion', value: cfg.defaultEmotion || '—', tone: 'plain' },
|
|
206
|
-
{ label: 'queue', value: stats.queued, tone: stats.queued ? 'on' : 'plain' },
|
|
207
226
|
],
|
|
208
227
|
config: voiceConfigGroups(language),
|
|
228
|
+
links: [{ label: t.servicePage, href: '#/provider/world:service' }],
|
|
209
229
|
promptDocs: [{
|
|
210
230
|
key: 'worlds.voice.envPrompt',
|
|
211
231
|
title: t.envTitle,
|
|
@@ -214,11 +234,9 @@ export class VoiceWorld implements World {
|
|
|
214
234
|
role: 'envPrompt',
|
|
215
235
|
vars: [
|
|
216
236
|
{ name: 'voice.auto', description: 'Automatic reading probability, or off.' },
|
|
217
|
-
{ name: 'voice.backend', description: 'Active protocol id.' },
|
|
218
237
|
{ name: 'voice.voice', description: 'Current voice id.' },
|
|
219
238
|
{ name: 'voice.emotion', description: 'Standing emotion.' },
|
|
220
239
|
{ name: 'voice.instruct', description: 'Standing direction.' },
|
|
221
|
-
{ name: 'voice.sing', description: 'Whether a singing service is configured.' },
|
|
222
240
|
],
|
|
223
241
|
}],
|
|
224
242
|
};
|
|
@@ -234,8 +252,9 @@ export class VoiceWorld implements World {
|
|
|
234
252
|
async start(host: WorldHost): Promise<void> {
|
|
235
253
|
this.host = host;
|
|
236
254
|
this.started = true;
|
|
255
|
+
this.registerCatalogOptions();
|
|
237
256
|
this.log.info('语音 World 启动', {
|
|
238
|
-
|
|
257
|
+
service: serviceHost(this.cfg.serviceUrl),
|
|
239
258
|
playback: this.cfg.playback,
|
|
240
259
|
auto: this.cfg.autoSpeak ? this.cfg.autoProbability : 'off',
|
|
241
260
|
});
|
|
@@ -244,22 +263,19 @@ export class VoiceWorld implements World {
|
|
|
244
263
|
this.log.warn('播放器预热失败', { err: error instanceof Error ? error.message : String(error) });
|
|
245
264
|
});
|
|
246
265
|
}
|
|
247
|
-
if (this.cfg.httpApi) await this.startHttp();
|
|
248
266
|
this.syncPoller();
|
|
249
267
|
void this.probe();
|
|
250
|
-
this.healthTimer = setInterval(() => { void this.probe(); },
|
|
268
|
+
this.healthTimer = setInterval(() => { void this.probe(); }, HEALTH_INTERVAL_MS);
|
|
251
269
|
this.healthTimer.unref?.();
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
async stop(): Promise<void> {
|
|
255
273
|
this.started = false;
|
|
274
|
+
this.unregisterCatalogOptions();
|
|
256
275
|
if (this.healthTimer) clearInterval(this.healthTimer);
|
|
257
276
|
this.healthTimer = null;
|
|
258
277
|
this.poller?.stop();
|
|
259
278
|
this.poller = null;
|
|
260
|
-
const http = this.http;
|
|
261
|
-
this.http = null;
|
|
262
|
-
if (http) await http.close().catch(() => {});
|
|
263
279
|
await this.queue.close();
|
|
264
280
|
if (this.tempDir) {
|
|
265
281
|
try { rmSync(this.tempDir, { recursive: true, force: true }); } catch { /* 临时目录 */ }
|
|
@@ -286,13 +302,6 @@ export class VoiceWorld implements World {
|
|
|
286
302
|
const cfg = this.cfg;
|
|
287
303
|
if (!cfg.autoSpeak) return;
|
|
288
304
|
|
|
289
|
-
// 唱歌标记是显式的,不掷概率。没配唱歌服务时按普通文本念。
|
|
290
|
-
const sing = singRequestOf(raw);
|
|
291
|
-
if (sing.marked && cfg.singUrl.trim()) {
|
|
292
|
-
if (sing.lyrics && this.fresh(sing.lyrics)) this.enqueueSing(sing.lyrics, source);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
305
|
const filtered = filterSpeech(raw, cfg);
|
|
297
306
|
if (!filtered.text) return;
|
|
298
307
|
|
|
@@ -310,31 +319,11 @@ export class VoiceWorld implements World {
|
|
|
310
319
|
this.log.debug('自动朗读', { source, explicit, chars: [...filtered.text].length });
|
|
311
320
|
}
|
|
312
321
|
|
|
313
|
-
private enqueueSing(lyrics: string, source: string): void {
|
|
314
|
-
const cfg = this.cfg;
|
|
315
|
-
if (!cfg.singUrl.trim()) return;
|
|
316
|
-
this.queue.enqueue({
|
|
317
|
-
text: lyrics,
|
|
318
|
-
chunks: [lyrics],
|
|
319
|
-
silent: cfg.playback === 'none',
|
|
320
|
-
source,
|
|
321
|
-
produce: (chunk, signal) => singRemote({
|
|
322
|
-
url: cfg.singUrl,
|
|
323
|
-
token: cfg.singToken,
|
|
324
|
-
lyrics: chunk,
|
|
325
|
-
timeoutMs: Math.max(cfg.timeoutMs, 180_000),
|
|
326
|
-
fetch: this.deps.fetch,
|
|
327
|
-
signal,
|
|
328
|
-
}),
|
|
329
|
-
});
|
|
330
|
-
this.log.info('自动唱歌', { source, chars: [...lyrics].length });
|
|
331
|
-
}
|
|
332
|
-
|
|
333
322
|
private enqueue(text: string, opts: EnqueueOptions): ReturnType<SpeechQueue['enqueue']> | null {
|
|
334
323
|
const cfg = this.cfg;
|
|
335
324
|
const clipped = truncateSpeech(text, cfg.maxChars);
|
|
336
325
|
if ([...clipped].length < Math.max(1, cfg.minChars)) return null;
|
|
337
|
-
const chunks =
|
|
326
|
+
const chunks = splitSpeech(clipped, cfg.chunkMax);
|
|
338
327
|
if (chunks.length === 0) return null;
|
|
339
328
|
const job: SpeechJob = {
|
|
340
329
|
text: clipped,
|
|
@@ -403,73 +392,6 @@ export class VoiceWorld implements World {
|
|
|
403
392
|
return this.spokenOutcome(undefined, 0, false);
|
|
404
393
|
}
|
|
405
394
|
|
|
406
|
-
private async handleSing(args: Record<string, unknown>): Promise<ToolOutcome> {
|
|
407
|
-
const { lyrics } = singRequestOf(asString(args.lyrics));
|
|
408
|
-
if (!lyrics) return { text: 'voice_sing failed: there are no lyrics to sing.', failed: true };
|
|
409
|
-
if (!this.cfg.singUrl.trim()) {
|
|
410
|
-
return { text: 'voice_sing failed: no singing service is configured (worlds.voice.singUrl).', failed: true };
|
|
411
|
-
}
|
|
412
|
-
const silent = this.cfg.playback === 'none';
|
|
413
|
-
const share = this.cfg.shareAudio;
|
|
414
|
-
const timeoutMs = Math.max(this.cfg.timeoutMs, 180_000);
|
|
415
|
-
const produce = (text: string, signal: AbortSignal) => singRemote({
|
|
416
|
-
url: this.cfg.singUrl,
|
|
417
|
-
token: this.cfg.singToken,
|
|
418
|
-
lyrics: text,
|
|
419
|
-
timeoutMs,
|
|
420
|
-
fetch: this.deps.fetch,
|
|
421
|
-
signal,
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// 要交附件:合成完整一首就回执,播放交给队列,不等播完。
|
|
425
|
-
if (share) {
|
|
426
|
-
const controller = new AbortController();
|
|
427
|
-
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
|
|
428
|
-
try {
|
|
429
|
-
const audio = await produce(lyrics, controller.signal);
|
|
430
|
-
if (!silent) {
|
|
431
|
-
this.queue.enqueue({
|
|
432
|
-
text: lyrics,
|
|
433
|
-
chunks: [lyrics],
|
|
434
|
-
silent: false,
|
|
435
|
-
source: 'tool:voice_sing',
|
|
436
|
-
produce: async () => audio,
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
const outcome = this.spokenOutcome(audio, audio.seconds ?? 0, silent);
|
|
440
|
-
return { ...outcome, text: outcome.text.replace('one line', 'one sung line') };
|
|
441
|
-
} catch (error) {
|
|
442
|
-
return { text: `voice_sing failed: ${error instanceof Error ? error.message : String(error)}`, failed: true };
|
|
443
|
-
} finally {
|
|
444
|
-
clearTimeout(timer);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const job: SpeechJob = {
|
|
449
|
-
text: lyrics,
|
|
450
|
-
chunks: [lyrics],
|
|
451
|
-
silent,
|
|
452
|
-
source: 'tool:voice_sing',
|
|
453
|
-
produce,
|
|
454
|
-
};
|
|
455
|
-
const enqueued = this.queue.enqueue(job);
|
|
456
|
-
if (!silent) {
|
|
457
|
-
const first = await within(Promise.race([
|
|
458
|
-
enqueued.started.then(() => 'started' as const),
|
|
459
|
-
enqueued.done.then((r) => r),
|
|
460
|
-
]), timeoutMs);
|
|
461
|
-
if (first === 'timeout') return { text: 'The singing service is still working; the song will play when ready.' };
|
|
462
|
-
if (first !== 'started' && !first.ok) {
|
|
463
|
-
return { text: `voice_sing failed: ${first.error ?? 'cancelled'}`, failed: true };
|
|
464
|
-
}
|
|
465
|
-
return { text: 'Singing now.' };
|
|
466
|
-
}
|
|
467
|
-
const result = await within(enqueued.done, timeoutMs + 60_000);
|
|
468
|
-
if (result === 'timeout') return { text: 'voice_sing failed: the singing service timed out.', failed: true };
|
|
469
|
-
if (!result.ok) return { text: `voice_sing failed: ${result.error ?? 'cancelled'}`, failed: true };
|
|
470
|
-
return { text: 'Sang one line without playing.' };
|
|
471
|
-
}
|
|
472
|
-
|
|
473
395
|
private async handleStyle(args: Record<string, unknown>): Promise<ToolOutcome> {
|
|
474
396
|
const has = (key: string): boolean => Object.prototype.hasOwnProperty.call(args, key);
|
|
475
397
|
const patch: Partial<VoiceConfigSection> = {};
|
|
@@ -492,8 +414,7 @@ export class VoiceWorld implements World {
|
|
|
492
414
|
const cfg = this.cfg;
|
|
493
415
|
return `Voice: ${cfg.voice || 'default'}; emotion: ${cfg.defaultEmotion || 'neutral'}; `
|
|
494
416
|
+ `direction: ${cfg.defaultInstruct || 'none'}; speed: ${cfg.speed}; `
|
|
495
|
-
+ `automatic: ${cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off'}
|
|
496
|
-
+ `singing: ${cfg.singUrl ? 'configured' : 'not configured'}.`;
|
|
417
|
+
+ `automatic: ${cfg.autoSpeak ? `${Math.round(cfg.autoProbability * 100)}%` : 'off'}.`;
|
|
497
418
|
}
|
|
498
419
|
|
|
499
420
|
private spokenOutcome(audio: TtsAudio | undefined, seconds: number, silent: boolean): ToolOutcome {
|
|
@@ -518,26 +439,45 @@ export class VoiceWorld implements World {
|
|
|
518
439
|
|
|
519
440
|
// ———————— 合成与出口 ————————
|
|
520
441
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
private async synthesize(text: string, style: { emotion?: string; instruct?: string; voice?: string; speed?: number }, signal: AbortSignal): Promise<TtsAudio> {
|
|
442
|
+
/**
|
|
443
|
+
* 语义字段按 emotionMode 只发一种:instruct 发指令,categorical 发情绪,
|
|
444
|
+
* basic 发语速,tags/none 不发;voice/model/format 每次都带。
|
|
445
|
+
*/
|
|
446
|
+
private async synthesize(
|
|
447
|
+
text: string,
|
|
448
|
+
style: { emotion?: string; instruct?: string; voice?: string; speed?: number },
|
|
449
|
+
signal: AbortSignal,
|
|
450
|
+
): Promise<TtsAudio> {
|
|
531
451
|
const cfg = this.cfg;
|
|
532
|
-
const
|
|
533
|
-
const request: TtsRequest = {
|
|
452
|
+
const resolved = resolveStyle(cfg, style);
|
|
453
|
+
const request: TtsRequest = {
|
|
454
|
+
text,
|
|
455
|
+
format: cfg.format,
|
|
456
|
+
signal,
|
|
457
|
+
...(style.voice ? { voice: style.voice } : {}),
|
|
458
|
+
};
|
|
459
|
+
switch (cfg.emotionMode) {
|
|
460
|
+
case 'instruct': {
|
|
461
|
+
const instruct = buildInstruct(resolved);
|
|
462
|
+
if (instruct) request.instruct = instruct;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
case 'categorical':
|
|
466
|
+
request.emotion = resolved.emotion || '平静';
|
|
467
|
+
break;
|
|
468
|
+
case 'basic':
|
|
469
|
+
request.speed = resolved.speed;
|
|
470
|
+
break;
|
|
471
|
+
default:
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
534
474
|
try {
|
|
535
|
-
const audio = await
|
|
536
|
-
this.
|
|
475
|
+
const audio = await this.service.synthesize(request, signal);
|
|
476
|
+
this.markService({ ok: true });
|
|
537
477
|
return audio;
|
|
538
478
|
} catch (error) {
|
|
539
479
|
const detail = error instanceof Error ? error.message : String(error);
|
|
540
|
-
this.
|
|
480
|
+
this.markService({ ok: false, detail });
|
|
541
481
|
void this.pushFailure(detail, { source: 'synthesize', chars: [...text].length });
|
|
542
482
|
throw error;
|
|
543
483
|
}
|
|
@@ -570,7 +510,7 @@ export class VoiceWorld implements World {
|
|
|
570
510
|
private async pushFailure(detail: string, meta: Record<string, unknown>): Promise<void> {
|
|
571
511
|
const host = this.host;
|
|
572
512
|
if (!host) return;
|
|
573
|
-
//
|
|
513
|
+
// 服务长时间不可用时,自动朗读会每句都失败;事件留一条,日志照常。
|
|
574
514
|
const now = this.deps.now();
|
|
575
515
|
if (now - this.lastFailureAt < 30_000) return;
|
|
576
516
|
this.lastFailureAt = now;
|
|
@@ -587,11 +527,11 @@ export class VoiceWorld implements World {
|
|
|
587
527
|
} catch { /* 只归档,失败就只留日志 */ }
|
|
588
528
|
}
|
|
589
529
|
|
|
590
|
-
// ————————
|
|
530
|
+
// ———————— 状态与目录 ————————
|
|
591
531
|
|
|
592
|
-
private
|
|
593
|
-
const previous = this.
|
|
594
|
-
this.
|
|
532
|
+
private markService(state: Pick<ServiceState, 'ok' | 'detail'>): void {
|
|
533
|
+
const previous = this.serviceState;
|
|
534
|
+
this.serviceState = { ...state, at: this.deps.now() };
|
|
595
535
|
if (previous?.ok !== state.ok) {
|
|
596
536
|
if (state.ok) this.log.info('语音服务可用');
|
|
597
537
|
else this.log.warn('语音服务不可用', { detail: state.detail ?? '' });
|
|
@@ -600,44 +540,44 @@ export class VoiceWorld implements World {
|
|
|
600
540
|
|
|
601
541
|
private async probe(): Promise<void> {
|
|
602
542
|
if (this.probing) return;
|
|
543
|
+
// 探活会刷新目录;每次探活都重新登记,停用后重新启用的实例也能接上。
|
|
544
|
+
this.registerCatalogOptions();
|
|
603
545
|
this.probing = true;
|
|
604
546
|
try {
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
url: this.cfg.singUrl,
|
|
610
|
-
token: this.cfg.singToken || undefined,
|
|
611
|
-
fetch: this.deps.fetch,
|
|
547
|
+
const health: ServiceHealth = await this.service.health();
|
|
548
|
+
this.markService({
|
|
549
|
+
ok: health.ok && health.ready,
|
|
550
|
+
detail: health.detail ?? health.status,
|
|
612
551
|
});
|
|
613
|
-
else this.singReady = false;
|
|
614
|
-
await this.refreshVoices();
|
|
615
552
|
} catch (error) {
|
|
616
|
-
this.
|
|
553
|
+
this.markService({ ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
617
554
|
} finally {
|
|
618
555
|
this.probing = false;
|
|
619
556
|
}
|
|
557
|
+
// 探活顺带刷新目录;读不到时 refresh 会保留上一次的表。
|
|
558
|
+
await this.refreshVoices(true);
|
|
559
|
+
await this.refreshModels(true);
|
|
620
560
|
}
|
|
621
561
|
|
|
622
562
|
private maybeRefresh(): void {
|
|
623
563
|
if (!this.started) return;
|
|
624
564
|
const now = this.deps.now();
|
|
625
|
-
if (!this.probing && (this.
|
|
565
|
+
if (!this.probing && (this.serviceState === null || now - this.serviceState.at > HEALTH_TTL_MS)) {
|
|
626
566
|
void this.probe();
|
|
627
567
|
}
|
|
628
|
-
if (
|
|
568
|
+
if (now - this.voicesAt > CATALOG_TTL_MS) void this.refreshVoices();
|
|
569
|
+
if (now - this.modelsAt > CATALOG_TTL_MS) void this.refreshModels();
|
|
629
570
|
}
|
|
630
571
|
|
|
631
572
|
private async refreshVoices(force = false): Promise<void> {
|
|
632
573
|
const cfg = this.cfg;
|
|
633
|
-
const signature = `${cfg.
|
|
574
|
+
const signature = `${cfg.serviceUrl.trim()}|${cfg.model.trim()}`;
|
|
634
575
|
if (this.voicesLoading && !force) return;
|
|
635
|
-
if (!force && signature === this.voicesSignature && this.deps.now() - this.voicesAt <
|
|
576
|
+
if (!force && signature === this.voicesSignature && this.deps.now() - this.voicesAt < CATALOG_TTL_MS) return;
|
|
636
577
|
this.voicesLoading = true;
|
|
637
578
|
try {
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
if (signature !== `${this.cfg.backend}|${this.cfg.baseUrl}|${this.cfg.model}`) return;
|
|
579
|
+
const list = await this.service.voices(cfg.model);
|
|
580
|
+
if (signature !== `${this.cfg.serviceUrl.trim()}|${this.cfg.model.trim()}`) return;
|
|
641
581
|
this.voices = list;
|
|
642
582
|
this.voicesSignature = signature;
|
|
643
583
|
this.voicesAt = this.deps.now();
|
|
@@ -648,73 +588,35 @@ export class VoiceWorld implements World {
|
|
|
648
588
|
}
|
|
649
589
|
}
|
|
650
590
|
|
|
651
|
-
private
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
if (
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
private async startHttp(): Promise<void> {
|
|
659
|
-
if (this.http) return;
|
|
591
|
+
private async refreshModels(force = false): Promise<void> {
|
|
592
|
+
const signature = this.cfg.serviceUrl.trim();
|
|
593
|
+
if (this.modelsLoading && !force) return;
|
|
594
|
+
if (!force && signature === this.modelsSignature && this.deps.now() - this.modelsAt < CATALOG_TTL_MS) return;
|
|
595
|
+
this.modelsLoading = true;
|
|
660
596
|
try {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
ready: this.backendState?.ok === true,
|
|
667
|
-
state: this.backendState?.detail ?? (this.backendState?.ok ? 'ready' : 'unknown'),
|
|
668
|
-
backend: backendOf(this.cfg.backend).label,
|
|
669
|
-
model: this.cfg.model,
|
|
670
|
-
voice: this.cfg.voice,
|
|
671
|
-
queue: this.queue.stats().queued,
|
|
672
|
-
sing: !!this.cfg.singUrl.trim(),
|
|
673
|
-
}),
|
|
674
|
-
synthesize: async ({ request, play }, signal) => {
|
|
675
|
-
const audio = await this.synthesize(request.text, {
|
|
676
|
-
...(request.emotion ? { emotion: request.emotion } : {}),
|
|
677
|
-
...(request.instruct ? { instruct: request.instruct } : {}),
|
|
678
|
-
...(request.voice ? { voice: request.voice } : {}),
|
|
679
|
-
...(request.speed !== undefined ? { speed: request.speed } : {}),
|
|
680
|
-
}, signal);
|
|
681
|
-
if (play || this.cfg.httpPlay) {
|
|
682
|
-
this.queue.enqueue({
|
|
683
|
-
text: request.text,
|
|
684
|
-
chunks: [request.text],
|
|
685
|
-
silent: false,
|
|
686
|
-
source: 'http',
|
|
687
|
-
produce: async () => audio,
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
return audio;
|
|
691
|
-
},
|
|
692
|
-
sing: async (lyrics, signal) => {
|
|
693
|
-
if (!this.cfg.singUrl.trim()) throw new Error('no singing service configured');
|
|
694
|
-
return singRemote({
|
|
695
|
-
url: this.cfg.singUrl,
|
|
696
|
-
token: this.cfg.singToken,
|
|
697
|
-
lyrics,
|
|
698
|
-
timeoutMs: Math.max(this.cfg.timeoutMs, 180_000),
|
|
699
|
-
fetch: this.deps.fetch,
|
|
700
|
-
signal,
|
|
701
|
-
});
|
|
702
|
-
},
|
|
703
|
-
voices: async () => {
|
|
704
|
-
await this.refreshVoices(true);
|
|
705
|
-
return this.voiceOptions();
|
|
706
|
-
},
|
|
707
|
-
log: this.log,
|
|
708
|
-
},
|
|
709
|
-
});
|
|
710
|
-
this.httpError = '';
|
|
711
|
-
this.log.info('本地语音接口已就绪', { port: this.http.port });
|
|
597
|
+
const list = await this.service.models();
|
|
598
|
+
if (signature !== this.cfg.serviceUrl.trim()) return;
|
|
599
|
+
this.models = list;
|
|
600
|
+
this.modelsSignature = signature;
|
|
601
|
+
this.modelsAt = this.deps.now();
|
|
712
602
|
} catch (error) {
|
|
713
|
-
this.
|
|
714
|
-
|
|
603
|
+
this.log.debug('模型表读取失败', { err: error instanceof Error ? error.message : String(error) });
|
|
604
|
+
} finally {
|
|
605
|
+
this.modelsLoading = false;
|
|
715
606
|
}
|
|
716
607
|
}
|
|
717
608
|
|
|
609
|
+
/** 下拉读的是缓存表;服务读不到时保留上一次的结果。当前值由框架补入。 */
|
|
610
|
+
private registerCatalogOptions(): void {
|
|
611
|
+
registerOptions(VOICE_MODELS_KIND, this.modelOptions);
|
|
612
|
+
registerOptions(VOICE_OPTIONS_KIND, this.voiceOptions);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private unregisterCatalogOptions(): void {
|
|
616
|
+
unregisterOptions(VOICE_MODELS_KIND, this.modelOptions);
|
|
617
|
+
unregisterOptions(VOICE_OPTIONS_KIND, this.voiceOptions);
|
|
618
|
+
}
|
|
619
|
+
|
|
718
620
|
private syncPoller(): void {
|
|
719
621
|
const types = eventTypes(this.cfg);
|
|
720
622
|
if (types.size === 0) {
|
|
@@ -753,4 +655,4 @@ export class VoiceWorld implements World {
|
|
|
753
655
|
}
|
|
754
656
|
}
|
|
755
657
|
|
|
756
|
-
export { VOICE_DEFAULTS, VOICE_ID, VOICE_OPTIONS_KIND };
|
|
658
|
+
export { VOICE_DEFAULTS, VOICE_ID, VOICE_MODELS_KIND, VOICE_OPTIONS_KIND };
|