@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/src/world.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * 语音 World:把回复念出来。
3
3
  *
4
4
  * 触发层从主 session 输出流与事件库认出要念的话;文本层过滤与切句;
5
- * 合成层按配置的协议适配器取音频;出口层本机播放、发事件或只返回附件。
6
- * 这一层不 import 任何具体 TTS 包,也不 import 唱歌插件。
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 { backendOf } from './backends/index.ts';
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 { startVoiceHttp, type VoiceHttpHandle } from './http.ts';
23
- import { registerVoiceOptions } from './options.ts';
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 { BackendHealth, TtsAudio, TtsBackend, TtsRequest, VoiceConfigSection, VoiceOption } from './types.ts';
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 VOICES_TTL_MS = 10 * 60_000;
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
- backend?: TtsBackend;
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
- single?: boolean;
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 backendState: { ok: boolean; detail?: string; at: number } | null = null;
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 backendLamp: WorldLamp = this.backendState === null
186
- ? { label: t.lampBackend, state: this.started ? 'loading' : 'offline' }
187
- : this.backendState.ok
188
- ? { label: t.lampBackend, state: 'online', ...(this.backendState.detail ? { hint: this.backendState.detail } : {}) }
189
- : { label: t.lampBackend, state: 'error', ...(this.backendState.detail ? { hint: this.backendState.detail } : {}) };
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
- backendLamp,
199
- { label: t.lampQueue, state: stats.speaking ? 'loading' : 'online', hint: `${stats.queued}` },
200
- apiLamp,
201
- { label: t.lampSinger, state: cfg.singUrl ? (this.singReady ? 'online' : 'offline') : 'offline' },
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
- backend: this.cfg.backend,
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(); }, HEALTH_TTL_MS * 2);
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 = opts.single ? [clipped] : splitSpeech(clipped, cfg.chunkMax);
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
- private backendContext() {
522
- return {
523
- cfg: this.cfg,
524
- secret: (name: string) => (name ? this.opts.secret(name) : ''),
525
- fetch: this.deps.fetch,
526
- log: this.log,
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 backend = this.deps.backend ?? backendOf(cfg.backend);
533
- const request: TtsRequest = { text, format: cfg.format, signal, ...style };
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 backend.synthesize(request, this.backendContext());
536
- this.markBackend({ ok: true });
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.markBackend({ ok: false, detail });
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
- // ———————— 状态、HTTP 与音色 ————————
530
+ // ———————— 状态与目录 ————————
591
531
 
592
- private markBackend(state: BackendHealth): void {
593
- const previous = this.backendState;
594
- this.backendState = { ...state, at: this.deps.now() };
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 backend = this.deps.backend ?? backendOf(this.cfg.backend);
606
- const state = await backend.health(this.backendContext());
607
- this.markBackend(state);
608
- if (this.cfg.singUrl.trim()) this.singReady = await singHealth({
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.markBackend({ ok: false, detail: error instanceof Error ? error.message : String(error) });
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.backendState === null || now - this.backendState.at > HEALTH_TTL_MS)) {
565
+ if (!this.probing && (this.serviceState === null || now - this.serviceState.at > HEALTH_TTL_MS)) {
626
566
  void this.probe();
627
567
  }
628
- if (!this.voicesLoading && now - this.voicesAt > VOICES_TTL_MS) void this.refreshVoices();
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.backend}|${cfg.baseUrl}|${cfg.model}`;
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 < VOICES_TTL_MS) return;
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 backend = this.deps.backend ?? backendOf(cfg.backend);
639
- const list = await backend.listVoices(this.backendContext());
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 voiceOptions(): VoiceOption[] {
652
- const current = this.cfg.voice.trim();
653
- const out = [...this.voices];
654
- if (current && !out.some((v) => v.value === current)) out.unshift({ value: current, label: current });
655
- return out;
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
- this.http = await startVoiceHttp({
662
- port: this.cfg.httpPort,
663
- deps: {
664
- token: () => this.cfg.httpToken,
665
- status: () => ({
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.httpError = error instanceof Error ? error.message : String(error);
714
- this.log.error('本地语音接口启动失败', { err: this.httpError });
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 };