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/CHANGELOG.md +329 -0
- package/README.md +49 -19
- package/client.js +986 -172
- package/index.js +51 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
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
|
-
'
|
|
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.
|
|
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.
|
|
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.
|
|
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': '
|
|
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
|
-
'
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
420
|
+
list = window.speechSynthesis.getVoices() || [];
|
|
345
421
|
} catch {
|
|
346
|
-
|
|
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
|
-
/**
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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 =
|
|
1215
|
-
const speech =
|
|
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 === '
|
|
1224
|
-
|
|
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
|
|
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
|
|
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
|
-
// 判定离线后每
|
|
1450
|
-
|
|
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(
|
|
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
|
-
|
|
1465
|
-
else setOffline();
|
|
2010
|
+
retryOrOffline('http:' + response.status);
|
|
1466
2011
|
return;
|
|
1467
2012
|
}
|
|
1468
2013
|
return response
|
|
1469
2014
|
.json()
|
|
1470
|
-
.then((payload) =>
|
|
1471
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
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
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2260
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
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(
|
|
2438
|
-
h(
|
|
2439
|
-
h(
|
|
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-
|
|
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.
|