dsh-voice-mode 0.2.3 → 0.5.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/README.en.md +28 -15
- package/README.md +69 -32
- package/assets/architecture.svg +1 -159
- package/assets/demo.gif +0 -0
- package/lib/client.js +2252 -359
- package/lib/index.js +1899 -245
- package/lib/sense-worker.mjs +128 -0
- package/lib/tts-vits-worker.cjs +147 -0
- package/package.json +18 -7
- package/scripts/bench-asr.mjs +289 -0
- package/scripts/prefetch.mjs +158 -37
- package/scripts/verify.mjs +34 -0
package/lib/client.js
CHANGED
|
@@ -42,6 +42,23 @@ module.exports = __toCommonJS(client_exports);
|
|
|
42
42
|
var React = __toESM(require("react"), 1);
|
|
43
43
|
var import_react2 = require("react");
|
|
44
44
|
|
|
45
|
+
// src/resample.ts
|
|
46
|
+
function resampleLinear(src, srcRate, dstRate) {
|
|
47
|
+
if (srcRate === dstRate) return src;
|
|
48
|
+
if (src.length === 0) return src;
|
|
49
|
+
const ratio = srcRate / dstRate;
|
|
50
|
+
const outLen = Math.max(1, Math.floor(src.length / ratio));
|
|
51
|
+
const out = new Float32Array(outLen);
|
|
52
|
+
for (let i = 0; i < outLen; i++) {
|
|
53
|
+
const pos = i * ratio;
|
|
54
|
+
const i0 = Math.floor(pos);
|
|
55
|
+
const i1 = Math.min(i0 + 1, src.length - 1);
|
|
56
|
+
const frac = pos - i0;
|
|
57
|
+
out[i] = src[i0] + (src[i1] - src[i0]) * frac;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
// src/wakeword.ts
|
|
46
63
|
function normalizeWake(text) {
|
|
47
64
|
return String(text ?? "").replace(/[\s\u3000]+/g, "").toLowerCase().replace(/[,。!?!?;;、,.]/g, "");
|
|
@@ -56,21 +73,20 @@ function matchWakeWord(partial, wakeWord) {
|
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
// src/asr.ts
|
|
76
|
+
var workletBlobUrl = null;
|
|
59
77
|
var SAMPLE_RATE = 16e3;
|
|
60
78
|
var SPEECH_RMS = 0.015;
|
|
61
79
|
var LEVEL_CEILING = 0.25;
|
|
62
80
|
var MAX_SEGMENT_MS = 3e4;
|
|
81
|
+
var MIN_SPEECH_MS = 250;
|
|
63
82
|
var PRE_PAD_MS = 250;
|
|
64
|
-
var PARTIAL_INTERVAL_MS =
|
|
83
|
+
var PARTIAL_INTERVAL_MS = 100;
|
|
65
84
|
var PARTIAL_MIN_S = 0.4;
|
|
66
85
|
var PARTIAL_MAX_S = 30;
|
|
67
86
|
var BUFFER_SIZE = 1024;
|
|
68
|
-
var INTERRUPT_LEVELS = {
|
|
69
|
-
0: { rms: 0.1, ms: 500 },
|
|
70
|
-
1: { rms: 0.06, ms: 400 },
|
|
71
|
-
2: { rms: 0.035, ms: 300 }
|
|
72
|
-
};
|
|
73
87
|
function createAsrEngine(config, sessionId) {
|
|
88
|
+
const wakeWord = (config.wakeWord ?? "").trim().toLowerCase().replace(/[\s\u3000]+/g, "");
|
|
89
|
+
const wakeEnabled = wakeWord !== "" && config.mode !== "hold";
|
|
74
90
|
let state = "idle";
|
|
75
91
|
const stateListeners = /* @__PURE__ */ new Set();
|
|
76
92
|
const errorListeners = /* @__PURE__ */ new Set();
|
|
@@ -84,29 +100,45 @@ function createAsrEngine(config, sessionId) {
|
|
|
84
100
|
};
|
|
85
101
|
const transcriptListeners = /* @__PURE__ */ new Set();
|
|
86
102
|
const partialListeners = /* @__PURE__ */ new Set();
|
|
87
|
-
const speechStartListeners = /* @__PURE__ */ new Set();
|
|
88
103
|
const levelListeners = /* @__PURE__ */ new Set();
|
|
104
|
+
const telemetryListeners = /* @__PURE__ */ new Set();
|
|
105
|
+
let utteranceEndAt = null;
|
|
106
|
+
const emitTelemetry = (stage) => {
|
|
107
|
+
const ev = { stage, at: Date.now() };
|
|
108
|
+
for (const fn of telemetryListeners) {
|
|
109
|
+
try {
|
|
110
|
+
fn(ev);
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
89
115
|
let audioCtx = null;
|
|
90
116
|
let stream = null;
|
|
91
117
|
let processor = null;
|
|
118
|
+
let workletNode = null;
|
|
92
119
|
let active = false;
|
|
120
|
+
let stopRequested = false;
|
|
121
|
+
let startSeq = 0;
|
|
93
122
|
let inFlush = false;
|
|
94
123
|
let ctxRate = SAMPLE_RATE;
|
|
95
124
|
let speechActive = false;
|
|
96
125
|
let segment = [];
|
|
97
126
|
let segmentMs = 0;
|
|
127
|
+
let speechMs = 0;
|
|
98
128
|
let silenceMs = 0;
|
|
99
129
|
let prePad = [];
|
|
100
130
|
let holdActive = false;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
let interruptCandidateMs = 0;
|
|
104
|
-
let bargeInDampingUntil = 0;
|
|
105
|
-
let sincePartialMs = 0;
|
|
131
|
+
const echo = config.echo;
|
|
132
|
+
let lastPollAt = 0;
|
|
106
133
|
let partialInFlight = false;
|
|
107
134
|
let segmentEpoch = 0;
|
|
108
135
|
let forcePending = false;
|
|
109
|
-
|
|
136
|
+
let uploadedSamples = 0;
|
|
137
|
+
let detectChunks = [];
|
|
138
|
+
let detectSent = 0;
|
|
139
|
+
let detectInFlight = false;
|
|
140
|
+
let detectGeneration = 0;
|
|
141
|
+
const asrUrl = (final, offset, epoch) => `${location.origin}${config.basePath.replace(/\/+$/, "")}/asr?sessionId=${encodeURIComponent(sessionId)}&final=${final ? 1 : 0}` + (offset !== void 0 ? `&offset=${offset}` : "") + (epoch !== void 0 ? `&epoch=${epoch}` : "");
|
|
110
142
|
const setState = (s) => {
|
|
111
143
|
state = s;
|
|
112
144
|
for (const fn of stateListeners) {
|
|
@@ -126,25 +158,36 @@ function createAsrEngine(config, sessionId) {
|
|
|
126
158
|
}
|
|
127
159
|
}
|
|
128
160
|
};
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
const
|
|
161
|
+
const sliceChunks = (chunks, from) => {
|
|
162
|
+
let total = 0;
|
|
163
|
+
for (const c of chunks) total += c.length;
|
|
164
|
+
const out = new Float32Array(Math.max(0, total - from));
|
|
165
|
+
if (out.length === 0) return out;
|
|
132
166
|
let off = 0;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
off
|
|
167
|
+
let acc = 0;
|
|
168
|
+
for (const c of chunks) {
|
|
169
|
+
if (off >= out.length) break;
|
|
170
|
+
const sub = c.subarray(Math.max(0, from - acc));
|
|
171
|
+
const n = Math.min(sub.length, out.length - off);
|
|
172
|
+
out.set(sub.subarray(0, n), off);
|
|
173
|
+
off += n;
|
|
174
|
+
acc += c.length;
|
|
136
175
|
}
|
|
137
176
|
return out;
|
|
138
177
|
};
|
|
178
|
+
const sliceSince = (from) => sliceChunks(segment, from);
|
|
139
179
|
const requestPartial = async () => {
|
|
140
180
|
if (partialInFlight || segment.length === 0) return;
|
|
141
|
-
const
|
|
181
|
+
const total = segment.reduce((n, c) => n + c.length, 0);
|
|
182
|
+
const seconds = total / SAMPLE_RATE;
|
|
142
183
|
if (seconds < PARTIAL_MIN_S || seconds > PARTIAL_MAX_S) return;
|
|
143
|
-
const
|
|
184
|
+
const from = uploadedSamples;
|
|
185
|
+
if (total - from <= 0) return;
|
|
186
|
+
const samples = sliceSince(from);
|
|
144
187
|
const epoch = segmentEpoch;
|
|
145
188
|
partialInFlight = true;
|
|
146
189
|
try {
|
|
147
|
-
let res = await fetch(asrUrl(false), {
|
|
190
|
+
let res = await fetch(asrUrl(false, from, epoch), {
|
|
148
191
|
method: "POST",
|
|
149
192
|
headers: { "content-type": "application/octet-stream" },
|
|
150
193
|
body: samples.buffer
|
|
@@ -154,7 +197,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
154
197
|
const retry = await new Promise((resolve) => {
|
|
155
198
|
setTimeout(async () => {
|
|
156
199
|
try {
|
|
157
|
-
const r2 = await fetch(asrUrl(false), {
|
|
200
|
+
const r2 = await fetch(asrUrl(false, from, epoch), {
|
|
158
201
|
method: "POST",
|
|
159
202
|
headers: { "content-type": "application/octet-stream" },
|
|
160
203
|
body: samples.buffer
|
|
@@ -167,42 +210,113 @@ function createAsrEngine(config, sessionId) {
|
|
|
167
210
|
});
|
|
168
211
|
res = retry;
|
|
169
212
|
}
|
|
213
|
+
if (res.status === 403 && config.onSessionExpired) {
|
|
214
|
+
const recovered = await config.onSessionExpired();
|
|
215
|
+
if (recovered && epoch === segmentEpoch) {
|
|
216
|
+
uploadedSamples = 0;
|
|
217
|
+
try {
|
|
218
|
+
res = await fetch(asrUrl(false, 0, epoch), {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "content-type": "application/octet-stream" },
|
|
221
|
+
body: sliceSince(0).buffer
|
|
222
|
+
});
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
170
227
|
if (epoch !== segmentEpoch) return;
|
|
171
228
|
if (!res.ok) return;
|
|
172
229
|
const out = await res.json();
|
|
173
230
|
if (epoch !== segmentEpoch) return;
|
|
174
|
-
if (state === "
|
|
175
|
-
|
|
231
|
+
if (state === "wake" && wakeEnabled) {
|
|
232
|
+
uploadedSamples = Math.max(uploadedSamples, from + samples.length);
|
|
176
233
|
if (matchWakeWord(out.text ?? "", wakeWord)) {
|
|
177
234
|
segmentEpoch++;
|
|
178
235
|
segment = [];
|
|
179
236
|
segmentMs = 0;
|
|
237
|
+
speechMs = 0;
|
|
180
238
|
silenceMs = 0;
|
|
181
239
|
prePad = [];
|
|
182
|
-
|
|
240
|
+
uploadedSamples = 0;
|
|
241
|
+
utteranceEndAt = null;
|
|
183
242
|
await resetHostStream();
|
|
184
243
|
if (active) setState("listening");
|
|
185
244
|
}
|
|
186
245
|
return;
|
|
187
246
|
}
|
|
247
|
+
if (out.isSpeech !== void 0) config.onIsSpeech?.(out.isSpeech);
|
|
248
|
+
if (state === "loading-model") setState("speech");
|
|
249
|
+
uploadedSamples = Math.max(uploadedSamples, from + samples.length);
|
|
188
250
|
emit(partialListeners, out.text ?? "");
|
|
251
|
+
if (out.endpoint && active && speechActive && !holdActive) finalizeSegment();
|
|
189
252
|
} catch {
|
|
190
253
|
} finally {
|
|
191
254
|
partialInFlight = false;
|
|
192
255
|
}
|
|
193
256
|
};
|
|
257
|
+
const requestDetect = async () => {
|
|
258
|
+
if (detectInFlight) return;
|
|
259
|
+
let total = detectChunks.reduce((n, c) => n + c.length, 0);
|
|
260
|
+
const MAX_DETECT_PENDING = 30 * SAMPLE_RATE;
|
|
261
|
+
if (total - detectSent > MAX_DETECT_PENDING) {
|
|
262
|
+
detectSent = Math.max(0, total - MAX_DETECT_PENDING);
|
|
263
|
+
}
|
|
264
|
+
while (detectChunks.length > 0 && detectSent >= detectChunks[0].length) {
|
|
265
|
+
detectSent -= detectChunks[0].length;
|
|
266
|
+
detectChunks.shift();
|
|
267
|
+
}
|
|
268
|
+
total = detectChunks.reduce((n, c) => n + c.length, 0);
|
|
269
|
+
if (total - detectSent <= 0) return;
|
|
270
|
+
const samples = sliceChunks(detectChunks, detectSent);
|
|
271
|
+
const epoch = segmentEpoch;
|
|
272
|
+
const gen = detectGeneration;
|
|
273
|
+
detectInFlight = true;
|
|
274
|
+
try {
|
|
275
|
+
const res = await fetch(asrUrl(false, detectSent, epoch) + "&vadOnly=1", {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "content-type": "application/octet-stream" },
|
|
278
|
+
body: samples.buffer,
|
|
279
|
+
signal: AbortSignal.timeout(5e3)
|
|
280
|
+
// 防服务端挂起长期锁死 detectInFlight(Important#2)
|
|
281
|
+
});
|
|
282
|
+
if (epoch !== segmentEpoch || gen !== detectGeneration) return;
|
|
283
|
+
if (!res.ok) return;
|
|
284
|
+
const out = await res.json();
|
|
285
|
+
if (epoch !== segmentEpoch || gen !== detectGeneration) return;
|
|
286
|
+
if (out.isSpeech !== void 0) config.onIsSpeech?.(out.isSpeech);
|
|
287
|
+
detectSent += samples.length;
|
|
288
|
+
while (detectChunks.length > 0 && detectSent >= detectChunks[0].length) {
|
|
289
|
+
detectSent -= detectChunks[0].length;
|
|
290
|
+
detectChunks.shift();
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
} finally {
|
|
294
|
+
detectInFlight = false;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
194
297
|
const resetHostStream = async () => {
|
|
195
298
|
try {
|
|
196
|
-
await fetch(`${asrUrl(false)}&reset=1`, { method: "POST" });
|
|
299
|
+
await fetch(`${asrUrl(false)}&reset=1`, { method: "POST", signal: AbortSignal.timeout(5e3) });
|
|
197
300
|
} catch {
|
|
198
301
|
}
|
|
199
302
|
};
|
|
200
|
-
const finalizeSegment = () => {
|
|
303
|
+
const finalizeSegment = (force = false) => {
|
|
201
304
|
if (segment.length === 0) return;
|
|
202
|
-
|
|
203
|
-
|
|
305
|
+
if (config.isPlaying?.() && !forcePending && !force) return;
|
|
306
|
+
if (utteranceEndAt === null) {
|
|
307
|
+
utteranceEndAt = Date.now();
|
|
308
|
+
emitTelemetry("utterance-end");
|
|
309
|
+
}
|
|
310
|
+
emitTelemetry("endpoint-fired");
|
|
311
|
+
const from = uploadedSamples;
|
|
312
|
+
const samples = sliceSince(from);
|
|
313
|
+
const recoverySegment = segment;
|
|
314
|
+
const epochSnapshot = segmentEpoch;
|
|
315
|
+
segmentEpoch++;
|
|
204
316
|
const meta = { force: forcePending };
|
|
205
317
|
forcePending = false;
|
|
318
|
+
speechMs = 0;
|
|
319
|
+
uploadedSamples = 0;
|
|
206
320
|
segment = [];
|
|
207
321
|
speechActive = false;
|
|
208
322
|
silenceMs = 0;
|
|
@@ -210,22 +324,41 @@ function createAsrEngine(config, sessionId) {
|
|
|
210
324
|
prePad = [];
|
|
211
325
|
setState("transcribing");
|
|
212
326
|
void (async () => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
327
|
+
emitTelemetry("submitted");
|
|
328
|
+
const MAX_FINAL_ATTEMPTS = 3;
|
|
329
|
+
const restoreState = () => setState(active ? speechActive || holdActive ? "speech" : wakeEnabled ? "wake" : "listening" : "idle");
|
|
330
|
+
for (let attempt = 0; attempt < MAX_FINAL_ATTEMPTS; attempt++) {
|
|
331
|
+
if (attempt > 0) {
|
|
332
|
+
if (segmentEpoch !== epochSnapshot + 1) return;
|
|
333
|
+
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
334
|
+
}
|
|
335
|
+
const useFull = attempt > 0;
|
|
336
|
+
const off = useFull ? 0 : from;
|
|
337
|
+
const body = useFull ? sliceChunks(recoverySegment, 0).buffer : samples.buffer;
|
|
338
|
+
let res;
|
|
339
|
+
try {
|
|
340
|
+
res = await fetch(asrUrl(true, off, epochSnapshot), {
|
|
341
|
+
signal: AbortSignal.timeout(1e4),
|
|
342
|
+
method: "POST",
|
|
343
|
+
headers: { "content-type": "application/octet-stream" },
|
|
344
|
+
body
|
|
345
|
+
});
|
|
346
|
+
} catch {
|
|
347
|
+
restoreState();
|
|
348
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize fetch \u5F02\u5E38\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
219
351
|
if (res.status === 202) {
|
|
220
352
|
setState("loading-model");
|
|
221
353
|
res = await new Promise((resolve) => {
|
|
222
354
|
setTimeout(async () => {
|
|
223
355
|
try {
|
|
224
356
|
resolve(
|
|
225
|
-
await fetch(asrUrl(true), {
|
|
357
|
+
await fetch(asrUrl(true, off, epochSnapshot), {
|
|
358
|
+
signal: AbortSignal.timeout(1e4),
|
|
226
359
|
method: "POST",
|
|
227
360
|
headers: { "content-type": "application/octet-stream" },
|
|
228
|
-
body
|
|
361
|
+
body
|
|
229
362
|
})
|
|
230
363
|
);
|
|
231
364
|
} catch {
|
|
@@ -234,21 +367,53 @@ function createAsrEngine(config, sessionId) {
|
|
|
234
367
|
}, 5e3);
|
|
235
368
|
});
|
|
236
369
|
}
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
370
|
+
if (res.status === 202) {
|
|
371
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize \u6A21\u578B\u52A0\u8F7D\u8D85\u65F6\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (res.status === 403 && config.onSessionExpired) {
|
|
375
|
+
const recovered = await config.onSessionExpired();
|
|
376
|
+
if (recovered && segmentEpoch === epochSnapshot + 1) {
|
|
377
|
+
try {
|
|
378
|
+
res = await fetch(asrUrl(true, 0, epochSnapshot), {
|
|
379
|
+
signal: AbortSignal.timeout(1e4),
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: { "content-type": "application/octet-stream" },
|
|
382
|
+
body: sliceChunks(recoverySegment, 0).buffer
|
|
383
|
+
});
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
restoreState();
|
|
389
|
+
if (!res.ok) {
|
|
390
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize 5xx\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
let out;
|
|
394
|
+
try {
|
|
395
|
+
out = await res.json();
|
|
396
|
+
} catch {
|
|
397
|
+
if (attempt === MAX_FINAL_ATTEMPTS - 1) console.warn("[dsh-voice-mode] finalize \u54CD\u5E94\u975E JSON\uFF08\u91CD\u8BD5\u8017\u5C3D\uFF09");
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (segmentEpoch !== epochSnapshot + 1) return;
|
|
242
401
|
if (out.text) emit(transcriptListeners, out.text, meta);
|
|
243
|
-
|
|
244
|
-
setState(active ? speechActive ? "speech" : "listening" : "idle");
|
|
245
|
-
emitError("recognitionFail");
|
|
402
|
+
return;
|
|
246
403
|
}
|
|
404
|
+
emitError("recognitionFail");
|
|
247
405
|
})();
|
|
248
406
|
};
|
|
407
|
+
let latestResidualRms = 0;
|
|
408
|
+
let echoFloorRms = 0;
|
|
409
|
+
let echoPeak = 0;
|
|
249
410
|
const handleAudio = (raw) => {
|
|
250
411
|
if (!active || inFlush) return;
|
|
251
|
-
|
|
412
|
+
let data = ctxRate !== SAMPLE_RATE ? resampleLinear(raw, ctxRate, SAMPLE_RATE) : raw;
|
|
413
|
+
if (echo) {
|
|
414
|
+
const ref = echo.windowAt(performance.now(), data.length);
|
|
415
|
+
data = echo.process(data, ref);
|
|
416
|
+
}
|
|
252
417
|
let sum = 0;
|
|
253
418
|
for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
|
|
254
419
|
const rms = Math.sqrt(sum / data.length);
|
|
@@ -259,23 +424,25 @@ function createAsrEngine(config, sessionId) {
|
|
|
259
424
|
} catch {
|
|
260
425
|
}
|
|
261
426
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
}
|
|
427
|
+
const playingNow = config.isPlaying?.() ?? false;
|
|
428
|
+
const gateRatio = Math.pow(10, (config.echoGateDb ?? 6) / 20);
|
|
429
|
+
const peakDecay = Math.pow(0.9, durationMs / 64);
|
|
430
|
+
const floorAlpha = 1 - Math.pow(0.98, durationMs / 64);
|
|
431
|
+
if (playingNow) {
|
|
432
|
+
latestResidualRms = rms;
|
|
433
|
+
echoPeak = Math.max(echoPeak * peakDecay, rms);
|
|
434
|
+
}
|
|
435
|
+
const doubleTalk = playingNow && echoFloorRms > 0 && rms > echoFloorRms * gateRatio;
|
|
436
|
+
if (playingNow) {
|
|
437
|
+
if (echoFloorRms === 0) echoFloorRms = rms;
|
|
438
|
+
else if (!doubleTalk) echoFloorRms = echoFloorRms * (1 - floorAlpha) + rms * floorAlpha;
|
|
276
439
|
} else {
|
|
277
|
-
|
|
440
|
+
echoPeak = 0;
|
|
278
441
|
}
|
|
442
|
+
if (echo) {
|
|
443
|
+
echo.setFrozen(doubleTalk);
|
|
444
|
+
}
|
|
445
|
+
if (playingNow && !holdActive && config.mode !== "hold") detectChunks.push(data);
|
|
279
446
|
if (holdActive) {
|
|
280
447
|
if (!speechActive) {
|
|
281
448
|
speechActive = true;
|
|
@@ -285,6 +452,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
285
452
|
silenceMs = 0;
|
|
286
453
|
segment.push(data);
|
|
287
454
|
if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
|
|
455
|
+
} else if (config.mode === "hold") {
|
|
288
456
|
} else if (state === "wake") {
|
|
289
457
|
if (rms > SPEECH_RMS) {
|
|
290
458
|
segmentMs += durationMs;
|
|
@@ -293,38 +461,55 @@ function createAsrEngine(config, sessionId) {
|
|
|
293
461
|
segment = [];
|
|
294
462
|
segmentMs = 0;
|
|
295
463
|
silenceMs = 0;
|
|
296
|
-
|
|
464
|
+
uploadedSamples = 0;
|
|
297
465
|
void resetHostStream();
|
|
298
466
|
}
|
|
299
|
-
} else {
|
|
300
|
-
prePad.push(data);
|
|
301
|
-
let total = 0;
|
|
302
|
-
let cut = 0;
|
|
303
|
-
for (let i = prePad.length - 1; i >= 0; i--) {
|
|
304
|
-
total += prePad[i].length / SAMPLE_RATE * 1e3;
|
|
305
|
-
if (total > PRE_PAD_MS) {
|
|
306
|
-
cut = i + 1;
|
|
307
|
-
break;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (cut > 0) prePad = prePad.slice(cut);
|
|
311
467
|
}
|
|
312
468
|
} else if (rms > SPEECH_RMS) {
|
|
469
|
+
if (config.isPlaying?.()) {
|
|
470
|
+
if (speechActive) finalizeSegment(true);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
313
473
|
if (!speechActive) {
|
|
314
474
|
speechActive = true;
|
|
475
|
+
detectChunks = [];
|
|
476
|
+
detectSent = 0;
|
|
477
|
+
detectGeneration++;
|
|
478
|
+
utteranceEndAt = null;
|
|
315
479
|
setState("speech");
|
|
316
480
|
for (const p of prePad) segment.push(p);
|
|
317
481
|
prePad = [];
|
|
318
482
|
}
|
|
483
|
+
speechMs += durationMs;
|
|
319
484
|
segmentMs += durationMs;
|
|
320
485
|
silenceMs = 0;
|
|
321
486
|
segment.push(data);
|
|
322
487
|
if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
|
|
323
488
|
} else if (speechActive) {
|
|
489
|
+
if (utteranceEndAt === null) {
|
|
490
|
+
utteranceEndAt = Date.now();
|
|
491
|
+
emitTelemetry("utterance-end");
|
|
492
|
+
}
|
|
324
493
|
segmentMs += durationMs;
|
|
325
494
|
silenceMs += durationMs;
|
|
326
495
|
segment.push(data);
|
|
327
|
-
if (silenceMs > config.silenceMs)
|
|
496
|
+
if (silenceMs > config.silenceMs) {
|
|
497
|
+
if (speechMs >= MIN_SPEECH_MS) {
|
|
498
|
+
finalizeSegment();
|
|
499
|
+
} else {
|
|
500
|
+
segmentEpoch++;
|
|
501
|
+
segment = [];
|
|
502
|
+
speechActive = false;
|
|
503
|
+
speechMs = 0;
|
|
504
|
+
silenceMs = 0;
|
|
505
|
+
segmentMs = 0;
|
|
506
|
+
prePad = [];
|
|
507
|
+
utteranceEndAt = null;
|
|
508
|
+
uploadedSamples = 0;
|
|
509
|
+
void resetHostStream();
|
|
510
|
+
setState("listening");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
328
513
|
} else {
|
|
329
514
|
prePad.push(data);
|
|
330
515
|
let total = 0;
|
|
@@ -338,34 +523,41 @@ function createAsrEngine(config, sessionId) {
|
|
|
338
523
|
}
|
|
339
524
|
if (cut > 0) prePad = prePad.slice(cut);
|
|
340
525
|
}
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
526
|
+
const nowMs = Date.now();
|
|
527
|
+
if (nowMs - lastPollAt >= PARTIAL_INTERVAL_MS) {
|
|
528
|
+
if (playingNow && !speechActive && !holdActive) {
|
|
529
|
+
lastPollAt = nowMs;
|
|
530
|
+
void requestDetect();
|
|
531
|
+
} else if (speechActive || holdActive || state === "wake") {
|
|
532
|
+
lastPollAt = nowMs;
|
|
533
|
+
void requestPartial();
|
|
534
|
+
}
|
|
345
535
|
}
|
|
346
536
|
};
|
|
347
|
-
function resampleTo16k(src, srcRate) {
|
|
348
|
-
const ratio = srcRate / SAMPLE_RATE;
|
|
349
|
-
const outLen = Math.max(1, Math.floor(src.length / ratio));
|
|
350
|
-
const out = new Float32Array(outLen);
|
|
351
|
-
for (let i = 0; i < outLen; i++) {
|
|
352
|
-
const pos = i * ratio;
|
|
353
|
-
const i0 = Math.floor(pos);
|
|
354
|
-
const i1 = Math.min(i0 + 1, src.length - 1);
|
|
355
|
-
const frac = pos - i0;
|
|
356
|
-
out[i] = src[i0] + (src[i1] - src[i0]) * frac;
|
|
357
|
-
}
|
|
358
|
-
return out;
|
|
359
|
-
}
|
|
360
537
|
const startRecorder = async () => {
|
|
538
|
+
const mySeq = startSeq;
|
|
361
539
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
362
540
|
audio: {
|
|
363
541
|
channelCount: 1,
|
|
364
542
|
echoCancellation: true,
|
|
365
|
-
|
|
366
|
-
|
|
543
|
+
// L0(研究核实):AGC/NS 是时变非线性,放在 AEC 前破坏线性回声路径假设——
|
|
544
|
+
// WebRTC GainController2 与 Speex 均把增益放 AEC 之后。禁用后原生 AEC 与
|
|
545
|
+
// 自研 AEC 的输入才线性,回声消得净;AGC 放大近端语音由「残差地板门控」
|
|
546
|
+
// 的归一化语义替代,不需要采集端增益。
|
|
547
|
+
noiseSuppression: false,
|
|
548
|
+
autoGainControl: false
|
|
367
549
|
}
|
|
368
550
|
});
|
|
551
|
+
if (stopRequested || mySeq !== startSeq) {
|
|
552
|
+
stream.getTracks().forEach((t3) => t3.stop());
|
|
553
|
+
stream = null;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const aecOn = stream.getAudioTracks()[0]?.getSettings().echoCancellation === true;
|
|
557
|
+
if (!aecOn) {
|
|
558
|
+
console.warn("[dsh-voice-mode] \u6D4F\u89C8\u5668\u539F\u751F echoCancellation \u672A\u751F\u6548\uFF08\u5916\u653E\u53EF\u80FD\u81EA\u6253\u65AD\uFF09\uFF0C\u5EFA\u8BAE\u7528\u8033\u673A\u6216\u300C\u624B\u52A8\u6253\u65AD\u300D");
|
|
559
|
+
}
|
|
560
|
+
config.onAecState?.(aecOn);
|
|
369
561
|
const AC = window.AudioContext ?? window.webkitAudioContext;
|
|
370
562
|
audioCtx = new AC({ sampleRate: SAMPLE_RATE });
|
|
371
563
|
try {
|
|
@@ -374,6 +566,29 @@ function createAsrEngine(config, sessionId) {
|
|
|
374
566
|
}
|
|
375
567
|
ctxRate = audioCtx.sampleRate;
|
|
376
568
|
const source = audioCtx.createMediaStreamSource(stream);
|
|
569
|
+
if (audioCtx.audioWorklet) {
|
|
570
|
+
try {
|
|
571
|
+
if (!workletBlobUrl) {
|
|
572
|
+
workletBlobUrl = URL.createObjectURL(new Blob(['"use strict";\n(() => {\n // src/resample.ts\n function resampleLinear(src, srcRate, dstRate) {\n if (srcRate === dstRate) return src;\n if (src.length === 0) return src;\n const ratio = srcRate / dstRate;\n const outLen = Math.max(1, Math.floor(src.length / ratio));\n const out = new Float32Array(outLen);\n for (let i = 0; i < outLen; i++) {\n const pos = i * ratio;\n const i0 = Math.floor(pos);\n const i1 = Math.min(i0 + 1, src.length - 1);\n const frac = pos - i0;\n out[i] = src[i0] + (src[i1] - src[i0]) * frac;\n }\n return out;\n }\n\n // src/audio-worklet.ts\n var TARGET_RATE = 16e3;\n var CHUNK = 1024;\n var RATIO = sampleRate / TARGET_RATE;\n var NEED = Math.ceil(CHUNK * RATIO);\n var VoiceCaptureProcessor = class extends AudioWorkletProcessor {\n acc = new Float32Array(0);\n accLen = 0;\n process(inputs) {\n const ch = inputs[0]?.[0];\n if (ch && ch.length > 0) this.push(ch);\n this.drain();\n return true;\n }\n push(ch) {\n if (this.accLen + ch.length > this.acc.length) {\n let cap = this.acc.length > 0 ? this.acc.length : NEED * 2;\n while (cap < this.accLen + ch.length) cap *= 2;\n const next = new Float32Array(cap);\n next.set(this.acc.subarray(0, this.accLen));\n this.acc = next;\n }\n this.acc.set(ch, this.accLen);\n this.accLen += ch.length;\n }\n drain() {\n while (this.accLen >= NEED) {\n const src = sampleRate === TARGET_RATE ? this.acc.subarray(0, CHUNK) : resampleLinear(this.acc.subarray(0, NEED), sampleRate, TARGET_RATE);\n const chunk = new Float32Array(CHUNK);\n chunk.set(src.length >= CHUNK ? src.subarray(0, CHUNK) : src);\n this.port.postMessage(chunk, [chunk.buffer]);\n this.acc.copyWithin(0, NEED, this.accLen);\n this.accLen -= NEED;\n }\n }\n };\n registerProcessor("voice-capture", VoiceCaptureProcessor);\n})();\n'], { type: "text/javascript" }));
|
|
573
|
+
}
|
|
574
|
+
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
|
575
|
+
workletNode = new AudioWorkletNode(audioCtx, "voice-capture", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] });
|
|
576
|
+
workletNode.port.onmessage = (e) => {
|
|
577
|
+
handleAudio(e.data);
|
|
578
|
+
};
|
|
579
|
+
source.connect(workletNode);
|
|
580
|
+
workletNode.connect(audioCtx.destination);
|
|
581
|
+
ctxRate = SAMPLE_RATE;
|
|
582
|
+
active = true;
|
|
583
|
+
return;
|
|
584
|
+
} catch {
|
|
585
|
+
try {
|
|
586
|
+
workletNode?.disconnect();
|
|
587
|
+
} catch {
|
|
588
|
+
}
|
|
589
|
+
workletNode = null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
377
592
|
processor = audioCtx.createScriptProcessor(BUFFER_SIZE, 1, 1);
|
|
378
593
|
processor.onaudioprocess = (e) => {
|
|
379
594
|
handleAudio(new Float32Array(e.inputBuffer.getChannelData(0)));
|
|
@@ -383,8 +598,6 @@ function createAsrEngine(config, sessionId) {
|
|
|
383
598
|
active = true;
|
|
384
599
|
};
|
|
385
600
|
const stopRecorder = async () => {
|
|
386
|
-
if (!active) return;
|
|
387
|
-
active = false;
|
|
388
601
|
inFlush = true;
|
|
389
602
|
segmentEpoch++;
|
|
390
603
|
forcePending = false;
|
|
@@ -393,13 +606,23 @@ function createAsrEngine(config, sessionId) {
|
|
|
393
606
|
speechActive = false;
|
|
394
607
|
silenceMs = 0;
|
|
395
608
|
segmentMs = 0;
|
|
609
|
+
speechMs = 0;
|
|
610
|
+
uploadedSamples = 0;
|
|
396
611
|
prePad = [];
|
|
397
|
-
|
|
612
|
+
detectChunks = [];
|
|
613
|
+
detectSent = 0;
|
|
614
|
+
detectGeneration++;
|
|
615
|
+
utteranceEndAt = null;
|
|
398
616
|
try {
|
|
399
617
|
processor?.disconnect();
|
|
400
618
|
} catch {
|
|
401
619
|
}
|
|
402
620
|
processor = null;
|
|
621
|
+
try {
|
|
622
|
+
workletNode?.disconnect();
|
|
623
|
+
} catch {
|
|
624
|
+
}
|
|
625
|
+
workletNode = null;
|
|
403
626
|
try {
|
|
404
627
|
stream?.getTracks().forEach((t3) => t3.stop());
|
|
405
628
|
} catch {
|
|
@@ -417,13 +640,28 @@ function createAsrEngine(config, sessionId) {
|
|
|
417
640
|
get state() {
|
|
418
641
|
return state;
|
|
419
642
|
},
|
|
643
|
+
get holding() {
|
|
644
|
+
return holdActive;
|
|
645
|
+
},
|
|
646
|
+
/** A2.5 回声门控:当前残差是否明显高于回声地板(marginDb 默认 6dB)——判用户人声而非回声。 */
|
|
647
|
+
aboveEchoFloor(marginDb = 6) {
|
|
648
|
+
if (echoFloorRms === 0) return false;
|
|
649
|
+
return echoPeak > echoFloorRms * Math.pow(10, marginDb / 20);
|
|
650
|
+
},
|
|
651
|
+
echoLevels() {
|
|
652
|
+
return { floorRms: echoFloorRms, residualRms: latestResidualRms, peakRms: echoPeak };
|
|
653
|
+
},
|
|
420
654
|
async start() {
|
|
421
655
|
if (active) return;
|
|
656
|
+
stopRequested = false;
|
|
657
|
+
startSeq++;
|
|
422
658
|
segmentEpoch++;
|
|
423
|
-
|
|
424
|
-
interruptCandidateMs = 0;
|
|
659
|
+
lastPollAt = 0;
|
|
425
660
|
holdActive = false;
|
|
426
|
-
|
|
661
|
+
detectChunks = [];
|
|
662
|
+
detectSent = 0;
|
|
663
|
+
detectGeneration++;
|
|
664
|
+
setState(wakeEnabled ? "wake" : "listening");
|
|
427
665
|
try {
|
|
428
666
|
await startRecorder();
|
|
429
667
|
} catch (error) {
|
|
@@ -432,7 +670,10 @@ function createAsrEngine(config, sessionId) {
|
|
|
432
670
|
}
|
|
433
671
|
},
|
|
434
672
|
async stop() {
|
|
435
|
-
|
|
673
|
+
stopRequested = true;
|
|
674
|
+
const wasActive = active;
|
|
675
|
+
active = false;
|
|
676
|
+
if (!wasActive) {
|
|
436
677
|
setState("idle");
|
|
437
678
|
return;
|
|
438
679
|
}
|
|
@@ -443,7 +684,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
443
684
|
const speechS = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE;
|
|
444
685
|
if (speechActive && speechS >= 0.25) {
|
|
445
686
|
forcePending = true;
|
|
446
|
-
|
|
687
|
+
lastPollAt = 0;
|
|
447
688
|
finalizeSegment();
|
|
448
689
|
}
|
|
449
690
|
},
|
|
@@ -452,15 +693,39 @@ function createAsrEngine(config, sessionId) {
|
|
|
452
693
|
holdActive = true;
|
|
453
694
|
forcePending = true;
|
|
454
695
|
segmentEpoch++;
|
|
696
|
+
utteranceEndAt = null;
|
|
455
697
|
segment = [];
|
|
456
698
|
segmentMs = 0;
|
|
699
|
+
speechMs = 0;
|
|
457
700
|
silenceMs = 0;
|
|
458
701
|
prePad = [];
|
|
702
|
+
uploadedSamples = 0;
|
|
703
|
+
detectChunks = [];
|
|
704
|
+
detectSent = 0;
|
|
705
|
+
detectGeneration++;
|
|
459
706
|
speechActive = true;
|
|
460
|
-
|
|
461
|
-
bargeInDampingUntil = Date.now() + 800;
|
|
707
|
+
lastPollAt = 0;
|
|
462
708
|
setState("speech");
|
|
463
709
|
},
|
|
710
|
+
discardSegment() {
|
|
711
|
+
segmentEpoch++;
|
|
712
|
+
segment = [];
|
|
713
|
+
segmentMs = 0;
|
|
714
|
+
speechMs = 0;
|
|
715
|
+
silenceMs = 0;
|
|
716
|
+
speechActive = false;
|
|
717
|
+
prePad = [];
|
|
718
|
+
uploadedSamples = 0;
|
|
719
|
+
detectChunks = [];
|
|
720
|
+
detectSent = 0;
|
|
721
|
+
detectGeneration++;
|
|
722
|
+
utteranceEndAt = null;
|
|
723
|
+
forcePending = false;
|
|
724
|
+
lastPollAt = 0;
|
|
725
|
+
return resetHostStream().then(() => {
|
|
726
|
+
if (active) setState(wakeEnabled ? "wake" : "listening");
|
|
727
|
+
});
|
|
728
|
+
},
|
|
464
729
|
endHeld(cancel = false) {
|
|
465
730
|
if (!active || !holdActive) return;
|
|
466
731
|
holdActive = false;
|
|
@@ -471,12 +736,18 @@ function createAsrEngine(config, sessionId) {
|
|
|
471
736
|
silenceMs = 0;
|
|
472
737
|
prePad = [];
|
|
473
738
|
speechActive = false;
|
|
474
|
-
|
|
739
|
+
forcePending = false;
|
|
740
|
+
setState(wakeEnabled ? "wake" : "listening");
|
|
475
741
|
return;
|
|
476
742
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
743
|
+
if (segment.length > 0) {
|
|
744
|
+
forcePending = true;
|
|
745
|
+
lastPollAt = 0;
|
|
746
|
+
finalizeSegment();
|
|
747
|
+
} else {
|
|
748
|
+
forcePending = false;
|
|
749
|
+
setState(wakeEnabled ? "wake" : "listening");
|
|
750
|
+
}
|
|
480
751
|
},
|
|
481
752
|
onSegment(fn) {
|
|
482
753
|
transcriptListeners.add(fn);
|
|
@@ -496,12 +767,6 @@ function createAsrEngine(config, sessionId) {
|
|
|
496
767
|
partialListeners.delete(fn);
|
|
497
768
|
};
|
|
498
769
|
},
|
|
499
|
-
onSpeechStart(fn) {
|
|
500
|
-
speechStartListeners.add(fn);
|
|
501
|
-
return () => {
|
|
502
|
-
speechStartListeners.delete(fn);
|
|
503
|
-
};
|
|
504
|
-
},
|
|
505
770
|
onState(fn) {
|
|
506
771
|
stateListeners.add(fn);
|
|
507
772
|
fn(state);
|
|
@@ -514,10 +779,154 @@ function createAsrEngine(config, sessionId) {
|
|
|
514
779
|
return () => {
|
|
515
780
|
levelListeners.delete(fn);
|
|
516
781
|
};
|
|
782
|
+
},
|
|
783
|
+
onTelemetry(fn) {
|
|
784
|
+
telemetryListeners.add(fn);
|
|
785
|
+
return () => {
|
|
786
|
+
telemetryListeners.delete(fn);
|
|
787
|
+
};
|
|
517
788
|
}
|
|
518
789
|
};
|
|
519
790
|
}
|
|
520
791
|
|
|
792
|
+
// src/aec.ts
|
|
793
|
+
var DEFAULT_FILTER_LENGTH = 256;
|
|
794
|
+
var DEFAULT_DELAY = 64;
|
|
795
|
+
var DEFAULT_STEP = 0.1;
|
|
796
|
+
var DEFAULT_EPSILON = 1e-6;
|
|
797
|
+
var MIN_REF_NORM = 1e-6;
|
|
798
|
+
var NlmsAec = class {
|
|
799
|
+
w;
|
|
800
|
+
xBuf;
|
|
801
|
+
filterLength;
|
|
802
|
+
delay;
|
|
803
|
+
mu;
|
|
804
|
+
eps;
|
|
805
|
+
/** 参考历史环形游标。 */
|
|
806
|
+
cursor = 0;
|
|
807
|
+
/** 已缓冲参考样本数(预热期)。 */
|
|
808
|
+
filled = 0;
|
|
809
|
+
/** A2.5 双讲冻结:用户说话时暂停权重更新,防滤波器被用户语音带偏。 */
|
|
810
|
+
frozen = false;
|
|
811
|
+
constructor(options = {}) {
|
|
812
|
+
this.filterLength = options.filterLength ?? DEFAULT_FILTER_LENGTH;
|
|
813
|
+
this.delay = options.delay ?? DEFAULT_DELAY;
|
|
814
|
+
this.mu = options.step ?? DEFAULT_STEP;
|
|
815
|
+
this.eps = options.epsilon ?? DEFAULT_EPSILON;
|
|
816
|
+
this.w = new Float32Array(this.filterLength);
|
|
817
|
+
this.xBuf = new Float32Array(this.delay + this.filterLength);
|
|
818
|
+
}
|
|
819
|
+
/** A2.5 双讲冻结:true 时暂停权重更新(回声相减照常,仅停止自适应)。 */
|
|
820
|
+
setFrozen(frozen) {
|
|
821
|
+
this.frozen = frozen;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* 送入下一块麦克风/参考;返回去回声后的麦克风样本(与输入等长)。
|
|
825
|
+
* 参考可比麦克风块短(如静音填充)——不足部分补零。
|
|
826
|
+
*/
|
|
827
|
+
process(mic, ref) {
|
|
828
|
+
const n = mic.length;
|
|
829
|
+
const out = new Float32Array(n);
|
|
830
|
+
if (n === 0) return out;
|
|
831
|
+
const xBuf = this.xBuf;
|
|
832
|
+
const bufLen = xBuf.length;
|
|
833
|
+
let cursor = this.cursor;
|
|
834
|
+
for (let i = 0; i < n; i++) {
|
|
835
|
+
xBuf[cursor] = i < ref.length ? ref[i] : 0;
|
|
836
|
+
cursor = (cursor + 1) % bufLen;
|
|
837
|
+
this.filled = Math.min(this.filled + 1, bufLen);
|
|
838
|
+
const d = mic[i];
|
|
839
|
+
if (this.filled >= this.delay + this.filterLength) {
|
|
840
|
+
let y = 0;
|
|
841
|
+
let norm = 0;
|
|
842
|
+
let idx = (cursor - this.delay + bufLen) % bufLen;
|
|
843
|
+
for (let t3 = 0; t3 < this.filterLength; t3++) {
|
|
844
|
+
const x = xBuf[idx];
|
|
845
|
+
y += this.w[t3] * x;
|
|
846
|
+
norm += x * x;
|
|
847
|
+
idx = (idx - 1 + bufLen) % bufLen;
|
|
848
|
+
}
|
|
849
|
+
const e = d - y;
|
|
850
|
+
out[i] = Number.isFinite(e) ? e : 0;
|
|
851
|
+
if (norm > MIN_REF_NORM && !this.frozen) {
|
|
852
|
+
const denom = norm + this.eps;
|
|
853
|
+
const gain = this.mu * e / denom;
|
|
854
|
+
idx = (cursor - this.delay + bufLen) % bufLen;
|
|
855
|
+
for (let t3 = 0; t3 < this.filterLength; t3++) {
|
|
856
|
+
this.w[t3] += gain * xBuf[idx];
|
|
857
|
+
idx = (idx - 1 + bufLen) % bufLen;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
} else {
|
|
861
|
+
out[i] = d;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
this.cursor = cursor;
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
function estimateBulkDelay(mic, ref, opts = {}) {
|
|
869
|
+
const sr = opts.sampleRate ?? 16e3;
|
|
870
|
+
const ds = Math.max(1, Math.floor(opts.downsample ?? 4));
|
|
871
|
+
const minLag = Math.max(0, opts.minLag ?? 0);
|
|
872
|
+
const maxLag = opts.maxLag ?? Math.floor(300 * sr / 1e3);
|
|
873
|
+
const n = Math.min(mic.length, ref.length);
|
|
874
|
+
if (n < ds * 64) return { lag: 0, peak: 0 };
|
|
875
|
+
const N = Math.floor(n / ds);
|
|
876
|
+
const maxLagD = Math.floor(maxLag / ds);
|
|
877
|
+
const minLagD = Math.floor(minLag / ds);
|
|
878
|
+
if (maxLagD >= N) return { lag: 0, peak: 0 };
|
|
879
|
+
const m = new Float32Array(N);
|
|
880
|
+
const r = new Float32Array(N);
|
|
881
|
+
let mSum = 0;
|
|
882
|
+
let rSum = 0;
|
|
883
|
+
for (let i = 0; i < N; i++) {
|
|
884
|
+
let mv = 0;
|
|
885
|
+
let rv = 0;
|
|
886
|
+
for (let k = 0; k < ds; k++) {
|
|
887
|
+
mv += mic[i * ds + k];
|
|
888
|
+
rv += ref[i * ds + k];
|
|
889
|
+
}
|
|
890
|
+
mv /= ds;
|
|
891
|
+
rv /= ds;
|
|
892
|
+
m[i] = mv;
|
|
893
|
+
r[i] = rv;
|
|
894
|
+
mSum += mv;
|
|
895
|
+
rSum += rv;
|
|
896
|
+
}
|
|
897
|
+
const mMu = mSum / N;
|
|
898
|
+
const rMu = rSum / N;
|
|
899
|
+
let mE = 0;
|
|
900
|
+
let rE = 0;
|
|
901
|
+
for (let i = 0; i < N; i++) {
|
|
902
|
+
m[i] -= mMu;
|
|
903
|
+
r[i] -= rMu;
|
|
904
|
+
mE += m[i] * m[i];
|
|
905
|
+
rE += r[i] * r[i];
|
|
906
|
+
}
|
|
907
|
+
if (mE < 1e-9 || rE < 1e-9) return { lag: 0, peak: 0 };
|
|
908
|
+
const prefM = new Float32Array(N + 1);
|
|
909
|
+
const prefR = new Float32Array(N + 1);
|
|
910
|
+
for (let i = 0; i < N; i++) {
|
|
911
|
+
prefM[i + 1] = prefM[i] + m[i] * m[i];
|
|
912
|
+
prefR[i + 1] = prefR[i] + r[i] * r[i];
|
|
913
|
+
}
|
|
914
|
+
let bestLag = 0;
|
|
915
|
+
let bestPeak = -Infinity;
|
|
916
|
+
for (let d = minLagD; d <= maxLagD; d++) {
|
|
917
|
+
let corr = 0;
|
|
918
|
+
for (let i = d; i < N; i++) corr += m[i] * r[i - d];
|
|
919
|
+
const em = prefM[N] - prefM[d];
|
|
920
|
+
const er = prefR[N - d];
|
|
921
|
+
const peak = em > 1e-12 && er > 1e-12 ? corr / Math.sqrt(em * er) : 0;
|
|
922
|
+
if (peak > bestPeak) {
|
|
923
|
+
bestPeak = peak;
|
|
924
|
+
bestLag = d;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return { lag: bestLag * ds, peak: bestPeak };
|
|
928
|
+
}
|
|
929
|
+
|
|
521
930
|
// src/strings.ts
|
|
522
931
|
var zh = {
|
|
523
932
|
stateVoiceMode: "\u8BED\u97F3\u6A21\u5F0F",
|
|
@@ -530,6 +939,7 @@ var zh = {
|
|
|
530
939
|
hold: "\u6309\u4F4F",
|
|
531
940
|
recognizing: "\u8BC6\u522B\u4E2D\u2026",
|
|
532
941
|
holdToTalk: "\u6309\u4F4F\u8BF4\u8BDD",
|
|
942
|
+
releaseToSend: "\u677E\u5F00\u53D1\u9001",
|
|
533
943
|
voiceDetected: "\u8BED\u97F3\u4E2D",
|
|
534
944
|
entering: "\u8FDB\u5165\u4E2D\u2026",
|
|
535
945
|
voiceBtn: "\u8BED\u97F3",
|
|
@@ -540,15 +950,18 @@ var zh = {
|
|
|
540
950
|
titleEnter: "\u8FDB\u5165\u8BED\u97F3\u5BF9\u8BDD\u6A21\u5F0F\uFF08Ctrl+Shift+V\uFF09",
|
|
541
951
|
loadingModel: "\u6B63\u5728\u52A0\u8F7D\u6A21\u578B\u2026",
|
|
542
952
|
listening: "\u8046\u542C\u4E2D\u2026",
|
|
543
|
-
|
|
953
|
+
thinking: "\u601D\u8003\u4E2D\u2026",
|
|
544
954
|
barHold: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u6309\u4F4F\u8BF4\u8BDD\uFF08\u77ED\u6309\u9000\u51FA\uFF09",
|
|
545
955
|
barListening: "\u8BED\u97F3\u6A21\u5F0F \xB7 \u8046\u542C\u4E2D\u2026",
|
|
956
|
+
wakeWord: "\u5524\u9192\u8BCD",
|
|
957
|
+
sayWake: "\u8BF4\u300C{wake}\u300D\u5F00\u59CB",
|
|
546
958
|
reading: "\u6717\u8BFB\u4E2D\u2026",
|
|
547
959
|
recognitionFail: "\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5",
|
|
960
|
+
sessionExpired: "\u8BED\u97F3\u4F1A\u8BDD\u5DF2\u65AD\u5F00\uFF0C\u6B63\u5728\u91CD\u8FDE\u2026",
|
|
961
|
+
sessionExpiredFail: "\u8BED\u97F3\u4F1A\u8BDD\u91CD\u8FDE\u5931\u8D25\uFF0C\u8BF7\u91CD\u65B0\u5F00\u542F\u8BED\u97F3\u6A21\u5F0F",
|
|
548
962
|
modelDownloadFail: "\u8BED\u97F3\u6A21\u578B\u4E0B\u8F7D\u5931\u8D25\uFF08{file}\uFF09\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u65B0\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u91CD\u8BD5",
|
|
549
963
|
startFail: "\u8BED\u97F3\u6A21\u5F0F\u542F\u52A8\u5931\u8D25\uFF1A{err}",
|
|
550
964
|
holdDots: "\u6309\u4F4F\u8BF4\u8BDD\u2026",
|
|
551
|
-
sayWake: "\u8BF4\u300C{wake}\u300D\u5F00\u59CB",
|
|
552
965
|
exit: "\u9000\u51FA",
|
|
553
966
|
skip: "\u8DF3\u8FC7",
|
|
554
967
|
configUnavailableNote: "\uFF08\u8BBE\u7F6E\u6587\u6863\u672A\u5C31\u7EEA\uFF0C\u9762\u677F\u5C31\u7EEA\u540E\u4F1A\u81EA\u52A8\u51FA\u73B0\uFF09\u3002",
|
|
@@ -558,29 +971,95 @@ var zh = {
|
|
|
558
971
|
previewPlayFail: "\u8BD5\u542C\u5931\u8D25\uFF1A\u65E0\u6CD5\u64AD\u653E\u8BE5\u97F3\u8272",
|
|
559
972
|
previewAutoplay: "\u6D4F\u89C8\u5668\u62E6\u622A\u4E86\u81EA\u52A8\u64AD\u653E\uFF0C\u8BF7\u518D\u70B9\u4E00\u6B21\u8BD5\u542C",
|
|
560
973
|
previewCheck: "\u8BD5\u542C\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E",
|
|
974
|
+
previewRateLimited: "\u8BD5\u542C\u592A\u9891\u7E41\u4E86\uFF0C\u7A0D\u5019\u51E0\u79D2\u518D\u70B9\uFF08\u6BCF\u5206\u949F\u9650 20 \u6B21\uFF09",
|
|
975
|
+
previewTimeout: "\u5408\u6210\u8D85\u65F6\uFF1A\u6A21\u578B\u4ECD\u5728\u52A0\u8F7D\uFF0C\u8BF7\u7A0D\u5019\u51E0\u79D2\u518D\u8BD5",
|
|
976
|
+
previewSynthesisFail: "\u5408\u6210\u5931\u8D25",
|
|
561
977
|
previewBtnTitle: "\u8BD5\u542C\u5F53\u524D\u97F3\u8272\uFF08\u5F53\u524D\u8BED\u901F\uFF09",
|
|
562
978
|
synthesizing: "\u5408\u6210\u4E2D\u2026",
|
|
563
979
|
preview: "\u8BD5\u542C",
|
|
564
980
|
custom: "\u81EA\u5B9A\u4E49",
|
|
565
981
|
// settings rows
|
|
566
|
-
|
|
982
|
+
voicePrev: "\u4E0A\u4E00\u97F3\u8272",
|
|
983
|
+
voiceNext: "\u4E0B\u4E00\u97F3\u8272",
|
|
984
|
+
modeBtnToggle: "\u6301\u7EED\u8046\u542C",
|
|
985
|
+
modeBtnHold: "\u6309\u4F4F\u8BF4",
|
|
986
|
+
modeBtnTitle: "\u70B9\u51FB\u5207\u6362\u4EA4\u4E92\u6A21\u5F0F\uFF08\u4FDD\u5B58\u5230\u8BBE\u7F6E\uFF09",
|
|
987
|
+
descVoice: "Edge \u4E91\u7AEF\u97F3\u8272\uFF08\u8FDB\u5165\u65F6\u81EA\u52A8\u52A0\u8F7D\u5FAE\u8F6F\u5168\u90E8\u97F3\u8272\uFF0C\u4E0B\u62C9\u6216 \u25C0\u25B6 \u9009\uFF1B\u7F51\u7EDC\u5F02\u5E38\u56DE\u9000\u5E38\u7528 14 \u4E2A\uFF0C\u4E5F\u53EF\u300C\u81EA\u5B9A\u4E49\u300D\u586B ShortName\uFF09",
|
|
988
|
+
descVoiceLocal: "\u672C\u5730\u97F3\u8272\uFF08vits \u4E94\u4E2A\u8BF4\u8BDD\u4EBA\u5168\u90E8\u5217\u51FA\uFF0C\u4E0B\u62C9\u9009\u6216 \u25C0\u25B6 \u5207\u6362\uFF1B\u65E0\u9700\u81EA\u5B9A\u4E49\uFF09",
|
|
989
|
+
ttsEngine: "\u6717\u8BFB\u5F15\u64CE",
|
|
990
|
+
descTtsEngine: "\u672C\u5730 VITS\uFF08\u7EAF\u4E2D\u6587\uFF09/ \u672C\u5730 Kokoro\uFF08\u4E2D\u82F1\u5747\u53EF\uFF09/ Edge \u4E91\u7AEF\uFF08\u97F3\u8D28\u6700\u81EA\u7136\uFF0C\u6587\u672C\u4E0A\u5FAE\u8F6F\uFF09",
|
|
991
|
+
engineVits: "\u672C\u5730 VITS",
|
|
992
|
+
engineKokoro: "\u672C\u5730\u4E2D\u82F1",
|
|
993
|
+
engineEdge: "Edge \u4E91\u7AEF",
|
|
994
|
+
descVoiceKokoro: "Kokoro \u4E2D\u82F1\u97F3\u8272\uFF08103 \u4E2A\u5168\u90E8\u5217\u51FA\uFF0C\u4E0B\u62C9\u9009\u6216 \u25C0\u25B6 \u5207\u6362\uFF1B48-51 \u4E2D\u6587\u540D\uFF0C\u5176\u4F59\u6309\u7F16\u53F7+\u5B9E\u6D4B\u6027\u522B\u6807\u6CE8\uFF0C\u4E2D\u82F1\u6DF7\u8BFB\u5747\u53EF\uFF09",
|
|
567
995
|
descRate: "\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 \u6162\u901F \uFF5E 2.0 \u5FEB\u901F\uFF0C1.0 \u6B63\u5E38\uFF09",
|
|
568
|
-
descInterrupt: "\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF080 \u9AD8\u95E8\u69DB / 1 \u4E2D / 2 \u4F4E\uFF09",
|
|
996
|
+
descInterrupt: "\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF080 \u9AD8\u95E8\u69DB / 1 \u4E2D / 2 \u4F4E\uFF1B\u53D1\u58F0\u786E\u8BA4\u7EA6 0.3/0.2/0.1 \u79D2\uFF09",
|
|
997
|
+
descBargeIn: "\u6253\u65AD\u65B9\u5F0F\uFF08auto \u81EA\u52A8\u6253\u65AD\uFF1A\u5F00\u53E3\u5373\u6253\u65AD\uFF0C\u8033\u673A/\u5B89\u9759\u73AF\u5883\u63A8\u8350\uFF1Bmanual \u624B\u52A8\u6253\u65AD\uFF1A\u5916\u653E\u63A8\u8350\u2014\u2014\u56DE\u58F0\u4E0D\u4F1A\u8BEF\u89E6\u53D1\u81EA\u6253\u65AD\uFF0C\u6309\u4F4F\u9EA6\u514B\u98CE/Ctrl \u663E\u5F0F\u6253\u65AD\uFF09",
|
|
998
|
+
bargeInAuto: "\u81EA\u52A8",
|
|
999
|
+
bargeInManual: "\u624B\u52A8",
|
|
1000
|
+
descEchoGate: "\u56DE\u58F0\u95E8\u63A7\u9608\u503C\uFF08dB\uFF0C\u9ED8\u8BA4 6\uFF09\uFF1A\u81EA\u52A8\u6253\u65AD\u8981\u6C42\u6B8B\u5DEE\u9AD8\u4E8E\u56DE\u58F0\u5730\u677F\u6B64\u503C\uFF1B\u5916\u653E\u4ECD\u8BEF\u6253\u65AD\u8C03\u5927\uFF088~10\uFF09\uFF0C\u592A\u96BE\u6253\u65AD\u8C03\u5C0F\uFF083~4\uFF09",
|
|
1001
|
+
descShortcut: "\u8FDB\u5165/\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5FEB\u6377\u952E\uFF08\u5F62\u5982 Ctrl+Shift+V\uFF1B\u7559\u7A7A\u7981\u7528\u5FEB\u6377\u952E\uFF0C\u53EA\u7528\u9EA6\u514B\u98CE\u6309\u94AE\uFF1B\u907F\u514D\u6D4F\u89C8\u5668\u4FDD\u7559\u7EC4\u5408\u5982 Ctrl+W/N/T\uFF09",
|
|
1002
|
+
vadDetected: "VAD \u68C0\u6D4B\u5230\u8BED\u97F3",
|
|
1003
|
+
aecOff: "\u539F\u751F\u56DE\u58F0\u6D88\u9664\u672A\u751F\u6548",
|
|
1004
|
+
aecOffHint: "\u6D4F\u89C8\u5668\u539F\u751F\u56DE\u58F0\u6D88\u9664\u672A\u751F\u6548\uFF08\u5916\u653E\u53EF\u80FD\u81EA\u6253\u65AD\uFF09\uFF0C\u5EFA\u8BAE\u7528\u8033\u673A\u6216\u5207\u6362\u300C\u624B\u52A8\u6253\u65AD\u300D",
|
|
1005
|
+
interruptConfirm: "\u6253\u65AD\u786E\u8BA4",
|
|
569
1006
|
sev0: "0 \u9AD8\u95E8\u69DB",
|
|
570
1007
|
sev1: "1 \u4E2D",
|
|
571
1008
|
sev2: "2 \u4F4E",
|
|
572
|
-
descSilence: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4
|
|
1009
|
+
descSilence: "\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 700 \u6BEB\u79D2\uFF1B\u81F3\u5C11 250ms \u8BED\u97F3\u624D\u5224\u53E5\uFF0C\u9632\u77ED\u4FC3\u566A\u58F0\u8BEF\u89E6\u53D1\uFF09",
|
|
573
1010
|
descIdle: "\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09",
|
|
574
1011
|
descModelHost: "ASR \u6A21\u578B\u4E0B\u8F7D\u6E90\uFF08\u5B98\u65B9\u6E90 / \u56FD\u5185\u955C\u50CF\uFF0C\u6216\u9009\u300C\u81EA\u5B9A\u4E49\u300D\u586B\u4EFB\u610F\u955C\u50CF\uFF09",
|
|
575
1012
|
descAutoSend: "\u8BC6\u522B\u5B9A\u7A3F\u540E\u81EA\u52A8\u53D1\u9001\uFF08\u5173=\u53EA\u8FDB\u8349\u7A3F\uFF1B\u6309\u4F4F Ctrl / hold \u677E\u624B\u4ECD\u53D1\u9001\uFF09",
|
|
576
|
-
|
|
1013
|
+
descAutoResume: "\u5207\u6362\u56DE\u4E0A\u6B21\u8BED\u97F3\u4F1A\u8BDD\u65F6\u81EA\u52A8\u6062\u590D\u8BED\u97F3\u6A21\u5F0F\uFF08\u9ED8\u8BA4\u5173\uFF0C\u9700\u9EA6\u514B\u98CE\u6743\u9650\u5DF2\u6388\u4E88\uFF1B\u7701\u53BB\u6BCF\u6B21\u5207\u6362\u4F1A\u8BDD\u540E\u91CD\u65B0\u70B9\u9EA6\u514B\u98CE\uFF09",
|
|
1014
|
+
descSpokenFormat: "\u8BED\u97F3\u4F1A\u8BDD\u6CE8\u5165\u53E3\u8BED\u5316\u63D0\u793A\u8BCD\uFF08\u56DE\u590D\u53E3\u8BED\u5316\u3001\u77ED\u53E5\u3001\u4E0D\u7528 Markdown \u6392\u7248\u7B26\u53F7\uFF0C\u6717\u8BFB\u66F4\u987A\u66F4\u5FEB\uFF1B\u9ED8\u8BA4\u5F00\uFF0C\u6539\u52A8\u5373\u65F6\u751F\u6548\uFF09",
|
|
1015
|
+
descSenseVoice: "\u5B9A\u7A3F\u7528 SenseVoice \u91CD\u8BD1\uFF08\u5E26\u6807\u70B9 + \u6570\u5B57\u5F52\u4E00\u5316\u3001\u8BC6\u522B\u66F4\u51C6\uFF1B\u9ED8\u8BA4\u5F00\u3002\u5173\u95ED\u53EF\u7701 228MB \u6A21\u578B\uFF0C\u53EA\u8D70\u6D41\u5F0F\u8BC6\u522B\uFF09",
|
|
1016
|
+
descToolBeep: '\u5DE5\u5177\u8C03\u7528\u63D0\u793A\u97F3\uFF08\u9ED8\u8BA4\u5173\uFF09\uFF1AAI \u601D\u8003/\u8C03\u7528\u5DE5\u5177\u65F6"\u6EF4"\u4E00\u58F0\uFF1B\u5ACC\u5435\u5C31\u4FDD\u6301\u5173\u95ED',
|
|
577
1017
|
descMode: "\u4EA4\u4E92\u6A21\u5F0F\uFF08toggle \u6301\u7EED\u8046\u542C+\u9759\u97F3\u65AD\u53E5 / hold \u6309\u4F4F\u8BF4\u8BDD\uFF09",
|
|
578
1018
|
modeToggle: "\u6301\u7EED\u8046\u542C",
|
|
579
1019
|
modeHold: "\u6309\u4F4F\u8BF4\u8BDD",
|
|
580
1020
|
descWakeWord: "\u5524\u9192\u8BCD\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF0C\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF09",
|
|
581
1021
|
wakePlaceholder: "\u5982\uFF1A\u4F60\u597D\u5C0FD",
|
|
582
|
-
settingsCardDesc: "\u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
|
|
583
|
-
|
|
1022
|
+
settingsCardDesc: "\u6717\u8BFB\u5F15\u64CE / \u97F3\u8272 / \u8BED\u901F / \u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u9759\u97F3\u505C\u987F / \u7A7A\u95F2\u8D85\u65F6 / \u6A21\u578B\u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
|
|
1023
|
+
settingsEffectiveNote: "\u6717\u8BFB\u5F15\u64CE / \u97F3\u8272 / \u8BED\u901F / \u53E3\u8BED\u5316\u63D0\u793A\u8BCD / \u91CD\u8BD1 \u5373\u65F6\u751F\u6548\uFF1B\u5176\u4F59\uFF08\u6253\u65AD\u7075\u654F\u5EA6 / \u6253\u65AD\u65B9\u5F0F / \u56DE\u58F0\u95E8\u63A7 / \u5FEB\u6377\u952E / \u9759\u97F3 / \u7A7A\u95F2 / \u955C\u50CF / \u81EA\u52A8\u53D1\u9001 / \u81EA\u52A8\u6062\u590D / \u4EA4\u4E92\u6A21\u5F0F / \u5524\u9192\u8BCD / \u5DE5\u5177\u63D0\u793A\u97F3\uFF09\u4E0B\u6B21\u8FDB\u5165\u8BED\u97F3\u6A21\u5F0F\u65F6\u751F\u6548\u3002",
|
|
1024
|
+
configUnavailable: "\u914D\u7F6E\u6682\u4E0D\u53EF\u7528",
|
|
1025
|
+
// telemetry(P1-5 开发模式延迟埋点状态条:各段耗时标签)
|
|
1026
|
+
telUtteranceEnd: "\u8BF4\u5B8C",
|
|
1027
|
+
telEndpoint: "\u7AEF\u70B9",
|
|
1028
|
+
telSubmitted: "\u5B9A\u7A3F",
|
|
1029
|
+
telFirstToken: "\u9996Token",
|
|
1030
|
+
telFirstSentence: "\u9996\u53E5",
|
|
1031
|
+
telFirstChunk: "\u9996chunk",
|
|
1032
|
+
telFirstPlayed: "\u9996\u97F3",
|
|
1033
|
+
// 模型管理(设置面板实时状态/重试)
|
|
1034
|
+
modelsTitle: "\u8BED\u97F3\u6A21\u578B",
|
|
1035
|
+
modelsDisabled: "\u5DF2\u5173\u95ED\uFF08\u8BBE\u7F6E\u4E2D\u5F00\u542F\uFF09",
|
|
1036
|
+
modelStreamingAsr: "\u6D41\u5F0F\u8BC6\u522B",
|
|
1037
|
+
modelVad: "\u7AEF\u70B9 VAD",
|
|
1038
|
+
modelSense: "\u5B9A\u7A3F\u91CD\u8BD1",
|
|
1039
|
+
modelsReady: "\u5C31\u7EEA",
|
|
1040
|
+
modelsDownloading: "{file} {percent}%",
|
|
1041
|
+
modelsFail: "\u4E0B\u8F7D\u5931\u8D25\uFF08{sec} \u79D2\u540E\u81EA\u52A8\u91CD\u8BD5\uFF09",
|
|
1042
|
+
modelsMissing: "\u672A\u4E0B\u8F7D",
|
|
1043
|
+
modelsRetry: "\u91CD\u8BD5\u4E0B\u8F7D",
|
|
1044
|
+
modelsRetrying: "\u91CD\u8BD5\u4E2D\u2026",
|
|
1045
|
+
modelsRetryHint: "\u955C\u50CF\u5207\u6362\u6216\u4E0B\u8F7D\u5931\u8D25\u540E\u70B9\u51FB\u7ACB\u5373\u91CD\u8BD5",
|
|
1046
|
+
modelsHint: "\u4E0B\u8F7D/\u8FDB\u5EA6\u5B9E\u65F6\u8DDF\u8FDB\uFF1B\u5931\u8D25\u81EA\u52A8\u9000\u907F 60s \u91CD\u8BD5\u3002\u955C\u50CF\u6E90\u5207\u6362\u540E\u70B9\u300C\u91CD\u8BD5\u4E0B\u8F7D\u300D\u7ACB\u5373\u751F\u6548\uFF1B\u4E5F\u53EF\u7528 npm run prefetch \u9884\u4E0B\u8F7D\u3002",
|
|
1047
|
+
// 朗读引擎状态(设置面板内联展示)
|
|
1048
|
+
engineLoading: "\u52A0\u8F7D\u4E2D\u2026",
|
|
1049
|
+
engineReady: "\u5C31\u7EEA",
|
|
1050
|
+
engineError: "\u52A0\u8F7D\u5931\u8D25",
|
|
1051
|
+
engineIdle: "\u53EF\u8BD5\u542C",
|
|
1052
|
+
ttsModelsMissing: "\u672C\u5730\u6A21\u578B\u672A\u5C31\u7EEA",
|
|
1053
|
+
ttsRedownload: "\u91CD\u65B0\u4E0B\u8F7D",
|
|
1054
|
+
ttsRedownloadHint: "\u6E05\u7406\u8BE5\u5F15\u64CE\u6A21\u578B\u7F13\u5B58\u5E76\u91CD\u65B0\u4E0B\u8F7D",
|
|
1055
|
+
ttsCleaning: "\u6E05\u7406\u4E2D\u2026",
|
|
1056
|
+
// 设置分组标题
|
|
1057
|
+
secRead: "\u6717\u8BFB\u4E0E\u97F3\u8272",
|
|
1058
|
+
secInterrupt: "\u6253\u65AD\u4E0E\u9759\u97F3",
|
|
1059
|
+
secInteraction: "\u4EA4\u4E92\u4E0E\u8F93\u5165",
|
|
1060
|
+
secRecognition: "\u8BC6\u522B\u4E0E\u53E3\u8BED",
|
|
1061
|
+
secModel: "\u6A21\u578B\u4E0E\u955C\u50CF",
|
|
1062
|
+
telTotal: "\u5408\u8BA1"
|
|
584
1063
|
};
|
|
585
1064
|
var en = {
|
|
586
1065
|
stateVoiceMode: "Voice Mode",
|
|
@@ -593,6 +1072,7 @@ var en = {
|
|
|
593
1072
|
hold: "Hold",
|
|
594
1073
|
recognizing: "Recognizing\u2026",
|
|
595
1074
|
holdToTalk: "Hold to talk",
|
|
1075
|
+
releaseToSend: "Release to send",
|
|
596
1076
|
voiceDetected: "Voice active",
|
|
597
1077
|
entering: "Entering\u2026",
|
|
598
1078
|
voiceBtn: "Voice",
|
|
@@ -603,15 +1083,18 @@ var en = {
|
|
|
603
1083
|
titleEnter: "Enter voice mode (Ctrl+Shift+V)",
|
|
604
1084
|
loadingModel: "Loading model\u2026",
|
|
605
1085
|
listening: "Listening\u2026",
|
|
606
|
-
|
|
1086
|
+
thinking: "Thinking\u2026",
|
|
607
1087
|
barHold: "Voice mode \xB7 hold to talk (tap to exit)",
|
|
608
1088
|
barListening: "Voice mode \xB7 listening\u2026",
|
|
1089
|
+
wakeWord: "Wake word",
|
|
1090
|
+
sayWake: 'Say "{wake}" to start',
|
|
609
1091
|
reading: "Reading\u2026",
|
|
610
1092
|
recognitionFail: "Recognition failed, try again",
|
|
1093
|
+
sessionExpired: "Voice session expired, reconnecting\u2026",
|
|
1094
|
+
sessionExpiredFail: "Voice session reconnect failed; please re-enter voice mode",
|
|
611
1095
|
modelDownloadFail: "Model download failed ({file}): check network and re-enter voice mode",
|
|
612
1096
|
startFail: "Voice mode failed to start: {err}",
|
|
613
1097
|
holdDots: "Hold to talk\u2026",
|
|
614
|
-
sayWake: 'Say "{wake}" to start',
|
|
615
1098
|
exit: "Exit",
|
|
616
1099
|
skip: "Skip",
|
|
617
1100
|
configUnavailableNote: " (settings document not ready; the panel will appear when it is).",
|
|
@@ -620,28 +1103,90 @@ var en = {
|
|
|
620
1103
|
previewPlayFail: "Preview failed: cannot play this voice",
|
|
621
1104
|
previewAutoplay: "Autoplay blocked \u2014 click preview again",
|
|
622
1105
|
previewCheck: "Preview failed: check network or ShortName",
|
|
1106
|
+
previewRateLimited: "Preview too frequent \u2014 wait a few seconds (20/min limit)",
|
|
1107
|
+
previewTimeout: "Synthesis timed out: model still loading, retry in a few seconds",
|
|
1108
|
+
previewSynthesisFail: "Synthesis failed",
|
|
623
1109
|
previewBtnTitle: "Preview voice (current rate)",
|
|
624
1110
|
synthesizing: "Synthesizing\u2026",
|
|
625
1111
|
preview: "Preview",
|
|
626
1112
|
custom: "Custom",
|
|
627
|
-
|
|
1113
|
+
voicePrev: "Previous voice",
|
|
1114
|
+
voiceNext: "Next voice",
|
|
1115
|
+
modeBtnToggle: "Continuous",
|
|
1116
|
+
modeBtnHold: "Hold to talk",
|
|
1117
|
+
modeBtnTitle: "Click to switch interaction mode (saved to settings)",
|
|
1118
|
+
descVoice: "Edge cloud voices (auto-loads all Microsoft voices on entry; \u25C0\u25B6 or dropdown; falls back to 14 common on network failure; custom ShortName allowed)",
|
|
1119
|
+
descVoiceLocal: "Local voice (vits, all 5 speakers listed; dropdown or \u25C0\u25B6; no custom needed)",
|
|
1120
|
+
ttsEngine: "Read-aloud engine",
|
|
1121
|
+
descTtsEngine: "Local VITS (Chinese only) / Local Kokoro (Chinese + English) / Edge cloud (most natural, text sent to Microsoft)",
|
|
1122
|
+
engineVits: "Local VITS",
|
|
1123
|
+
engineKokoro: "Local zh-en",
|
|
1124
|
+
engineEdge: "Edge cloud",
|
|
1125
|
+
descVoiceKokoro: "Kokoro zh-en voices (103; \u25C0\u25B6 to cycle; 48-51 named Chinese, others numbered with measured gender; mixed zh-en supported)",
|
|
628
1126
|
descRate: "Speech rate (0.5 slow \u2013 2.0 fast, 1.0 normal)",
|
|
629
|
-
descInterrupt: "Interrupt sensitivity (0 high barrier / 2 low)",
|
|
1127
|
+
descInterrupt: "Interrupt sensitivity (0 high barrier / 1 medium / 2 low; ~0.3/0.2/0.1 s speech confirmation)",
|
|
1128
|
+
descBargeIn: "Barge-in mode (auto: interrupt by speaking \u2014 headphones/quiet; manual: for loudspeaker, no echo-triggered self-interrupt \u2014 hold mic/Ctrl to interrupt)",
|
|
1129
|
+
bargeInAuto: "Auto",
|
|
1130
|
+
bargeInManual: "Manual",
|
|
1131
|
+
descEchoGate: "Echo gate threshold (dB, default 6): auto barge-in requires the residual to exceed the echo floor by this value; raise (8-10) if speaker echo still interrupts, lower (3-4) if hard to interrupt",
|
|
1132
|
+
descShortcut: "Shortcut to enter/exit voice mode (e.g. Ctrl+Shift+V; empty disables it, mic button only; avoid browser-reserved combos like Ctrl+W/N/T)",
|
|
1133
|
+
vadDetected: "VAD speech",
|
|
1134
|
+
aecOff: "Native AEC off",
|
|
1135
|
+
aecOffHint: "Native echo cancellation is not active (speaker echo may self-interrupt); use headphones or Manual barge-in",
|
|
1136
|
+
interruptConfirm: "interrupt confirm",
|
|
630
1137
|
sev0: "0 high",
|
|
631
1138
|
sev1: "1 medium",
|
|
632
1139
|
sev2: "2 low",
|
|
633
|
-
descSilence: "Silence pause before a sentence is committed (default
|
|
1140
|
+
descSilence: "Silence pause before a sentence is committed (default 700 ms; at least 250 ms of speech required, guards against noise triggers)",
|
|
634
1141
|
descIdle: "Auto-exit voice mode after idle minutes (default 10)",
|
|
635
1142
|
descModelHost: "ASR model download source (official source / mirror, or any custom URL)",
|
|
636
1143
|
descAutoSend: "Auto-send after finalized recognition (off = draft only; Ctrl / hold still sends)",
|
|
637
|
-
|
|
1144
|
+
descAutoResume: "Auto-resume voice mode when switching back to the last voice session (default off, requires granted mic permission)",
|
|
1145
|
+
descSpokenFormat: "Inject spoken-format prompt into voice replies (colloquial, short sentences, no Markdown; default on, live)",
|
|
1146
|
+
descSenseVoice: "Re-transcribe the finalized utterance with SenseVoice (punctuation + ITN, more accurate; default on \u2014 turn off to skip the 228 MB model and keep streaming only)",
|
|
1147
|
+
descToolBeep: "Tool-call beep (default off): beep when the agent is thinking/calling tools; keep off if it annoys you",
|
|
638
1148
|
descMode: "Interaction mode (toggle: continuous listen + auto-send / hold: press to talk)",
|
|
639
1149
|
modeToggle: "Continue listen",
|
|
640
1150
|
modeHold: "Hold to talk",
|
|
641
1151
|
descWakeWord: "Wake word (default off; e.g. Hey D)",
|
|
642
1152
|
wakePlaceholder: "e.g. Hey D",
|
|
643
|
-
settingsCardDesc: "
|
|
644
|
-
|
|
1153
|
+
settingsCardDesc: "Engine / voice / rate / interrupt / barge-in / silence / idle / model host / auto-send / mode / wake word / spoken format",
|
|
1154
|
+
settingsEffectiveNote: "Engine / voice / rate / spoken format / re-transcribe apply immediately; the rest (interrupt / barge-in / echo gate / shortcut / silence / idle / mirror / auto-send / auto-resume / mode / wake word / tool beep) apply next time you enter voice mode.",
|
|
1155
|
+
configUnavailable: "Configuration unavailable",
|
|
1156
|
+
telUtteranceEnd: "end",
|
|
1157
|
+
telEndpoint: "endpoint",
|
|
1158
|
+
telSubmitted: "submit",
|
|
1159
|
+
telFirstToken: "1st token",
|
|
1160
|
+
telFirstSentence: "1st sentence",
|
|
1161
|
+
telFirstChunk: "1st chunk",
|
|
1162
|
+
telFirstPlayed: "1st audio",
|
|
1163
|
+
modelsTitle: "Voice models",
|
|
1164
|
+
modelsDisabled: "off (enable in settings)",
|
|
1165
|
+
modelStreamingAsr: "Streaming ASR",
|
|
1166
|
+
modelVad: "Endpoint VAD",
|
|
1167
|
+
modelSense: "Finalize",
|
|
1168
|
+
modelsReady: "Ready",
|
|
1169
|
+
modelsDownloading: "{file} {percent}%",
|
|
1170
|
+
modelsFail: "Download failed (auto-retry in {sec}s)",
|
|
1171
|
+
modelsMissing: "not downloaded",
|
|
1172
|
+
modelsRetry: "Retry",
|
|
1173
|
+
modelsRetrying: "Retrying\u2026",
|
|
1174
|
+
modelsRetryHint: "Click to retry now after switching mirror or a failure",
|
|
1175
|
+
modelsHint: "Live download state; failures auto-backoff 60s. After switching the mirror, click Retry to take effect immediately; npm run prefetch pre-downloads.",
|
|
1176
|
+
engineLoading: "loading\u2026",
|
|
1177
|
+
engineReady: "ready",
|
|
1178
|
+
engineError: "failed",
|
|
1179
|
+
engineIdle: "ready to preview",
|
|
1180
|
+
ttsModelsMissing: "local models missing",
|
|
1181
|
+
ttsRedownload: "Re-download",
|
|
1182
|
+
ttsRedownloadHint: "Clear this engine model cache and re-download",
|
|
1183
|
+
ttsCleaning: "Clearing\u2026",
|
|
1184
|
+
secRead: "Reading & voice",
|
|
1185
|
+
secInterrupt: "Interrupt & silence",
|
|
1186
|
+
secInteraction: "Interaction",
|
|
1187
|
+
secRecognition: "Recognition & speech",
|
|
1188
|
+
secModel: "Model & mirror",
|
|
1189
|
+
telTotal: "total"
|
|
645
1190
|
};
|
|
646
1191
|
var guess = () => /^zh\b/i.test(
|
|
647
1192
|
typeof document !== "undefined" && document.documentElement.lang || (typeof navigator !== "undefined" ? navigator.language : "") || ""
|
|
@@ -666,6 +1211,25 @@ var cardStyle = {
|
|
|
666
1211
|
borderRadius: 12,
|
|
667
1212
|
overflow: "hidden"
|
|
668
1213
|
};
|
|
1214
|
+
var FIELD_LABELS = {
|
|
1215
|
+
ttsEngine: "\u6717\u8BFB\u5F15\u64CE",
|
|
1216
|
+
voice: "\u97F3\u8272",
|
|
1217
|
+
rate: "\u8BED\u901F",
|
|
1218
|
+
interruptLevel: "\u6253\u65AD\u7075\u654F\u5EA6",
|
|
1219
|
+
bargeInMode: "\u6253\u65AD\u65B9\u5F0F",
|
|
1220
|
+
echoGateDb: "\u56DE\u58F0\u95E8\u63A7",
|
|
1221
|
+
mode: "\u4EA4\u4E92\u6A21\u5F0F",
|
|
1222
|
+
shortcut: "\u5FEB\u6377\u952E",
|
|
1223
|
+
wakeWord: "\u5524\u9192\u8BCD",
|
|
1224
|
+
toolBeep: "\u5DE5\u5177\u63D0\u793A\u97F3",
|
|
1225
|
+
autoSend: "\u81EA\u52A8\u53D1\u9001",
|
|
1226
|
+
autoResume: "\u81EA\u52A8\u6062\u590D",
|
|
1227
|
+
senseVoice: "\u5B9A\u7A3F\u91CD\u8BD1",
|
|
1228
|
+
spokenFormat: "\u53E3\u8BED\u5316\u63D0\u793A\u8BCD",
|
|
1229
|
+
silenceMs: "\u9759\u97F3\u505C\u987F",
|
|
1230
|
+
idleTimeoutMinutes: "\u7A7A\u95F2\u8D85\u65F6",
|
|
1231
|
+
modelHost: "\u6A21\u578B\u955C\u50CF"
|
|
1232
|
+
};
|
|
669
1233
|
var setHeader = {
|
|
670
1234
|
appearance: "none",
|
|
671
1235
|
width: "100%",
|
|
@@ -742,6 +1306,149 @@ var VOICE_OPTIONS = [
|
|
|
742
1306
|
{ v: "en-US-AriaNeural", label: "Aria \xB7 \u5973 \xB7 English" },
|
|
743
1307
|
{ v: "en-US-GuyNeural", label: "Guy \xB7 \u7537 \xB7 English" }
|
|
744
1308
|
];
|
|
1309
|
+
var VOICE_OPTIONS_LOCAL = [
|
|
1310
|
+
{ v: "suyingxue", label: "\u7D20\u6620\u96EA \xB7 \u5973" },
|
|
1311
|
+
{ v: "gunian", label: "\u987E\u5FF5 \xB7 \u7537" },
|
|
1312
|
+
{ v: "fushiyu", label: "\u5085\u65AF\u9047 \xB7 \u5973" },
|
|
1313
|
+
{ v: "bingjiao", label: "\u51B0\u5A07 \xB7 \u7537" },
|
|
1314
|
+
{ v: "bazong", label: "\u9738\u603B \xB7 \u7537" }
|
|
1315
|
+
];
|
|
1316
|
+
var KOKORO_F0 = [
|
|
1317
|
+
224,
|
|
1318
|
+
189,
|
|
1319
|
+
154,
|
|
1320
|
+
261,
|
|
1321
|
+
226,
|
|
1322
|
+
222,
|
|
1323
|
+
220,
|
|
1324
|
+
229,
|
|
1325
|
+
198,
|
|
1326
|
+
186,
|
|
1327
|
+
212,
|
|
1328
|
+
293,
|
|
1329
|
+
233,
|
|
1330
|
+
161,
|
|
1331
|
+
247,
|
|
1332
|
+
207,
|
|
1333
|
+
218,
|
|
1334
|
+
216,
|
|
1335
|
+
220,
|
|
1336
|
+
238,
|
|
1337
|
+
242,
|
|
1338
|
+
229,
|
|
1339
|
+
198,
|
|
1340
|
+
286,
|
|
1341
|
+
211,
|
|
1342
|
+
190,
|
|
1343
|
+
264,
|
|
1344
|
+
261,
|
|
1345
|
+
226,
|
|
1346
|
+
147,
|
|
1347
|
+
216,
|
|
1348
|
+
240,
|
|
1349
|
+
233,
|
|
1350
|
+
188,
|
|
1351
|
+
222,
|
|
1352
|
+
247,
|
|
1353
|
+
253,
|
|
1354
|
+
270,
|
|
1355
|
+
276,
|
|
1356
|
+
276,
|
|
1357
|
+
279,
|
|
1358
|
+
320,
|
|
1359
|
+
247,
|
|
1360
|
+
296,
|
|
1361
|
+
276,
|
|
1362
|
+
235,
|
|
1363
|
+
139,
|
|
1364
|
+
240,
|
|
1365
|
+
282,
|
|
1366
|
+
282,
|
|
1367
|
+
238,
|
|
1368
|
+
226,
|
|
1369
|
+
273,
|
|
1370
|
+
216,
|
|
1371
|
+
286,
|
|
1372
|
+
270,
|
|
1373
|
+
198,
|
|
1374
|
+
179,
|
|
1375
|
+
117,
|
|
1376
|
+
130,
|
|
1377
|
+
114,
|
|
1378
|
+
128,
|
|
1379
|
+
108,
|
|
1380
|
+
106,
|
|
1381
|
+
122,
|
|
1382
|
+
136,
|
|
1383
|
+
190,
|
|
1384
|
+
112,
|
|
1385
|
+
108,
|
|
1386
|
+
128,
|
|
1387
|
+
131,
|
|
1388
|
+
111,
|
|
1389
|
+
110,
|
|
1390
|
+
132,
|
|
1391
|
+
138,
|
|
1392
|
+
189,
|
|
1393
|
+
137,
|
|
1394
|
+
148,
|
|
1395
|
+
151,
|
|
1396
|
+
127,
|
|
1397
|
+
135,
|
|
1398
|
+
111,
|
|
1399
|
+
138,
|
|
1400
|
+
114,
|
|
1401
|
+
125,
|
|
1402
|
+
158,
|
|
1403
|
+
128,
|
|
1404
|
+
156,
|
|
1405
|
+
132,
|
|
1406
|
+
162,
|
|
1407
|
+
131,
|
|
1408
|
+
136,
|
|
1409
|
+
142,
|
|
1410
|
+
124,
|
|
1411
|
+
129,
|
|
1412
|
+
136,
|
|
1413
|
+
126,
|
|
1414
|
+
135,
|
|
1415
|
+
161,
|
|
1416
|
+
150,
|
|
1417
|
+
124,
|
|
1418
|
+
104,
|
|
1419
|
+
124
|
|
1420
|
+
];
|
|
1421
|
+
var KOKORO_NAMED = {
|
|
1422
|
+
48: { v: "zf_xiaobei", label: "\u5C0F\u5317 \xB7 \u4E2D\u6587\u5973" },
|
|
1423
|
+
49: { v: "zf_xiaoni", label: "\u5C0F\u59AE \xB7 \u4E2D\u6587\u5973" },
|
|
1424
|
+
50: { v: "zf_xiaoxiao", label: "\u5C0F\u5C0F \xB7 \u4E2D\u6587\u5973" },
|
|
1425
|
+
51: { v: "zf_xiaoyi", label: "\u5C0F\u827A \xB7 \u4E2D\u6587\u5973" }
|
|
1426
|
+
};
|
|
1427
|
+
var KOKORO_LABEL_OVERRIDES = {
|
|
1428
|
+
62: "62 \xB7 \u6DF1\u6C89 \xB7 \u5E38\u7528\u7537\u58F0",
|
|
1429
|
+
68: "68 \xB7 \u6D51\u539A \xB7 \u5E38\u7528\u7537\u58F0",
|
|
1430
|
+
75: "75 \xB7 \u6E05\u4EAE \xB7 \u5E38\u7528\u7537\u58F0",
|
|
1431
|
+
76: "76 \xB7 \u78C1\u6027 \xB7 \u5E38\u7528\u7537\u58F0"
|
|
1432
|
+
};
|
|
1433
|
+
var KOKORO_PINNED = [62, 68, 75, 76];
|
|
1434
|
+
function kokoroOption(sid) {
|
|
1435
|
+
const custom = KOKORO_LABEL_OVERRIDES[sid];
|
|
1436
|
+
if (custom) return { v: String(sid), label: custom };
|
|
1437
|
+
const named = KOKORO_NAMED[sid];
|
|
1438
|
+
if (named) return { v: named.v, label: named.label };
|
|
1439
|
+
const hz = KOKORO_F0[sid] ?? null;
|
|
1440
|
+
if (hz === null) return { v: String(sid), label: `${sid} \xB7 \u97F3\u8272` };
|
|
1441
|
+
return { v: String(sid), label: `${sid} \xB7 ${hz < 180 ? "\u7537\u58F0" : "\u5973\u58F0"} \xB7 ${hz}Hz` };
|
|
1442
|
+
}
|
|
1443
|
+
var VOICE_OPTIONS_KOKORO = [
|
|
1444
|
+
...KOKORO_PINNED.map((sid) => kokoroOption(sid)),
|
|
1445
|
+
...KOKORO_F0.map((_, sid) => kokoroOption(sid)).filter((o) => !KOKORO_PINNED.includes(Number(o.v)))
|
|
1446
|
+
];
|
|
1447
|
+
var ENGINE_DEFAULT_VOICE = {
|
|
1448
|
+
vits: "fushiyu",
|
|
1449
|
+
kokoro: "zf_xiaoni",
|
|
1450
|
+
edge: "zh-CN-XiaoxiaoNeural"
|
|
1451
|
+
};
|
|
745
1452
|
var HOST_OPTIONS = [
|
|
746
1453
|
{ v: "https://huggingface.co", label: "\u5B98\u65B9\u6E90 huggingface.co" },
|
|
747
1454
|
{ v: "https://hf-mirror.com", label: "\u56FD\u5185\u955C\u50CF hf-mirror.com" }
|
|
@@ -869,6 +1576,100 @@ function SelectField({
|
|
|
869
1576
|
footer?.(inOptions ? cur : custom)
|
|
870
1577
|
] });
|
|
871
1578
|
}
|
|
1579
|
+
var stepBtn = {
|
|
1580
|
+
boxSizing: "border-box",
|
|
1581
|
+
width: 36,
|
|
1582
|
+
flex: "0 0 auto",
|
|
1583
|
+
cursor: "pointer",
|
|
1584
|
+
border: `1px solid ${t2.border}`,
|
|
1585
|
+
borderRadius: 8,
|
|
1586
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
1587
|
+
color: t2.label,
|
|
1588
|
+
fontSize: 16,
|
|
1589
|
+
lineHeight: "28px",
|
|
1590
|
+
textAlign: "center",
|
|
1591
|
+
padding: 0,
|
|
1592
|
+
fontFamily: "inherit"
|
|
1593
|
+
};
|
|
1594
|
+
function VoiceSelect({
|
|
1595
|
+
score,
|
|
1596
|
+
field,
|
|
1597
|
+
value,
|
|
1598
|
+
options,
|
|
1599
|
+
placeholder,
|
|
1600
|
+
footer,
|
|
1601
|
+
showCustom = true
|
|
1602
|
+
}) {
|
|
1603
|
+
const cur = String(value ?? "");
|
|
1604
|
+
const inOptions = options.some((o) => o.v === cur);
|
|
1605
|
+
const idx = options.findIndex((o) => o.v === cur);
|
|
1606
|
+
const [custom, setCustom] = (0, import_react.useState)(inOptions ? "" : cur);
|
|
1607
|
+
(0, import_react.useEffect)(() => {
|
|
1608
|
+
if (!options.some((o) => o.v === cur)) setCustom(cur);
|
|
1609
|
+
}, [cur, options]);
|
|
1610
|
+
const move = (delta) => {
|
|
1611
|
+
if (options.length === 0) return;
|
|
1612
|
+
if (inOptions) {
|
|
1613
|
+
const n = options.length;
|
|
1614
|
+
const next = options[((idx + delta) % n + n) % n];
|
|
1615
|
+
void score.set(field, next.v);
|
|
1616
|
+
} else {
|
|
1617
|
+
void score.set(field, options[0].v);
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
const selectStyle = {
|
|
1621
|
+
...inputStyle,
|
|
1622
|
+
appearance: "none",
|
|
1623
|
+
cursor: "pointer",
|
|
1624
|
+
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")`,
|
|
1625
|
+
backgroundPosition: "right 12px center",
|
|
1626
|
+
backgroundRepeat: "no-repeat",
|
|
1627
|
+
backgroundSize: "12px 12px",
|
|
1628
|
+
paddingRight: 32
|
|
1629
|
+
};
|
|
1630
|
+
const label = inOptions ? options[idx].label : custom || placeholder || "";
|
|
1631
|
+
const selectValue = inOptions ? cur : showCustom ? "__custom__" : options[0]?.v ?? "";
|
|
1632
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", flexDirection: "column", gap: 6, width: 320, maxWidth: "100%", alignItems: "stretch" }, children: [
|
|
1633
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
|
|
1634
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", "aria-label": t("voicePrev"), onClick: () => move(-1), style: stepBtn, children: "\u2039" }),
|
|
1635
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
1636
|
+
"select",
|
|
1637
|
+
{
|
|
1638
|
+
style: selectStyle,
|
|
1639
|
+
value: selectValue,
|
|
1640
|
+
"aria-label": label,
|
|
1641
|
+
onChange: (e) => {
|
|
1642
|
+
const v = e.target.value;
|
|
1643
|
+
if (v === "__custom__") setCustom(inOptions ? "" : custom);
|
|
1644
|
+
else void score.set(field, v);
|
|
1645
|
+
},
|
|
1646
|
+
children: [
|
|
1647
|
+
options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: o.v, children: o.label }, o.v)),
|
|
1648
|
+
showCustom && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("option", { value: "__custom__", children: [
|
|
1649
|
+
t("custom"),
|
|
1650
|
+
"\u2026"
|
|
1651
|
+
] })
|
|
1652
|
+
]
|
|
1653
|
+
}
|
|
1654
|
+
),
|
|
1655
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", "aria-label": t("voiceNext"), onClick: () => move(1), style: stepBtn, children: "\u203A" })
|
|
1656
|
+
] }),
|
|
1657
|
+
showCustom && !inOptions && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1658
|
+
"input",
|
|
1659
|
+
{
|
|
1660
|
+
style: inputStyle,
|
|
1661
|
+
value: custom,
|
|
1662
|
+
placeholder,
|
|
1663
|
+
onChange: (e) => setCustom(e.target.value),
|
|
1664
|
+
onBlur: () => void score.set(field, custom),
|
|
1665
|
+
onKeyDown: (e) => {
|
|
1666
|
+
if (e.key === "Enter") void score.set(field, custom);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
),
|
|
1670
|
+
footer?.(inOptions ? cur : showCustom ? custom : cur)
|
|
1671
|
+
] });
|
|
1672
|
+
}
|
|
872
1673
|
function VoicePreviewButton({ voice, rate }) {
|
|
873
1674
|
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
874
1675
|
const [note, setNote] = (0, import_react.useState)(null);
|
|
@@ -895,13 +1696,26 @@ function VoicePreviewButton({ voice, rate }) {
|
|
|
895
1696
|
method: "POST",
|
|
896
1697
|
headers: { "content-type": "application/json" },
|
|
897
1698
|
body: JSON.stringify({ voice: v, rate }),
|
|
898
|
-
signal: AbortSignal.timeout(
|
|
1699
|
+
signal: AbortSignal.timeout(9e4)
|
|
899
1700
|
});
|
|
900
1701
|
if (res.status === 403) {
|
|
901
1702
|
setNote(t("previewDisabled"));
|
|
902
1703
|
return;
|
|
903
1704
|
}
|
|
904
|
-
if (
|
|
1705
|
+
if (res.status === 429) {
|
|
1706
|
+
setNote(t("previewRateLimited"));
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
if (!res.ok) {
|
|
1710
|
+
let detail = "";
|
|
1711
|
+
try {
|
|
1712
|
+
const parsed = await res.json();
|
|
1713
|
+
if (parsed && typeof parsed.error === "string") detail = parsed.error;
|
|
1714
|
+
} catch {
|
|
1715
|
+
}
|
|
1716
|
+
setNote(detail ? `${t("previewSynthesisFail")}\uFF1A${detail}` : t("previewCheck"));
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
905
1719
|
const blob = await res.blob();
|
|
906
1720
|
const url = URL.createObjectURL(blob);
|
|
907
1721
|
audio.src = url;
|
|
@@ -918,8 +1732,8 @@ function VoicePreviewButton({ voice, rate }) {
|
|
|
918
1732
|
e instanceof DOMException && e.name === "NotAllowedError" ? t("previewAutoplay") : t("previewPlayFail")
|
|
919
1733
|
);
|
|
920
1734
|
}
|
|
921
|
-
} catch {
|
|
922
|
-
setNote(t("previewCheck"));
|
|
1735
|
+
} catch (e) {
|
|
1736
|
+
setNote(e instanceof DOMException && e.name === "TimeoutError" ? t("previewTimeout") : t("previewCheck"));
|
|
923
1737
|
} finally {
|
|
924
1738
|
setBusy(false);
|
|
925
1739
|
}
|
|
@@ -949,41 +1763,264 @@ function VoicePreviewButton({ voice, rate }) {
|
|
|
949
1763
|
] });
|
|
950
1764
|
}
|
|
951
1765
|
function Row({ name, desc, children }) {
|
|
1766
|
+
const label = FIELD_LABELS[name] ?? name;
|
|
952
1767
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: setRow, children: [
|
|
953
1768
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: setLabelBox, children: [
|
|
954
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setLabel, children:
|
|
1769
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setLabel, children: label }),
|
|
955
1770
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setHint, children: desc })
|
|
956
1771
|
] }),
|
|
957
1772
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { flexShrink: 0, maxWidth: 300 }, children })
|
|
958
1773
|
] });
|
|
959
1774
|
}
|
|
1775
|
+
function Section({ title, children }) {
|
|
1776
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 2 }, children: [
|
|
1777
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "10px 0 2px" }, children: [
|
|
1778
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: "var(--dsw-alias-label-secondary)" }, children: title }),
|
|
1779
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { flex: 1, height: 1, background: t2.border } })
|
|
1780
|
+
] }),
|
|
1781
|
+
children
|
|
1782
|
+
] });
|
|
1783
|
+
}
|
|
960
1784
|
function SegGroup({
|
|
961
1785
|
score,
|
|
962
1786
|
field,
|
|
963
1787
|
value,
|
|
964
|
-
options
|
|
1788
|
+
options,
|
|
1789
|
+
onSelect
|
|
965
1790
|
}) {
|
|
966
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { role: "group", style: setSeg, children: options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1791
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { role: "group", style: setSeg, children: options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1792
|
+
"button",
|
|
1793
|
+
{
|
|
1794
|
+
style: setSegBtn(value === o.v),
|
|
1795
|
+
"aria-pressed": value === o.v,
|
|
1796
|
+
onClick: () => {
|
|
1797
|
+
void score.set(field, o.v);
|
|
1798
|
+
onSelect?.(o.v);
|
|
1799
|
+
},
|
|
1800
|
+
children: o.label
|
|
1801
|
+
},
|
|
1802
|
+
String(o.v)
|
|
1803
|
+
)) });
|
|
967
1804
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const [
|
|
971
|
-
(0, import_react.
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1805
|
+
var fmtMB = (b) => b >= 1048576 ? `${(b / 1048576).toFixed(0)}MB` : b > 0 ? `${Math.round(b / 1024)}KB` : "\u2013";
|
|
1806
|
+
function EngineStatusInline() {
|
|
1807
|
+
const [st, setSt] = (0, import_react.useState)(null);
|
|
1808
|
+
const [cleaning, setCleaning] = (0, import_react.useState)(false);
|
|
1809
|
+
(0, import_react.useEffect)(() => {
|
|
1810
|
+
let alive = true;
|
|
1811
|
+
const poll = async () => {
|
|
1812
|
+
try {
|
|
1813
|
+
const res = await fetch(location.origin + BASE_PATH + "/models/status");
|
|
1814
|
+
if (res.ok && alive) setSt(await res.json());
|
|
1815
|
+
} catch {
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
void poll();
|
|
1819
|
+
const timer = setInterval(() => void poll(), 3e3);
|
|
1820
|
+
return () => {
|
|
1821
|
+
alive = false;
|
|
1822
|
+
clearInterval(timer);
|
|
1823
|
+
};
|
|
1824
|
+
}, []);
|
|
1825
|
+
const clean = (engine) => {
|
|
1826
|
+
setCleaning(true);
|
|
1827
|
+
void fetch(location.origin + BASE_PATH + "/models/clean", {
|
|
1828
|
+
method: "POST",
|
|
1829
|
+
headers: { "content-type": "application/json" },
|
|
1830
|
+
body: JSON.stringify({ engine })
|
|
1831
|
+
}).catch(() => void 0).finally(() => setTimeout(() => setCleaning(false), 1500));
|
|
1832
|
+
};
|
|
1833
|
+
const tts = st?.tts;
|
|
1834
|
+
if (!tts) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, {});
|
|
1835
|
+
const engineName = tts.engine === "vits" ? t("engineVits") : tts.engine === "kokoro" ? t("engineKokoro") : t("engineEdge");
|
|
1836
|
+
const isLocal = !!tts.local;
|
|
1837
|
+
let statusText;
|
|
1838
|
+
let statusColor;
|
|
1839
|
+
if (tts.loading) {
|
|
1840
|
+
statusText = t("engineLoading");
|
|
1841
|
+
statusColor = t2.term;
|
|
1842
|
+
} else if (tts.ready) {
|
|
1843
|
+
statusText = t("engineReady");
|
|
1844
|
+
statusColor = "var(--dsw-alias-state-success-primary)";
|
|
1845
|
+
} else if (tts.error) {
|
|
1846
|
+
statusText = t("engineError");
|
|
1847
|
+
statusColor = "var(--dsw-alias-state-error-primary)";
|
|
1848
|
+
} else if (isLocal && !tts.local.ready) {
|
|
1849
|
+
statusText = t("ttsModelsMissing");
|
|
1850
|
+
statusColor = "var(--dsw-alias-state-error-primary)";
|
|
1851
|
+
} else {
|
|
1852
|
+
statusText = t("engineIdle");
|
|
1853
|
+
statusColor = t2.term;
|
|
984
1854
|
}
|
|
985
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "
|
|
986
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("
|
|
1855
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: 8, padding: "2px 0 10px" }, children: [
|
|
1856
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: t2.term }, children: engineName }),
|
|
1857
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, fontWeight: tts.ready || tts.error ? 600 : 400, color: statusColor }, children: statusText }),
|
|
1858
|
+
tts.loading && st?.progress?.file && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
|
|
1859
|
+
st.progress.file,
|
|
1860
|
+
" ",
|
|
1861
|
+
st.progress.percent,
|
|
1862
|
+
"%"
|
|
1863
|
+
] }),
|
|
1864
|
+
isLocal && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1865
|
+
"button",
|
|
1866
|
+
{
|
|
1867
|
+
type: "button",
|
|
1868
|
+
onClick: () => clean(tts.engine),
|
|
1869
|
+
disabled: cleaning || tts.loading,
|
|
1870
|
+
style: {
|
|
1871
|
+
font: "inherit",
|
|
1872
|
+
fontSize: 12,
|
|
1873
|
+
cursor: tts.loading ? "default" : "pointer",
|
|
1874
|
+
color: t2.label,
|
|
1875
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
1876
|
+
border: `1px solid ${t2.border}`,
|
|
1877
|
+
borderRadius: 8,
|
|
1878
|
+
padding: "3px 10px",
|
|
1879
|
+
flexShrink: 0
|
|
1880
|
+
},
|
|
1881
|
+
title: t("ttsRedownloadHint"),
|
|
1882
|
+
children: cleaning ? t("ttsCleaning") : t("ttsRedownload")
|
|
1883
|
+
}
|
|
1884
|
+
),
|
|
1885
|
+
tts.error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 11, color: "var(--dsw-alias-state-error-primary)", flexBasis: "100%" }, children: tts.error })
|
|
1886
|
+
] });
|
|
1887
|
+
}
|
|
1888
|
+
function ModelStatusView() {
|
|
1889
|
+
const [st, setSt] = (0, import_react.useState)(null);
|
|
1890
|
+
const [retrying, setRetrying] = (0, import_react.useState)(null);
|
|
1891
|
+
(0, import_react.useEffect)(() => {
|
|
1892
|
+
let alive = true;
|
|
1893
|
+
const poll = async () => {
|
|
1894
|
+
try {
|
|
1895
|
+
const res = await fetch(`${location.origin}${BASE_PATH}/models/status`);
|
|
1896
|
+
if (res.ok && alive) setSt(await res.json());
|
|
1897
|
+
} catch {
|
|
1898
|
+
}
|
|
1899
|
+
};
|
|
1900
|
+
void poll();
|
|
1901
|
+
const timer = setInterval(() => void poll(), 3e3);
|
|
1902
|
+
return () => {
|
|
1903
|
+
alive = false;
|
|
1904
|
+
clearInterval(timer);
|
|
1905
|
+
};
|
|
1906
|
+
}, []);
|
|
1907
|
+
const retry = (kind) => {
|
|
1908
|
+
setRetrying(kind);
|
|
1909
|
+
void fetch(`${location.origin}${BASE_PATH}/models/retry`, {
|
|
1910
|
+
method: "POST",
|
|
1911
|
+
headers: { "content-type": "application/json" },
|
|
1912
|
+
body: JSON.stringify({ kind })
|
|
1913
|
+
}).catch(() => void 0).finally(() => {
|
|
1914
|
+
setTimeout(() => setRetrying(null), 2e3);
|
|
1915
|
+
});
|
|
1916
|
+
};
|
|
1917
|
+
const mkRow = (label, info, key, progressFor) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "6px 0" }, children: [
|
|
1918
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { width: 92, flexShrink: 0, fontSize: 12, color: t2.label }, children: label }),
|
|
1919
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { flex: 1, minWidth: 0 }, children: info.disabledText ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: t2.term }, children: info.disabledText }) : info.ready ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success-primary)", fontWeight: 600 }, children: t("modelsReady") }) : progressFor && progressFor.file ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
|
|
1920
|
+
t("modelsDownloading").replace("{file}", progressFor.file).replace("{percent}", String(progressFor.percent)),
|
|
1921
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { display: "block", height: 4, borderRadius: 99, background: t2.border, marginTop: 4, overflow: "hidden" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { display: "block", height: "100%", width: `${progressFor.percent}%`, background: "var(--dsw-alias-brand-primary)", transition: "width .3s" } }) })
|
|
1922
|
+
] }) : info.failLatchMs !== void 0 && info.failLatchMs > 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-error-primary)" }, children: t("modelsFail").replace("{sec}", String(Math.ceil(info.failLatchMs / 1e3))) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
|
|
1923
|
+
fmtMB(info.size),
|
|
1924
|
+
t("modelsMissing")
|
|
1925
|
+
] }) }),
|
|
1926
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1927
|
+
"button",
|
|
1928
|
+
{
|
|
1929
|
+
type: "button",
|
|
1930
|
+
disabled: retrying === key || info.ready || !!info.disabledText,
|
|
1931
|
+
onClick: () => retry(key),
|
|
1932
|
+
style: {
|
|
1933
|
+
font: "inherit",
|
|
1934
|
+
fontSize: 12,
|
|
1935
|
+
cursor: info.ready ? "default" : "pointer",
|
|
1936
|
+
color: info.ready ? t2.term : t2.label,
|
|
1937
|
+
background: "var(--dsw-alias-bg-layer-2)",
|
|
1938
|
+
border: `1px solid ${t2.border}`,
|
|
1939
|
+
borderRadius: 8,
|
|
1940
|
+
padding: "3px 10px",
|
|
1941
|
+
opacity: info.ready || info.disabledText ? 0.5 : 1,
|
|
1942
|
+
flexShrink: 0
|
|
1943
|
+
},
|
|
1944
|
+
title: t("modelsRetryHint"),
|
|
1945
|
+
children: retrying === key ? t("modelsRetrying") : t("modelsRetry")
|
|
1946
|
+
}
|
|
1947
|
+
)
|
|
1948
|
+
] });
|
|
1949
|
+
const anyDownloading = !!st?.progress;
|
|
1950
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 4 }, children: [
|
|
1951
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "8px 0" }, children: [
|
|
1952
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 13, fontWeight: 600, color: t2.label }, children: t("modelsTitle") }),
|
|
1953
|
+
anyDownloading && st?.progress && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontSize: 12, color: t2.term }, children: [
|
|
1954
|
+
st.progress.file,
|
|
1955
|
+
" ",
|
|
1956
|
+
st.progress.percent,
|
|
1957
|
+
"%"
|
|
1958
|
+
] })
|
|
1959
|
+
] }),
|
|
1960
|
+
mkRow(
|
|
1961
|
+
t("modelStreamingAsr"),
|
|
1962
|
+
{ ready: !!st?.asr.ready, size: st?.asr.files.reduce((a, f) => a + f.size, 0) ?? 0, failLatchMs: st?.asr.failLatchMs ?? 0 },
|
|
1963
|
+
"asr",
|
|
1964
|
+
anyDownloading ? st.progress : null
|
|
1965
|
+
),
|
|
1966
|
+
mkRow(t("modelVad"), { ready: !!st?.vad.ready, size: st?.vad.size ?? 0, failLatchMs: st?.vad.failLatchMs ?? 0 }, "vad", anyDownloading ? st.progress : null),
|
|
1967
|
+
mkRow(
|
|
1968
|
+
t("modelSense"),
|
|
1969
|
+
{
|
|
1970
|
+
ready: !!st?.sense.ready,
|
|
1971
|
+
size: st?.sense.size ?? 0,
|
|
1972
|
+
failLatchMs: st?.sense.enabled ? st?.sense.failLatchMs ?? 0 : 0,
|
|
1973
|
+
disabledText: st?.sense.enabled ? void 0 : t("modelsDisabled")
|
|
1974
|
+
},
|
|
1975
|
+
"sense",
|
|
1976
|
+
anyDownloading ? st.progress : null
|
|
1977
|
+
),
|
|
1978
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: t2.term, lineHeight: "18px", padding: "4px 0 8px" }, children: t("modelsHint") })
|
|
1979
|
+
] });
|
|
1980
|
+
}
|
|
1981
|
+
function VoiceSettingsCard({ scope }) {
|
|
1982
|
+
const [snap, setSnap] = (0, import_react.useState)(() => scope.getSnapshot());
|
|
1983
|
+
const [collapsed, setCollapsed] = (0, import_react.useState)(true);
|
|
1984
|
+
(0, import_react.useEffect)(
|
|
1985
|
+
() => scope.subscribe(() => {
|
|
1986
|
+
setSnap({ ...scope.getSnapshot() });
|
|
1987
|
+
}),
|
|
1988
|
+
[scope]
|
|
1989
|
+
);
|
|
1990
|
+
const value = snap?.value ?? {};
|
|
1991
|
+
const unavailable = snap?.status === "unavailable" || snap?.status === "error";
|
|
1992
|
+
const engine = value.ttsEngine === "edge" ? "edge" : value.ttsEngine === "kokoro" ? "kokoro" : "vits";
|
|
1993
|
+
const [edgeVoices, setEdgeVoices] = (0, import_react.useState)(null);
|
|
1994
|
+
(0, import_react.useEffect)(() => {
|
|
1995
|
+
if (engine !== "edge") return;
|
|
1996
|
+
let alive = true;
|
|
1997
|
+
void fetch(location.origin + BASE_PATH + "/voices").then((res) => res.ok ? res.json() : null).then((data) => {
|
|
1998
|
+
if (!alive || !data?.voices) return;
|
|
1999
|
+
const localeName = (lk) => {
|
|
2000
|
+
const m = /^([a-z]{2,3})(?:-[A-Z]{2})?/.exec(lk);
|
|
2001
|
+
return m ? m[1].toUpperCase() : lk;
|
|
2002
|
+
};
|
|
2003
|
+
const genderName = (g) => g === "Female" ? "\u5973" : g === "Male" ? "\u7537" : g === "Neutral" ? "\u4E2D\u6027" : g;
|
|
2004
|
+
setEdgeVoices(
|
|
2005
|
+
data.voices.map((v) => ({
|
|
2006
|
+
v: v.ShortName,
|
|
2007
|
+
label: v.FriendlyName.replace(/ Microsoft.*/, "") + " \xB7 " + genderName(v.Gender) + " \xB7 " + localeName(v.Locale) + " (" + v.ShortName + ")"
|
|
2008
|
+
})).sort((a, b) => a.label.localeCompare(b.label))
|
|
2009
|
+
);
|
|
2010
|
+
}).catch(() => void 0);
|
|
2011
|
+
return () => {
|
|
2012
|
+
alive = false;
|
|
2013
|
+
};
|
|
2014
|
+
}, [engine]);
|
|
2015
|
+
const voiceOptions = engine === "edge" ? edgeVoices ?? VOICE_OPTIONS : engine === "kokoro" ? VOICE_OPTIONS_KOKORO : VOICE_OPTIONS_LOCAL;
|
|
2016
|
+
if (unavailable) {
|
|
2017
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: { color: t2.term, fontSize: 12, padding: "14px 16px", ...cardStyle }, children: [
|
|
2018
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: "var(--dsw-alias-state-error-primary)" }, children: t("configUnavailable") }),
|
|
2019
|
+
t("configUnavailableNote")
|
|
2020
|
+
] });
|
|
2021
|
+
}
|
|
2022
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-dshvm-settings": "card", style: cardStyle, children: [
|
|
2023
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: focusVisibleCss }),
|
|
987
2024
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", { type: "button", "aria-expanded": !collapsed, onClick: () => setCollapsed((c) => !c), style: { ...setHeader, background: collapsed ? "transparent" : t2.bgOpen }, children: [
|
|
988
2025
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: setHeadText, children: [
|
|
989
2026
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: setName, children: t("stateVoiceMode") }),
|
|
@@ -992,59 +2029,205 @@ function VoiceSettingsCard({ scope }) {
|
|
|
992
2029
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...setChevron, transform: collapsed ? "rotate(0deg)" : "rotate(180deg)" }, "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 16 16", width: 14, height: 14, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "currentColor", d: "M4 6l4 4 4-4z" }) }) })
|
|
993
2030
|
] }),
|
|
994
2031
|
!collapsed && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: setBody, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: 4 }, children: [
|
|
995
|
-
/* @__PURE__ */ (0, import_jsx_runtime.
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
)
|
|
1037
|
-
|
|
2032
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secRead"), children: [
|
|
2033
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "ttsEngine", desc: t("descTtsEngine"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2034
|
+
SegGroup,
|
|
2035
|
+
{
|
|
2036
|
+
score: scope,
|
|
2037
|
+
field: "ttsEngine",
|
|
2038
|
+
value: engine,
|
|
2039
|
+
options: [
|
|
2040
|
+
{ v: "vits", label: t("engineVits") },
|
|
2041
|
+
{ v: "kokoro", label: t("engineKokoro") },
|
|
2042
|
+
{ v: "edge", label: t("engineEdge") }
|
|
2043
|
+
],
|
|
2044
|
+
onSelect: (v) => {
|
|
2045
|
+
if (v !== engine) {
|
|
2046
|
+
void scope.set("voice", ENGINE_DEFAULT_VOICE[String(v)] ?? "zh-CN-XiaoxiaoNeural");
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
) }),
|
|
2051
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(EngineStatusInline, {}),
|
|
2052
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2053
|
+
Row,
|
|
2054
|
+
{
|
|
2055
|
+
name: "voice",
|
|
2056
|
+
desc: engine === "edge" ? t("descVoice") : engine === "kokoro" ? t("descVoiceKokoro") : t("descVoiceLocal"),
|
|
2057
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2058
|
+
VoiceSelect,
|
|
2059
|
+
{
|
|
2060
|
+
score: scope,
|
|
2061
|
+
field: "voice",
|
|
2062
|
+
value: value.voice ?? "",
|
|
2063
|
+
options: voiceOptions,
|
|
2064
|
+
placeholder: ENGINE_DEFAULT_VOICE[engine] ?? "zh-CN-XiaoxiaoNeural",
|
|
2065
|
+
showCustom: engine === "edge",
|
|
2066
|
+
footer: (v) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoicePreviewButton, { voice: v, rate: Number(value.rate ?? 1) })
|
|
2067
|
+
}
|
|
2068
|
+
)
|
|
2069
|
+
}
|
|
2070
|
+
),
|
|
2071
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "rate", desc: t("descRate"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "rate", value: value.rate ?? 1, min: 0.5, max: 2, step: 0.1 }) })
|
|
2072
|
+
] }),
|
|
2073
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secInterrupt"), children: [
|
|
2074
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "interruptLevel", desc: t("descInterrupt"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2075
|
+
SegGroup,
|
|
2076
|
+
{
|
|
2077
|
+
score: scope,
|
|
2078
|
+
field: "interruptLevel",
|
|
2079
|
+
value: value.interruptLevel,
|
|
2080
|
+
options: [
|
|
2081
|
+
{ v: 0, label: t("sev0") },
|
|
2082
|
+
{ v: 1, label: t("sev1") },
|
|
2083
|
+
{ v: 2, label: t("sev2") }
|
|
2084
|
+
]
|
|
2085
|
+
}
|
|
2086
|
+
) }),
|
|
2087
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "bargeInMode", desc: t("descBargeIn"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2088
|
+
SegGroup,
|
|
2089
|
+
{
|
|
2090
|
+
score: scope,
|
|
2091
|
+
field: "bargeInMode",
|
|
2092
|
+
value: value.bargeInMode,
|
|
2093
|
+
options: [
|
|
2094
|
+
{ v: "auto", label: t("bargeInAuto") },
|
|
2095
|
+
{ v: "manual", label: t("bargeInManual") }
|
|
2096
|
+
]
|
|
2097
|
+
}
|
|
2098
|
+
) }),
|
|
2099
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "echoGateDb", desc: t("descEchoGate"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "echoGateDb", value: value.echoGateDb ?? 6, min: 3, max: 12, step: 1 }) })
|
|
2100
|
+
] }),
|
|
2101
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secInteraction"), children: [
|
|
2102
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "mode", desc: t("descMode"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
2103
|
+
SegGroup,
|
|
2104
|
+
{
|
|
2105
|
+
score: scope,
|
|
2106
|
+
field: "mode",
|
|
2107
|
+
value: value.mode,
|
|
2108
|
+
options: [
|
|
2109
|
+
{ v: "toggle", label: t("modeToggle") },
|
|
2110
|
+
{ v: "hold", label: t("modeHold") }
|
|
2111
|
+
]
|
|
2112
|
+
}
|
|
2113
|
+
) }),
|
|
2114
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "shortcut", desc: t("descShortcut"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextField, { score: scope, field: "shortcut", value: value.shortcut ?? "Ctrl+Shift+V", placeholder: "Ctrl+Shift+V" }) }),
|
|
2115
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "wakeWord", desc: t("descWakeWord"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextField, { score: scope, field: "wakeWord", value: value.wakeWord ?? "", placeholder: t("wakePlaceholder") }) }),
|
|
2116
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "toolBeep", desc: t("descToolBeep"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.toolBeep), onChange: (e) => void scope.set("toolBeep", e.target.checked) }) }),
|
|
2117
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoSend", desc: t("descAutoSend"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.autoSend), onChange: (e) => void scope.set("autoSend", e.target.checked) }) }),
|
|
2118
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "autoResume", desc: t("descAutoResume"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.autoResume), onChange: (e) => void scope.set("autoResume", e.target.checked) }) })
|
|
2119
|
+
] }),
|
|
2120
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, { title: t("secRecognition"), children: [
|
|
2121
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "senseVoice", desc: t("descSenseVoice"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.senseVoice), onChange: (e) => void scope.set("senseVoice", e.target.checked) }) }),
|
|
2122
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "spokenFormat", desc: t("descSpokenFormat"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { type: "checkbox", checked: Boolean(value.spokenFormat), onChange: (e) => void scope.set("spokenFormat", e.target.checked) }) }),
|
|
2123
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "silenceMs", desc: t("descSilence"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "silenceMs", value: value.silenceMs ?? 700, min: 500, max: 3e4, step: 100 }) }),
|
|
2124
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "idleTimeoutMinutes", desc: t("descIdle"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NumberField, { score: scope, field: "idleTimeoutMinutes", value: value.idleTimeoutMinutes ?? 10, min: 1, max: 120, step: 1 }) })
|
|
2125
|
+
] }),
|
|
2126
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Section, { title: t("secModel"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { name: "modelHost", desc: t("descModelHost"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SelectField, { score: scope, field: "modelHost", value: value.modelHost ?? "", options: HOST_OPTIONS, placeholder: "https://..." }) }) }),
|
|
2127
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontSize: 12, color: t2.term, lineHeight: "18px", padding: "4px 0 8px" }, children: t("settingsEffectiveNote") }),
|
|
2128
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModelStatusView, {})
|
|
1038
2129
|
] }) })
|
|
1039
2130
|
] });
|
|
1040
2131
|
}
|
|
1041
2132
|
|
|
1042
2133
|
// src/client.tsx
|
|
1043
2134
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
1044
|
-
var
|
|
2135
|
+
var isSpeechTrueCount = 0;
|
|
2136
|
+
var interruptFirstAt = 0;
|
|
2137
|
+
var isSpeechFalseRun = 0;
|
|
2138
|
+
var lastReenterAt = 0;
|
|
2139
|
+
var INT_CONFIRM_FRAMES = { 0: 3, 1: 2, 2: 1 };
|
|
1045
2140
|
var inject = ["slots", "sessions", "settingsScope"];
|
|
2141
|
+
var TELEMETRY_VIEW = [
|
|
2142
|
+
{ stage: "utterance-end", key: "telUtteranceEnd" },
|
|
2143
|
+
{ stage: "endpoint-fired", key: "telEndpoint" },
|
|
2144
|
+
{ stage: "submitted", key: "telSubmitted" },
|
|
2145
|
+
{ stage: "first-llm-token", key: "telFirstToken" },
|
|
2146
|
+
{ stage: "first-sentence-text", key: "telFirstSentence" },
|
|
2147
|
+
{ stage: "first-tts-chunk", key: "telFirstChunk" },
|
|
2148
|
+
{ stage: "first-audio-played", key: "telFirstPlayed" }
|
|
2149
|
+
];
|
|
2150
|
+
var BUILD_TAG = "a76aad8";
|
|
2151
|
+
var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
|
|
2152
|
+
var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
|
|
2153
|
+
console.log("[dsh-voice] build=" + BUILD_TAG);
|
|
2154
|
+
var debugLog = (event, fields = {}) => {
|
|
2155
|
+
if (!telemetryEnabled) return;
|
|
2156
|
+
const out = {};
|
|
2157
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
2158
|
+
out[k] = typeof v === "number" && !Number.isInteger(v) ? Number(v.toFixed(4)) : v;
|
|
2159
|
+
}
|
|
2160
|
+
console.log("[dsh-voice]", event, JSON.stringify(out));
|
|
2161
|
+
};
|
|
2162
|
+
var beepCtx = null;
|
|
2163
|
+
function playToolBeep() {
|
|
2164
|
+
try {
|
|
2165
|
+
if (!beepCtx) beepCtx = new AudioContext();
|
|
2166
|
+
void beepCtx.resume?.();
|
|
2167
|
+
const ctx = beepCtx;
|
|
2168
|
+
const osc = ctx.createOscillator();
|
|
2169
|
+
const gain = ctx.createGain();
|
|
2170
|
+
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
|
2171
|
+
gain.gain.exponentialRampToValueAtTime(1e-3, ctx.currentTime + 0.1);
|
|
2172
|
+
osc.connect(gain);
|
|
2173
|
+
gain.connect(ctx.destination);
|
|
2174
|
+
osc.start();
|
|
2175
|
+
osc.stop(ctx.currentTime + 0.1);
|
|
2176
|
+
} catch {
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
var SAMPLE_RATE_16K = 16e3;
|
|
2180
|
+
var ECHO_DELAY_MS = 0;
|
|
2181
|
+
var ECHO_TAIL_MS = 400;
|
|
1046
2182
|
var WAVE_BARS = 14;
|
|
1047
2183
|
var BASE_PATH2 = "/voice-mode";
|
|
2184
|
+
function getTabId() {
|
|
2185
|
+
try {
|
|
2186
|
+
const KEY = "dshvm-tabId";
|
|
2187
|
+
let id = sessionStorage.getItem(KEY);
|
|
2188
|
+
if (!id) {
|
|
2189
|
+
id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
2190
|
+
sessionStorage.setItem(KEY, id);
|
|
2191
|
+
}
|
|
2192
|
+
return id;
|
|
2193
|
+
} catch {
|
|
2194
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
var TAB_ID = getTabId();
|
|
2198
|
+
function parseShortcut(s) {
|
|
2199
|
+
const parts = (s || "").split("+");
|
|
2200
|
+
const mods = { ctrl: false, shift: false, alt: false, meta: false };
|
|
2201
|
+
let key = "";
|
|
2202
|
+
for (const raw of parts) {
|
|
2203
|
+
const t3 = raw.trim().toLowerCase();
|
|
2204
|
+
if (t3 === "ctrl" || t3 === "control") mods.ctrl = true;
|
|
2205
|
+
else if (t3 === "shift") mods.shift = true;
|
|
2206
|
+
else if (t3 === "alt" || t3 === "option") mods.alt = true;
|
|
2207
|
+
else if (t3 === "meta" || t3 === "cmd" || t3 === "command") mods.meta = true;
|
|
2208
|
+
else if (t3.length === 1 && /^[a-z0-9]$/.test(t3)) {
|
|
2209
|
+
if (key) return null;
|
|
2210
|
+
key = t3;
|
|
2211
|
+
} else return null;
|
|
2212
|
+
}
|
|
2213
|
+
if (!key) return null;
|
|
2214
|
+
if (!mods.ctrl && !mods.shift && !mods.alt && !mods.meta) return null;
|
|
2215
|
+
return { ...mods, key };
|
|
2216
|
+
}
|
|
2217
|
+
function getLastVoiceSession() {
|
|
2218
|
+
try {
|
|
2219
|
+
return localStorage.getItem("dshvm-last-voice");
|
|
2220
|
+
} catch {
|
|
2221
|
+
return null;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
function setLastVoiceSession(id) {
|
|
2225
|
+
try {
|
|
2226
|
+
if (id) localStorage.setItem("dshvm-last-voice", id);
|
|
2227
|
+
else localStorage.removeItem("dshvm-last-voice");
|
|
2228
|
+
} catch {
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
1048
2231
|
function apply(ctx) {
|
|
1049
2232
|
const bus = createVoiceBus(void 0, ctx);
|
|
1050
2233
|
ctx.slots.inject(
|
|
@@ -1089,6 +2272,7 @@ function apply(ctx) {
|
|
|
1089
2272
|
() => ctx.slots.register(
|
|
1090
2273
|
{
|
|
1091
2274
|
name: "settings.plugin.item",
|
|
2275
|
+
id: "voice-mode",
|
|
1092
2276
|
key: "voice-mode",
|
|
1093
2277
|
order: 100,
|
|
1094
2278
|
label: t("stateVoiceMode")
|
|
@@ -1098,74 +2282,173 @@ function apply(ctx) {
|
|
|
1098
2282
|
);
|
|
1099
2283
|
}
|
|
1100
2284
|
}
|
|
1101
|
-
function createAudioEngine(setUi) {
|
|
1102
|
-
const
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
2285
|
+
function createAudioEngine(setUi, onPlayed, onPlaybackRef, onAllPlayed) {
|
|
2286
|
+
const pending = [];
|
|
2287
|
+
const fallbackAudio = new Audio();
|
|
2288
|
+
let fallback = false;
|
|
2289
|
+
let ctx = null;
|
|
2290
|
+
let duckGain = null;
|
|
2291
|
+
let nextEndAt = 0;
|
|
2292
|
+
const activeSrcs = /* @__PURE__ */ new Set();
|
|
2293
|
+
let decoding = false;
|
|
2294
|
+
const captionQueue = [];
|
|
2295
|
+
const warm = () => {
|
|
2296
|
+
if (ctx) {
|
|
2297
|
+
void ctx.resume?.();
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
try {
|
|
2301
|
+
const AC = window.AudioContext ?? window.webkitAudioContext;
|
|
2302
|
+
ctx = new AC();
|
|
2303
|
+
duckGain = ctx.createGain();
|
|
2304
|
+
duckGain.gain.value = 1;
|
|
2305
|
+
duckGain.connect(ctx.destination);
|
|
2306
|
+
void ctx.resume?.();
|
|
2307
|
+
} catch {
|
|
2308
|
+
ctx = null;
|
|
2309
|
+
}
|
|
2310
|
+
};
|
|
2311
|
+
const playFallback = () => {
|
|
2312
|
+
const frame = pending.shift() ?? null;
|
|
1106
2313
|
if (!frame) {
|
|
1107
2314
|
setUi({ playing: false, playingCaption: null });
|
|
1108
2315
|
return;
|
|
1109
2316
|
}
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
const url = URL.createObjectURL(new Blob([bytes], { type: "audio/mpeg" }));
|
|
1114
|
-
audio.src = url;
|
|
1115
|
-
audio.onended = () => {
|
|
2317
|
+
const url = URL.createObjectURL(new Blob([frame.audio], { type: frame.mime === "audio/wav" ? "audio/wav" : "audio/mpeg" }));
|
|
2318
|
+
fallbackAudio.src = url;
|
|
2319
|
+
fallbackAudio.onended = () => {
|
|
1116
2320
|
URL.revokeObjectURL(url);
|
|
1117
|
-
|
|
2321
|
+
playFallback();
|
|
1118
2322
|
};
|
|
1119
|
-
|
|
2323
|
+
fallbackAudio.onerror = () => {
|
|
1120
2324
|
URL.revokeObjectURL(url);
|
|
1121
|
-
|
|
2325
|
+
playFallback();
|
|
2326
|
+
};
|
|
2327
|
+
fallbackAudio.onplaying = () => {
|
|
2328
|
+
try {
|
|
2329
|
+
onPlayed?.();
|
|
2330
|
+
} catch {
|
|
2331
|
+
}
|
|
2332
|
+
try {
|
|
2333
|
+
if (ctx && frame.audio.length) {
|
|
2334
|
+
void ctx.decodeAudioData(frame.audio.buffer.slice(0)).then((buf) => {
|
|
2335
|
+
onPlaybackRef?.(buf.getChannelData(0), buf.sampleRate, performance.now());
|
|
2336
|
+
}).catch(() => {
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
} catch {
|
|
2340
|
+
}
|
|
1122
2341
|
};
|
|
1123
2342
|
setUi({ playing: true, playingCaption: frame.text, ttsNotice: null });
|
|
1124
|
-
void
|
|
2343
|
+
void fallbackAudio.play().catch(() => playFallback());
|
|
1125
2344
|
};
|
|
1126
|
-
const
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
2345
|
+
const drainPending = () => {
|
|
2346
|
+
if (decoding || !ctx || !duckGain || pending.length === 0) return;
|
|
2347
|
+
decoding = true;
|
|
2348
|
+
void (async () => {
|
|
2349
|
+
try {
|
|
2350
|
+
while (pending.length > 0) {
|
|
2351
|
+
const frame = pending[0];
|
|
2352
|
+
const buf = await ctx.decodeAudioData(frame.audio.buffer.slice(0));
|
|
2353
|
+
if (pending.length === 0 || pending[0] !== frame) return;
|
|
2354
|
+
pending.shift();
|
|
2355
|
+
const t0 = ctx.currentTime;
|
|
2356
|
+
const at = Math.max(t0 + 0.02, nextEndAt);
|
|
2357
|
+
const src = ctx.createBufferSource();
|
|
2358
|
+
src.buffer = buf;
|
|
2359
|
+
src.connect(duckGain);
|
|
2360
|
+
activeSrcs.add(src);
|
|
2361
|
+
src.onended = () => {
|
|
2362
|
+
activeSrcs.delete(src);
|
|
2363
|
+
captionQueue.shift();
|
|
2364
|
+
if (activeSrcs.size === 0 && pending.length === 0) {
|
|
2365
|
+
setUi({ playing: false, playingCaption: null });
|
|
2366
|
+
onAllPlayed?.();
|
|
2367
|
+
} else if (captionQueue.length > 0) {
|
|
2368
|
+
setUi({ playingCaption: captionQueue[0] });
|
|
2369
|
+
}
|
|
2370
|
+
};
|
|
2371
|
+
src.start(at);
|
|
2372
|
+
nextEndAt = at + buf.duration;
|
|
2373
|
+
try {
|
|
2374
|
+
const outLat = ctx.outputLatency ?? 0;
|
|
2375
|
+
const wallMs = performance.now() + (at + outLat - ctx.currentTime) * 1e3;
|
|
2376
|
+
onPlaybackRef?.(buf.getChannelData(0), buf.sampleRate, wallMs);
|
|
2377
|
+
} catch {
|
|
2378
|
+
}
|
|
2379
|
+
try {
|
|
2380
|
+
onPlayed?.();
|
|
2381
|
+
} catch {
|
|
2382
|
+
}
|
|
2383
|
+
captionQueue.push(frame.text);
|
|
2384
|
+
setUi({ playing: true, playingCaption: captionQueue[0], ttsNotice: null });
|
|
2385
|
+
}
|
|
2386
|
+
} catch {
|
|
2387
|
+
for (const src of activeSrcs) {
|
|
2388
|
+
try {
|
|
2389
|
+
src.stop();
|
|
2390
|
+
} catch {
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
activeSrcs.clear();
|
|
2394
|
+
captionQueue.length = 0;
|
|
2395
|
+
fallback = true;
|
|
2396
|
+
playFallback();
|
|
2397
|
+
} finally {
|
|
2398
|
+
decoding = false;
|
|
1131
2399
|
}
|
|
1132
|
-
|
|
1133
|
-
const gain = beepCtx.createGain();
|
|
1134
|
-
osc.frequency.value = 880;
|
|
1135
|
-
gain.gain.setValueAtTime(0.08, beepCtx.currentTime);
|
|
1136
|
-
gain.gain.exponentialRampToValueAtTime(1e-3, beepCtx.currentTime + 0.1);
|
|
1137
|
-
osc.connect(gain);
|
|
1138
|
-
gain.connect(beepCtx.destination);
|
|
1139
|
-
osc.start();
|
|
1140
|
-
osc.stop(beepCtx.currentTime + 0.1);
|
|
1141
|
-
} catch {
|
|
1142
|
-
}
|
|
2400
|
+
})();
|
|
1143
2401
|
};
|
|
1144
2402
|
return {
|
|
1145
2403
|
push(frame) {
|
|
1146
|
-
|
|
1147
|
-
|
|
2404
|
+
if (fallback || !ctx) {
|
|
2405
|
+
pending.push(frame);
|
|
2406
|
+
if (fallbackAudio.paused) playFallback();
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
pending.push(frame);
|
|
2410
|
+
drainPending();
|
|
1148
2411
|
},
|
|
1149
2412
|
skip() {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
2413
|
+
pending.length = 0;
|
|
2414
|
+
nextEndAt = 0;
|
|
2415
|
+
fallbackAudio.pause();
|
|
2416
|
+
fallbackAudio.onended = null;
|
|
2417
|
+
fallbackAudio.onerror = null;
|
|
2418
|
+
for (const src of activeSrcs) {
|
|
2419
|
+
try {
|
|
2420
|
+
src.stop();
|
|
2421
|
+
} catch {
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
activeSrcs.clear();
|
|
2425
|
+
captionQueue.length = 0;
|
|
1154
2426
|
setUi({ playing: false, playingCaption: null });
|
|
1155
2427
|
},
|
|
1156
|
-
|
|
2428
|
+
warm,
|
|
2429
|
+
unduck() {
|
|
2430
|
+
if (!ctx || !duckGain) return;
|
|
2431
|
+
const now = ctx.currentTime;
|
|
2432
|
+
duckGain.gain.cancelScheduledValues(now);
|
|
2433
|
+
duckGain.gain.setTargetAtTime(1, now, 0.035);
|
|
2434
|
+
}
|
|
1157
2435
|
};
|
|
1158
2436
|
}
|
|
1159
2437
|
function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
1160
2438
|
let activeSessionId = null;
|
|
1161
2439
|
const DEFAULT_BOOT = {
|
|
1162
2440
|
basePath: BASE_PATH2,
|
|
1163
|
-
silenceMs:
|
|
2441
|
+
silenceMs: 700,
|
|
1164
2442
|
interruptLevel: 0,
|
|
1165
2443
|
idleTimeoutMinutes: 10,
|
|
1166
2444
|
autoSend: true,
|
|
2445
|
+
autoResume: false,
|
|
1167
2446
|
mode: "toggle",
|
|
1168
|
-
|
|
2447
|
+
bargeInMode: "auto",
|
|
2448
|
+
echoGateDb: 6,
|
|
2449
|
+
shortcut: "Ctrl+Shift+V",
|
|
2450
|
+
wakeWord: "",
|
|
2451
|
+
toolBeep: false
|
|
1169
2452
|
};
|
|
1170
2453
|
const ui = {
|
|
1171
2454
|
state: "idle",
|
|
@@ -1178,16 +2461,142 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1178
2461
|
ttsNotice: null,
|
|
1179
2462
|
boot: DEFAULT_BOOT,
|
|
1180
2463
|
mode: "toggle",
|
|
2464
|
+
telemetry: null,
|
|
2465
|
+
turn: "idle",
|
|
1181
2466
|
wakeWord: ""
|
|
1182
2467
|
};
|
|
1183
2468
|
const listeners = /* @__PURE__ */ new Set();
|
|
1184
2469
|
const audioListeners = /* @__PURE__ */ new Set();
|
|
1185
|
-
const toolListeners = /* @__PURE__ */ new Set();
|
|
1186
2470
|
let source = null;
|
|
1187
|
-
|
|
1188
|
-
|
|
2471
|
+
let playingEndAt = 0;
|
|
2472
|
+
const telemetryStages = {};
|
|
2473
|
+
const stampTelemetry = (stage, at) => {
|
|
2474
|
+
if (!telemetryEnabled) return;
|
|
2475
|
+
if (stage === "utterance-end") {
|
|
2476
|
+
for (const k of Object.keys(telemetryStages)) delete telemetryStages[k];
|
|
2477
|
+
}
|
|
2478
|
+
if (telemetryStages[stage] === void 0) {
|
|
2479
|
+
telemetryStages[stage] = at ?? Date.now();
|
|
2480
|
+
ui.telemetry = { ...telemetryStages };
|
|
2481
|
+
notify();
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
const resetTelemetry = () => {
|
|
2485
|
+
if (!telemetryEnabled) return;
|
|
2486
|
+
for (const k of Object.keys(telemetryStages)) delete telemetryStages[k];
|
|
2487
|
+
ui.telemetry = null;
|
|
2488
|
+
ui.interruptConfirmMs = void 0;
|
|
1189
2489
|
notify();
|
|
1190
|
-
}
|
|
2490
|
+
};
|
|
2491
|
+
const refChunks = [];
|
|
2492
|
+
let refTotal = 0;
|
|
2493
|
+
let refStartWall = 0;
|
|
2494
|
+
let refActive = false;
|
|
2495
|
+
const pushRef = (pcmSrc, srcRate, startWallMs) => {
|
|
2496
|
+
const pcm = resampleLinear(pcmSrc, srcRate, SAMPLE_RATE_16K);
|
|
2497
|
+
if (!refActive) {
|
|
2498
|
+
refActive = true;
|
|
2499
|
+
refStartWall = startWallMs;
|
|
2500
|
+
refChunks.length = 0;
|
|
2501
|
+
refTotal = 0;
|
|
2502
|
+
}
|
|
2503
|
+
const tailWall = refStartWall + refTotal / SAMPLE_RATE_16K * 1e3;
|
|
2504
|
+
const gapMs = startWallMs - tailWall;
|
|
2505
|
+
if (gapMs > 250) {
|
|
2506
|
+
refChunks.length = 0;
|
|
2507
|
+
refTotal = 0;
|
|
2508
|
+
refStartWall = startWallMs;
|
|
2509
|
+
} else if (gapMs > 1) {
|
|
2510
|
+
const padN = Math.floor(gapMs / 1e3 * SAMPLE_RATE_16K);
|
|
2511
|
+
refChunks.push(new Float32Array(padN));
|
|
2512
|
+
refTotal += padN;
|
|
2513
|
+
}
|
|
2514
|
+
refChunks.push(pcm);
|
|
2515
|
+
refTotal += pcm.length;
|
|
2516
|
+
const maxTotal = SAMPLE_RATE_16K * 60;
|
|
2517
|
+
while (refTotal - (refChunks[0]?.length ?? 0) > maxTotal) {
|
|
2518
|
+
refTotal -= refChunks.shift().length;
|
|
2519
|
+
}
|
|
2520
|
+
};
|
|
2521
|
+
const refWindowAt = (tWallMs, n) => {
|
|
2522
|
+
const out = new Float32Array(n);
|
|
2523
|
+
if (!refActive || refTotal === 0) return out;
|
|
2524
|
+
const idx = Math.floor((tWallMs - ECHO_DELAY_MS - refStartWall) / 1e3 * SAMPLE_RATE_16K);
|
|
2525
|
+
if (idx < 0 || idx >= refTotal) return out;
|
|
2526
|
+
let acc = 0;
|
|
2527
|
+
let outOff = 0;
|
|
2528
|
+
for (const c of refChunks) {
|
|
2529
|
+
if (outOff >= n) break;
|
|
2530
|
+
if (idx >= acc + c.length) {
|
|
2531
|
+
acc += c.length;
|
|
2532
|
+
continue;
|
|
2533
|
+
}
|
|
2534
|
+
const start = Math.max(0, idx - acc);
|
|
2535
|
+
const cnt = Math.min(c.length - start, n - outOff);
|
|
2536
|
+
out.set(c.subarray(start, start + cnt), outOff);
|
|
2537
|
+
outOff += cnt;
|
|
2538
|
+
acc += c.length;
|
|
2539
|
+
}
|
|
2540
|
+
return out;
|
|
2541
|
+
};
|
|
2542
|
+
const aec = new NlmsAec({ filterLength: 1024, delay: 0 });
|
|
2543
|
+
let refDelaySamples = 0;
|
|
2544
|
+
let estMic = [];
|
|
2545
|
+
let estRef = [];
|
|
2546
|
+
const EST_CAP = SAMPLE_RATE_16K;
|
|
2547
|
+
let lastEstimateAt = 0;
|
|
2548
|
+
let echoBypass = false;
|
|
2549
|
+
const echoSource = {
|
|
2550
|
+
process: (mic, ref) => {
|
|
2551
|
+
if (echoBypass) return mic;
|
|
2552
|
+
const now = performance.now();
|
|
2553
|
+
if (ui.playing) {
|
|
2554
|
+
for (let i = 0; i < mic.length; i++) estMic.push(mic[i]);
|
|
2555
|
+
for (let i = 0; i < ref.length; i++) estRef.push(ref[i]);
|
|
2556
|
+
if (estMic.length > EST_CAP) {
|
|
2557
|
+
const drop = estMic.length - EST_CAP;
|
|
2558
|
+
estMic.splice(0, drop);
|
|
2559
|
+
estRef.splice(0, drop);
|
|
2560
|
+
}
|
|
2561
|
+
if (now - lastEstimateAt > 2e3 && estMic.length > SAMPLE_RATE_16K * 0.5) {
|
|
2562
|
+
lastEstimateAt = now;
|
|
2563
|
+
const est = estimateBulkDelay(
|
|
2564
|
+
Float32Array.from(estMic),
|
|
2565
|
+
Float32Array.from(estRef),
|
|
2566
|
+
{ sampleRate: SAMPLE_RATE_16K, maxLag: Math.floor(0.25 * SAMPLE_RATE_16K) }
|
|
2567
|
+
);
|
|
2568
|
+
if (est.peak > 0.5) {
|
|
2569
|
+
refDelaySamples = refDelaySamples === 0 ? est.lag : Math.round(refDelaySamples * 0.8 + est.lag * 0.2);
|
|
2570
|
+
}
|
|
2571
|
+
estMic.length = 0;
|
|
2572
|
+
estRef.length = 0;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
let refForAec = ref;
|
|
2576
|
+
if (refDelaySamples > 0 && refActive && refTotal > refDelaySamples) {
|
|
2577
|
+
const shiftMs = refDelaySamples / SAMPLE_RATE_16K * 1e3;
|
|
2578
|
+
refForAec = refWindowAt(now - shiftMs, ref.length);
|
|
2579
|
+
}
|
|
2580
|
+
return aec.process(mic, refForAec);
|
|
2581
|
+
},
|
|
2582
|
+
windowAt: refWindowAt,
|
|
2583
|
+
// A2.5 双讲冻结:用户说话时暂停 NLMS 自适应。
|
|
2584
|
+
setFrozen: (frozen) => aec.setFrozen(frozen)
|
|
2585
|
+
};
|
|
2586
|
+
const engine = createAudioEngine(
|
|
2587
|
+
(patch) => {
|
|
2588
|
+
Object.assign(ui, patch);
|
|
2589
|
+
notify();
|
|
2590
|
+
},
|
|
2591
|
+
() => stampTelemetry("first-audio-played"),
|
|
2592
|
+
(pcm, sampleRate, wallMs) => pushRef(pcm, sampleRate, wallMs),
|
|
2593
|
+
// Fix:自然播完(无 TTS 在播)即清参考池——AEC 不再拿旧回合参考适配新语音。
|
|
2594
|
+
() => {
|
|
2595
|
+
refActive = false;
|
|
2596
|
+
refChunks.length = 0;
|
|
2597
|
+
refTotal = 0;
|
|
2598
|
+
}
|
|
2599
|
+
);
|
|
1191
2600
|
const notify = () => {
|
|
1192
2601
|
for (const fn of listeners) {
|
|
1193
2602
|
try {
|
|
@@ -1198,13 +2607,23 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1198
2607
|
};
|
|
1199
2608
|
const connect = () => {
|
|
1200
2609
|
if (source) return;
|
|
1201
|
-
source = new EventSource(`${location.origin}${basePath}/stream`);
|
|
2610
|
+
source = new EventSource(`${location.origin}${basePath}/stream?tabId=${encodeURIComponent(TAB_ID)}`);
|
|
2611
|
+
source.addEventListener("open", () => {
|
|
2612
|
+
rejectSeqUpTo.clear();
|
|
2613
|
+
lastFinalSeq.clear();
|
|
2614
|
+
});
|
|
1202
2615
|
source.addEventListener("mode", (e) => {
|
|
1203
2616
|
try {
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
2617
|
+
const data = JSON.parse(e.data);
|
|
2618
|
+
const active = data.active ?? null;
|
|
2619
|
+
const ownerTabId = data.ownerTabId ?? null;
|
|
2620
|
+
const preempted = active !== activeSessionId || ownerTabId !== null && activeSessionId !== null && ownerTabId !== TAB_ID;
|
|
2621
|
+
if (activeSessionId !== null && preempted) {
|
|
2622
|
+
const prev = activeSessionId;
|
|
2623
|
+
activeSessionId = null;
|
|
2624
|
+
if (ui.turn !== "idle") ui.turn = "idle";
|
|
2625
|
+
doSkipAudio(prev);
|
|
2626
|
+
resetTelemetry();
|
|
1208
2627
|
notify();
|
|
1209
2628
|
}
|
|
1210
2629
|
} catch {
|
|
@@ -1223,18 +2642,23 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1223
2642
|
} catch {
|
|
1224
2643
|
}
|
|
1225
2644
|
});
|
|
1226
|
-
source.addEventListener("
|
|
2645
|
+
source.addEventListener("turn", (e) => {
|
|
1227
2646
|
try {
|
|
1228
2647
|
const ev = JSON.parse(e.data);
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
} catch {
|
|
1233
|
-
}
|
|
2648
|
+
if (ev.sessionId === activeSessionId && ev.state) {
|
|
2649
|
+
ui.turn = ev.state;
|
|
2650
|
+
notify();
|
|
1234
2651
|
}
|
|
1235
2652
|
} catch {
|
|
1236
2653
|
}
|
|
1237
2654
|
});
|
|
2655
|
+
source.addEventListener("latency", (e) => {
|
|
2656
|
+
try {
|
|
2657
|
+
const ev = JSON.parse(e.data);
|
|
2658
|
+
if (ev.sessionId === activeSessionId && ev.stage) stampTelemetry(ev.stage);
|
|
2659
|
+
} catch {
|
|
2660
|
+
}
|
|
2661
|
+
});
|
|
1238
2662
|
source.addEventListener("asr-progress", (e) => {
|
|
1239
2663
|
try {
|
|
1240
2664
|
const p = JSON.parse(e.data);
|
|
@@ -1268,12 +2692,85 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1268
2692
|
} catch {
|
|
1269
2693
|
}
|
|
1270
2694
|
});
|
|
2695
|
+
source.addEventListener("tool", (e) => {
|
|
2696
|
+
try {
|
|
2697
|
+
const p = JSON.parse(e.data);
|
|
2698
|
+
if (p.sessionId === activeSessionId && ui.boot.toolBeep === true) playToolBeep();
|
|
2699
|
+
} catch {
|
|
2700
|
+
}
|
|
2701
|
+
});
|
|
1271
2702
|
};
|
|
1272
2703
|
connect();
|
|
2704
|
+
const rejectSeqUpTo = /* @__PURE__ */ new Map();
|
|
2705
|
+
const lastFinalSeq = /* @__PURE__ */ new Map();
|
|
2706
|
+
let curSentenceId = null;
|
|
2707
|
+
let curChunks = [];
|
|
2708
|
+
let curBytes = 0;
|
|
2709
|
+
let curChunkCount = 0;
|
|
1273
2710
|
audioListeners.add((frame) => {
|
|
1274
|
-
if (frame.sessionId
|
|
2711
|
+
if (frame.sessionId !== activeSessionId) return;
|
|
2712
|
+
const rejectLine = rejectSeqUpTo.get(frame.sessionId);
|
|
2713
|
+
if (rejectLine !== void 0 && frame.sentenceId <= rejectLine) return;
|
|
2714
|
+
stampTelemetry("first-tts-chunk");
|
|
2715
|
+
if (frame.sentenceId !== curSentenceId) {
|
|
2716
|
+
curSentenceId = frame.sentenceId;
|
|
2717
|
+
curChunks = [];
|
|
2718
|
+
curBytes = 0;
|
|
2719
|
+
curChunkCount = 0;
|
|
2720
|
+
}
|
|
2721
|
+
if (frame.final) {
|
|
2722
|
+
if (frame.chunkId !== curChunkCount) {
|
|
2723
|
+
curSentenceId = null;
|
|
2724
|
+
curChunks = [];
|
|
2725
|
+
curBytes = 0;
|
|
2726
|
+
curChunkCount = 0;
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
const buf = new Uint8Array(curBytes);
|
|
2730
|
+
let off = 0;
|
|
2731
|
+
for (const c of curChunks) {
|
|
2732
|
+
buf.set(c, off);
|
|
2733
|
+
off += c.length;
|
|
2734
|
+
}
|
|
2735
|
+
curSentenceId = null;
|
|
2736
|
+
curChunks = [];
|
|
2737
|
+
curBytes = 0;
|
|
2738
|
+
curChunkCount = 0;
|
|
2739
|
+
if (buf.length === 0) return;
|
|
2740
|
+
const isMp3 = buf[0] === 255;
|
|
2741
|
+
const isWav = buf.length >= 4 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70;
|
|
2742
|
+
if (!isMp3 && !isWav) return;
|
|
2743
|
+
engine.push({
|
|
2744
|
+
sessionId: frame.sessionId,
|
|
2745
|
+
seq: frame.sentenceId,
|
|
2746
|
+
text: frame.text ?? "",
|
|
2747
|
+
audio: buf,
|
|
2748
|
+
mime: frame.mime
|
|
2749
|
+
});
|
|
2750
|
+
lastFinalSeq.set(frame.sessionId, frame.sentenceId);
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
const bin = atob(frame.audio);
|
|
2754
|
+
const bytes = new Uint8Array(bin.length);
|
|
2755
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
2756
|
+
curChunks.push(bytes);
|
|
2757
|
+
curBytes += bytes.length;
|
|
2758
|
+
curChunkCount += 1;
|
|
1275
2759
|
});
|
|
1276
|
-
|
|
2760
|
+
const doSkipAudio = (sidArg) => {
|
|
2761
|
+
const sid = sidArg ?? activeSessionId;
|
|
2762
|
+
if (sid) {
|
|
2763
|
+
rejectSeqUpTo.set(sid, Math.max(lastFinalSeq.get(sid) ?? -1, curSentenceId ?? -1));
|
|
2764
|
+
}
|
|
2765
|
+
curSentenceId = null;
|
|
2766
|
+
curChunks = [];
|
|
2767
|
+
curBytes = 0;
|
|
2768
|
+
refActive = false;
|
|
2769
|
+
refChunks.length = 0;
|
|
2770
|
+
refTotal = 0;
|
|
2771
|
+
engine.skip();
|
|
2772
|
+
playingEndAt = 0;
|
|
2773
|
+
};
|
|
1277
2774
|
return {
|
|
1278
2775
|
get activeSessionId() {
|
|
1279
2776
|
return activeSessionId;
|
|
@@ -1287,34 +2784,47 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1287
2784
|
};
|
|
1288
2785
|
},
|
|
1289
2786
|
setUi(patch) {
|
|
2787
|
+
if (patch.playing === false && ui.playing === true) playingEndAt = Date.now();
|
|
1290
2788
|
Object.assign(ui, patch);
|
|
1291
2789
|
notify();
|
|
1292
2790
|
},
|
|
2791
|
+
/** isPlaying 尾音截止墙钟:playing 或尾音宽限期内均视为「AI 正在朗读」。 */
|
|
2792
|
+
playingTailUntil() {
|
|
2793
|
+
return playingEndAt + ECHO_TAIL_MS + refDelaySamples / SAMPLE_RATE_16K * 1e3;
|
|
2794
|
+
},
|
|
1293
2795
|
async enter(sessionId) {
|
|
1294
2796
|
try {
|
|
1295
2797
|
const res = await fetch(`${location.origin}${basePath}/toggle`, {
|
|
1296
2798
|
method: "POST",
|
|
1297
2799
|
headers: { "content-type": "application/json" },
|
|
1298
|
-
body: JSON.stringify({ sessionId, on: true })
|
|
2800
|
+
body: JSON.stringify({ sessionId, on: true, tabId: TAB_ID })
|
|
1299
2801
|
});
|
|
1300
2802
|
const out = await res.json();
|
|
1301
|
-
activeSessionId = out.active
|
|
2803
|
+
activeSessionId = out.active === sessionId ? sessionId : null;
|
|
1302
2804
|
notify();
|
|
1303
2805
|
if (!res.ok) return { ok: false, error: out.error ?? t("enterFail") };
|
|
1304
|
-
|
|
2806
|
+
if (out.active === sessionId) setLastVoiceSession(sessionId);
|
|
2807
|
+
return {
|
|
2808
|
+
ok: out.active === sessionId,
|
|
2809
|
+
preempted: out.active !== null && out.active !== sessionId,
|
|
2810
|
+
error: out.active === sessionId ? void 0 : t("enterFail")
|
|
2811
|
+
};
|
|
1305
2812
|
} catch {
|
|
1306
2813
|
return { ok: false, error: t("enterFail") };
|
|
1307
2814
|
}
|
|
1308
2815
|
},
|
|
1309
2816
|
async exit(sessionId) {
|
|
2817
|
+
resetTelemetry();
|
|
2818
|
+
ui.turn = "idle";
|
|
2819
|
+
doSkipAudio();
|
|
1310
2820
|
try {
|
|
1311
2821
|
const res = await fetch(`${location.origin}${basePath}/toggle`, {
|
|
1312
2822
|
method: "POST",
|
|
1313
2823
|
headers: { "content-type": "application/json" },
|
|
1314
|
-
body: JSON.stringify({ sessionId, on: false })
|
|
2824
|
+
body: JSON.stringify({ sessionId, on: false, tabId: TAB_ID })
|
|
1315
2825
|
});
|
|
1316
|
-
|
|
1317
|
-
activeSessionId =
|
|
2826
|
+
await res.json();
|
|
2827
|
+
activeSessionId = null;
|
|
1318
2828
|
notify();
|
|
1319
2829
|
} catch {
|
|
1320
2830
|
}
|
|
@@ -1325,20 +2835,31 @@ function createVoiceBus(basePath = BASE_PATH2, ctx) {
|
|
|
1325
2835
|
audioListeners.delete(fn);
|
|
1326
2836
|
};
|
|
1327
2837
|
},
|
|
1328
|
-
onToolEvent(fn) {
|
|
1329
|
-
toolListeners.add(fn);
|
|
1330
|
-
return () => {
|
|
1331
|
-
toolListeners.delete(fn);
|
|
1332
|
-
};
|
|
1333
|
-
},
|
|
1334
2838
|
skipAudio() {
|
|
1335
|
-
|
|
2839
|
+
doSkipAudio();
|
|
2840
|
+
},
|
|
2841
|
+
echoForAsr() {
|
|
2842
|
+
return echoSource;
|
|
2843
|
+
},
|
|
2844
|
+
setEchoBypass(on) {
|
|
2845
|
+
echoBypass = on;
|
|
2846
|
+
},
|
|
2847
|
+
echoDelayMs() {
|
|
2848
|
+
return refDelaySamples / SAMPLE_RATE_16K * 1e3;
|
|
2849
|
+
},
|
|
2850
|
+
unduckAudio() {
|
|
2851
|
+
engine.unduck();
|
|
1336
2852
|
},
|
|
1337
2853
|
cancelTurn(sessionId) {
|
|
1338
2854
|
try {
|
|
1339
2855
|
ctx?.sessions?.binding?.(sessionId)?.session.cancel?.();
|
|
1340
2856
|
} catch {
|
|
1341
2857
|
}
|
|
2858
|
+
},
|
|
2859
|
+
stampTelemetry,
|
|
2860
|
+
resetTelemetry,
|
|
2861
|
+
warmAudio() {
|
|
2862
|
+
engine.warm();
|
|
1342
2863
|
}
|
|
1343
2864
|
};
|
|
1344
2865
|
}
|
|
@@ -1372,8 +2893,12 @@ function MicButton({
|
|
|
1372
2893
|
const submitTimerRef = (0, import_react2.useRef)(null);
|
|
1373
2894
|
const idleTimerRef = (0, import_react2.useRef)(null);
|
|
1374
2895
|
const runningRef = (0, import_react2.useRef)(false);
|
|
2896
|
+
const mountedRef = (0, import_react2.useRef)(true);
|
|
1375
2897
|
const holdCtrlRef = (0, import_react2.useRef)(false);
|
|
1376
|
-
const
|
|
2898
|
+
const manualHoldRef = (0, import_react2.useRef)(false);
|
|
2899
|
+
const breakRef = (0, import_react2.useRef)(null);
|
|
2900
|
+
const pausedForHiddenRef = (0, import_react2.useRef)(false);
|
|
2901
|
+
const bootNow = () => bus.ui.boot ?? { basePath: "/voice-mode", silenceMs: 700, interruptLevel: 0, idleTimeoutMinutes: 10, autoSend: true, autoResume: false, mode: "toggle", bargeInMode: "auto", echoGateDb: 6, shortcut: "Ctrl+Shift+V", wakeWord: "", toolBeep: false };
|
|
1377
2902
|
useVoiceCss();
|
|
1378
2903
|
const [, bumpUi] = (0, import_react2.useState)(0);
|
|
1379
2904
|
(0, import_react2.useEffect)(
|
|
@@ -1398,8 +2923,13 @@ function MicButton({
|
|
|
1398
2923
|
interruptLevel: c.interruptLevel ?? cur.interruptLevel,
|
|
1399
2924
|
idleTimeoutMinutes: c.idleTimeoutMinutes ?? cur.idleTimeoutMinutes,
|
|
1400
2925
|
autoSend: c.autoSend ?? cur.autoSend,
|
|
2926
|
+
autoResume: c.autoResume === true,
|
|
1401
2927
|
mode: c.mode === "hold" ? "hold" : "toggle",
|
|
1402
|
-
|
|
2928
|
+
bargeInMode: c.bargeInMode === "manual" ? "manual" : "auto",
|
|
2929
|
+
echoGateDb: typeof c.echoGateDb === "number" ? Math.min(12, Math.max(3, c.echoGateDb)) : cur.echoGateDb,
|
|
2930
|
+
shortcut: typeof c.shortcut === "string" ? c.shortcut : cur.shortcut,
|
|
2931
|
+
wakeWord: typeof c.wakeWord === "string" ? c.wakeWord : cur.wakeWord,
|
|
2932
|
+
toolBeep: c.toolBeep === true
|
|
1403
2933
|
};
|
|
1404
2934
|
bus.setUi({ boot: next, mode: next.mode, wakeWord: next.wakeWord });
|
|
1405
2935
|
return next;
|
|
@@ -1428,14 +2958,24 @@ function MicButton({
|
|
|
1428
2958
|
if (bus.activeSessionId !== sid) {
|
|
1429
2959
|
setLocalMode("off");
|
|
1430
2960
|
clearIdle();
|
|
1431
|
-
|
|
2961
|
+
cancelPendingSubmit();
|
|
2962
|
+
clearBreakTimer();
|
|
2963
|
+
setHolding(false);
|
|
2964
|
+
isSpeechTrueCount = 0;
|
|
2965
|
+
breakRef.current = null;
|
|
2966
|
+
manualHoldRef.current = false;
|
|
2967
|
+
holdCtrlRef.current = false;
|
|
2968
|
+
const engine = engineRef.current;
|
|
1432
2969
|
engineRef.current = null;
|
|
2970
|
+
if (engine) void engine.stop();
|
|
2971
|
+
bus.resetTelemetry();
|
|
2972
|
+
bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null, isSpeech: void 0 });
|
|
1433
2973
|
}
|
|
1434
2974
|
});
|
|
1435
2975
|
}, [bus]);
|
|
1436
2976
|
const cancelPendingSubmit = () => {
|
|
1437
2977
|
if (submitTimerRef.current) {
|
|
1438
|
-
|
|
2978
|
+
clearInterval(submitTimerRef.current);
|
|
1439
2979
|
submitTimerRef.current = null;
|
|
1440
2980
|
}
|
|
1441
2981
|
};
|
|
@@ -1444,33 +2984,175 @@ function MicButton({
|
|
|
1444
2984
|
setLocalMode("off");
|
|
1445
2985
|
clearIdle();
|
|
1446
2986
|
cancelPendingSubmit();
|
|
2987
|
+
isSpeechTrueCount = 0;
|
|
2988
|
+
breakRef.current = null;
|
|
2989
|
+
manualHoldRef.current = false;
|
|
2990
|
+
clearBreakTimer();
|
|
2991
|
+
setHolding(false);
|
|
1447
2992
|
const engine = engineRef.current;
|
|
1448
2993
|
engineRef.current = null;
|
|
1449
|
-
if (engine)
|
|
1450
|
-
bus.
|
|
2994
|
+
if (engine) await engine.stop();
|
|
2995
|
+
bus.resetTelemetry();
|
|
2996
|
+
bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null, isSpeech: void 0 });
|
|
1451
2997
|
const sid = sidRef.current;
|
|
1452
|
-
if (sid)
|
|
2998
|
+
if (sid) await bus.exit(sid);
|
|
1453
2999
|
};
|
|
1454
3000
|
const enterMode = async () => {
|
|
1455
3001
|
const sid = sidRef.current;
|
|
1456
3002
|
if (!sid || localRef.current !== "off") return;
|
|
3003
|
+
isSpeechTrueCount = 0;
|
|
1457
3004
|
setLocalMode("pending");
|
|
1458
3005
|
try {
|
|
1459
3006
|
const entered = await bus.enter(sid);
|
|
3007
|
+
if (!mountedRef.current) {
|
|
3008
|
+
if (entered.ok) void bus.exit(sid);
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
1460
3011
|
if (!entered.ok) {
|
|
1461
3012
|
setLocalMode("off");
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
3013
|
+
if (!entered.preempted) {
|
|
3014
|
+
bus.setUi({
|
|
3015
|
+
error: entered.error === "voice mode disabled" ? t("disabled") : entered.error ?? t("enterFail")
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
1465
3018
|
return;
|
|
1466
3019
|
}
|
|
1467
3020
|
const cfg = await fetchConfig();
|
|
1468
3021
|
const basePath = cfg.basePath;
|
|
1469
3022
|
const silenceMs = cfg.silenceMs;
|
|
1470
3023
|
const interruptLevel = cfg.interruptLevel;
|
|
1471
|
-
const
|
|
1472
|
-
|
|
3024
|
+
const confirmFrames = INT_CONFIRM_FRAMES[interruptLevel] ?? 2;
|
|
3025
|
+
const bargeInMode = cfg.bargeInMode;
|
|
3026
|
+
debugLog("enter", {
|
|
3027
|
+
build: BUILD_TAG,
|
|
3028
|
+
mode: cfg.mode,
|
|
3029
|
+
bargeInMode,
|
|
3030
|
+
echoGateDb: cfg.echoGateDb,
|
|
3031
|
+
interruptLevel,
|
|
3032
|
+
silenceMs,
|
|
3033
|
+
sessionId: sid
|
|
3034
|
+
});
|
|
3035
|
+
const hardBreak = async () => {
|
|
3036
|
+
bus.skipAudio();
|
|
3037
|
+
bus.unduckAudio();
|
|
3038
|
+
if (runningRef.current && sidRef.current) {
|
|
3039
|
+
bus.cancelTurn(sidRef.current);
|
|
3040
|
+
}
|
|
3041
|
+
const cancelP = fetch(`${location.origin}${BASE_PATH2}/cancel`, {
|
|
3042
|
+
method: "POST",
|
|
3043
|
+
headers: { "content-type": "application/json" },
|
|
3044
|
+
// hold 按压中保留 host ASR 段(松手定稿续传前半句,防吃句)。
|
|
3045
|
+
body: JSON.stringify({ sessionId: sidRef.current, keepAsr: engineRef.current?.holding === true }),
|
|
3046
|
+
signal: AbortSignal.timeout(3e3)
|
|
3047
|
+
}).catch(() => {
|
|
3048
|
+
});
|
|
3049
|
+
if (engineRef.current && !engineRef.current.holding) await engineRef.current.discardSegment();
|
|
3050
|
+
await cancelP;
|
|
3051
|
+
bus.setUi({ partial: "\u2026" });
|
|
3052
|
+
};
|
|
3053
|
+
breakRef.current = hardBreak;
|
|
3054
|
+
const engine = createAsrEngine(
|
|
3055
|
+
{
|
|
3056
|
+
silenceMs,
|
|
3057
|
+
basePath,
|
|
3058
|
+
mode: cfg.mode,
|
|
3059
|
+
wakeWord: cfg.wakeWord,
|
|
3060
|
+
echoGateDb: cfg.echoGateDb,
|
|
3061
|
+
echo: bus.echoForAsr(),
|
|
3062
|
+
// 回声尾音宽限:playing 或尾音窗口内均视为朗读中,防句播完瞬间的残响漏入 ASR。
|
|
3063
|
+
isPlaying: () => bus.ui.playing || Date.now() < bus.playingTailUntil(),
|
|
3064
|
+
// 打断根治阶段二:服务端 Silero VAD 帧级检测下行 → 驱动打断(替代 RMS 能量快
|
|
3065
|
+
// 路径)。连续 confirmFrames 次 true(墙钟节拍 100ms/拍,三档确认约 0.3/0.2/0.1s)
|
|
3066
|
+
// 判真实人声前沿;仅 AI 朗读中(bus.ui.playing)触发 hardBreak,
|
|
3067
|
+
// 防 TTS 回声被 VAD 误判为语音而自打断。
|
|
3068
|
+
onIsSpeech: (speech) => {
|
|
3069
|
+
if (bargeInMode === "manual") return;
|
|
3070
|
+
if (!bus.ui.playing) {
|
|
3071
|
+
isSpeechTrueCount = 0;
|
|
3072
|
+
interruptFirstAt = 0;
|
|
3073
|
+
bus.setUi({ isSpeech: speech, echoDelayMs: bus.echoDelayMs(), echoLevels: engineRef.current?.echoLevels() });
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
if (speech === true && isSpeechTrueCount === 0) {
|
|
3077
|
+
const lv = engineRef.current?.echoLevels();
|
|
3078
|
+
debugLog("vad-speech-start", {
|
|
3079
|
+
playing: bus.ui.playing,
|
|
3080
|
+
delayMs: Math.round(bus.echoDelayMs()),
|
|
3081
|
+
floor: lv?.floorRms,
|
|
3082
|
+
resid: lv?.residualRms,
|
|
3083
|
+
peak: lv?.peakRms
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
if (speech === true) {
|
|
3087
|
+
isSpeechFalseRun = 0;
|
|
3088
|
+
isSpeechTrueCount++;
|
|
3089
|
+
if (isSpeechTrueCount === 1) interruptFirstAt = Date.now();
|
|
3090
|
+
if (isSpeechTrueCount >= confirmFrames) {
|
|
3091
|
+
if (engineRef.current && !engineRef.current.aboveEchoFloor(cfg.echoGateDb ?? 6)) {
|
|
3092
|
+
const lv2 = engineRef.current?.echoLevels();
|
|
3093
|
+
debugLog("echo-gate-reject", {
|
|
3094
|
+
gateDb: cfg.echoGateDb ?? 6,
|
|
3095
|
+
floor: lv2?.floorRms,
|
|
3096
|
+
resid: lv2?.residualRms,
|
|
3097
|
+
peak: lv2?.peakRms,
|
|
3098
|
+
confirmFrames
|
|
3099
|
+
});
|
|
3100
|
+
isSpeechTrueCount = 0;
|
|
3101
|
+
interruptFirstAt = 0;
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
const confirmMs = interruptFirstAt > 0 ? Date.now() - interruptFirstAt : 0;
|
|
3105
|
+
const lv = engineRef.current?.echoLevels();
|
|
3106
|
+
debugLog("interrupt-trigger", {
|
|
3107
|
+
confirmMs,
|
|
3108
|
+
floor: lv?.floorRms,
|
|
3109
|
+
resid: lv?.residualRms,
|
|
3110
|
+
peak: lv?.peakRms,
|
|
3111
|
+
delayMs: Math.round(bus.echoDelayMs())
|
|
3112
|
+
});
|
|
3113
|
+
interruptFirstAt = 0;
|
|
3114
|
+
isSpeechTrueCount = 0;
|
|
3115
|
+
resetIdle();
|
|
3116
|
+
bus.resetTelemetry();
|
|
3117
|
+
bus.setUi({ interruptConfirmMs: confirmMs });
|
|
3118
|
+
void hardBreak();
|
|
3119
|
+
}
|
|
3120
|
+
} else {
|
|
3121
|
+
isSpeechFalseRun++;
|
|
3122
|
+
if (isSpeechFalseRun >= 2) {
|
|
3123
|
+
isSpeechFalseRun = 0;
|
|
3124
|
+
isSpeechTrueCount = Math.max(0, isSpeechTrueCount - 1);
|
|
3125
|
+
if (isSpeechTrueCount === 0) interruptFirstAt = 0;
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
bus.setUi({ isSpeech: speech, echoDelayMs: bus.echoDelayMs(), echoLevels: engineRef.current?.echoLevels() });
|
|
3129
|
+
},
|
|
3130
|
+
onSessionExpired: async () => {
|
|
3131
|
+
if (localRef.current !== "on") return false;
|
|
3132
|
+
if (Date.now() - lastReenterAt < 2e3) return false;
|
|
3133
|
+
lastReenterAt = Date.now();
|
|
3134
|
+
bus.setUi({ error: t("sessionExpired") });
|
|
3135
|
+
const reentered = await bus.enter(sid);
|
|
3136
|
+
if (!reentered.ok) {
|
|
3137
|
+
bus.setUi({ error: t("sessionExpiredFail") });
|
|
3138
|
+
} else {
|
|
3139
|
+
bus.setUi({ error: null });
|
|
3140
|
+
}
|
|
3141
|
+
return reentered.ok;
|
|
3142
|
+
},
|
|
3143
|
+
// A1:原生 AEC 生效状态 → 状态条提示(外放且原生 AEC 失效时引导用耳机/手动打断)。
|
|
3144
|
+
onAecState: (on2) => {
|
|
3145
|
+
debugLog("aec-state", { nativeEchoCancellation: on2 });
|
|
3146
|
+
bus.setEchoBypass(on2);
|
|
3147
|
+
bus.setUi({ aecOff: !on2 });
|
|
3148
|
+
}
|
|
3149
|
+
},
|
|
3150
|
+
sid
|
|
3151
|
+
);
|
|
3152
|
+
bus.setUi({ mode: cfg.mode });
|
|
1473
3153
|
engineRef.current = engine;
|
|
3154
|
+
engine.onTelemetry((e) => bus.stampTelemetry(e.stage, e.at));
|
|
3155
|
+
bus.warmAudio();
|
|
1474
3156
|
try {
|
|
1475
3157
|
if (!beepCtx) beepCtx = new AudioContext();
|
|
1476
3158
|
void beepCtx.resume?.();
|
|
@@ -1491,6 +3173,7 @@ function MicButton({
|
|
|
1491
3173
|
engine.onPartial((text) => bus.setUi({ partial: text }));
|
|
1492
3174
|
engine.onSegment((text, meta) => {
|
|
1493
3175
|
resetIdle();
|
|
3176
|
+
bus.setUi({ partial: "" });
|
|
1494
3177
|
const actions = actionsRef.current;
|
|
1495
3178
|
const trimmed = text.trim();
|
|
1496
3179
|
if (!trimmed) return;
|
|
@@ -1507,6 +3190,7 @@ function MicButton({
|
|
|
1507
3190
|
} catch {
|
|
1508
3191
|
}
|
|
1509
3192
|
}
|
|
3193
|
+
if (bus.ui.playing && !meta?.force) return;
|
|
1510
3194
|
if (bootNow().autoSend === false && !meta?.force) return;
|
|
1511
3195
|
const doSubmit = () => {
|
|
1512
3196
|
try {
|
|
@@ -1522,29 +3206,35 @@ function MicButton({
|
|
|
1522
3206
|
};
|
|
1523
3207
|
cancelPendingSubmit();
|
|
1524
3208
|
doSubmit();
|
|
1525
|
-
|
|
3209
|
+
let retryCount = 0;
|
|
3210
|
+
submitTimerRef.current = setInterval(() => {
|
|
3211
|
+
retryCount++;
|
|
1526
3212
|
const phase = phaseRef.current;
|
|
1527
|
-
if (phase
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
try {
|
|
1534
|
-
await fetch(`${location.origin}${BASE_PATH2}/cancel`, {
|
|
1535
|
-
method: "POST",
|
|
1536
|
-
headers: { "content-type": "application/json" },
|
|
1537
|
-
body: JSON.stringify({ sessionId: sidRef.current })
|
|
1538
|
-
});
|
|
1539
|
-
} catch {
|
|
1540
|
-
}
|
|
1541
|
-
if (runningRef.current && sidRef.current) {
|
|
1542
|
-
bus.cancelTurn(sidRef.current);
|
|
1543
|
-
}
|
|
1544
|
-
bus.setUi({ partial: "\u2026" });
|
|
3213
|
+
if (retryCount > 3 || phase === "submitting" || phase === "adjudicating" || draftRef.current.trim() !== trimmed) {
|
|
3214
|
+
cancelPendingSubmit();
|
|
3215
|
+
return;
|
|
3216
|
+
}
|
|
3217
|
+
doSubmit();
|
|
3218
|
+
}, 500);
|
|
1545
3219
|
});
|
|
1546
3220
|
bus.setUi({ state: "idle", partial: "", levels: [], error: null, model: null, ttsNotice: null });
|
|
3221
|
+
if (!mountedRef.current) {
|
|
3222
|
+
engineRef.current = null;
|
|
3223
|
+
void bus.exit(sid);
|
|
3224
|
+
return;
|
|
3225
|
+
}
|
|
1547
3226
|
await engine.start();
|
|
3227
|
+
if (!mountedRef.current) {
|
|
3228
|
+
engineRef.current = null;
|
|
3229
|
+
await engine.stop();
|
|
3230
|
+
void bus.exit(sid);
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
if (bus.activeSessionId !== sid) {
|
|
3234
|
+
engineRef.current = null;
|
|
3235
|
+
await engine.stop();
|
|
3236
|
+
return;
|
|
3237
|
+
}
|
|
1548
3238
|
setLocalMode("on");
|
|
1549
3239
|
resetIdle();
|
|
1550
3240
|
} catch (e) {
|
|
@@ -1569,21 +3259,40 @@ function MicButton({
|
|
|
1569
3259
|
(0, import_react2.useEffect)(() => {
|
|
1570
3260
|
sidRef.current = sessionId;
|
|
1571
3261
|
}, [sessionId]);
|
|
3262
|
+
const autoResumeTriedForRef = (0, import_react2.useRef)(null);
|
|
3263
|
+
(0, import_react2.useEffect)(() => {
|
|
3264
|
+
const sid = sessionId;
|
|
3265
|
+
if (!sid || sid === autoResumeTriedForRef.current) return;
|
|
3266
|
+
autoResumeTriedForRef.current = sid;
|
|
3267
|
+
void (async () => {
|
|
3268
|
+
const cfg = await fetchConfig();
|
|
3269
|
+
if (!cfg.autoResume) return;
|
|
3270
|
+
if (getLastVoiceSession() !== sid) return;
|
|
3271
|
+
if (bus.activeSessionId !== null) return;
|
|
3272
|
+
if (localRef.current !== "off") return;
|
|
3273
|
+
await enterMode().catch(() => {
|
|
3274
|
+
setLocalMode("off");
|
|
3275
|
+
});
|
|
3276
|
+
})();
|
|
3277
|
+
}, [sessionId]);
|
|
1572
3278
|
const runningSel = useSession ? useSession((s) => s === void 0 ? void 0 : s.running) : void 0;
|
|
1573
3279
|
(0, import_react2.useEffect)(() => {
|
|
1574
3280
|
runningRef.current = runningSel === true;
|
|
1575
3281
|
}, [runningSel]);
|
|
1576
3282
|
(0, import_react2.useEffect)(() => {
|
|
3283
|
+
mountedRef.current = true;
|
|
1577
3284
|
return () => {
|
|
3285
|
+
mountedRef.current = false;
|
|
1578
3286
|
clearIdle();
|
|
1579
3287
|
cancelPendingSubmit();
|
|
3288
|
+
isSpeechTrueCount = 0;
|
|
1580
3289
|
const sid = sidRef.current;
|
|
1581
|
-
if (localRef.current === "on" && sid) {
|
|
3290
|
+
if ((localRef.current === "on" || localRef.current === "pending") && sid) {
|
|
1582
3291
|
void engineRef.current?.stop();
|
|
1583
3292
|
void fetch(`${location.origin}${BASE_PATH2}/toggle`, {
|
|
1584
3293
|
method: "POST",
|
|
1585
3294
|
headers: { "content-type": "application/json" },
|
|
1586
|
-
body: JSON.stringify({ sessionId: sid, on: false }),
|
|
3295
|
+
body: JSON.stringify({ sessionId: sid, on: false, tabId: TAB_ID }),
|
|
1587
3296
|
keepalive: true
|
|
1588
3297
|
}).catch(() => {
|
|
1589
3298
|
});
|
|
@@ -1592,6 +3301,8 @@ function MicButton({
|
|
|
1592
3301
|
}, []);
|
|
1593
3302
|
(0, import_react2.useEffect)(() => {
|
|
1594
3303
|
let ctrlTimer = null;
|
|
3304
|
+
let ctrlHoldStart = 0;
|
|
3305
|
+
let otherKeyDuringCtrl = false;
|
|
1595
3306
|
const cancelCtrl = () => {
|
|
1596
3307
|
if (ctrlTimer) {
|
|
1597
3308
|
clearTimeout(ctrlTimer);
|
|
@@ -1599,33 +3310,68 @@ function MicButton({
|
|
|
1599
3310
|
}
|
|
1600
3311
|
if (holdCtrlRef.current) {
|
|
1601
3312
|
holdCtrlRef.current = false;
|
|
3313
|
+
setHolding(false);
|
|
3314
|
+
engineRef.current?.endHeld(false);
|
|
3315
|
+
}
|
|
3316
|
+
if (manualHoldRef.current) {
|
|
3317
|
+
manualHoldRef.current = false;
|
|
3318
|
+
setHolding(false);
|
|
1602
3319
|
engineRef.current?.endHeld(false);
|
|
1603
3320
|
}
|
|
1604
3321
|
};
|
|
1605
3322
|
const onKeyDown = (e) => {
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
3323
|
+
const combo = parseShortcut(bootNow().shortcut);
|
|
3324
|
+
const codeKey = e.code.replace("Key", "").replace("Digit", "").toLowerCase();
|
|
3325
|
+
if (combo && !e.repeat && (e.key.toLowerCase() === combo.key || codeKey === combo.key) && e.ctrlKey === combo.ctrl && e.shiftKey === combo.shift && e.altKey === combo.alt && e.metaKey === combo.meta) {
|
|
3326
|
+
const el = e.target;
|
|
3327
|
+
const editable = el instanceof HTMLElement && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable);
|
|
3328
|
+
if (!editable && !e.isComposing) {
|
|
3329
|
+
e.preventDefault();
|
|
3330
|
+
cancelCtrl();
|
|
3331
|
+
toggleRef.current();
|
|
3332
|
+
}
|
|
1610
3333
|
return;
|
|
1611
3334
|
}
|
|
1612
3335
|
const eng = engineRef.current;
|
|
1613
|
-
if (e.key
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
3336
|
+
if (e.key === "Control" && !e.shiftKey && !e.altKey && !e.metaKey && !e.repeat && eng) {
|
|
3337
|
+
ctrlHoldStart = Date.now();
|
|
3338
|
+
otherKeyDuringCtrl = false;
|
|
3339
|
+
if (bootNow().mode === "hold") {
|
|
3340
|
+
ctrlTimer = setTimeout(() => {
|
|
3341
|
+
ctrlTimer = null;
|
|
3342
|
+
holdCtrlRef.current = true;
|
|
3343
|
+
setHolding(true);
|
|
3344
|
+
eng.beginHeld();
|
|
3345
|
+
}, 600);
|
|
3346
|
+
} else if (bootNow().bargeInMode === "manual" && bus.ui.playing) {
|
|
3347
|
+
manualHoldRef.current = true;
|
|
3348
|
+
setHolding(true);
|
|
1618
3349
|
eng.beginHeld();
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
3350
|
+
void breakRef.current?.();
|
|
3351
|
+
}
|
|
3352
|
+
return;
|
|
3353
|
+
}
|
|
3354
|
+
if (ctrlHoldStart > 0 && e.key !== "Control") {
|
|
3355
|
+
otherKeyDuringCtrl = true;
|
|
3356
|
+
if (ctrlTimer) {
|
|
3357
|
+
clearTimeout(ctrlTimer);
|
|
3358
|
+
ctrlTimer = null;
|
|
3359
|
+
}
|
|
1622
3360
|
}
|
|
1623
3361
|
};
|
|
1624
3362
|
const onKeyUp = (e) => {
|
|
1625
|
-
if (e.key
|
|
3363
|
+
if (e.key !== "Control") return;
|
|
3364
|
+
if (bootNow().mode !== "hold" && !manualHoldRef.current && !otherKeyDuringCtrl && ctrlHoldStart > 0 && Date.now() - ctrlHoldStart >= 250) {
|
|
3365
|
+
engineRef.current?.forceSend();
|
|
3366
|
+
}
|
|
3367
|
+
cancelCtrl();
|
|
3368
|
+
ctrlHoldStart = 0;
|
|
3369
|
+
otherKeyDuringCtrl = false;
|
|
1626
3370
|
};
|
|
1627
3371
|
const onBlur = () => {
|
|
1628
3372
|
cancelCtrl();
|
|
3373
|
+
ctrlHoldStart = 0;
|
|
3374
|
+
otherKeyDuringCtrl = false;
|
|
1629
3375
|
if (localRef.current === "on" && bootNow().mode === "hold") engineRef.current?.endHeld(true);
|
|
1630
3376
|
};
|
|
1631
3377
|
window.addEventListener("keydown", onKeyDown);
|
|
@@ -1654,12 +3400,25 @@ function MicButton({
|
|
|
1654
3400
|
if (localRef.current !== "on" || bootNow().mode !== "hold") return;
|
|
1655
3401
|
engineRef.current?.endHeld(true);
|
|
1656
3402
|
holdCtrlRef.current = false;
|
|
3403
|
+
setHolding(false);
|
|
1657
3404
|
bus.setUi({ partial: "" });
|
|
1658
3405
|
};
|
|
1659
3406
|
const onVisibility = () => {
|
|
1660
|
-
if (document.hidden
|
|
1661
|
-
engineRef.current
|
|
1662
|
-
|
|
3407
|
+
if (document.hidden) {
|
|
3408
|
+
if (localRef.current === "on" && engineRef.current) {
|
|
3409
|
+
if (bootNow().mode === "hold") {
|
|
3410
|
+
engineRef.current?.endHeld(true);
|
|
3411
|
+
holdCtrlRef.current = false;
|
|
3412
|
+
setHolding(false);
|
|
3413
|
+
}
|
|
3414
|
+
pausedForHiddenRef.current = true;
|
|
3415
|
+
void engineRef.current.stop();
|
|
3416
|
+
}
|
|
3417
|
+
} else if (pausedForHiddenRef.current && localRef.current === "on") {
|
|
3418
|
+
pausedForHiddenRef.current = false;
|
|
3419
|
+
void engineRef.current?.start().catch(() => {
|
|
3420
|
+
setLocalMode("off");
|
|
3421
|
+
});
|
|
1663
3422
|
}
|
|
1664
3423
|
};
|
|
1665
3424
|
window.addEventListener("keydown", onKeyDown);
|
|
@@ -1690,28 +3449,76 @@ function MicButton({
|
|
|
1690
3449
|
const livePhase = useInput ? useInput((s) => s?.phase ?? "") : "";
|
|
1691
3450
|
const phaseRef = (0, import_react2.useRef)("");
|
|
1692
3451
|
phaseRef.current = livePhase;
|
|
1693
|
-
const
|
|
3452
|
+
const [holding, setHolding] = (0, import_react2.useState)(false);
|
|
3453
|
+
const label = on ? busy ? t("recognizing") : holdMode ? holding ? t("releaseToSend") : t("holdToTalk") : bus.ui.state === "wake" ? t("sayWake").replace("{wake}", bus.ui.wakeWord || t("wakeWord")) : t("voiceDetected") : local === "pending" ? t("entering") : t("voiceBtn");
|
|
1694
3454
|
const holdPtrRef = (0, import_react2.useRef)(null);
|
|
3455
|
+
const toggleHoldRef = (0, import_react2.useRef)(false);
|
|
3456
|
+
const suppressClickUntilRef = (0, import_react2.useRef)(0);
|
|
3457
|
+
const breakTimerRef = (0, import_react2.useRef)(null);
|
|
3458
|
+
const clearBreakTimer = () => {
|
|
3459
|
+
if (breakTimerRef.current !== null) {
|
|
3460
|
+
clearTimeout(breakTimerRef.current);
|
|
3461
|
+
breakTimerRef.current = null;
|
|
3462
|
+
}
|
|
3463
|
+
};
|
|
1695
3464
|
const onPointerDown = (e) => {
|
|
1696
|
-
if (bootNow().mode !== "hold") return;
|
|
1697
3465
|
holdPtrRef.current = { t: Date.now(), y: e.clientY, id: e.pointerId };
|
|
1698
3466
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
1699
|
-
if (
|
|
3467
|
+
if (bootNow().mode === "hold") {
|
|
3468
|
+
if (localRef.current === "on") {
|
|
3469
|
+
setHolding(true);
|
|
3470
|
+
const eng = engineRef.current;
|
|
3471
|
+
eng?.beginHeld();
|
|
3472
|
+
if (eng && bootNow().bargeInMode === "manual" && bus.ui.playing) {
|
|
3473
|
+
breakTimerRef.current = setTimeout(() => {
|
|
3474
|
+
breakTimerRef.current = null;
|
|
3475
|
+
if (holdPtrRef.current && bus.ui.playing) void breakRef.current?.();
|
|
3476
|
+
}, 250);
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
} else if (localRef.current === "on" && bus.ui.playing) {
|
|
3480
|
+
toggleHoldRef.current = true;
|
|
3481
|
+
setHolding(true);
|
|
3482
|
+
const eng = engineRef.current;
|
|
3483
|
+
eng?.beginHeld();
|
|
3484
|
+
breakTimerRef.current = setTimeout(() => {
|
|
3485
|
+
breakTimerRef.current = null;
|
|
3486
|
+
if (holdPtrRef.current && bus.ui.playing) void breakRef.current?.();
|
|
3487
|
+
}, 250);
|
|
3488
|
+
}
|
|
1700
3489
|
};
|
|
1701
3490
|
const onPointerMove = (e) => {
|
|
1702
3491
|
const p = holdPtrRef.current;
|
|
1703
3492
|
if (!p || p.id !== e.pointerId) return;
|
|
1704
3493
|
if (p.y - e.clientY >= 40) {
|
|
1705
3494
|
holdPtrRef.current = null;
|
|
3495
|
+
toggleHoldRef.current = false;
|
|
3496
|
+
clearBreakTimer();
|
|
3497
|
+
setHolding(false);
|
|
1706
3498
|
engineRef.current?.endHeld(true);
|
|
1707
3499
|
bus.setUi({ partial: "" });
|
|
3500
|
+
suppressClickUntilRef.current = Date.now() + 500;
|
|
1708
3501
|
}
|
|
1709
3502
|
};
|
|
1710
3503
|
const onPointerUp = (e) => {
|
|
1711
3504
|
const p = holdPtrRef.current;
|
|
1712
3505
|
holdPtrRef.current = null;
|
|
3506
|
+
clearBreakTimer();
|
|
3507
|
+
setHolding(false);
|
|
1713
3508
|
if (!p || p.id !== e.pointerId) return;
|
|
1714
3509
|
const ms = Date.now() - p.t;
|
|
3510
|
+
if (bootNow().mode !== "hold") {
|
|
3511
|
+
if (toggleHoldRef.current) {
|
|
3512
|
+
toggleHoldRef.current = false;
|
|
3513
|
+
if (ms >= 250) {
|
|
3514
|
+
suppressClickUntilRef.current = Date.now() + 500;
|
|
3515
|
+
engineRef.current?.endHeld(false);
|
|
3516
|
+
} else {
|
|
3517
|
+
engineRef.current?.endHeld(true);
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
1715
3522
|
if (ms < 250) {
|
|
1716
3523
|
if (localRef.current === "on") {
|
|
1717
3524
|
engineRef.current?.endHeld(true);
|
|
@@ -1725,12 +3532,16 @@ function MicButton({
|
|
|
1725
3532
|
};
|
|
1726
3533
|
const onPointerCancel = () => {
|
|
1727
3534
|
holdPtrRef.current = null;
|
|
3535
|
+
toggleHoldRef.current = false;
|
|
3536
|
+
clearBreakTimer();
|
|
3537
|
+
setHolding(false);
|
|
1728
3538
|
engineRef.current?.endHeld(true);
|
|
1729
3539
|
};
|
|
1730
3540
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1731
3541
|
"button",
|
|
1732
3542
|
{
|
|
1733
3543
|
onClick: (e) => {
|
|
3544
|
+
if (Date.now() < suppressClickUntilRef.current) return;
|
|
1734
3545
|
if (holdMode) {
|
|
1735
3546
|
if (e.detail !== 0) return;
|
|
1736
3547
|
}
|
|
@@ -1740,29 +3551,34 @@ function MicButton({
|
|
|
1740
3551
|
onPointerMove,
|
|
1741
3552
|
onPointerUp,
|
|
1742
3553
|
onPointerCancel,
|
|
3554
|
+
onContextMenu: (e) => e.preventDefault(),
|
|
1743
3555
|
"data-dshvm": "mic",
|
|
1744
3556
|
"aria-label": on ? t("ariaActive") : t("ariaEnter"),
|
|
1745
3557
|
"aria-pressed": on,
|
|
1746
3558
|
title: on ? holdMode ? t("titleHold") : t("titleToggle") : t("titleEnter"),
|
|
1747
3559
|
style: {
|
|
1748
|
-
border: "
|
|
1749
|
-
background: on ? holdMode ? "rgba(88, 166, 255, 0.16)" : "rgba(63, 185, 80, 0.16)" : local === "pending" ? "rgba(88, 166, 255, 0.14)" : "
|
|
3560
|
+
border: holding ? "1px solid rgba(248, 81, 73, 0.6)" : on ? holdMode ? "1px solid rgba(88, 166, 255, 0.45)" : "1px solid rgba(63, 185, 80, 0.45)" : "1px solid rgba(139, 148, 158, 0.35)",
|
|
3561
|
+
background: holding ? "rgba(248, 81, 73, 0.2)" : on ? holdMode ? "rgba(88, 166, 255, 0.16)" : "rgba(63, 185, 80, 0.16)" : local === "pending" ? "rgba(88, 166, 255, 0.14)" : "rgba(139, 148, 158, 0.08)",
|
|
1750
3562
|
cursor: "pointer",
|
|
1751
|
-
padding: "
|
|
3563
|
+
padding: "5px 10px",
|
|
1752
3564
|
borderRadius: 8,
|
|
1753
3565
|
display: "flex",
|
|
1754
3566
|
alignItems: "center",
|
|
1755
3567
|
gap: 6,
|
|
1756
|
-
fontSize:
|
|
3568
|
+
fontSize: 12,
|
|
1757
3569
|
fontFamily: "system-ui, sans-serif",
|
|
1758
|
-
color: on ? holdMode ? "#58a6ff" : "#3fb950" : local === "pending" ? "#58a6ff" : "#8b949e",
|
|
1759
|
-
transition: "background 0.15s ease, color 0.2s ease",
|
|
3570
|
+
color: holding ? "#f85149" : on ? holdMode ? "#58a6ff" : "#3fb950" : local === "pending" ? "#58a6ff" : "#8b949e",
|
|
3571
|
+
transition: "background 0.15s ease, color 0.2s ease, border-color 0.15s ease",
|
|
1760
3572
|
touchAction: "none",
|
|
1761
3573
|
// 触摸设备上让 pointer 事件独占(滑出取消可用)
|
|
1762
|
-
userSelect: "none"
|
|
3574
|
+
userSelect: "none",
|
|
3575
|
+
WebkitUserSelect: "none",
|
|
3576
|
+
// iOS Safari 前缀,防长按选中文字
|
|
3577
|
+
WebkitTouchCallout: "none"
|
|
3578
|
+
// iOS 长按弹出「拷贝/选择」菜单
|
|
1763
3579
|
},
|
|
1764
3580
|
children: [
|
|
1765
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 24 24", width:
|
|
3581
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("svg", { viewBox: "0 0 24 24", width: 16, height: 16, "aria-hidden": "true", children: [
|
|
1766
3582
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
1767
3583
|
"path",
|
|
1768
3584
|
{
|
|
@@ -1790,15 +3606,38 @@ function VoiceStatusBar({ bus, sessionId }) {
|
|
|
1790
3606
|
}, [bus]);
|
|
1791
3607
|
const isActive = b.active === sessionId;
|
|
1792
3608
|
if (!isActive) return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_jsx_runtime2.Fragment, {});
|
|
1793
|
-
const stateText = b.ui.state === "loading-model" ? t("loadingModel") : b.ui.state === "transcribing" ? t("recognizing") : b.ui.state === "speech" ? b.ui.mode === "hold" ? t("holdDots") : t("listening") : b.ui.
|
|
3609
|
+
const stateText = b.ui.state === "loading-model" ? t("loadingModel") : b.ui.state === "transcribing" ? t("recognizing") : b.ui.state === "wake" ? t("sayWake").replace("{wake}", b.ui.wakeWord || t("wakeWord")) : b.ui.state === "speech" ? b.ui.mode === "hold" ? t("holdDots") : t("listening") : b.ui.playing ? t("reading") : b.ui.turn === "agent-speaking" ? t("thinking") : b.ui.mode === "hold" ? t("barHold") : t("barListening");
|
|
1794
3610
|
const bars = Array.from({ length: WAVE_BARS }, (_, i) => b.ui.levels[i] ?? 0);
|
|
3611
|
+
const telParts = [];
|
|
3612
|
+
const fmt = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(2)}s` : `${Math.round(ms)}ms`;
|
|
3613
|
+
const tel = b.ui.telemetry;
|
|
3614
|
+
if (tel) {
|
|
3615
|
+
for (let i = 1; i < TELEMETRY_VIEW.length; i++) {
|
|
3616
|
+
const cur = tel[TELEMETRY_VIEW[i].stage];
|
|
3617
|
+
const prev = tel[TELEMETRY_VIEW[i - 1].stage];
|
|
3618
|
+
if (cur === void 0 || prev === void 0) continue;
|
|
3619
|
+
telParts.push(`${t(TELEMETRY_VIEW[i].key)} ${fmt(cur - prev)}`);
|
|
3620
|
+
}
|
|
3621
|
+
const begin = tel["utterance-end"];
|
|
3622
|
+
const end = tel["first-audio-played"];
|
|
3623
|
+
if (begin !== void 0 && end !== void 0) telParts.push(`${t("telTotal")} ${fmt(end - begin)}`);
|
|
3624
|
+
}
|
|
3625
|
+
if (b.ui.interruptConfirmMs !== void 0) {
|
|
3626
|
+
telParts.push(`${t("interruptConfirm")} ${fmt(b.ui.interruptConfirmMs)}`);
|
|
3627
|
+
}
|
|
3628
|
+
if (b.ui.echoLevels) {
|
|
3629
|
+
const el = b.ui.echoLevels;
|
|
3630
|
+
telParts.push(
|
|
3631
|
+
`AEC delay=${Math.round(b.ui.echoDelayMs ?? 0)}ms floor=${el.floorRms.toFixed(4)} resid=${el.residualRms.toFixed(4)}`
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
1795
3634
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
1796
3635
|
"div",
|
|
1797
3636
|
{
|
|
1798
3637
|
style: {
|
|
1799
3638
|
display: "flex",
|
|
1800
|
-
|
|
1801
|
-
gap:
|
|
3639
|
+
flexDirection: "column",
|
|
3640
|
+
gap: 2,
|
|
1802
3641
|
padding: "6px 12px",
|
|
1803
3642
|
borderRadius: 10,
|
|
1804
3643
|
fontSize: 12,
|
|
@@ -1809,34 +3648,84 @@ function VoiceStatusBar({ bus, sessionId }) {
|
|
|
1809
3648
|
animation: "dshvm-fadein 0.2s ease"
|
|
1810
3649
|
},
|
|
1811
3650
|
children: [
|
|
1812
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.
|
|
1813
|
-
"span",
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
3651
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
|
|
3652
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { display: "inline-flex", alignItems: "flex-end", gap: 2, height: 14, flexShrink: 0 }, children: bars.map((v, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3653
|
+
"span",
|
|
3654
|
+
{
|
|
3655
|
+
className: "dshvm-bar",
|
|
3656
|
+
style: {
|
|
3657
|
+
height: `${3 + v * 12}px`,
|
|
3658
|
+
background: "#3fb950",
|
|
3659
|
+
opacity: 0.4 + v * 0.6
|
|
3660
|
+
}
|
|
3661
|
+
},
|
|
3662
|
+
i
|
|
3663
|
+
)) }),
|
|
3664
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flexGrow: 1 }, children: b.ui.error ? b.ui.error : b.ui.state === "loading-model" || b.ui.model ? b.ui.model ? `${t("loadingModel")} ${b.ui.model.file} ${b.ui.model.percent}%` : stateText : b.ui.playing || b.ui.turn === "agent-speaking" ? stateText : b.ui.partial ? b.ui.partial : b.ui.ttsNotice ? b.ui.ttsNotice : stateText }),
|
|
3665
|
+
b.ui.isSpeech === true && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3666
|
+
"span",
|
|
3667
|
+
{
|
|
3668
|
+
title: t("vadDetected"),
|
|
3669
|
+
style: {
|
|
3670
|
+
flexShrink: 0,
|
|
3671
|
+
padding: "0 6px",
|
|
3672
|
+
borderRadius: 8,
|
|
3673
|
+
fontSize: 10,
|
|
3674
|
+
lineHeight: "16px",
|
|
3675
|
+
color: "#ffa657",
|
|
3676
|
+
background: "rgba(255, 166, 87, 0.15)",
|
|
3677
|
+
border: "1px solid rgba(255, 166, 87, 0.35)"
|
|
3678
|
+
},
|
|
3679
|
+
children: t("vadDetected")
|
|
1820
3680
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
3681
|
+
),
|
|
3682
|
+
b.ui.aecOff === true && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3683
|
+
"span",
|
|
3684
|
+
{
|
|
3685
|
+
title: t("aecOffHint"),
|
|
3686
|
+
style: {
|
|
3687
|
+
flexShrink: 0,
|
|
3688
|
+
padding: "0 6px",
|
|
3689
|
+
borderRadius: 8,
|
|
3690
|
+
fontSize: 10,
|
|
3691
|
+
lineHeight: "16px",
|
|
3692
|
+
color: "#ffa657",
|
|
3693
|
+
background: "rgba(255, 166, 87, 0.15)",
|
|
3694
|
+
border: "1px solid rgba(255, 166, 87, 0.35)"
|
|
3695
|
+
},
|
|
3696
|
+
children: t("aecOff")
|
|
3697
|
+
}
|
|
3698
|
+
),
|
|
3699
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3700
|
+
"button",
|
|
3701
|
+
{
|
|
3702
|
+
onClick: () => {
|
|
3703
|
+
void bus.exit(sessionId);
|
|
3704
|
+
},
|
|
3705
|
+
style: {
|
|
3706
|
+
border: "none",
|
|
3707
|
+
background: "transparent",
|
|
3708
|
+
color: "#8b949e",
|
|
3709
|
+
cursor: "pointer",
|
|
3710
|
+
fontSize: 12,
|
|
3711
|
+
flexShrink: 0
|
|
3712
|
+
},
|
|
3713
|
+
children: t("exit")
|
|
3714
|
+
}
|
|
3715
|
+
)
|
|
3716
|
+
] }),
|
|
3717
|
+
telParts.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
3718
|
+
"div",
|
|
1827
3719
|
{
|
|
1828
|
-
onClick: () => {
|
|
1829
|
-
void bus.exit(sessionId);
|
|
1830
|
-
},
|
|
1831
3720
|
style: {
|
|
1832
|
-
|
|
1833
|
-
background: "transparent",
|
|
3721
|
+
fontSize: 11,
|
|
1834
3722
|
color: "#8b949e",
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
3723
|
+
fontVariantNumeric: "tabular-nums",
|
|
3724
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
3725
|
+
whiteSpace: "nowrap",
|
|
3726
|
+
overflowX: "auto"
|
|
1838
3727
|
},
|
|
1839
|
-
children:
|
|
3728
|
+
children: telParts.join(" \xB7 ")
|
|
1840
3729
|
}
|
|
1841
3730
|
)
|
|
1842
3731
|
]
|
|
@@ -1857,7 +3746,8 @@ function VoiceOverlay({ bus }) {
|
|
|
1857
3746
|
style: {
|
|
1858
3747
|
position: "fixed",
|
|
1859
3748
|
right: 16,
|
|
1860
|
-
bottom:
|
|
3749
|
+
bottom: 96,
|
|
3750
|
+
// 上移,避免盖住底部输入框/麦克风按钮
|
|
1861
3751
|
zIndex: 9999,
|
|
1862
3752
|
display: "flex",
|
|
1863
3753
|
alignItems: "center",
|
|
@@ -1866,7 +3756,8 @@ function VoiceOverlay({ bus }) {
|
|
|
1866
3756
|
borderRadius: 999,
|
|
1867
3757
|
fontSize: 12,
|
|
1868
3758
|
fontFamily: "system-ui, sans-serif",
|
|
1869
|
-
pointerEvents: "
|
|
3759
|
+
pointerEvents: "none",
|
|
3760
|
+
// 浮层不挡输入框/麦克风按钮的点击(仅内部「跳过」按钮可点)
|
|
1870
3761
|
background: "rgba(22, 24, 28, 0.85)",
|
|
1871
3762
|
backdropFilter: "blur(14px)",
|
|
1872
3763
|
WebkitBackdropFilter: "blur(14px)",
|
|
@@ -1904,7 +3795,9 @@ function VoiceOverlay({ bus }) {
|
|
|
1904
3795
|
padding: "3px 12px",
|
|
1905
3796
|
fontSize: 11,
|
|
1906
3797
|
cursor: "pointer",
|
|
1907
|
-
flexShrink: 0
|
|
3798
|
+
flexShrink: 0,
|
|
3799
|
+
pointerEvents: "auto"
|
|
3800
|
+
// 仅此按钮可点
|
|
1908
3801
|
},
|
|
1909
3802
|
children: t("skip")
|
|
1910
3803
|
}
|