dsh-avatar-frame 0.11.4 → 0.14.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/client.js CHANGED
@@ -17,6 +17,10 @@ window.__ModuleLoader__.load({
17
17
  const ENDPOINT_KEY = 'dsh-avatar-frame/endpoint/v1';
18
18
  const RATE_KEY = 'dsh-avatar-frame/rate/v1';
19
19
  const TONE_KEY = 'dsh-avatar-frame/tone/v1';
20
+ const SYSVOICE_KEY = 'dsh-avatar-frame/sysvoice/v1';
21
+ const AUTO_SPEECH_KEY = 'dsh-avatar-frame/autospeech/v1';
22
+ const VOLUME_KEY = 'dsh-avatar-frame/volume/v1';
23
+ const DEFAULT_VOLUME = 0.5;
20
24
  /**
21
25
  * The plugin ships no model, no reference audio, and no service: it only
22
26
  * calls a TTS endpoint the user runs. This is just the default address.
@@ -69,20 +73,32 @@ window.__ModuleLoader__.load({
69
73
  'engine.label': '音色',
70
74
  'engine.system': '系统语音',
71
75
  'engine.clone': '本地服务',
72
- 'engine.hint': '插件只负责调用;语音服务、模型和参考音由你自己准备。',
76
+ 'engine.hint': '想用本地语音服务请在本机配置(模型和参考音频自备)。',
77
+ 'sysvoice.label': '系统语音',
78
+ 'sysvoice.auto': '自动选择',
79
+ 'sysvoice.test': '试听',
80
+ 'sysvoice.refresh': '刷新列表',
81
+ 'sysvoice.count': '共 {n} 个音色',
73
82
  'endpoint.label': '服务地址',
74
83
  'endpoint.placeholder': '例如 http://127.0.0.1:8123',
75
84
  'status.checking': '检测中',
76
85
  'status.online': '已连接',
77
86
  'status.offline': '未连接',
78
- 'endpoint.offlineHint': '连不上时先确认语音服务已经启动;启动后这里每 8 秒自动重试,不用刷新。',
87
+ 'status.warming': '正在启动语音服务,已等待 {s} 秒(模型加载通常要 10–30 秒)',
88
+ 'status.reasonTimeout': '连接超时:服务 4 秒没有响应',
89
+ 'status.reasonUnreachable': '连不上服务:可能还没启动,或地址不对',
90
+ 'status.reasonHttp': '服务返回 HTTP {code}',
91
+ 'endpoint.offlineHint': '连不上时先确认语音服务已经启动;启动后这里每 2.5 秒自动重试,不用刷新。',
79
92
  'tone.label': '语气',
80
93
  'tone.calm': '平稳',
81
94
  'tone.normal': '自然',
82
95
  'tone.lively': '活泼',
83
96
  'speech.rate': '语速',
97
+ 'auto.label': '自动朗读新回复',
98
+ 'volume.label': '音量',
84
99
  'audition.label': '试听一句',
85
- 'audition.stopHint': '正在朗读',
100
+ 'audition.playing': '试听中 {n}/{m}',
101
+ 'audition.failed': '试听失败',
86
102
  'audition.text': '程序启动,访问人,用户,你好!',
87
103
  'bar.label': '朗读控制',
88
104
  'bar.pause': '暂停',
@@ -97,13 +113,14 @@ window.__ModuleLoader__.load({
97
113
  'section.speech': '朗读',
98
114
  'section.service': '语音服务',
99
115
  'section.diagnostics': '诊断',
100
- 'service.title': '服务进程',
116
+ 'service.onlyForLocal': '这一段只对「本地服务」生效;进程还在跑,所以留一个停止入口。',
101
117
  'service.running': '运行中',
102
118
  'service.stopped': '未运行',
103
119
  'service.external': '端口上已有服务(非本插件启动)',
104
120
  'service.start': '启动服务',
105
121
  'service.stop': '停止服务',
106
- 'service.autostart': '随 DSH 启动',
122
+ 'service.restart': '重启服务',
123
+ 'service.autostart': 'DSH 启动时拉起语音服务',
107
124
  'service.configure': '启动配置',
108
125
  'service.command': '启动命令',
109
126
  'service.commandPlaceholder': '例如 "C:\\Python\\python.exe" server.py',
@@ -122,7 +139,6 @@ window.__ModuleLoader__.load({
122
139
  'diag.lastError': '最近错误',
123
140
  'diag.none': '无',
124
141
  'dialog.title': '设置 AI 头像',
125
- 'dialog.pickHint': '在这里写 ID;要换图时再点下面的按钮。',
126
142
  'dialog.choose': '选择头像图片',
127
143
  'dialog.hint': '拖动图片调整位置,滚轮或滑块缩放。',
128
144
  'dialog.cancel': '取消',
@@ -161,20 +177,32 @@ window.__ModuleLoader__.load({
161
177
  'engine.label': 'Voice',
162
178
  'engine.system': 'System voice',
163
179
  'engine.clone': 'Local service',
164
- 'engine.hint': 'The plugin only calls the service; the model and reference audio are yours to provide.',
180
+ 'engine.hint': 'To use a local voice, set up your own speech service on this machine (model and reference audio are yours to provide).',
181
+ 'sysvoice.label': 'System voice',
182
+ 'sysvoice.auto': 'Automatic',
183
+ 'sysvoice.test': 'Preview',
184
+ 'sysvoice.refresh': 'Refresh list',
185
+ 'sysvoice.count': '{n} voices found',
165
186
  'endpoint.label': 'Service address',
166
187
  'endpoint.placeholder': 'e.g. http://127.0.0.1:8123',
167
188
  'status.checking': 'Checking',
168
189
  'status.online': 'Connected',
169
190
  'status.offline': 'Not connected',
170
- 'endpoint.offlineHint': 'Start your TTS service first; this dot retries every 8 seconds and reconnects on its own.',
191
+ 'status.warming': 'Starting the speech service — {s}s elapsed (model loading usually takes 10–30s)',
192
+ 'status.reasonTimeout': 'Timed out: no response within 4s',
193
+ 'status.reasonUnreachable': 'Cannot reach the service: not running, or wrong address',
194
+ 'status.reasonHttp': 'The service answered HTTP {code}',
195
+ 'endpoint.offlineHint': 'Start your TTS service first; this dot retries every 2.5 seconds and reconnects on its own.',
171
196
  'tone.label': 'Tone',
172
197
  'tone.calm': 'Calm',
173
198
  'tone.normal': 'Natural',
174
199
  'tone.lively': 'Lively',
175
200
  'speech.rate': 'Speed',
201
+ 'auto.label': 'Read new replies automatically',
202
+ 'volume.label': 'Volume',
176
203
  'audition.label': 'Preview',
177
- 'audition.stopHint': 'Reading…',
204
+ 'audition.playing': 'Previewing {n}/{m}',
205
+ 'audition.failed': 'Preview failed',
178
206
  'audition.text': 'Program initializing. Accessor: user.',
179
207
  'bar.label': 'Read-aloud controls',
180
208
  'bar.pause': 'Pause',
@@ -189,13 +217,14 @@ window.__ModuleLoader__.load({
189
217
  'section.speech': 'Read aloud',
190
218
  'section.service': 'Speech service',
191
219
  'section.diagnostics': 'Diagnostics',
192
- 'service.title': 'Service process',
220
+ 'service.onlyForLocal': 'This section applies to the local service only; the process is still running, so the stop button stays here.',
193
221
  'service.running': 'Running',
194
222
  'service.stopped': 'Stopped',
195
223
  'service.external': 'A service is already listening (started outside the plugin)',
196
224
  'service.start': 'Start service',
197
225
  'service.stop': 'Stop service',
198
- 'service.autostart': 'Start with DSH',
226
+ 'service.restart': 'Restart',
227
+ 'service.autostart': 'Start the speech service with DSH',
199
228
  'service.configure': 'Launch settings',
200
229
  'service.command': 'Command',
201
230
  'service.commandPlaceholder': 'e.g. "C:\\Python\\python.exe" server.py',
@@ -214,7 +243,6 @@ window.__ModuleLoader__.load({
214
243
  'diag.lastError': 'Last error',
215
244
  'diag.none': 'none',
216
245
  'dialog.title': 'Assistant avatar',
217
- 'dialog.pickHint': 'Write the ID here; pick an image with the button below.',
218
246
  'dialog.choose': 'Choose avatar image',
219
247
  'dialog.hint': 'Drag to reposition; scroll or use the slider to zoom.',
220
248
  'dialog.cancel': 'Cancel',
@@ -259,6 +287,15 @@ window.__ModuleLoader__.load({
259
287
  return Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(numeric)));
260
288
  }
261
289
 
290
+ /** 音量:没设置过就是 50%,显式存 0 才表示静音。 */
291
+ function readStoredVolume() {
292
+ const raw = readStored(VOLUME_KEY);
293
+ if (raw === null || raw === '') return DEFAULT_VOLUME;
294
+ const value = Number(raw);
295
+ if (!Number.isFinite(value)) return DEFAULT_VOLUME;
296
+ return Math.min(1, Math.max(0, value));
297
+ }
298
+
262
299
  function readStoredSize() {
263
300
  const raw = readStored(SIZE_KEY);
264
301
  return raw === null ? DEFAULT_SIZE : clampSize(raw);
@@ -282,13 +319,16 @@ window.__ModuleLoader__.load({
282
319
  endpoint: readStored(ENDPOINT_KEY) || DEFAULT_ENDPOINT,
283
320
  rate: Number(readStored(RATE_KEY)) > 0 ? Number(readStored(RATE_KEY)) : DEFAULT_RATE,
284
321
  tone: TONES.includes(readStored(TONE_KEY)) ? readStored(TONE_KEY) : 'normal',
322
+ sysVoice: readStored(SYSVOICE_KEY) || '',
323
+ autoSpeech: readStored(AUTO_SPEECH_KEY) === '1',
324
+ volume: readStoredVolume(),
285
325
  lang: readStoredLang(),
286
326
  });
287
327
  let translate = (key) => key;
288
328
  const dialogStore = createStore({ open: false, mode: 'idle', draft: null });
289
329
  const statsStore = createStore({ lastMs: null });
290
330
  /** 计数器:每次 +1 让健康检查重新探一次(启动服务后立刻刷新状态)。 */
291
- const probeStore = createStore({ tick: 0 });
331
+ const probeStore = createStore({ tick: 0, startingSince: null });
292
332
  const speechStore = createStore({ status: 'idle', sessionId: null, turn: null, index: 0, total: 0, rate: DEFAULT_RATE, error: null, engine: null });
293
333
 
294
334
  function persist(key, value) {
@@ -320,7 +360,24 @@ window.__ModuleLoader__.load({
320
360
 
321
361
  /* --------------------------------------------------------------- speech */
322
362
 
363
+ /** 诊断:把一行信息发到宿主端,落到 ~/.dsh/storages/dsh-avatar-frame/debug.log。 */
364
+ function trace(message) {
365
+ try {
366
+ fetch('/dsh-avatar-frame/service', {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: JSON.stringify({ action: 'debug', message: String(message) }),
370
+ }).catch(() => {});
371
+ } catch {
372
+ // 忽略。
373
+ }
374
+ }
375
+
323
376
  let cachedVoices = [];
377
+ /** 是否已经向浏览器要过一次列表(列表可能是空的,那就别再问了)。 */
378
+ let voicesProbed = false;
379
+ /** 系统语音列表(浏览器异步加载,加载完要让下拉框刷新)。 */
380
+ const voicesStore = createStore({ list: [] });
324
381
  /** One active playback: a token guards against stopped or replaced runs. */
325
382
  const speechRun = {
326
383
  token: 0,
@@ -334,30 +391,112 @@ window.__ModuleLoader__.load({
334
391
  seek: null,
335
392
  rate: DEFAULT_RATE,
336
393
  paused: false,
394
+ /** 当前这段朗读是不是走浏览器自带语音。 */
395
+ system: false,
396
+ /** 本地播放的音量节点。 */
397
+ gain: null,
398
+ /** 看门狗用:状态进入「在播」但没有音源时的起始时间。 */
399
+ settledAt: null,
400
+ /** 正在等待返回的合成请求数量;>0 说明在正常等待,不算卡住。 */
401
+ pendingFetches: 0,
337
402
  sessionId: null,
338
403
  turn: null,
339
404
  error: null,
340
405
  };
341
406
 
407
+ function sameVoiceList(a, b) {
408
+ if (a.length !== b.length) return false;
409
+ for (let i = 0; i < a.length; i += 1) {
410
+ if (a[i].name !== b[i].name || a[i].lang !== b[i].lang) return false;
411
+ }
412
+ return true;
413
+ }
414
+
415
+ /** 读取浏览器音色列表。只有内容真的变了才写 store —— 渲染期间调用绝不能触发 set。 */
342
416
  function refreshVoices() {
417
+ voicesProbed = true;
418
+ let list = [];
343
419
  try {
344
- cachedVoices = window.speechSynthesis.getVoices() || [];
420
+ list = window.speechSynthesis.getVoices() || [];
345
421
  } catch {
346
- cachedVoices = [];
422
+ list = [];
347
423
  }
424
+ cachedVoices = list;
425
+ const previous = voicesStore.get().list;
426
+ if (!sameVoiceList(previous, list)) voicesStore.set({ list: list.slice() });
348
427
  }
349
428
 
350
- /** Prefer a Chinese female-sounding system voice; fall back to any Chinese voice. */
351
- function pickVoice() {
352
- if (cachedVoices.length === 0) refreshVoices();
353
- const chinese = cachedVoices.filter((voice) => /^zh/i.test(voice.lang || ''));
354
- const pool = chinese.length > 0 ? chinese : cachedVoices;
355
- const preferred = ['Xiaoxiao', 'Xiaoyi', 'Yaoyao', 'Huihui', 'Natural', 'Female', '女'];
356
- for (const keyword of preferred) {
357
- const hit = pool.find((voice) => (voice.name || '').includes(keyword));
358
- if (hit) return hit;
429
+ /**
430
+ * 系统语音排序:神经网络音色(Natural / Online / Neural)> 中文 > 女声常见命名,
431
+ * 老的 SAPI 音色(Huihui 等)排最后。用户手动选过就直接用它。
432
+ */
433
+ function scoreVoice(voice) {
434
+ const name = String(voice.name || '');
435
+ const lang = String(voice.lang || '');
436
+ let score = 0;
437
+ if (/natural|neural|online|premium|enhanced/i.test(name)) score += 100;
438
+ if (/^zh/i.test(lang)) score += 20;
439
+ // 成年女声优先(和默认人设一致);童声、男声略降
440
+ if (/xiaoxiao|xiaoyi|xiaomo|xiaoxuan|xiaohan|xiaorui|huihui|zira|aria|jenny|female|女子|女/i.test(name)) score += 10;
441
+ if (/yaoyao|童|child/i.test(name)) score -= 10;
442
+ if (/kangkang|mark|guy|david|male/i.test(name)) score -= 10;
443
+ if (/google/i.test(name)) score += 15;
444
+ return score;
445
+ }
446
+
447
+ /** 判断一段文本该用哪种语言的系统语音:有汉字就是中文,否则英文。 */
448
+ function detectTextLang(text) {
449
+ return /[\u3400-\u9fff]/.test(String(text || '')) ? 'zh' : 'en';
450
+ }
451
+
452
+ /**
453
+ * 可选的系统语音。
454
+ * - 传 'zh' / 'en' 时只留该语言(没有就退回全部);
455
+ * - 不传则全部返回,中文按界面语言排在前面。
456
+ */
457
+ function voiceOptions(lang) {
458
+ if (cachedVoices.length === 0 && !voicesProbed) {
459
+ voicesProbed = true;
460
+ try {
461
+ cachedVoices = window.speechSynthesis.getVoices() || [];
462
+ } catch {
463
+ cachedVoices = [];
464
+ }
359
465
  }
360
- return pool[0] || null;
466
+ const all = cachedVoices.slice().sort((a, b) => scoreVoice(b) - scoreVoice(a));
467
+ if (lang === 'zh' || lang === 'en') {
468
+ const hit = all.filter((voice) => new RegExp('^' + lang, 'i').test(voice.lang || ''));
469
+ if (hit.length > 0) return hit;
470
+ return all;
471
+ }
472
+ const uiZh = avatarStore.get().lang !== 'en';
473
+ return all.sort((a, b) => {
474
+ const za = /^zh/i.test(a.lang || '') === uiZh ? 0 : 1;
475
+ const zb = /^zh/i.test(b.lang || '') === uiZh ? 0 : 1;
476
+ return za - zb || scoreVoice(b) - scoreVoice(a);
477
+ });
478
+ }
479
+
480
+ /**
481
+ * 挑一个声音:用户手动选过就用它;否则按**要读的文本**挑语言——
482
+ * 英文回复配英文音色,中文回复配中文音色,界面是哪种语言都不影响。
483
+ */
484
+ function pickVoice(text) {
485
+ const chosen = avatarStore.get().sysVoice;
486
+ if (chosen !== '') {
487
+ if (cachedVoices.length === 0 && !voicesProbed) {
488
+ voicesProbed = true;
489
+ try {
490
+ cachedVoices = window.speechSynthesis.getVoices() || [];
491
+ } catch {
492
+ cachedVoices = [];
493
+ }
494
+ }
495
+ const hit = cachedVoices.find((voice) => voice.name === chosen);
496
+ if (hit !== undefined) return hit;
497
+ }
498
+ const options = voiceOptions(detectTextLang(text));
499
+ return options[0] || null;
361
500
  }
362
501
 
363
502
  function interruptPlayback() {
@@ -393,10 +532,19 @@ window.__ModuleLoader__.load({
393
532
  speechRun.index = 0;
394
533
  speechRun.seek = null;
395
534
  try {
396
- window.speechSynthesis.cancel();
535
+ const synth = window.speechSynthesis;
536
+ // Chromium 的已知毛病:cancel() 偶尔只清掉队列、当前这句继续念完。
537
+ // 先 pause 再 cancel,并补一次 cancel,确保真的停住。
538
+ synth.pause();
539
+ synth.cancel();
540
+ if (synth.speaking) synth.cancel();
397
541
  } catch {
398
542
  // An absent or failed engine simply leaves nothing playing.
399
543
  }
544
+ speechRun.system = false;
545
+ speechRun.paused = false;
546
+ speechRun.pendingFetches = 0;
547
+ speechRun.settledAt = null;
400
548
  speechRun.sessionId = null;
401
549
  speechRun.turn = null;
402
550
  speechRun.error = null;
@@ -409,12 +557,32 @@ window.__ModuleLoader__.load({
409
557
  if (Ctor === undefined) return null;
410
558
  speechRun.ctx = new Ctor();
411
559
  }
412
- if (speechRun.ctx.state === 'suspended' && speechRun.paused !== true) {
413
- speechRun.ctx.resume().catch(() => {});
414
- }
415
560
  return speechRun.ctx;
416
561
  }
417
562
 
563
+ /**
564
+ * 浏览器默认会把没有用户手势的 AudioContext 挂起(suspended),
565
+ * 此时 source.start() 不会有声音、onended 也不会触发——表现就是「点了没反应」,
566
+ * 而且状态会一直卡在 speaking。所以播放前必须确认它真的在 running。
567
+ */
568
+ async function runningAudioContext() {
569
+ const ctx = ensureAudioContext();
570
+ if (ctx === null) return null;
571
+ if (ctx.state !== 'running') {
572
+ try {
573
+ // 有些环境下 resume() 会一直挂起(没有用户手势、被策略拦住),
574
+ // 不能无限等——最多等 1.5 秒,之后照常返回,由播放层判断。
575
+ await Promise.race([
576
+ ctx.resume(),
577
+ new Promise((resolve) => window.setTimeout(resolve, 1500)),
578
+ ]);
579
+ } catch {
580
+ // 恢复失败就按失败处理,让界面显示原因。
581
+ }
582
+ }
583
+ return ctx;
584
+ }
585
+
418
586
  function publishSpeech(status) {
419
587
  speechStore.set({
420
588
  status,
@@ -429,7 +597,18 @@ window.__ModuleLoader__.load({
429
597
  }
430
598
 
431
599
  function pauseSpeech() {
432
- if (speechRun.ctx === null || speechStore.get().status !== 'speaking') return;
600
+ if (speechStore.get().status !== 'speaking') return;
601
+ if (speechRun.system) {
602
+ try {
603
+ window.speechSynthesis.pause();
604
+ } catch {
605
+ return;
606
+ }
607
+ speechRun.paused = true;
608
+ publishSystem('paused');
609
+ return;
610
+ }
611
+ if (speechRun.ctx === null) return;
433
612
  speechRun.paused = true;
434
613
  speechRun.ctx.suspend().catch(() => {});
435
614
  publishSpeech('paused');
@@ -438,6 +617,15 @@ window.__ModuleLoader__.load({
438
617
  function resumeSpeech() {
439
618
  if (speechStore.get().status !== 'paused') return;
440
619
  speechRun.paused = false;
620
+ if (speechRun.system) {
621
+ try {
622
+ window.speechSynthesis.resume();
623
+ } catch {
624
+ return;
625
+ }
626
+ publishSystem('speaking');
627
+ return;
628
+ }
441
629
  if (speechRun.ctx !== null) speechRun.ctx.resume().catch(() => {});
442
630
  publishSpeech('speaking');
443
631
  }
@@ -471,6 +659,17 @@ window.__ModuleLoader__.load({
471
659
  function skipSentence(delta) {
472
660
  if (speechRun.chunks.length === 0) return;
473
661
  const target = Math.min(speechRun.chunks.length - 1, Math.max(0, speechRun.index + delta));
662
+ if (speechRun.system) {
663
+ speechRun.token += 1;
664
+ speechRun.paused = false;
665
+ try {
666
+ window.speechSynthesis.cancel();
667
+ } catch {
668
+ // 忽略。
669
+ }
670
+ speakSystemFrom(target, speechRun.token);
671
+ return;
672
+ }
474
673
  speechRun.seek = target;
475
674
  interruptPlayback();
476
675
  publishSpeech(speechStore.get().status);
@@ -511,10 +710,88 @@ window.__ModuleLoader__.load({
511
710
  }
512
711
 
513
712
  /** One synthesis request for one chunk. */
713
+ /** base64 → Blob(宿主端代理回来的音频)。 */
714
+ function base64ToBlob(base64, contentType) {
715
+ const binary = window.atob(base64);
716
+ const bytes = new Uint8Array(binary.length);
717
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
718
+ return new Blob([bytes], { type: contentType || 'audio/wav' });
719
+ }
720
+
721
+ /** 走宿主端转发:浏览器直连被网络策略拦下时的兜底通道。 */
722
+ async function requestSpeechViaHost(chunk, avatar) {
723
+ const response = await fetch(SERVICE_ROUTE, {
724
+ method: 'POST',
725
+ headers: { 'Content-Type': 'application/json' },
726
+ body: JSON.stringify({
727
+ action: 'tts',
728
+ endpoint: resolveEndpoint(),
729
+ text: chunk,
730
+ speed: avatar.rate || DEFAULT_RATE,
731
+ tone: avatar.tone || 'normal',
732
+ }),
733
+ });
734
+ if (!response.ok) throw new Error('host tts proxy responded ' + response.status);
735
+ const payload = await response.json();
736
+ if (!payload || payload.ok !== true || typeof payload.base64 !== 'string') {
737
+ throw new Error((payload && payload.error) || 'host tts proxy returned no audio');
738
+ }
739
+ return base64ToBlob(payload.base64, payload.contentType);
740
+ }
741
+
742
+ /** 一旦直连失败过,就记住这个事实,后续直接走宿主代理,不再每段等超时。 */
743
+ let directTtsBroken = false;
744
+
514
745
  function requestSpeech(chunk, controller) {
515
746
  const avatar = avatarStore.get();
516
747
  const started = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
517
- return fetch(resolveEndpoint() + '/tts', {
748
+ const url = resolveEndpoint() + '/tts';
749
+ trace('tts: post ' + url + ' chunk=' + chunk.slice(0, 12));
750
+ // 直连只等 1.5 秒:dsh-app:// 页面访问 127.0.0.1 常被网络策略拦下,
751
+ // 与其干等,不如尽快改走宿主代理(宿主进程不受这层限制)。
752
+ speechRun.pendingFetches += 1;
753
+ const done = () => {
754
+ speechRun.pendingFetches = Math.max(0, speechRun.pendingFetches - 1);
755
+ };
756
+
757
+ if (directTtsBroken) {
758
+ trace('tts: direct disabled, going straight to host proxy');
759
+ return requestSpeechViaHost(chunk, avatar).then(
760
+ (blob) => {
761
+ done();
762
+ return blob;
763
+ },
764
+ (error) => {
765
+ done();
766
+ trace('tts: host proxy failed ' + String(error && error.message));
767
+ throw error;
768
+ },
769
+ );
770
+ }
771
+
772
+ // 独立的取消器:只影响这一次请求;同时跟随整体停止信号。
773
+ const local = new AbortController();
774
+ const forwardAbort = () => {
775
+ try {
776
+ local.abort();
777
+ } catch {
778
+ // 忽略。
779
+ }
780
+ };
781
+ if (controller && controller.signal && typeof controller.signal.addEventListener === 'function') {
782
+ if (controller.signal.aborted) local.abort();
783
+ else controller.signal.addEventListener('abort', forwardAbort, { once: true });
784
+ }
785
+ const timer = typeof window.setTimeout === 'function'
786
+ ? window.setTimeout(() => {
787
+ try {
788
+ local.abort();
789
+ } catch {
790
+ // 忽略。
791
+ }
792
+ }, 1500)
793
+ : null;
794
+ return fetch(url, {
518
795
  method: 'POST',
519
796
  headers: { 'Content-Type': 'application/json' },
520
797
  body: JSON.stringify({
@@ -522,12 +799,33 @@ window.__ModuleLoader__.load({
522
799
  speed: avatar.rate || DEFAULT_RATE,
523
800
  tone: avatar.tone || 'normal',
524
801
  }),
525
- signal: controller.signal,
802
+ signal: local.signal,
526
803
  }).then((response) => {
804
+ trace('tts: status ' + response.status);
527
805
  if (!response.ok) throw new Error('tts responded ' + response.status);
528
806
  const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
529
807
  statsStore.set({ lastMs: now - started });
530
- return response.blob();
808
+ return response.blob().then((blob) => {
809
+ trace('tts: blob ' + blob.size);
810
+ return blob;
811
+ });
812
+ }).catch(async (error) => {
813
+ directTtsBroken = true;
814
+ trace('tts: direct failed (' + String(error && error.name) + ' ' + String(error && error.message) + ') -> host proxy');
815
+ const blob = await requestSpeechViaHost(chunk, avatar);
816
+ trace('tts: host proxy ok ' + blob.size);
817
+ return blob;
818
+ }).then((blob) => {
819
+ done();
820
+ return blob;
821
+ }, (error) => {
822
+ done();
823
+ throw error;
824
+ }).finally(() => {
825
+ if (timer !== null && typeof window.clearTimeout === 'function') window.clearTimeout(timer);
826
+ if (controller && controller.signal && typeof controller.signal.removeEventListener === 'function') {
827
+ controller.signal.removeEventListener('abort', forwardAbort);
828
+ }
531
829
  });
532
830
  }
533
831
 
@@ -545,6 +843,7 @@ window.__ModuleLoader__.load({
545
843
  }
546
844
 
547
845
  function playSpeechBuffer(buffer) {
846
+ const volume = Math.min(1, Math.max(0, Number(avatarStore.get().volume)));
548
847
  return new Promise((resolve) => {
549
848
  const ctx = ensureAudioContext();
550
849
  if (ctx === null) {
@@ -558,7 +857,17 @@ window.__ModuleLoader__.load({
558
857
  } catch {
559
858
  // Rate stays at 1 if unsupported.
560
859
  }
561
- source.connect(ctx.destination);
860
+ // 有 GainNode 才走音量节点;极老的引擎没有就直连,别让播放在这里断掉。
861
+ if (typeof ctx.createGain === 'function') {
862
+ if (speechRun.gain === null) {
863
+ speechRun.gain = ctx.createGain();
864
+ speechRun.gain.connect(ctx.destination);
865
+ }
866
+ speechRun.gain.gain.value = volume;
867
+ source.connect(speechRun.gain);
868
+ } else {
869
+ source.connect(ctx.destination);
870
+ }
562
871
  speechRun.source = source;
563
872
  let done = false;
564
873
  const finish = () => {
@@ -583,9 +892,6 @@ window.__ModuleLoader__.load({
583
892
  */
584
893
  async function speakClone(text, sessionId, turn) {
585
894
  stopSpeech();
586
- // Prime the audio graph inside the click itself; creating it after the
587
- // first await would land outside the gesture and stay suspended.
588
- ensureAudioContext();
589
895
  const token = speechRun.token;
590
896
  const chunks = splitSpeech(text);
591
897
  if (chunks.length === 0) return;
@@ -599,7 +905,19 @@ window.__ModuleLoader__.load({
599
905
  speechRun.turn = turn;
600
906
  speechRun.error = null;
601
907
  speechRun.controller = new AbortController();
908
+ // 先让界面进入「试听中」,再做需要 await 的准备工作:
909
+ // 任何一步卡住都不会表现成"点了没反应"。
602
910
  publishSpeech('speaking');
911
+ trace('clone: start chunks=' + chunks.length + ' rate=' + speechRun.rate);
912
+ try {
913
+ // 音频上下文要在这次点击的手势里创建/恢复,晚了会被浏览器挂起。
914
+ const ctx = await runningAudioContext();
915
+ trace('clone: ctx=' + (ctx === null ? 'null' : ctx.state));
916
+ } catch (error) {
917
+ speechRun.error = String(error && error.message ? error.message : error);
918
+ trace('clone: ctx error ' + speechRun.error);
919
+ }
920
+ if (speechRun.token !== token) return;
603
921
  prefetchSpeech(0);
604
922
  let played = 0;
605
923
  try {
@@ -617,13 +935,21 @@ window.__ModuleLoader__.load({
617
935
  publishSpeech('speaking');
618
936
  continue;
619
937
  }
620
- const ctx = ensureAudioContext();
938
+ const ctx = await runningAudioContext();
621
939
  if (ctx === null) throw new Error('no audio context');
940
+ if (ctx.state !== 'running') throw new Error('audio context is suspended: ' + ctx.state);
622
941
  const data = await result.arrayBuffer();
942
+ trace('clone: chunk ' + index + ' bytes=' + (data && data.byteLength));
623
943
  if (speechRun.token !== token) return;
624
944
  const buffer = await ctx.decodeAudioData(data);
945
+ trace('clone: decoded ' + index + ' duration=' + (buffer ? buffer.duration : 'n/a'));
946
+ if (speechRun.error !== null) {
947
+ speechRun.error = null;
948
+ publishSpeech(speechStore.get().status);
949
+ }
625
950
  if (speechRun.token !== token) return;
626
951
  await playSpeechBuffer(buffer);
952
+ trace('clone: played ' + index);
627
953
  if (speechRun.token !== token) return;
628
954
  if (speechRun.seek !== null) {
629
955
  speechRun.index = speechRun.seek;
@@ -641,57 +967,97 @@ window.__ModuleLoader__.load({
641
967
  speechStore.set({ status: 'idle', sessionId: null, turn: null, index: 0, total: 0, rate: speechRun.rate, error: speechRun.error, engine: null });
642
968
  }
643
969
  } catch (error) {
970
+ trace('clone: error ' + String(error && error.message ? error.message : error));
644
971
  if (speechRun.token !== token) return;
645
972
  speechRun.error = String(error && error.message ? error.message : error);
646
973
  console.warn('[dsh-avatar-frame] clone voice playback problem', error);
647
974
  // Only fall back when nothing has been heard yet; otherwise the reader
648
975
  // would hear the reply twice, once from each engine.
649
- if (played === 0) startSpeech(text, sessionId, turn);
976
+ if (played === 0) {
977
+ speechRun.fallbackReason = speechRun.error;
978
+ startSpeech(text, sessionId, turn);
979
+ }
650
980
  // Keep the owning turn so its row can show the failure dot.
651
981
  else speechStore.set({ status: 'idle', sessionId: speechRun.sessionId, turn: speechRun.turn, index: 0, total: 0, rate: speechRun.rate, error: speechRun.error, engine: null });
652
982
  }
653
983
  }
654
984
 
985
+ /** 系统语音的状态广播:和本地服务的 publishSpeech 对齐,界面上控制条才认得。 */
986
+ function publishSystem(status) {
987
+ speechStore.set({
988
+ status,
989
+ sessionId: speechRun.sessionId,
990
+ turn: speechRun.turn,
991
+ index: speechRun.index,
992
+ total: speechRun.chunks.length,
993
+ rate: speechRun.rate,
994
+ error: speechRun.error,
995
+ engine: 'system',
996
+ });
997
+ }
998
+
999
+ /** 从第 index 句开始念;token 变了说明这次朗读已被取消。 */
1000
+ function speakSystemFrom(index, token) {
1001
+ if (token !== speechRun.token) return;
1002
+ const synth = window.speechSynthesis;
1003
+ if (!synth) return;
1004
+ if (index >= speechRun.chunks.length) {
1005
+ speechRun.system = false;
1006
+ speechStore.set({ status: 'idle', sessionId: null, turn: null, index: 0, total: 0, engine: null, error: null });
1007
+ return;
1008
+ }
1009
+ speechRun.index = index;
1010
+ const chunk = speechRun.chunks[index];
1011
+ const utterance = new SpeechSynthesisUtterance(chunk);
1012
+ const voice = pickVoice(chunk);
1013
+ if (voice !== null) {
1014
+ utterance.voice = voice;
1015
+ utterance.lang = voice.lang;
1016
+ } else {
1017
+ utterance.lang = /[\u3400-\u9fff]/.test(chunk) ? 'zh-CN' : 'en-US';
1018
+ }
1019
+ // 跟随设置里的语速;系统语音略放慢更自然。
1020
+ utterance.rate = Math.min(1.3, Math.max(0.7, Number(avatarStore.get().rate) || 1)) * 0.95;
1021
+ utterance.pitch = 1;
1022
+ utterance.volume = Math.min(1, Math.max(0, Number(avatarStore.get().volume)));
1023
+ utterance.onend = () => {
1024
+ if (token !== speechRun.token) return;
1025
+ speakSystemFrom(index + 1, token);
1026
+ };
1027
+ utterance.onerror = () => {
1028
+ if (token !== speechRun.token) return;
1029
+ speakSystemFrom(index + 1, token);
1030
+ };
1031
+ publishSystem(speechRun.paused ? 'paused' : 'speaking');
1032
+ try {
1033
+ // 之前 stopSpeech 里 pause 过,Chromium 会一直保持暂停态;
1034
+ // 不 resume 的话新句子只入队不出声。
1035
+ synth.resume();
1036
+ synth.speak(utterance);
1037
+ } catch {
1038
+ speechRun.system = false;
1039
+ speechStore.set({ status: 'idle', sessionId: null, turn: null, index: 0, total: 0, engine: null, error: null });
1040
+ }
1041
+ }
1042
+
655
1043
  function startSpeech(text, sessionId, turn) {
656
1044
  const synth = window.speechSynthesis;
657
1045
  if (!synth || text === '') return;
658
1046
  synth.cancel();
659
- const speak = () => {
660
- const utterance = new SpeechSynthesisUtterance(text);
661
- const voice = pickVoice();
662
- if (voice !== null) {
663
- utterance.voice = voice;
664
- utterance.lang = voice.lang;
665
- } else {
666
- utterance.lang = 'zh-CN';
667
- }
668
- // Slow and even, closer to a composed reading voice than the default.
669
- utterance.rate = 0.95;
670
- utterance.pitch = 1;
671
- utterance.volume = 1;
672
- utterance.onend = () => {
673
- if (speechStore.get().turn === turn) speechStore.set({ status: 'idle', sessionId: null, turn: null, engine: null });
674
- };
675
- utterance.onerror = () => {
676
- speechStore.set({ status: 'idle', sessionId: null, turn: null, engine: null });
677
- };
678
- speechStore.set({ status: 'speaking', sessionId, turn, engine: 'system', index: 0, total: 0, error: null });
679
- synth.speak(utterance);
680
- };
681
- if ((synth.getVoices() || []).length === 0) {
682
- let started = false;
683
- const kick = () => {
684
- if (started) return;
685
- started = true;
686
- refreshVoices();
687
- speak();
688
- };
689
- if (typeof synth.addEventListener === 'function') synth.addEventListener('voiceschanged', kick, { once: true });
690
- window.setTimeout(kick, 400);
691
- } else {
692
- refreshVoices();
693
- speak();
1047
+ refreshVoices();
1048
+ speechRun.token += 1;
1049
+ speechRun.system = true;
1050
+ speechRun.paused = false;
1051
+ speechRun.chunks = splitSpeech(text);
1052
+ speechRun.index = 0;
1053
+ speechRun.sessionId = sessionId;
1054
+ speechRun.turn = turn;
1055
+ speechRun.error = null;
1056
+ if (speechRun.chunks.length === 0) {
1057
+ speechRun.system = false;
1058
+ return;
694
1059
  }
1060
+ speakSystemFrom(0, speechRun.token);
695
1061
  }
696
1062
 
697
1063
  function setSpeech(enabled) {
@@ -710,6 +1076,56 @@ window.__ModuleLoader__.load({
710
1076
  function setEndpoint(value) {
711
1077
  avatarStore.set({ endpoint: value });
712
1078
  persist(ENDPOINT_KEY, value);
1079
+ // 换了地址就重新给直连一次机会。
1080
+ directTtsBroken = false;
1081
+ }
1082
+
1083
+ function setAutoSpeech(enabled) {
1084
+ avatarStore.set({ autoSpeech: enabled === true });
1085
+ persist(AUTO_SPEECH_KEY, enabled === true ? '1' : '0');
1086
+ if (enabled !== true) {
1087
+ // 关掉时清空基线,重新打开后从当时的会话状态起算。
1088
+ autoSpeechState.baselines.clear();
1089
+ autoSpeechState.highest.clear();
1090
+ lastAutoSession = null;
1091
+ }
1092
+ }
1093
+
1094
+ function setVolume(value) {
1095
+ const next = Math.min(1, Math.max(0, Math.round(Number(value) * 100) / 100 || 0));
1096
+ avatarStore.set({ volume: next });
1097
+ persist(VOLUME_KEY, String(next));
1098
+ if (speechRun.gain !== null) {
1099
+ try {
1100
+ speechRun.gain.gain.value = next;
1101
+ } catch {
1102
+ // 已经销毁的节点忽略。
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ function setSysVoice(name) {
1108
+ const next = typeof name === 'string' ? name : '';
1109
+ avatarStore.set({ sysVoice: next });
1110
+ persist(SYSVOICE_KEY, next);
1111
+ }
1112
+
1113
+ /** 用系统语音念一句,给「试听」按钮用。 */
1114
+ function auditionSystemVoice() {
1115
+ if (typeof window.speechSynthesis === 'undefined') return;
1116
+ const sample = tRaw('audition.text');
1117
+ const utterance = new SpeechSynthesisUtterance(sample);
1118
+ const voice = pickVoice(sample);
1119
+ if (voice !== null) {
1120
+ utterance.voice = voice;
1121
+ utterance.lang = voice.lang;
1122
+ }
1123
+ utterance.rate = Math.min(1.3, Math.max(0.7, Number(avatarStore.get().rate) || 1));
1124
+ utterance.pitch = 1;
1125
+ utterance.volume = Math.min(1, Math.max(0, Number(avatarStore.get().volume)));
1126
+ window.speechSynthesis.cancel();
1127
+ window.speechSynthesis.resume();
1128
+ window.speechSynthesis.speak(utterance);
713
1129
  }
714
1130
 
715
1131
  function chooseTone(value) {
@@ -957,6 +1373,9 @@ window.__ModuleLoader__.load({
957
1373
  endpoint: readStored(ENDPOINT_KEY) || DEFAULT_ENDPOINT,
958
1374
  rate: Number(readStored(RATE_KEY)) > 0 ? Number(readStored(RATE_KEY)) : DEFAULT_RATE,
959
1375
  tone: TONES.includes(readStored(TONE_KEY)) ? readStored(TONE_KEY) : 'normal',
1376
+ sysVoice: readStored(SYSVOICE_KEY) || '',
1377
+ autoSpeech: readStored(AUTO_SPEECH_KEY) === '1',
1378
+ volume: readStoredVolume(),
960
1379
  lang: readStoredLang(),
961
1380
  });
962
1381
  presetStore.set({ items: readPresets() });
@@ -1049,6 +1468,35 @@ window.__ModuleLoader__.load({
1049
1468
  return React.useSyncExternalStore(speechStore.subscribe, speechStore.get, speechStore.get);
1050
1469
  }
1051
1470
 
1471
+ /**
1472
+ * 聊天里每一轮都有一个行组件,它们只需要头像设置里的少数几项。
1473
+ * 用字符串快照订阅,打字改 ID、调语速这些都不会牵动所有行。
1474
+ */
1475
+ function useRowAvatar() {
1476
+ React.useSyncExternalStore(
1477
+ avatarStore.subscribe,
1478
+ () => {
1479
+ const a = avatarStore.get();
1480
+ return [a.id, a.size, a.speech ? 1 : 0, a.engine, a.autoSpeech ? 1 : 0, a.src === null ? 0 : a.src.length].join('|');
1481
+ },
1482
+ () => '',
1483
+ );
1484
+ return avatarStore.get();
1485
+ }
1486
+
1487
+ /** 行组件关心的播放状态:进度变化不应该让所有行重渲染。 */
1488
+ function useRowSpeech() {
1489
+ React.useSyncExternalStore(
1490
+ speechStore.subscribe,
1491
+ () => {
1492
+ const s = speechStore.get();
1493
+ return [s.status, s.sessionId, s.turn, s.engine, s.error === null ? '' : 'e' + s.error.length].join('|');
1494
+ },
1495
+ () => '',
1496
+ );
1497
+ return speechStore.get();
1498
+ }
1499
+
1052
1500
  function useStats() {
1053
1501
  return React.useSyncExternalStore(statsStore.subscribe, statsStore.get, statsStore.get);
1054
1502
  }
@@ -1202,6 +1650,62 @@ window.__ModuleLoader__.load({
1202
1650
  );
1203
1651
  }
1204
1652
 
1653
+ /**
1654
+ * 自动朗读的状态。
1655
+ * - 每个会话各记一条"基线"(打开自动朗读时已有的最新一轮),基线及更早的不念;
1656
+ * - 基线要等这一批渲染结束再定,否则会把最新的那条当成"新回复"立刻念出来。
1657
+ */
1658
+ const autoSpeechState = { baselines: new Map(), highest: new Map(), settleTimers: new Map(), spoken: null, pending: null, timer: null };
1659
+ /** 最近一次渲染所属的会话;切会话时用它判断,并把该会话的基线重置到当前最新。 */
1660
+ let lastAutoSession = null;
1661
+
1662
+ function scheduleAutoSpeech(turn, text, enabled, sessionId) {
1663
+ if (!enabled) {
1664
+ // 关掉再打开时重新取基线,别把中间那段时间的回复补念了。
1665
+ autoSpeechState.baselines.clear();
1666
+ autoSpeechState.highest.clear();
1667
+ return;
1668
+ }
1669
+ if (text === '' || turn === undefined || sessionId === undefined) return;
1670
+ const state = autoSpeechState;
1671
+ const key = String(sessionId);
1672
+
1673
+ const seen = state.highest.get(key);
1674
+ state.highest.set(key, seen === undefined ? turn : Math.max(seen, turn));
1675
+
1676
+ if (!state.baselines.has(key)) {
1677
+ // 等这一批行都渲染完,再把这个会话当前最高的轮次定为基线。
1678
+ const timer = state.settleTimers.get(key);
1679
+ if (timer !== undefined) window.clearTimeout(timer);
1680
+ state.settleTimers.set(
1681
+ key,
1682
+ window.setTimeout(() => {
1683
+ state.settleTimers.delete(key);
1684
+ state.baselines.set(key, state.highest.get(key));
1685
+ }, 400),
1686
+ );
1687
+ return;
1688
+ }
1689
+
1690
+ const baseline = state.baselines.get(key);
1691
+ if (turn <= baseline || turn === state.spoken) return;
1692
+ state.pending = { turn, text, sessionId };
1693
+ if (state.timer !== null) window.clearTimeout(state.timer);
1694
+ // 等一轮渲染稳定再念,避免同一轮被重复触发。
1695
+ state.timer = window.setTimeout(() => {
1696
+ state.timer = null;
1697
+ const pending = state.pending;
1698
+ if (pending === null) return;
1699
+ state.pending = null;
1700
+ state.spoken = pending.turn;
1701
+ // 上一轮还没念完就来了新回复:打断它,先念新的。
1702
+ if (speechStore.get().status !== 'idle') stopSpeech();
1703
+ const engine = avatarStore.get().engine;
1704
+ if (engine === 'clone') speakClone(pending.text, pending.sessionId, pending.turn);
1705
+ else startSpeech(pending.text, pending.sessionId, pending.turn);
1706
+ }, 600);
1707
+ }
1708
+
1205
1709
  /**
1206
1710
  * Replacement renderer for the settled Turn header row ("已完成,用时 …").
1207
1711
  * It keeps the shipped disclosure behaviour, adds the character avatar in
@@ -1211,17 +1715,34 @@ window.__ModuleLoader__.load({
1211
1715
  const { node, turnProcess, t: hostT, useChat, sessionId } = props;
1212
1716
  // 语言按钮改的是插件自己的文案,这里把宿主的 t 换掉。
1213
1717
  const t = useT(hostT);
1214
- const avatar = useAvatar();
1215
- const speech = useSpeech();
1718
+ const avatar = useRowAvatar();
1719
+ const speech = useRowSpeech();
1216
1720
  const turnNumber = node !== undefined && node.data !== undefined ? node.data.turn : undefined;
1217
1721
  const answerStep =
1218
1722
  turnProcess !== undefined && turnProcess.spec !== undefined ? turnProcess.spec.answerStep : undefined;
1219
1723
  const selectChat = typeof useChat === 'function' ? useChat : (selector) => selector(EMPTY_CHAT_SNAPSHOT);
1220
1724
  const replyText = selectChat((snapshot) => readReplyText(snapshot, turnNumber, answerStep, avatar.speech));
1725
+ React.useEffect(() => {
1726
+ if (!avatar.speech || !avatar.autoSpeech) return;
1727
+ // 切进一个会话时,把它已有的最新一轮当作基线:旧内容不念,之后的回复照常念。
1728
+ const current = String(sessionId);
1729
+ if (lastAutoSession !== current) {
1730
+ lastAutoSession = current;
1731
+ autoSpeechState.baselines.delete(current);
1732
+ autoSpeechState.settleTimers.forEach((timer) => window.clearTimeout(timer));
1733
+ autoSpeechState.settleTimers.clear();
1734
+ }
1735
+ scheduleAutoSpeech(turnNumber, replyText, true, sessionId);
1736
+ }, [turnNumber, replyText, avatar.autoSpeech, avatar.speech, sessionId]);
1737
+ // 换会话(或这一轮已经不属于当前播放)时,把还在响的声音停掉:
1738
+ // 之前只监听 sessionId 变化,有时状态已经切走、声音却还在播。
1221
1739
  React.useEffect(() => {
1222
1740
  const current = speechStore.get();
1223
- if (current.status === 'speaking' && current.sessionId !== sessionId) stopSpeech();
1224
- }, [sessionId]);
1741
+ if (current.status === 'idle') return;
1742
+ // 试听(sessionId 为 null)不属于任何会话,不能被聊天行停掉。
1743
+ if (current.sessionId === null) return;
1744
+ if (current.sessionId !== sessionId) stopSpeech();
1745
+ }, [sessionId, speech.status, speech.sessionId]);
1225
1746
  if (turnProcess === undefined || node === undefined) return null;
1226
1747
  const location = node.location;
1227
1748
  const turn = location && (location.kind === 'turn' || location.kind === 'step') ? location.turn : undefined;
@@ -1297,7 +1818,7 @@ window.__ModuleLoader__.load({
1297
1818
  const failureDot =
1298
1819
  showSpeaker && failed ? h('span', { className: 'dsav-errorDot', title: speech.error, 'aria-label': speech.error }) : null;
1299
1820
  // 朗读时把控制条接在小喇叭右边,紧贴正在读的那一行。
1300
- const inlineControls = speaking && speech.engine === 'clone' ? h(SpeechControls, { t, speech }) : null;
1821
+ const inlineControls = speaking && speech.engine !== null ? h(SpeechControls, { t, speech }) : null;
1301
1822
 
1302
1823
  const captionRow =
1303
1824
  caption === null && speaker === null && failureDot === null
@@ -1439,58 +1960,74 @@ window.__ModuleLoader__.load({
1439
1960
  /** Live reachability of the user's own service, so a blank voice is explainable. */
1440
1961
  function useCloneStatus(endpoint) {
1441
1962
  const [state, setState] = React.useState({ status: 'checking', model: null });
1442
- const tick = React.useSyncExternalStore(probeStore.subscribe, probeStore.get, probeStore.get).tick;
1963
+ const probe = React.useSyncExternalStore(probeStore.subscribe, probeStore.get, probeStore.get);
1964
+ const tick = probe.tick;
1965
+ const startingSince = probe.startingSince;
1443
1966
  React.useEffect(() => {
1444
1967
  const address = (endpoint || '').trim().replace(/\/+$/, '') || DEFAULT_ENDPOINT;
1445
1968
  let alive = true;
1446
1969
  let retryTimer = null;
1447
1970
  let controller = null;
1448
1971
  // 服务重启时第一次请求可能落在旧连接上,先重试一次再判定离线;
1449
- // 判定离线后每 8 秒再探一次,服务起来后界面会自己变回已连接。
1450
- const schedule = (delay) => {
1972
+ // 判定离线后每 2.5 秒再探一次(本机 /health 只要 2ms,成本可忽略),
1973
+ // 连上后改成每 30 秒一次的静默保活,服务中途挂掉也能自己变红。
1974
+ const OFFLINE_RETRY_MS = 2500;
1975
+ const ONLINE_KEEPALIVE_MS = 30000;
1976
+ const TRANSIENT_RETRY_MS = 700;
1977
+ const schedule = (delay, attempt, silent) => {
1451
1978
  retryTimer = window.setTimeout(() => {
1452
1979
  if (!alive) return;
1453
- probe(0);
1980
+ probe(attempt, silent);
1454
1981
  }, delay);
1455
1982
  };
1456
- const probe = (attempt) => {
1983
+ const probe = (attempt, silent) => {
1457
1984
  if (!alive) return;
1458
1985
  controller = new AbortController();
1459
1986
  const timer = window.setTimeout(() => controller.abort(), 4000);
1987
+ const retryOrOffline = (reason) => {
1988
+ if (!alive) return;
1989
+ const since = probeStore.get().startingSince;
1990
+ const warming = since !== null && Date.now() - since < SERVICE_WARMUP_MS;
1991
+ if (warming) {
1992
+ // 暖机期:服务大概率还在加载模型,保持「检测中」,别显示「未连接」。
1993
+ setState({ status: 'checking', model: null, reason: null });
1994
+ schedule(1200, 0, true);
1995
+ return;
1996
+ }
1997
+ if (attempt < 1) schedule(TRANSIENT_RETRY_MS, attempt + 1, silent);
1998
+ else setOffline(reason);
1999
+ };
2000
+ const markOnline = (model) => {
2001
+ if (!alive) return;
2002
+ if (probeStore.get().startingSince !== null) probeStore.set({ startingSince: null });
2003
+ setState({ status: 'online', model, reason: null });
2004
+ schedule(ONLINE_KEEPALIVE_MS, 0, true);
2005
+ };
1460
2006
  fetch(address + '/health', { signal: controller.signal })
1461
2007
  .then((response) => {
1462
2008
  if (!alive) return;
1463
2009
  if (!response.ok) {
1464
- if (attempt === 0) schedule(700);
1465
- else setOffline();
2010
+ retryOrOffline('http:' + response.status);
1466
2011
  return;
1467
2012
  }
1468
2013
  return response
1469
2014
  .json()
1470
- .then((payload) => {
1471
- if (alive) setState({ status: 'online', model: payload && payload.model ? String(payload.model) : null });
1472
- })
1473
- .catch(() => {
1474
- if (alive) setState({ status: 'online', model: null });
1475
- });
1476
- })
1477
- .catch(() => {
1478
- if (!alive) return;
1479
- if (attempt === 0) schedule(700);
1480
- else setOffline();
2015
+ .then((payload) => markOnline(payload && payload.model ? String(payload.model) : null))
2016
+ .catch(() => markOnline(null));
1481
2017
  })
2018
+ .catch((error) => retryOrOffline(error && error.name === 'AbortError' ? 'timeout' : 'unreachable'))
1482
2019
  .finally(() => {
1483
2020
  window.clearTimeout(timer);
1484
2021
  });
1485
2022
  };
1486
- const setOffline = () => {
1487
- setState({ status: 'offline', model: null });
1488
- schedule(8000);
2023
+ const setOffline = (reason) => {
2024
+ setState({ status: 'offline', model: null, reason: typeof reason === 'string' ? reason : 'unreachable' });
2025
+ schedule(OFFLINE_RETRY_MS, 0, true);
1489
2026
  };
1490
2027
  // Wait for typing to settle, so the address is not probed per keystroke.
1491
2028
  const debounce = window.setTimeout(() => {
1492
2029
  setState({ status: 'checking', model: null });
1493
- probe(0);
2030
+ probe(0, false);
1494
2031
  }, 350);
1495
2032
  return () => {
1496
2033
  alive = false;
@@ -1498,8 +2035,20 @@ window.__ModuleLoader__.load({
1498
2035
  if (retryTimer !== null) window.clearTimeout(retryTimer);
1499
2036
  if (controller !== null) controller.abort();
1500
2037
  };
1501
- }, [endpoint, tick]);
1502
- return state;
2038
+ }, [endpoint, tick, startingSince]);
2039
+
2040
+ // 等待期间每秒重渲染一次,让「已等待 N 秒」走起来。
2041
+ const [nowTick, setNowTick] = React.useState(0);
2042
+ React.useEffect(() => {
2043
+ if (startingSince === null) return undefined;
2044
+ const timer = window.setInterval(() => setNowTick((value) => value + 1), 1000);
2045
+ return () => window.clearInterval(timer);
2046
+ }, [startingSince]);
2047
+
2048
+ const warming = startingSince !== null && Date.now() - startingSince < SERVICE_WARMUP_MS;
2049
+ return warming && state.status !== 'online'
2050
+ ? { status: 'checking', model: null, waitingSeconds: Math.max(1, Math.round((Date.now() - startingSince) / 1000)) }
2051
+ : state;
1503
2052
  }
1504
2053
 
1505
2054
  /** 让健康检查立刻再探一次。 */
@@ -1507,6 +2056,30 @@ window.__ModuleLoader__.load({
1507
2056
  probeStore.set({ tick: probeStore.get().tick + 1 });
1508
2057
  }
1509
2058
 
2059
+ /**
2060
+ * 点了「启动服务」之后调用:进入暖机期。
2061
+ * 模型加载要十几秒,这段时间探测必然失败,但不能因此显示「未连接」把人吓走。
2062
+ */
2063
+ const SERVICE_WARMUP_MS = 25000;
2064
+ function beginServiceWarmup() {
2065
+ probeStore.set({ tick: probeStore.get().tick + 1, startingSince: Date.now() });
2066
+ }
2067
+
2068
+ /** 状态下面那行小字:等待进度或失败原因。原因限制长度,避免撑破界面。 */
2069
+ function statusDetail(t, status) {
2070
+ if (status.waitingSeconds) {
2071
+ return t('status.warming').replace('{s}', String(status.waitingSeconds));
2072
+ }
2073
+ if (status.status !== 'offline' || !status.reason) return null;
2074
+ const reason = String(status.reason);
2075
+ const text = reason.startsWith('http:')
2076
+ ? t('status.reasonHttp').replace('{code}', reason.slice(5))
2077
+ : reason === 'timeout'
2078
+ ? t('status.reasonTimeout')
2079
+ : t('status.reasonUnreachable');
2080
+ return text.length > 70 ? text.slice(0, 70) + '…' : text;
2081
+ }
2082
+
1510
2083
  function EndpointField({ t }) {
1511
2084
  const avatar = useAvatar();
1512
2085
  const status = useCloneStatus(avatar.endpoint);
@@ -1538,6 +2111,51 @@ window.__ModuleLoader__.load({
1538
2111
  }
1539
2112
 
1540
2113
  /** Which engine reads the reply: the system voice or a local service the user runs. */
2114
+ /** 系统语音选择:浏览器自带的音色有软有硬,交给用户挑,附一个试听。 */
2115
+ function SystemVoiceField({ t }) {
2116
+ const avatar = useAvatar();
2117
+ const voices = React.useSyncExternalStore(voicesStore.subscribe, voicesStore.get, voicesStore.get).list;
2118
+ React.useEffect(() => {
2119
+ refreshVoices();
2120
+ const synth = window.speechSynthesis;
2121
+ if (synth && typeof synth.addEventListener === 'function') {
2122
+ const onChange = () => refreshVoices();
2123
+ synth.addEventListener('voiceschanged', onChange);
2124
+ return () => synth.removeEventListener('voiceschanged', onChange);
2125
+ }
2126
+ return undefined;
2127
+ }, []);
2128
+ const options = voiceOptions(); // 中英都列,按界面语言排序
2129
+ const autoLabel = options.length > 0 ? t('sysvoice.auto') + ' · ' + options[0].name : t('sysvoice.auto');
2130
+ return h(
2131
+ 'div',
2132
+ { className: 'dsav-field' },
2133
+ h(
2134
+ 'div',
2135
+ { className: 'dsav-fieldHead' },
2136
+ h('span', { className: 'dsav-toggleLabel' }, t('sysvoice.label')),
2137
+ h(
2138
+ 'span',
2139
+ { className: 'dsav-voiceTools' },
2140
+ h('span', { className: 'dsav-endpointHint dsav-voiceCount' }, t('sysvoice.count').replace('{n}', String(options.length))),
2141
+ h('button', { type: 'button', className: 'dsav-btn', onClick: () => refreshVoices() }, t('sysvoice.refresh')),
2142
+ h('button', { type: 'button', className: 'dsav-btn', onClick: () => auditionSystemVoice() }, t('sysvoice.test')),
2143
+ ),
2144
+ ),
2145
+ h(
2146
+ 'select',
2147
+ {
2148
+ className: 'dsav-select dsav-selectFull',
2149
+ value: avatar.sysVoice,
2150
+ 'aria-label': t('sysvoice.label'),
2151
+ onChange: (event) => setSysVoice(event.target.value),
2152
+ },
2153
+ h('option', { value: '' }, autoLabel),
2154
+ options.map((voice) => h('option', { key: voice.name, value: voice.name }, voice.name + ' · ' + voice.lang)),
2155
+ ),
2156
+ );
2157
+ }
2158
+
1541
2159
  function EngineField({ t }) {
1542
2160
  const avatar = useAvatar();
1543
2161
  const options = [
@@ -1788,11 +2406,18 @@ window.__ModuleLoader__.load({
1788
2406
  h(IdField, { t, id: avatar.id }),
1789
2407
  h(FontSizeField, { t }),
1790
2408
  h(SpeechToggle, { t }),
2409
+ h(AutoSpeechField, { t }),
2410
+ h(VolumeField, { t }),
1791
2411
  h(EngineField, { t }),
2412
+ avatar.engine === 'clone' ? null : h(SystemVoiceField, { t }),
2413
+ h(
2414
+ 'div',
2415
+ { className: 'dsav-sectionRow dsav-auditionRow' },
2416
+ h(AuditionButton, { t }),
2417
+ ),
1792
2418
  avatar.engine === 'clone' ? h(ToneField, { t }) : null,
1793
2419
  avatar.engine === 'clone' ? h(RateField, { t }) : null,
1794
2420
  h(PresetBar, { t }),
1795
- h('div', { className: 'dsav-pickHint' }, t('dialog.pickHint')),
1796
2421
  h(
1797
2422
  'div',
1798
2423
  { className: 'dsav-actions' },
@@ -1999,21 +2624,133 @@ window.__ModuleLoader__.load({
1999
2624
  }),
2000
2625
  ),
2001
2626
  h(FontSizeField, { t }),
2627
+ h(PresetBar, { t }),
2628
+ h(SpeechSettings, { t, status }),
2629
+ h(ServiceControl, { t, online: status.status === 'online', engine: avatar.engine }),
2630
+ h(DiagnosticsBlock, { t, status }),
2631
+ h('div', { className: 'dsav-bundleHint' }, t('bundle.hint')),
2632
+ );
2633
+ }
2634
+
2635
+ /**
2636
+ * 朗读相关的整块设置:设置页与插件详情页共用。
2637
+ * 以前两边各写一份,改了一边另一边就落后;现在只维护这里。
2638
+ */
2639
+ function SpeechSettings({ t, status }) {
2640
+ const avatar = useAvatar();
2641
+ return h(
2642
+ Fragment,
2643
+ null,
2644
+ h('div', { className: 'dsav-sectionTitle' }, t('section.speech')),
2002
2645
  h(SpeechToggle, { t }),
2646
+ h(AutoSpeechField, { t }),
2647
+ h(VolumeField, { t }),
2003
2648
  h(EngineField, { t }),
2649
+ avatar.engine === 'clone' ? null : h(SystemVoiceField, { t }),
2004
2650
  avatar.engine === 'clone' ? h(ToneField, { t }) : null,
2005
2651
  h(RateField, { t }),
2006
- avatar.engine === 'clone' ? h(AuditionButton, { t }) : null,
2007
- h(PresetBar, { t }),
2008
- h('div', { className: 'dsav-sectionTitle' }, t('section.service')),
2009
- h(ServiceControl, { t, online: status.status === 'online' }),
2010
- h('div', { className: 'dsav-bundleHint' }, t('bundle.hint')),
2652
+ h(
2653
+ 'div',
2654
+ { className: 'dsav-sectionRow' },
2655
+ h(AuditionButton, { t }),
2656
+ h(
2657
+ 'span',
2658
+ { className: 'dsav-statusLine' },
2659
+ h(
2660
+ 'span',
2661
+ { className: 'dsav-state dsav-state-' + status.status },
2662
+ h('span', { className: 'dsav-dot' }),
2663
+ t('status.' + status.status) + (status.model ? ' · ' + status.model : ''),
2664
+ ),
2665
+ statusDetail(t, status) === null
2666
+ ? null
2667
+ : h('span', { className: 'dsav-endpointHint dsav-warming' }, statusDetail(t, status)),
2668
+ ),
2669
+ ),
2670
+ );
2671
+ }
2672
+
2673
+ /** 诊断信息:两个页面共用。 */
2674
+ function DiagnosticsBlock({ t, status }) {
2675
+ const avatar = useAvatar();
2676
+ const speech = useSpeech();
2677
+ const stats = useStats();
2678
+ return h(
2679
+ Fragment,
2680
+ null,
2681
+ h('div', { className: 'dsav-sectionTitle' }, t('section.diagnostics')),
2682
+ h(
2683
+ 'div',
2684
+ { className: 'dsav-diagnostics' },
2685
+ avatar.engine === 'clone' ? h('div', null, t('diag.service') + ': ' + t('status.' + status.status)) : null,
2686
+ avatar.engine !== 'clone' || stats.lastMs === null ? null : h('div', null, t('diag.latency') + ': ' + Math.round(stats.lastMs) + ' ms'),
2687
+ speech.status === 'idle' ? null : h('div', null, t('diag.progress') + ': ' + (speech.index + 1) + ' / ' + speech.total),
2688
+ h(
2689
+ 'div',
2690
+ { className: speech.error ? 'dsav-diagError' : undefined },
2691
+ t('diag.lastError') + ': ' + (speech.error || t('diag.none')),
2692
+ ),
2693
+ ),
2011
2694
  );
2012
2695
  }
2013
2696
 
2014
2697
  /* ------------------------------------------------------ settings row */
2015
2698
 
2016
2699
  /** 情绪档位:映射到服务端的 temperature / top_k。 */
2700
+ /** 自动朗读新回复。 */
2701
+ function AutoSpeechField({ t }) {
2702
+ const avatar = useAvatar();
2703
+ const disabled = !avatar.speech;
2704
+ return h(
2705
+ 'div',
2706
+ { className: 'dsav-toggleRow' },
2707
+ h(
2708
+ 'span',
2709
+ { className: 'dsav-toggleLabel' + (disabled ? ' dsav-muted' : '') },
2710
+ t('auto.label'),
2711
+ ),
2712
+ h(
2713
+ 'button',
2714
+ {
2715
+ type: 'button',
2716
+ role: 'switch',
2717
+ 'aria-checked': avatar.autoSpeech ? 'true' : 'false',
2718
+ 'aria-label': t('auto.label'),
2719
+ disabled,
2720
+ className: 'dsav-switch' + (avatar.autoSpeech ? ' dsav-switchOn' : ''),
2721
+ onClick: () => setAutoSpeech(!avatar.autoSpeech),
2722
+ },
2723
+ h('span', { className: 'dsav-switchDot' }),
2724
+ ),
2725
+ );
2726
+ }
2727
+
2728
+ /** 音量:两种引擎都生效。 */
2729
+ function VolumeField({ t }) {
2730
+ const avatar = useAvatar();
2731
+ const percent = Math.round(avatar.volume * 100);
2732
+ return h(
2733
+ 'div',
2734
+ { className: 'dsav-toggleRow' },
2735
+ h('span', { className: 'dsav-toggleLabel' }, t('volume.label')),
2736
+ h(
2737
+ 'span',
2738
+ { className: 'dsav-volume' },
2739
+ h('input', {
2740
+ className: 'dsav-range',
2741
+ type: 'range',
2742
+ min: 0,
2743
+ max: 100,
2744
+ step: 5,
2745
+ value: percent,
2746
+ 'aria-label': t('volume.label'),
2747
+ onChange: (event) => setVolume(Number(event.target.value) / 100),
2748
+ }),
2749
+ h('span', { className: 'dsav-volumeValue' }, percent + '%'),
2750
+ ),
2751
+ );
2752
+ }
2753
+
2017
2754
  function ToneField({ t }) {
2018
2755
  const avatar = useAvatar();
2019
2756
  const options = [
@@ -2077,15 +2814,43 @@ window.__ModuleLoader__.load({
2077
2814
  function AuditionButton({ t }) {
2078
2815
  const speech = useSpeech();
2079
2816
  const busy = speech.status === 'speaking' || speech.status === 'paused';
2817
+ // 只显示试听自己产生的错误:试听不属于任何会话(sessionId 为 null),
2818
+ // 聊天里某句朗读失败不应该在设置页显示成「试听失败」。
2819
+ const failed = !busy && speech.error && speech.sessionId === null;
2820
+ const hint = busy
2821
+ ? t('audition.playing').replace('{n}', String(speech.index + 1)).replace('{m}', String(speech.total))
2822
+ : failed
2823
+ ? t('audition.failed') + ':' + String(speech.error).slice(0, 60)
2824
+ : null;
2080
2825
  return h(
2081
- 'button',
2082
- {
2083
- type: 'button',
2084
- className: 'dsav-btn dsav-audition',
2085
- disabled: busy,
2086
- onClick: () => speakClone(t('audition.text'), null, null),
2087
- },
2088
- busy ? t('audition.stopHint') : t('audition.label'),
2826
+ Fragment,
2827
+ null,
2828
+ h(
2829
+ 'button',
2830
+ {
2831
+ type: 'button',
2832
+ className: 'dsav-btn dsav-audition',
2833
+ // 永远可点:状态可能残留成 speaking(没有对应行能停掉),
2834
+ // 点一下就先收干净再从头念,保证有反应。
2835
+ onClick: () => {
2836
+ const text = t('audition.text');
2837
+ const engine = avatarStore.get().engine;
2838
+ trace('audition: click received engine=' + engine + ' text=' + text.slice(0, 20));
2839
+ stopSpeech();
2840
+ if (engine === 'clone') {
2841
+ speakClone(text, null, null).catch((error) => trace('audition: unhandled ' + String(error)));
2842
+ } else {
2843
+ auditionSystemVoice();
2844
+ }
2845
+ },
2846
+ onMouseDown: (event) => event.stopPropagation(),
2847
+ onPointerDown: (event) => event.stopPropagation(),
2848
+ },
2849
+ t('audition.label'),
2850
+ ),
2851
+ hint === null
2852
+ ? null
2853
+ : h('span', { className: failed ? 'dsav-endpointHint dsav-auditionError' : 'dsav-endpointHint' }, hint),
2089
2854
  );
2090
2855
  }
2091
2856
 
@@ -2182,7 +2947,7 @@ window.__ModuleLoader__.load({
2182
2947
  const EMPTY_SERVICE_CONFIG = { command: '', cwd: '', env: '', autostart: false };
2183
2948
 
2184
2949
  /** 语音服务:手动启动/停止、随 DSH 启动、启动命令配置。 */
2185
- function ServiceControl({ t, online }) {
2950
+ function ServiceControl({ t, online, engine }) {
2186
2951
  const [ready, setReady] = React.useState(false);
2187
2952
  const [available, setAvailable] = React.useState(true);
2188
2953
  const [running, setRunning] = React.useState(false);
@@ -2232,7 +2997,28 @@ window.__ModuleLoader__.load({
2232
2997
  React.useEffect(load, []);
2233
2998
 
2234
2999
  const act = (action, payload) => {
2235
- setBusy(action);
3000
+ const restart = action === 'restart';
3001
+ setBusy(restart ? 'restart' : action);
3002
+ if (restart) {
3003
+ serviceCall('POST', { action: 'stop' })
3004
+ .then((data) => {
3005
+ adopt(data);
3006
+ return new Promise((resolve) => window.setTimeout(resolve, 600));
3007
+ })
3008
+ .then(() => serviceCall('POST', { action: 'start' }))
3009
+ .then((data) => {
3010
+ adopt(data);
3011
+ setMessage(data.ok === false ? data.error || t('service.failed') : t('service.startDone'));
3012
+ beginServiceWarmup();
3013
+ window.setTimeout(load, 3000);
3014
+ })
3015
+ .catch((error) => {
3016
+ setAvailable(false);
3017
+ setMessage(String(error && error.message ? error.message : error));
3018
+ })
3019
+ .finally(() => setBusy(''));
3020
+ return;
3021
+ }
2236
3022
  serviceCall('POST', { action, ...(payload || {}) })
2237
3023
  .then((data) => {
2238
3024
  setAvailable(true);
@@ -2243,7 +3029,7 @@ window.__ModuleLoader__.load({
2243
3029
  setMessage(t('service.' + action + 'Done'));
2244
3030
  }
2245
3031
  if (action === 'start') {
2246
- window.setTimeout(recheckService, 1500);
3032
+ beginServiceWarmup();
2247
3033
  // 进程若几秒内就退出,把它的输出取回来给用户看。
2248
3034
  window.setTimeout(load, 3000);
2249
3035
  }
@@ -2256,14 +3042,15 @@ window.__ModuleLoader__.load({
2256
3042
  .finally(() => setBusy(''));
2257
3043
  };
2258
3044
 
2259
- if (!ready) {
2260
- return h('div', { className: 'dsav-service' }, h('div', { className: 'dsav-sectionTitle' }, t('service.title')));
2261
- }
3045
+ // 先拿到宿主端状态再决定显示什么:音色是「系统语音」时这块默认整段隐藏,
3046
+ // 但进程还在跑、或者开着「随 DSH 启动」时仍要露出来,否则用户没有收尾的入口。
3047
+ if (!ready) return null;
3048
+ if (engine !== 'clone' && !running && !config.autostart) return null;
2262
3049
  if (!available) {
2263
3050
  return h(
2264
3051
  'div',
2265
3052
  { className: 'dsav-service' },
2266
- h('div', { className: 'dsav-sectionTitle' }, t('service.title')),
3053
+ h('div', { className: 'dsav-sectionTitle' }, t('section.service')),
2267
3054
  h('div', { className: 'dsav-endpointHint' }, t('service.unavailable')),
2268
3055
  );
2269
3056
  }
@@ -2271,7 +3058,8 @@ window.__ModuleLoader__.load({
2271
3058
  return h(
2272
3059
  'div',
2273
3060
  { className: 'dsav-service' },
2274
- h('div', { className: 'dsav-sectionTitle' }, t('service.title')),
3061
+ h('div', { className: 'dsav-sectionTitle' }, t('section.service')),
3062
+ engine === 'clone' ? null : h('div', { className: 'dsav-endpointHint' }, t('service.onlyForLocal')),
2275
3063
  h(
2276
3064
  'div',
2277
3065
  { className: 'dsav-serviceRow' },
@@ -2281,16 +3069,18 @@ window.__ModuleLoader__.load({
2281
3069
  h('span', { className: 'dsav-dot' }),
2282
3070
  running ? t('service.running') : online ? t('service.external') : t('service.stopped'),
2283
3071
  ),
2284
- h(
2285
- 'button',
2286
- {
2287
- type: 'button',
2288
- className: 'dsav-btn',
2289
- disabled: busy !== '' || running || online,
2290
- onClick: () => act('start'),
2291
- },
2292
- t('service.start'),
2293
- ),
3072
+ engine === 'clone'
3073
+ ? h(
3074
+ 'button',
3075
+ {
3076
+ type: 'button',
3077
+ className: 'dsav-btn',
3078
+ disabled: busy !== '' || running || online,
3079
+ onClick: () => act('start'),
3080
+ },
3081
+ t('service.start'),
3082
+ )
3083
+ : null,
2294
3084
  h(
2295
3085
  'button',
2296
3086
  {
@@ -2301,6 +3091,18 @@ window.__ModuleLoader__.load({
2301
3091
  },
2302
3092
  t('service.stop'),
2303
3093
  ),
3094
+ engine === 'clone'
3095
+ ? h(
3096
+ 'button',
3097
+ {
3098
+ type: 'button',
3099
+ className: 'dsav-btn',
3100
+ disabled: busy !== '' || !running,
3101
+ onClick: () => act('restart'),
3102
+ },
3103
+ t('service.restart'),
3104
+ )
3105
+ : null,
2304
3106
  ),
2305
3107
  h(
2306
3108
  'div',
@@ -2391,9 +3193,7 @@ window.__ModuleLoader__.load({
2391
3193
  function SettingsSection({ t: hostT }) {
2392
3194
  const t = useT(hostT);
2393
3195
  const avatar = useAvatar();
2394
- const speech = useSpeech();
2395
3196
  const status = useCloneStatus(avatar.endpoint);
2396
- const stats = useStats();
2397
3197
  return h(
2398
3198
  'div',
2399
3199
  { className: 'dsav-section' },
@@ -2434,39 +3234,9 @@ window.__ModuleLoader__.load({
2434
3234
  ),
2435
3235
  h(FontSizeField, { t }),
2436
3236
  h(PresetBar, { t }),
2437
- h('div', { className: 'dsav-sectionTitle' }, t('section.speech')),
2438
- h(SpeechToggle, { t }),
2439
- h(EngineField, { t }),
2440
- avatar.engine === 'clone' ? h(ToneField, { t }) : null,
2441
- h(RateField, { t }),
2442
- avatar.engine === 'clone'
2443
- ? h(
2444
- 'div',
2445
- { className: 'dsav-sectionRow' },
2446
- h(AuditionButton, { t }),
2447
- h(
2448
- 'span',
2449
- { className: 'dsav-state dsav-state-' + status.status },
2450
- h('span', { className: 'dsav-dot' }),
2451
- t('status.' + status.status) + (status.model ? ' · ' + status.model : ''),
2452
- ),
2453
- )
2454
- : null,
2455
- h('div', { className: 'dsav-sectionTitle' }, t('section.service')),
2456
- h(ServiceControl, { t, online: status.status === 'online' }),
2457
- h('div', { className: 'dsav-sectionTitle' }, t('section.diagnostics')),
2458
- h(
2459
- 'div',
2460
- { className: 'dsav-diagnostics' },
2461
- h('div', null, t('diag.service') + ': ' + t('status.' + status.status)),
2462
- stats.lastMs === null ? null : h('div', null, t('diag.latency') + ': ' + Math.round(stats.lastMs) + ' ms'),
2463
- speech.status === 'idle' ? null : h('div', null, t('diag.progress') + ': ' + (speech.index + 1) + ' / ' + speech.total),
2464
- h(
2465
- 'div',
2466
- { className: speech.error ? 'dsav-diagError' : undefined },
2467
- t('diag.lastError') + ': ' + (speech.error || t('diag.none')),
2468
- ),
2469
- ),
3237
+ h(SpeechSettings, { t, status }),
3238
+ h(ServiceControl, { t, online: status.status === 'online', engine: avatar.engine }),
3239
+ h(DiagnosticsBlock, { t, status }),
2470
3240
  );
2471
3241
  }
2472
3242
 
@@ -2503,11 +3273,24 @@ window.__ModuleLoader__.load({
2503
3273
  .dsav-langBtn{height:22px;padding:0 10px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:11px;line-height:1;cursor:pointer}
2504
3274
  .dsav-langBtn:hover{color:var(--dsw-alias-label-primary)}
2505
3275
  .dsav-langOn{background:var(--dsw-alias-bg-l2);color:var(--dsw-alias-label-primary);font-weight:600}
2506
- .dsav-dialogSmall{width:min(368px,100%)}
3276
+ .dsav-field{display:flex;flex-direction:column;gap:8px;margin-top:12px}
3277
+ .dsav-fieldHead{display:flex;align-items:center;justify-content:space-between;gap:12px}
3278
+ .dsav-selectFull{width:100%;max-width:none}
3279
+ .dsav-voiceTools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3280
+ .dsav-voiceCount{white-space:nowrap}
3281
+ .dsav-warming{font-variant-numeric:tabular-nums;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3282
+ .dsav-muted{opacity:.5}
3283
+ .dsav-auditionError{color:var(--dsw-alias-label-error, #c0392b);max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:0}
3284
+ .dsav-sectionRow .dsav-auditionError,.dsav-sectionRow .dsav-endpointHint{margin-top:0;align-self:center}
3285
+ .dsav-volume{display:flex;align-items:center;gap:8px;min-width:160px}
3286
+ .dsav-volume .dsav-range{margin:0;flex:1}
3287
+ .dsav-volumeValue{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;min-width:36px;text-align:right}
3288
+ .dsav-select{box-sizing:border-box;height:28px;padding:0 8px;border-radius:var(--dsw-radius-sm,6px);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1}
3289
+ .dsav-select:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
3290
+ .dsav-dialogSmall{width:min(420px,100%)}
2507
3291
  .dsav-input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border-radius:var(--dsw-radius-sm,6px);border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px}
2508
3292
  .dsav-input::placeholder{color:var(--dsw-alias-label-tertiary)}
2509
3293
  .dsav-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
2510
- .dsav-pickHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;margin-top:10px}
2511
3294
  .dsav-stage{position:relative;width:${STAGE}px;height:${STAGE}px;max-width:100%;margin:0 auto;border-radius:50%;overflow:hidden;background:var(--dsw-alias-bg-layer-2);cursor:grab;touch-action:none;user-select:none}
2512
3295
  .dsav-stage:active{cursor:grabbing}
2513
3296
  .dsav-stageImg{position:absolute;left:50%;top:50%;max-width:none;object-fit:fill;pointer-events:none;-webkit-user-drag:none}
@@ -2593,6 +3376,9 @@ window.__ModuleLoader__.load({
2593
3376
  .dsav-section{display:flex;flex-direction:column;gap:10px;padding:4px 0 8px}
2594
3377
  .dsav-sectionTitle{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-top:6px}
2595
3378
  .dsav-sectionRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
3379
+ .dsav-statusLine{display:inline-flex;align-items:center;gap:8px;min-width:0;flex:1}
3380
+ .dsav-auditionRow{margin-top:12px}
3381
+ .dsav-statusLine .dsav-endpointHint{margin-top:0;flex:0 1 auto;min-width:0}
2596
3382
  .dsav-sectionPreview{width:44px;height:44px;border-radius:50%;overflow:hidden;flex:none;background:var(--dsw-alias-bg-l2);display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary)}
2597
3383
  .dsav-sectionPreview img{width:100%;height:100%;object-fit:cover}
2598
3384
  .dsav-sectionField{flex:1;min-width:180px}
@@ -2654,6 +3440,34 @@ window.__ModuleLoader__.load({
2654
3440
  tag.remove();
2655
3441
  };
2656
3442
  }, 'dsh-avatar-frame: styles');
3443
+ ctx.effect(() => {
3444
+ if (typeof window.setInterval !== 'function') return () => {};
3445
+ const timer = window.setInterval(() => {
3446
+ const state = speechStore.get();
3447
+ if (state.status === 'idle') return;
3448
+ if (speechRun.source !== null || speechRun.system || speechRun.pendingFetches > 0) {
3449
+ speechRun.settledAt = null;
3450
+ return;
3451
+ }
3452
+ if (speechRun.settledAt === null) {
3453
+ speechRun.settledAt = Date.now();
3454
+ return;
3455
+ }
3456
+ if (Date.now() - speechRun.settledAt < 3000) return;
3457
+ speechRun.settledAt = null;
3458
+ speechStore.set({
3459
+ status: 'idle',
3460
+ sessionId: null,
3461
+ turn: null,
3462
+ index: 0,
3463
+ total: 0,
3464
+ rate: speechRun.rate,
3465
+ error: 'playback did not start',
3466
+ engine: null,
3467
+ });
3468
+ }, 1000);
3469
+ return () => window.clearInterval(timer);
3470
+ }, 'dsh-avatar-frame: playback watchdog');
2657
3471
  // Do not keep talking after the plugin itself is unloaded.
2658
3472
  ctx.effect(() => () => stopSpeech(), 'dsh-avatar-frame: stop audio on unload');
2659
3473
  // A second window keeps its own copy of the stores; reload on its writes.