dsh-voice-mode 0.2.2 → 0.3.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/lib/index.js CHANGED
@@ -4,25 +4,199 @@ import { join as join2 } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
 
6
6
  // src/asr-host.ts
7
- import { createWriteStream } from "node:fs";
7
+ import { createWriteStream, statSync } from "node:fs";
8
8
  import { mkdir, rename, stat, unlink } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { Worker } from "node:worker_threads";
12
+
13
+ // src/sense-worker.ts
14
+ import { parentPort, workerData } from "node:worker_threads";
15
+ function createSenseWorkerClient(worker) {
16
+ let counter = 0;
17
+ const pending = /* @__PURE__ */ new Map();
18
+ let dead = false;
19
+ const deathFns = /* @__PURE__ */ new Set();
20
+ const die = () => {
21
+ if (dead) return;
22
+ dead = true;
23
+ for (const fn of deathFns) {
24
+ try {
25
+ fn();
26
+ } catch {
27
+ }
28
+ }
29
+ };
30
+ worker.on?.("message", (msg) => {
31
+ const p = pending.get(msg?.id);
32
+ if (!p) return;
33
+ pending.delete(msg.id);
34
+ if (!msg.ok) {
35
+ p.resolve(null);
36
+ return;
37
+ }
38
+ p.resolve(p.op === "create" ? true : msg.text ?? "");
39
+ });
40
+ worker.on?.("error", (e) => {
41
+ die();
42
+ const err = new Error("sense worker error: " + String(e?.message ?? e));
43
+ for (const [, p] of pending) p.reject(err);
44
+ pending.clear();
45
+ });
46
+ worker.on?.("exit", () => {
47
+ die();
48
+ const err = new Error("sense worker exited");
49
+ for (const [, p] of pending) p.reject(err);
50
+ pending.clear();
51
+ });
52
+ const request = (op, samples) => {
53
+ if (dead) return Promise.reject(new Error("sense worker dead"));
54
+ const id = counter++;
55
+ return new Promise((resolve, reject) => {
56
+ pending.set(id, { op, resolve, reject });
57
+ const msg = { id, op };
58
+ if (samples) msg.samples = samples;
59
+ try {
60
+ worker.postMessage(msg);
61
+ } catch (e) {
62
+ pending.delete(id);
63
+ reject(e instanceof Error ? e : new Error(String(e)));
64
+ }
65
+ });
66
+ };
67
+ return {
68
+ request,
69
+ onDeath(fn) {
70
+ deathFns.add(fn);
71
+ },
72
+ terminate: async () => {
73
+ dead = true;
74
+ const err = new Error("sense worker terminated");
75
+ for (const [, p] of pending) p.reject(err);
76
+ pending.clear();
77
+ try {
78
+ await worker.terminate?.();
79
+ } catch {
80
+ }
81
+ }
82
+ };
83
+ }
84
+ function startSenseWorker(data) {
85
+ const port = parentPort;
86
+ if (!port) return;
87
+ let recognizer = null;
88
+ let sherpa = null;
89
+ port.on("message", async (msg) => {
90
+ try {
91
+ if (msg.op === "create" || msg.op === "decode") {
92
+ if (!sherpa) {
93
+ sherpa = await import(data.sherpaModule);
94
+ }
95
+ if (!recognizer) {
96
+ recognizer = sherpa.createOfflineRecognizer({
97
+ featConfig: { sampleRate: 16e3, featureDim: 80 },
98
+ modelConfig: {
99
+ senseVoice: {
100
+ model: data.modelDir + "/model.int8.onnx",
101
+ language: "auto",
102
+ useInverseTextNormalization: 1
103
+ },
104
+ tokens: data.modelDir + "/tokens.txt",
105
+ provider: "cpu",
106
+ debug: 0
107
+ }
108
+ });
109
+ }
110
+ if (msg.op === "decode" && msg.samples) {
111
+ const stream = recognizer.createStream();
112
+ try {
113
+ stream.acceptWaveform(16e3, msg.samples);
114
+ recognizer.decode(stream);
115
+ const text = recognizer.getResult(stream).text.trim();
116
+ port.postMessage({ id: msg.id, ok: true, text });
117
+ } finally {
118
+ try {
119
+ stream.free();
120
+ } catch {
121
+ }
122
+ }
123
+ return;
124
+ }
125
+ port.postMessage({ id: msg.id, ok: true, text: "" });
126
+ return;
127
+ }
128
+ port.postMessage({ id: msg.id, ok: false, error: "unknown op: " + msg.op });
129
+ } catch (e) {
130
+ port.postMessage({ id: msg.id, ok: false, error: String(e) });
131
+ }
132
+ });
133
+ }
134
+ if (parentPort) {
135
+ startSenseWorker(workerData);
136
+ }
137
+
138
+ // src/asr-host.ts
10
139
  import sherpa_onnx from "sherpa-onnx";
11
- var { createOnlineRecognizer } = sherpa_onnx;
140
+ var { createOnlineRecognizer, createVad } = sherpa_onnx;
12
141
  var MODEL_REPO = "csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30";
13
142
  var MODEL_FILES = ["encoder.int8.onnx", "decoder.onnx", "joiner.int8.onnx", "tokens.txt"];
143
+ var VAD_REPO = "csukuangfj/vad";
144
+ var VAD_FILES = ["silero_vad.onnx"];
145
+ var SENSE_REPO = "csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17";
146
+ var SENSE_FILES = ["model.int8.onnx", "tokens.txt"];
14
147
  function pcmToSamples(buf) {
15
- if (buf.length % 4 !== 0 || buf.length === 0) return null;
148
+ if (buf.length % 4 !== 0) return null;
16
149
  return new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
17
150
  }
18
151
  var MAX_ASR_BYTES = 4 * 1024 * 1024;
152
+ var SEGMENT_IDLE_MS = 9e4;
153
+ var VAD_CONTINUE_RMS = 0.02;
154
+ var CONFIRM_CONJUNCTION_MS = 800;
155
+ var CONFIRM_LONG_SENTENCE_MS = 350;
156
+ var CONFIRM_LONG_SENTENCE_S = 8;
157
+ var CONFIRM_MIN_MS = 400;
158
+ var CONJUNCTION_TAIL = /(然后|还有|以及|并且|而且|此外|再说|接着|然后呢|比方说|比如说|比如|例如|等等|或者|或是|还有呢)$/;
159
+ function endpointConfirmMs(text, spokenMs) {
160
+ const tail = text.trimEnd();
161
+ if (CONJUNCTION_TAIL.test(tail)) return CONFIRM_CONJUNCTION_MS;
162
+ if (spokenMs > CONFIRM_LONG_SENTENCE_S * 1e3) return CONFIRM_LONG_SENTENCE_MS;
163
+ return 0;
164
+ }
165
+ function rmsOf(samples) {
166
+ if (samples.length === 0) return 0;
167
+ let sum = 0;
168
+ for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
169
+ return Math.sqrt(sum / samples.length);
170
+ }
19
171
  function createAsrRuntime(options) {
20
- const { cacheDir, modelHost, broadcast } = options;
172
+ const { cacheDir, modelHost, broadcast, senseVoice } = options;
173
+ let lastProgress = null;
174
+ const localBroadcast = (event, payload) => {
175
+ if (event === "asr-progress") lastProgress = payload;
176
+ broadcast(event, payload);
177
+ };
21
178
  const repoDir = join(cacheDir, MODEL_REPO);
179
+ const vadDir = join(cacheDir, VAD_REPO);
180
+ const senseDir = join(cacheDir, SENSE_REPO);
181
+ const modelHosts = () => {
182
+ const seen = /* @__PURE__ */ new Set();
183
+ const out = [];
184
+ for (const h of [modelHost(), HOST_PRIMARY, HOST_FALLBACK]) {
185
+ const v = (h ?? "").trim().replace(/\/+$/, "");
186
+ if (!v || seen.has(v)) continue;
187
+ seen.add(v);
188
+ out.push(v);
189
+ }
190
+ return out;
191
+ };
22
192
  const segments = /* @__PURE__ */ new Map();
193
+ const finalized = /* @__PURE__ */ new Map();
194
+ const finalizing = /* @__PURE__ */ new Map();
195
+ const resetGen = /* @__PURE__ */ new Map();
23
196
  let recognizer = null;
24
197
  let modelsReady = false;
25
198
  let modelsLoading = null;
199
+ let asrFailAt = 0;
26
200
  const haveAllModels = async () => {
27
201
  for (const f of MODEL_FILES) {
28
202
  const st = await stat(join(repoDir, f)).catch(() => null);
@@ -32,11 +206,13 @@ function createAsrRuntime(options) {
32
206
  };
33
207
  const ensureModels = async () => {
34
208
  if (modelsReady) return true;
209
+ if (Date.now() < asrFailAt) return false;
35
210
  if (!modelsLoading) {
36
211
  modelsLoading = (async () => {
37
212
  if (!await haveAllModels()) {
38
213
  for (const f of MODEL_FILES) {
39
- if (!await ensureFile(repoDir, f, modelHost(), broadcast)) {
214
+ if (!await ensureFile(repoDir, MODEL_REPO, f, modelHosts(), localBroadcast)) {
215
+ asrFailAt = Date.now() + 6e4;
40
216
  broadcast("asr-error", { file: f });
41
217
  return false;
42
218
  }
@@ -71,43 +247,452 @@ function createAsrRuntime(options) {
71
247
  });
72
248
  return recognizer;
73
249
  };
74
- const feed = async (sessionId, samples, final) => {
250
+ let vadModelReady = false;
251
+ let vadLoading = null;
252
+ let vadFailAt = 0;
253
+ const ensureVadModel = async () => {
254
+ if (vadModelReady) return join(vadDir, VAD_FILES[0]);
255
+ if (Date.now() < vadFailAt) return null;
256
+ if (!vadLoading) {
257
+ vadLoading = (async () => {
258
+ for (const f of VAD_FILES) {
259
+ if (!await ensureFile(vadDir, VAD_REPO, f, modelHosts(), localBroadcast)) {
260
+ vadFailAt = Date.now() + 6e4;
261
+ return null;
262
+ }
263
+ }
264
+ vadModelReady = true;
265
+ return join(vadDir, VAD_FILES[0]);
266
+ })().finally(() => {
267
+ vadLoading = null;
268
+ });
269
+ }
270
+ return vadLoading;
271
+ };
272
+ const newVad = (vadPath, threshold = 0.5) => createVad({
273
+ sileroVad: {
274
+ model: vadPath,
275
+ threshold,
276
+ minSilenceDuration: 0.5,
277
+ minSpeechDuration: 0.25,
278
+ maxSpeechDuration: 20,
279
+ windowSize: 512
280
+ },
281
+ sampleRate: 16e3,
282
+ numThreads: 1,
283
+ provider: "cpu",
284
+ debug: 0,
285
+ bufferSizeInSeconds: 30
286
+ });
287
+ const ensureSessionVad = async (seg) => {
288
+ if (seg.vad) return seg.vad;
289
+ const vadPath = await ensureVadModel();
290
+ if (!vadPath) return null;
291
+ seg.vad = newVad(vadPath);
292
+ return seg.vad;
293
+ };
294
+ const detectVads = /* @__PURE__ */ new Map();
295
+ const detectVadLastUse = /* @__PURE__ */ new Map();
296
+ const ensureDetectVad = async (sessionId) => {
297
+ const existing = detectVads.get(sessionId);
298
+ if (existing) return existing;
299
+ const vadPath = await ensureVadModel();
300
+ if (!vadPath) return null;
301
+ const vad = newVad(vadPath, 0.35);
302
+ detectVads.set(sessionId, vad);
303
+ return vad;
304
+ };
305
+ let senseModelReady = false;
306
+ let senseLoading = null;
307
+ let senseFailAt = 0;
308
+ const ensureSenseModel = async () => {
309
+ if (senseModelReady) return join(senseDir, SENSE_FILES[0]);
310
+ if (Date.now() < senseFailAt) return null;
311
+ if (!senseLoading) {
312
+ senseLoading = (async () => {
313
+ for (const f of SENSE_FILES) {
314
+ if (!await ensureFile(senseDir, SENSE_REPO, f, modelHosts(), localBroadcast)) {
315
+ senseFailAt = Date.now() + 6e4;
316
+ return null;
317
+ }
318
+ }
319
+ senseModelReady = true;
320
+ return join(senseDir, SENSE_FILES[0]);
321
+ })().finally(() => {
322
+ senseLoading = null;
323
+ });
324
+ }
325
+ return senseLoading;
326
+ };
327
+ let senseWorker = null;
328
+ let senseWorkerSyncing = null;
329
+ const getSenseWorker = async () => {
330
+ if (!senseVoice()) return null;
331
+ if (senseWorker) return senseWorker;
332
+ if (senseWorkerSyncing) return senseWorkerSyncing;
333
+ senseWorkerSyncing = (async () => {
334
+ const sensePath = await ensureSenseModel();
335
+ if (!sensePath) return null;
336
+ try {
337
+ const workerPath = fileURLToPath(new URL("./sense-worker.mjs", import.meta.url));
338
+ const w = new Worker(workerPath, {
339
+ workerData: { sherpaModule: "sherpa-onnx", modelDir: senseDir }
340
+ });
341
+ const client = createSenseWorkerClient(w);
342
+ client.onDeath(() => {
343
+ senseWorker = null;
344
+ senseWorkerSyncing = null;
345
+ });
346
+ if (!await client.request("create")) {
347
+ await client.terminate();
348
+ return null;
349
+ }
350
+ senseWorker = client;
351
+ return client;
352
+ } catch (e) {
353
+ console.warn("[dsh-voice-mode] SenseVoice worker init failed: " + String(e));
354
+ return null;
355
+ }
356
+ })().finally(() => {
357
+ if (!senseWorker) senseWorkerSyncing = null;
358
+ });
359
+ return senseWorkerSyncing;
360
+ };
361
+ const senseTranscribe = async (allSamples) => {
362
+ try {
363
+ const worker = await getSenseWorker();
364
+ if (!worker) return null;
365
+ const total = allSamples.reduce((acc, c) => acc + c.length, 0);
366
+ if (total === 0) return null;
367
+ const buf = new Float32Array(total);
368
+ let off = 0;
369
+ for (const c of allSamples) {
370
+ buf.set(c, off);
371
+ off += c.length;
372
+ }
373
+ return await worker.request("decode", buf);
374
+ } catch (e) {
375
+ console.warn("[dsh-voice-mode] SenseVoice re-transcribe failed: " + String(e));
376
+ return null;
377
+ }
378
+ };
379
+ const feed = async (sessionId, samples, final, offset = 0, epoch = 0) => {
75
380
  const rec = await getRecognizer();
76
381
  if (!rec) return { text: "", loading: true };
77
- let seg = segments.get(sessionId);
382
+ if (!final && senseVoice()) {
383
+ void getSenseWorker().catch(() => {
384
+ });
385
+ }
386
+ let finMap = finalized.get(sessionId);
387
+ const myGen = resetGen.get(sessionId) ?? 0;
388
+ const cached = finMap?.get(epoch);
389
+ if (cached !== void 0) return { text: cached };
390
+ let sessSegs = segments.get(sessionId);
391
+ if (!sessSegs) {
392
+ sessSegs = /* @__PURE__ */ new Map();
393
+ segments.set(sessionId, sessSegs);
394
+ }
395
+ let seg = sessSegs.get(epoch);
78
396
  if (!seg) {
79
397
  if (samples.length === 0 && final) return { text: "" };
80
- seg = { stream: rec.createStream(), fed: 0 };
81
- segments.set(sessionId, seg);
398
+ seg = { stream: rec.createStream(), fed: 0, vad: null, pendingEndpoint: null, lastText: "", allSamples: [], lastActivity: Date.now() };
399
+ sessSegs.set(epoch, seg);
400
+ }
401
+ seg.lastActivity = Date.now();
402
+ let endpoint = false;
403
+ let text = "";
404
+ let isSpeech;
405
+ if (offset + samples.length > seg.fed) {
406
+ const skip = Math.max(seg.fed - offset, 0);
407
+ const inc = samples.subarray(skip);
408
+ seg.stream.acceptWaveform(rec.config.featConfig.sampleRate, inc);
409
+ seg.fed = offset + samples.length;
410
+ if (seg.fed <= rec.config.featConfig.sampleRate * 60) seg.allSamples.push(inc);
411
+ while (rec.isReady(seg.stream)) rec.decode(seg.stream);
412
+ text = rec.getResult(seg.stream).text;
413
+ seg.lastText = text;
414
+ if (!final) {
415
+ const vad = await ensureSessionVad(seg);
416
+ if (vad) {
417
+ if (seg.pendingEndpoint) {
418
+ const now = Date.now();
419
+ const rms = rmsOf(inc);
420
+ if (rms > VAD_CONTINUE_RMS) {
421
+ seg.pendingEndpoint = null;
422
+ } else if (now - seg.pendingEndpoint.at >= CONFIRM_MIN_MS && text === seg.pendingEndpoint.textAtPending) {
423
+ seg.pendingEndpoint = null;
424
+ endpoint = true;
425
+ } else if (now - seg.pendingEndpoint.at >= seg.pendingEndpoint.confirmMs) {
426
+ seg.pendingEndpoint = null;
427
+ endpoint = true;
428
+ }
429
+ }
430
+ vad.acceptWaveform(inc);
431
+ isSpeech = vad.isDetected();
432
+ if (!vad.isEmpty()) {
433
+ let spokenMs = 0;
434
+ while (!vad.isEmpty()) {
435
+ const sp = vad.front();
436
+ spokenMs = sp.samples.length / 16e3 * 1e3;
437
+ vad.pop();
438
+ }
439
+ const confirmMs = endpointConfirmMs(seg.lastText, spokenMs);
440
+ if (confirmMs <= 0) {
441
+ endpoint = true;
442
+ } else {
443
+ seg.pendingEndpoint = { at: Date.now(), confirmMs, textAtPending: seg.lastText };
444
+ }
445
+ }
446
+ }
447
+ }
82
448
  }
83
- if (samples.length > seg.fed) {
84
- seg.stream.acceptWaveform(rec.config.featConfig.sampleRate, samples.subarray(seg.fed));
85
- seg.fed = samples.length;
449
+ if (!final) return { text, endpoint, isSpeech };
450
+ const inflightMap = finalizing.get(sessionId);
451
+ const inflightP = inflightMap?.get(epoch);
452
+ if (inflightP) return { text: await inflightP };
453
+ sessSegs.delete(epoch);
454
+ if (sessSegs.size === 0) segments.delete(sessionId);
455
+ const finalizeP = (async () => {
456
+ const all = seg.allSamples;
457
+ const senseP = all.length > 0 ? Promise.race([
458
+ senseTranscribe(all),
459
+ new Promise((resolve) => setTimeout(() => resolve(null), 1e4))
460
+ ]) : Promise.resolve(null);
461
+ const pad = new Float32Array(rec.config.featConfig.sampleRate / 2);
462
+ seg.stream.acceptWaveform(rec.config.featConfig.sampleRate, pad);
86
463
  while (rec.isReady(seg.stream)) rec.decode(seg.stream);
464
+ const settled = rec.getResult(seg.stream).text;
465
+ try {
466
+ seg.vad?.free?.();
467
+ } catch {
468
+ }
469
+ seg.stream.free();
470
+ const sense = await senseP;
471
+ return (sense && sense.trim() ? sense : settled) || "";
472
+ })().then((finalText) => {
473
+ if ((resetGen.get(sessionId) ?? 0) !== myGen) return finalText;
474
+ let fm = finalized.get(sessionId);
475
+ if (!fm) {
476
+ fm = /* @__PURE__ */ new Map();
477
+ finalized.set(sessionId, fm);
478
+ }
479
+ fm.set(epoch, finalText);
480
+ if (fm.size > 32) {
481
+ const first = fm.keys().next().value;
482
+ if (first !== void 0) fm.delete(first);
483
+ }
484
+ const ff = finalizing.get(sessionId);
485
+ ff?.delete(epoch);
486
+ if (ff && ff.size === 0) finalizing.delete(sessionId);
487
+ return finalText;
488
+ }).catch((e) => {
489
+ const ff = finalizing.get(sessionId);
490
+ ff?.delete(epoch);
491
+ if (ff && ff.size === 0) finalizing.delete(sessionId);
492
+ console.warn("[dsh-voice-mode] finalize failed: " + String(e));
493
+ return "";
494
+ });
495
+ if (!inflightMap) {
496
+ finalizing.set(sessionId, /* @__PURE__ */ new Map());
497
+ }
498
+ finalizing.get(sessionId).set(epoch, finalizeP);
499
+ return { text: await finalizeP };
500
+ };
501
+ const sweep = () => {
502
+ const now = Date.now();
503
+ for (const [sid, sessSegs] of segments) {
504
+ for (const [epoch, s] of sessSegs) {
505
+ if (now - s.lastActivity > SEGMENT_IDLE_MS) {
506
+ try {
507
+ s.vad?.free?.();
508
+ } catch {
509
+ }
510
+ try {
511
+ s.stream.free();
512
+ } catch {
513
+ }
514
+ sessSegs.delete(epoch);
515
+ }
516
+ }
517
+ if (sessSegs.size === 0) {
518
+ segments.delete(sid);
519
+ finalized.delete(sid);
520
+ }
521
+ }
522
+ for (const [sid, at] of detectVadLastUse) {
523
+ if (now - at > SEGMENT_IDLE_MS) {
524
+ try {
525
+ detectVads.get(sid)?.free?.();
526
+ } catch {
527
+ }
528
+ detectVads.delete(sid);
529
+ detectVadLastUse.delete(sid);
530
+ }
531
+ }
532
+ };
533
+ const sweepTimer = setInterval(sweep, 3e4);
534
+ return {
535
+ feed,
536
+ detect: async (sessionId, samples) => {
537
+ const vad = await ensureDetectVad(sessionId);
538
+ if (!vad) return { isSpeech: false };
539
+ detectVadLastUse.set(sessionId, Date.now());
540
+ if (samples.length > 0) vad.acceptWaveform(samples);
541
+ const speech = vad.isDetected();
542
+ while (!vad.isEmpty()) vad.pop();
543
+ return { isSpeech: speech };
544
+ },
545
+ reset: (sessionId) => {
546
+ const sessSegs = segments.get(sessionId);
547
+ if (sessSegs) {
548
+ for (const [, s] of sessSegs) {
549
+ try {
550
+ s.vad?.free?.();
551
+ } catch {
552
+ }
553
+ try {
554
+ s.stream.free();
555
+ } catch {
556
+ }
557
+ }
558
+ segments.delete(sessionId);
559
+ }
560
+ finalized.delete(sessionId);
561
+ resetGen.set(sessionId, (resetGen.get(sessionId) ?? 0) + 1);
562
+ finalizing.delete(sessionId);
563
+ const dv = detectVads.get(sessionId);
564
+ if (dv) {
565
+ try {
566
+ dv.free?.();
567
+ } catch {
568
+ }
569
+ detectVads.delete(sessionId);
570
+ }
571
+ detectVadLastUse.delete(sessionId);
572
+ },
573
+ dispose: () => {
574
+ clearInterval(sweepTimer);
575
+ let w = senseWorker;
576
+ senseWorker = null;
577
+ senseWorkerSyncing = null;
578
+ if (w) void w.terminate();
579
+ for (const [, sessSegs] of segments) {
580
+ for (const [, s] of sessSegs) {
581
+ try {
582
+ s.vad?.free?.();
583
+ } catch {
584
+ }
585
+ try {
586
+ s.stream.free();
587
+ } catch {
588
+ }
589
+ }
590
+ }
591
+ segments.clear();
592
+ finalized.clear();
593
+ finalizing.clear();
594
+ resetGen.clear();
595
+ try {
596
+ recognizer?.free?.();
597
+ } catch {
598
+ }
599
+ recognizer = null;
600
+ for (const [, dv] of detectVads) {
601
+ try {
602
+ dv.free?.();
603
+ } catch {
604
+ }
605
+ }
606
+ detectVads.clear();
607
+ detectVadLastUse.clear();
608
+ },
609
+ modelStatus: () => {
610
+ const statFile = async (dir, repo, name2) => {
611
+ const st = await stat(join(dir, repo, name2)).catch(() => null);
612
+ return { exists: !!st?.isFile(), size: st?.size ?? 0 };
613
+ };
614
+ const asrFiles = MODEL_FILES.map((n) => ({
615
+ name: n,
616
+ exists: (() => {
617
+ try {
618
+ return statSync(join(repoDir, n)).isFile();
619
+ } catch {
620
+ return false;
621
+ }
622
+ })(),
623
+ size: (() => {
624
+ try {
625
+ return statSync(join(repoDir, n)).size;
626
+ } catch {
627
+ return 0;
628
+ }
629
+ })()
630
+ }));
631
+ const vadSize = (() => {
632
+ try {
633
+ return statSync(join(vadDir, VAD_FILES[0])).size;
634
+ } catch {
635
+ return 0;
636
+ }
637
+ })();
638
+ const senseSize = (() => {
639
+ try {
640
+ return statSync(join(senseDir, SENSE_FILES[0])).size;
641
+ } catch {
642
+ return 0;
643
+ }
644
+ })();
645
+ return {
646
+ // ready 语义 = 文件可用(exists),而非进程内是否已实例化——
647
+ // 重启后文件齐全却显示「未下载」会误导用户(体验修复)。
648
+ asr: {
649
+ repo: MODEL_REPO,
650
+ ready: asrFiles.every((f) => f.exists),
651
+ files: asrFiles,
652
+ failLatchMs: Math.max(0, asrFailAt - Date.now())
653
+ },
654
+ vad: {
655
+ repo: VAD_REPO,
656
+ ready: vadSize > 0,
657
+ size: vadSize,
658
+ failLatchMs: Math.max(0, vadFailAt - Date.now())
659
+ },
660
+ sense: {
661
+ repo: SENSE_REPO,
662
+ ready: senseSize > 0,
663
+ size: senseSize,
664
+ failLatchMs: Math.max(0, senseFailAt - Date.now()),
665
+ enabled: senseVoice()
666
+ },
667
+ progress: lastProgress
668
+ };
669
+ },
670
+ retryModel: async (kind) => {
671
+ if (kind === "vad") {
672
+ vadFailAt = 0;
673
+ return !!await ensureVadModel();
674
+ }
675
+ if (kind === "sense") {
676
+ if (!senseVoice()) return false;
677
+ senseFailAt = 0;
678
+ return !!await ensureSenseModel();
679
+ }
680
+ if (modelsReady) return true;
681
+ asrFailAt = 0;
682
+ return await ensureModels();
87
683
  }
88
- const text = rec.getResult(seg.stream).text;
89
- if (!final) return { text };
90
- const pad = new Float32Array(rec.config.featConfig.sampleRate / 2);
91
- seg.stream.acceptWaveform(rec.config.featConfig.sampleRate, pad);
92
- while (rec.isReady(seg.stream)) rec.decode(seg.stream);
93
- const settled = rec.getResult(seg.stream).text;
94
- seg.stream.free();
95
- segments.delete(sessionId);
96
- return { text: settled };
97
684
  };
98
- return { feed, reset: (sessionId) => segments.delete(sessionId) };
99
685
  }
100
- async function ensureFile(repoDir, file, primaryHost, broadcast) {
686
+ async function ensureFile(repoDir, repo, file, hosts, broadcast) {
101
687
  const localPath = join(repoDir, file);
102
688
  const st = await stat(localPath).catch(() => null);
103
689
  if (st?.isFile()) return true;
104
690
  await mkdir(repoDir, { recursive: true }).catch(() => void 0);
105
691
  const partPath = `${localPath}.part`;
106
- const partSt = await stat(partPath).catch(() => null);
107
- const hosts = [...new Set([primaryHost, HOST_PRIMARY, HOST_FALLBACK].filter(Boolean))];
108
692
  for (const host of hosts) {
109
693
  try {
110
- const ok = await download(host, repoDir, file, partSt?.size ?? 0, broadcast);
694
+ const cur = (await stat(partPath).catch(() => null))?.size ?? 0;
695
+ const ok = await download(host, repoDir, repo, file, cur, broadcast);
111
696
  if (ok) {
112
697
  await rename(partPath, localPath).catch(() => void 0);
113
698
  if ((await stat(localPath).catch(() => null))?.isFile()) return true;
@@ -120,23 +705,32 @@ async function ensureFile(repoDir, file, primaryHost, broadcast) {
120
705
  }
121
706
  var HOST_PRIMARY = "https://huggingface.co";
122
707
  var HOST_FALLBACK = "https://hf-mirror.com";
123
- async function download(host, repoDir, file, resumeFrom, broadcast) {
124
- const url = `${host}/${MODEL_REPO}/resolve/main/${file}`;
708
+ async function download(host, repoDir, repo, file, resumeFrom, broadcast) {
709
+ const url = `${host}/${repo}/resolve/main/${file}`;
125
710
  const headers = { "user-agent": "dsh-voice-mode" };
126
711
  if (resumeFrom > 0) headers.range = `bytes=${resumeFrom}-`;
127
- const res = await fetch(url, { headers });
712
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(9e5) });
128
713
  if (res.status === 416) return true;
129
714
  if (res.status !== 200 && res.status !== 206) return false;
130
- const total = Number(res.headers.get("content-length") ?? 0) + resumeFrom;
715
+ const resume = res.status === 206 ? resumeFrom : 0;
716
+ const declared = Number(res.headers.get("content-length"));
717
+ const total = (Number.isFinite(declared) ? declared : 0) + resume;
131
718
  const partPath = join(repoDir, `${file}.part`);
132
- const sink = createWriteStream(partPath, resumeFrom > 0 ? { flags: "a" } : {});
719
+ const sink = createWriteStream(partPath, resume > 0 ? { flags: "a" } : {});
133
720
  const src = res.body;
134
721
  if (!src) return false;
135
722
  const reader = src.getReader();
136
- let received = resumeFrom;
723
+ let received = resume;
137
724
  const done = new Promise((resolve, reject) => {
138
725
  sink.on("error", (e) => reject(e));
139
- sink.on("finish", () => resolve(true));
726
+ sink.on("finish", () => {
727
+ if (total > 0 && received < total) {
728
+ sink.destroy(new Error("download truncated"));
729
+ reject(new Error("download truncated"));
730
+ } else {
731
+ resolve(true);
732
+ }
733
+ });
140
734
  (async () => {
141
735
  try {
142
736
  for (; ; ) {
@@ -162,6 +756,10 @@ async function download(host, repoDir, file, resumeFrom, broadcast) {
162
756
  });
163
757
  return done.catch(() => false);
164
758
  }
759
+ var respondJson = (res, status, payload) => {
760
+ res.writeHead(status, { "content-type": "application/json" });
761
+ res.end(JSON.stringify(payload));
762
+ };
165
763
  function handleAsrRequest(asr, activeSessionId, req, res) {
166
764
  const chunks = [];
167
765
  let received = 0;
@@ -171,45 +769,66 @@ function handleAsrRequest(asr, activeSessionId, req, res) {
171
769
  received += c.length;
172
770
  if (received > MAX_ASR_BYTES) {
173
771
  tooLarge = true;
174
- res.statusCode = 413;
175
- res.end(JSON.stringify({ error: "pcm payload too large" }));
772
+ respondJson(res, 413, { error: "pcm payload too large" });
176
773
  return;
177
774
  }
178
775
  chunks.push(c);
179
776
  });
180
777
  req.on("end", () => {
181
778
  if (tooLarge) return;
182
- res.setHeader("content-type", "application/json");
183
779
  const url = new URL(req.url ?? "/", "http://localhost");
184
780
  const sessionId = url.searchParams.get("sessionId") ?? "";
185
781
  const final = url.searchParams.get("final") === "1";
186
782
  const reset = url.searchParams.get("reset") === "1";
783
+ const epochParam = url.searchParams.get("epoch");
784
+ const epochN = Number(epochParam);
785
+ const epochOK = epochParam === null || Number.isFinite(epochN) && epochN >= 0 && Number.isInteger(epochN);
786
+ const offsetParam = url.searchParams.get("offset");
787
+ const offsetOK = offsetParam === null || Number.isFinite(Number(offsetParam)) && Number(offsetParam) >= 0 && Number(offsetParam) <= MAX_ASR_BYTES / 4;
788
+ if (!offsetOK) {
789
+ respondJson(res, 400, { error: "invalid offset" });
790
+ return;
791
+ }
792
+ if (!epochOK) {
793
+ respondJson(res, 400, { error: "invalid epoch" });
794
+ return;
795
+ }
796
+ const epoch = epochParam === null ? 0 : Math.floor(epochN);
797
+ const offset = offsetParam === null ? 0 : Math.floor(Number(offsetParam));
187
798
  if (!sessionId || sessionId !== activeSessionId) {
188
- res.statusCode = 403;
189
- res.end(JSON.stringify({ error: "not the active voice session" }));
799
+ respondJson(res, 403, { error: "not the active voice session" });
190
800
  return;
191
801
  }
192
802
  if (reset) {
193
803
  asr.reset(sessionId);
194
- res.end(JSON.stringify({ ok: true }));
804
+ respondJson(res, 200, { ok: true });
195
805
  return;
196
806
  }
197
- const samples = pcmToSamples(Buffer.concat(chunks));
807
+ const raw = Buffer.concat(chunks);
808
+ const samples = raw.length === 0 ? final ? new Float32Array(0) : null : pcmToSamples(raw);
198
809
  if (!samples) {
199
- res.statusCode = 400;
200
- res.end(JSON.stringify({ error: "invalid pcm payload" }));
810
+ respondJson(res, 400, { error: "invalid pcm payload" });
201
811
  return;
202
812
  }
203
- void asr.feed(sessionId, samples, final).then((out) => {
813
+ if (url.searchParams.get("vadOnly") === "1") {
814
+ void asr.detect(sessionId, samples).then((out) => {
815
+ respondJson(res, 200, { isSpeech: out.isSpeech });
816
+ }).catch((e) => {
817
+ respondJson(res, 500, { error: String(e) });
818
+ });
819
+ return;
820
+ }
821
+ void asr.feed(sessionId, samples, final, offset, epoch).then((out) => {
204
822
  if (out.loading) {
205
- res.statusCode = 202;
206
- res.end(JSON.stringify({ loading: true }));
823
+ respondJson(res, 202, { loading: true });
207
824
  return;
208
825
  }
209
- res.end(JSON.stringify({ text: out.text }));
826
+ const body = { text: out.text };
827
+ if (out.endpoint) body.endpoint = true;
828
+ if (out.isSpeech !== void 0) body.isSpeech = out.isSpeech;
829
+ respondJson(res, 200, body);
210
830
  }).catch((e) => {
211
- res.statusCode = 500;
212
- res.end(JSON.stringify({ error: String(e) }));
831
+ respondJson(res, 500, { error: String(e) });
213
832
  });
214
833
  });
215
834
  }
@@ -351,6 +970,11 @@ var TtsQueue = class {
351
970
  /**
352
971
  * 弃掉某会话的所有积压并作废正在合成的句子(打断)。之后入队的句子
353
972
  * 获得新 epoch 正常播放。
973
+ *
974
+ * 打断同时重置 WebSocket 连接:pump 的 break 会 destroy 当前 audioStream(删
975
+ * requestId),但服务端仍在发该句残留数据——msedge-tts 的 onmessage 访问已删
976
+ * stream 抛 TypeError,且复用同一条 ws 会累积脏状态导致后续合成静音/失败。
977
+ * close 后下次 ensureReady 重建连接,干净恢复。
354
978
  */
355
979
  cancel(sessionId) {
356
980
  const q = this.queues.get(sessionId);
@@ -358,9 +982,19 @@ var TtsQueue = class {
358
982
  q.epoch++;
359
983
  q.pending.length = 0;
360
984
  }
985
+ this.ready = null;
986
+ try {
987
+ this.tts.close();
988
+ } catch {
989
+ }
361
990
  }
362
991
  /** 会话退出/被抢占时彻底清理其队列(防止 Map 长期累积)。 */
363
992
  prune(sessionId) {
993
+ const q = this.queues.get(sessionId);
994
+ if (q) {
995
+ q.epoch++;
996
+ q.pending.length = 0;
997
+ }
364
998
  this.queues.delete(sessionId);
365
999
  }
366
1000
  async pump(sessionId, q) {
@@ -371,29 +1005,54 @@ var TtsQueue = class {
371
1005
  while (q.pending.length > 0) {
372
1006
  const item = q.pending.shift();
373
1007
  try {
1008
+ const sentenceId = q.seq++;
374
1009
  const { audioStream } = this.tts.toStream(item.text, this.prosody);
375
- const chunks = [];
1010
+ let chunkId = 0;
1011
+ let bytes = 0;
376
1012
  for await (const chunk of audioStream) {
377
- chunks.push(chunk);
378
- }
379
- const buf = Buffer.concat(chunks);
380
- if (!isValidMp3(buf)) continue;
381
- if (item.epoch !== q.epoch) continue;
382
- q.errorNotified = false;
383
- const frame = {
384
- sessionId,
385
- seq: q.seq++,
386
- text: item.text,
387
- audio: buf.toString("base64")
388
- };
389
- for (const fn of this.listeners) {
390
- try {
391
- fn(frame);
392
- } catch {
1013
+ const bin = chunk;
1014
+ if (!bin || bin.length === 0) continue;
1015
+ if (item.epoch !== q.epoch) break;
1016
+ bytes += bin.length;
1017
+ const frame = {
1018
+ sessionId,
1019
+ sentenceId,
1020
+ chunkId: chunkId++,
1021
+ final: false,
1022
+ audio: bin.toString("base64")
1023
+ };
1024
+ for (const fn of this.listeners) {
1025
+ try {
1026
+ fn(frame);
1027
+ } catch {
1028
+ }
1029
+ }
1030
+ }
1031
+ if (bytes > 0 && item.epoch === q.epoch) {
1032
+ q.errorNotified = false;
1033
+ q.backoff = 0;
1034
+ const frame = {
1035
+ sessionId,
1036
+ sentenceId,
1037
+ chunkId,
1038
+ final: true,
1039
+ text: item.text,
1040
+ audio: ""
1041
+ };
1042
+ for (const fn of this.listeners) {
1043
+ try {
1044
+ fn(frame);
1045
+ } catch {
1046
+ }
393
1047
  }
394
1048
  }
395
1049
  } catch (e) {
396
1050
  console.warn(`[dsh-voice-mode] synthesis failed: ${String(e)}`);
1051
+ this.ready = null;
1052
+ try {
1053
+ this.tts.close();
1054
+ } catch {
1055
+ }
397
1056
  }
398
1057
  }
399
1058
  } catch (e) {
@@ -404,6 +1063,7 @@ var TtsQueue = class {
404
1063
  }
405
1064
  } finally {
406
1065
  q.busy = false;
1066
+ if (this.queues.get(sessionId) !== q) return;
407
1067
  if (q.pending.length > 0) {
408
1068
  const delay = q.errorNotified ? q.backoff : 0;
409
1069
  q.backoff = Math.min(8e3, delay + 1e3);
@@ -422,6 +1082,10 @@ var TtsQueue = class {
422
1082
  var name = "voice-mode";
423
1083
  var NS_VOICE_MODE = "voice-mode";
424
1084
  var BASE_PATH = "/voice-mode";
1085
+ var respondJson2 = (res, status, payload) => {
1086
+ res.writeHead(status, { "content-type": "application/json" });
1087
+ res.end(JSON.stringify(payload));
1088
+ };
425
1089
  var VOICE_SPOKEN_PROMPT = "\u3010\u8BED\u97F3\u6A21\u5F0F\u3011\u5F53\u524D\u56DE\u590D\u4F1A\u88AB\u8BED\u97F3\u6717\u8BFB\uFF0C\u8BF7\u59CB\u7EC8\u7528\u7528\u6237\u6240\u7528\u8BED\u8A00\u3001\u4EE5\u53E3\u8BED\u5316\u7684\u77ED\u53E5\u76F4\u63A5\u56DE\u7B54\uFF0C\u50CF\u9762\u5BF9\u9762\u804A\u5929\u4E00\u6837\u81EA\u7136\uFF0C\u907F\u514D\u4E66\u9762\u8BED\u548C\u957F\u96BE\u53E5\u3002\u4E0D\u8981\u4F7F\u7528\u4EFB\u4F55 Markdown \u6216\u6392\u7248\u7B26\u53F7\uFF08\u661F\u53F7\u3001\u4E0B\u5212\u7EBF\u3001\u53CD\u5F15\u53F7\u3001\u4E95\u53F7\u3001\u5217\u8868\u4E0E\u8868\u683C\u6807\u8BB0\u3001\u4EE3\u7801\u5757\u7B49\uFF09\u3002\u9700\u8981\u5206\u70B9\u8BF4\u660E\u65F6\u7528\u300C\u7B2C\u4E00\u3001\u7B2C\u4E8C\u300D\u6216\u8FDE\u8D2F\u7684\u77ED\u53E5\u8868\u8FBE\uFF1B\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\uFF0C\u4E0D\u8981\u8F93\u51FA\u4EE3\u7801\u7247\u6BB5\u3001\u5B8C\u6574 URL \u6216\u5197\u957F\u5B9A\u4E49\uFF0C\u7528\u4E00\u4E24\u53E5\u8BDD\u6982\u62EC\u542B\u4E49\u5373\u53EF\u3002\u56DE\u7B54\u7B80\u6D01\u76F4\u63A5\uFF0C\u4E0D\u8981\u91CD\u590D\u548C\u5BD2\u6684\u3002";
426
1090
  var VOICE_SPOKEN_SECTION = "voice-mode:spoken-format";
427
1091
  var inject = ["webServer", "settings"];
@@ -430,13 +1094,17 @@ var VOICE_SETTINGS_DEFAULTS = {
430
1094
  voice: "zh-CN-XiaoxiaoNeural",
431
1095
  rate: 1,
432
1096
  interruptLevel: 0,
433
- silenceMs: 2e3,
1097
+ silenceMs: 700,
434
1098
  idleTimeoutMinutes: 10,
435
1099
  modelHost: "",
436
1100
  autoSend: true,
1101
+ autoResume: false,
437
1102
  mode: "toggle",
438
- wakeWord: "",
439
- spokenFormat: false
1103
+ bargeInMode: "auto",
1104
+ echoGateDb: 6,
1105
+ shortcut: "Ctrl+Shift+V",
1106
+ spokenFormat: false,
1107
+ senseVoice: true
440
1108
  };
441
1109
  function createVoiceSettingsSchema(defs) {
442
1110
  const d = { ...VOICE_SETTINGS_DEFAULTS, ...defs };
@@ -446,13 +1114,17 @@ function createVoiceSettingsSchema(defs) {
446
1114
  ),
447
1115
  rate: z.number().min(0.5).max(2).default(d.rate).description("\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 = \u6162\u901F\uFF0C2.0 = \u5FEB\u901F\uFF0C1.0 = \u6B63\u5E38\uFF09"),
448
1116
  interruptLevel: z.union([z.const(0), z.const(1), z.const(2)]).default(d.interruptLevel).description("\u53D1\u58F0\u6253\u65AD\u7075\u654F\u5EA6\uFF1A0 \u9AD8\u95E8\u69DB\uFF08\u5B89\u9759\u73AF\u5883\uFF0C\u9ED8\u8BA4\uFF09/ 1 \u4E2D / 2 \u4F4E\uFF08\u5608\u6742\u73AF\u5883\u66F4\u5BB9\u6613\u6253\u65AD\uFF09"),
449
- silenceMs: z.number().min(500).max(3e4).default(d.silenceMs).description("\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 2000 = 2 \u79D2\uFF09"),
1117
+ silenceMs: z.number().min(500).max(3e4).default(d.silenceMs).description("\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"),
450
1118
  idleTimeoutMinutes: z.number().min(1).max(120).default(d.idleTimeoutMinutes).description("\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09"),
451
1119
  modelHost: z.string().default(d.modelHost).description("ASR \u6A21\u578B\u4E0B\u8F7D\u6E90\uFF08\u7559\u7A7A\u7528\u9ED8\u8BA4\u6E90\uFF1B\u56FD\u5185\u7F51\u7EDC\u53EF\u586B https://hf-mirror.com\uFF09"),
452
1120
  autoSend: z.boolean().default(d.autoSend).description("\u8BC6\u522B\u5B9A\u7A3F\u540E\u81EA\u52A8\u53D1\u9001\uFF08\u5173\u95ED\u5219\u53EA\u8FDB\u8349\u7A3F\u4F9B\u7F16\u8F91\uFF1B\u6309\u4F4F Ctrl / hold \u677E\u624B\u4ECD\u4F1A\u53D1\u9001\uFF09"),
1121
+ autoResume: z.boolean().default(d.autoResume).description("\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\u5173\u95ED\u5219\u6BCF\u6B21\u5207\u6362\u4F1A\u8BDD\u540E\u9700\u91CD\u65B0\u70B9\u9EA6\u514B\u98CE\uFF09"),
453
1122
  mode: z.union([z.const("toggle"), z.const("hold")]).default(d.mode).description("\u4EA4\u4E92\u6A21\u5F0F\uFF1Atoggle \u6301\u7EED\u8046\u542C + \u9759\u97F3\u81EA\u52A8\u65AD\u53E5\uFF08\u9ED8\u8BA4\uFF09\uFF1Bhold \u6309\u4F4F\u8BF4\u8BDD\u3001\u677E\u624B\u53D1\u9001\uFF08\u77ED\u6309\u9000\u51FA\uFF09"),
454
- wakeWord: z.string().default(d.wakeWord).description("\u5524\u9192\u8BCD\uFF1A\u5728\u5F85\u673A\u6001\u8BF4\u51FA\u540E\u5F00\u59CB\u8BC6\u522B\uFF08\u9ED8\u8BA4\u5173\uFF1B\u5982\u300C\u4F60\u597D\u5C0FD\u300D\uFF09"),
455
- spokenFormat: z.boolean().default(d.spokenFormat).description("\u8BED\u97F3\u4F1A\u8BDD\u6CE8\u5165\u53E3\u8BED\u5316\u63D0\u793A\u8BCD\uFF08\u53E3\u8BED\u5316\u77ED\u53E5\u3001\u4E0D\u7528 Markdown \u6392\u7248\u7B26\u53F7\uFF0C\u6717\u8BFB\u66F4\u987A\uFF1B\u9ED8\u8BA4\u5173\uFF0C\u6539\u52A8\u5373\u65F6\u751F\u6548\uFF09")
1123
+ bargeInMode: z.union([z.const("auto"), z.const("manual")]).default(d.bargeInMode).description("\u6253\u65AD\u65B9\u5F0F\uFF1Aauto \u81EA\u52A8\u6253\u65AD\uFF08\u5F00\u53E3\u5373\u6253\u65AD\uFF0C\u8033\u673A/\u5B89\u9759\u73AF\u5883\u63A8\u8350\uFF09\uFF1Bmanual \u624B\u52A8\u6253\u65AD\uFF08\u5916\u653E\u63A8\u8350\u2014\u2014\u5916\u653E\u56DE\u58F0\u4F1A\u8BEF\u89E6\u53D1\u81EA\u52A8\u6253\u65AD\uFF0C\u6539\u6309\u4F4F\u9EA6\u514B\u98CE/Ctrl \u663E\u5F0F\u6253\u65AD\uFF0C\u6C38\u4E0D\u81EA\u6253\u65AD\uFF09"),
1124
+ echoGateDb: z.number().min(3).max(12).default(d.echoGateDb).description("\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"),
1125
+ shortcut: z.string().default(d.shortcut).description("\u8FDB\u5165/\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5FEB\u6377\u952E\uFF08\u5F62\u5982 Ctrl+Shift+V\uFF0C\u4FEE\u9970\u952E Ctrl/Shift/Alt/Meta + \u4E00\u4E2A\u5B57\u6BCD\u952E\uFF1B\u7559\u7A7A\u7981\u7528\u5FEB\u6377\u952E\uFF0C\u7528\u9EA6\u514B\u98CE\u6309\u94AE\uFF09"),
1126
+ spokenFormat: z.boolean().default(d.spokenFormat).description("\u8BED\u97F3\u4F1A\u8BDD\u6CE8\u5165\u53E3\u8BED\u5316\u63D0\u793A\u8BCD\uFF08\u53E3\u8BED\u5316\u77ED\u53E5\u3001\u4E0D\u7528 Markdown \u6392\u7248\u7B26\u53F7\uFF0C\u6717\u8BFB\u66F4\u987A\uFF1B\u9ED8\u8BA4\u5173\uFF0C\u6539\u52A8\u5373\u65F6\u751F\u6548\uFF09"),
1127
+ senseVoice: z.boolean().default(d.senseVoice).description("\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")
456
1128
  });
457
1129
  }
458
1130
  var VoiceSettingsSchema = createVoiceSettingsSchema();
@@ -463,16 +1135,26 @@ var Config = z.object({
463
1135
  voice: z.string().default("zh-CN-XiaoxiaoNeural"),
464
1136
  rate: z.number().default(1),
465
1137
  interruptLevel: z.union([z.const(0), z.const(1), z.const(2)]).default(0),
466
- silenceMs: z.number().default(2e3),
1138
+ silenceMs: z.number().default(700),
467
1139
  idleTimeoutMinutes: z.number().default(10)
468
1140
  });
469
1141
  function apply(ctx, config) {
470
1142
  let activeVoiceSession = null;
1143
+ let activeTabId = null;
1144
+ let ownerYieldTimer = null;
1145
+ const turnStates = /* @__PURE__ */ new Map();
1146
+ const setTurn = (sessionId, state) => {
1147
+ if (turnStates.get(sessionId) === state) return;
1148
+ turnStates.set(sessionId, state);
1149
+ broadcast("turn", { sessionId, state });
1150
+ };
1151
+ const turnGen = /* @__PURE__ */ new Map();
471
1152
  const sseClients = /* @__PURE__ */ new Set();
1153
+ const latestConnByTab = /* @__PURE__ */ new Map();
472
1154
  const broadcast = (event, payload) => {
473
- for (const send of sseClients) {
1155
+ for (const c of sseClients) {
474
1156
  try {
475
- send(event, payload);
1157
+ c.send(event, payload);
476
1158
  } catch {
477
1159
  }
478
1160
  }
@@ -495,8 +1177,11 @@ function apply(ctx, config) {
495
1177
  const asr = createAsrRuntime({
496
1178
  cacheDir: config.cacheDir,
497
1179
  modelHost: () => vset.modelHost,
1180
+ // P4:SenseVoice 定稿重译开关(实时读取,关闭则不下载/不创建模型)。
1181
+ senseVoice: () => vset.senseVoice,
498
1182
  broadcast
499
1183
  });
1184
+ ctx.effect(() => () => asr.dispose());
500
1185
  const queue = new TtsQueue({
501
1186
  voice: vset.voice,
502
1187
  rate: vset.rate,
@@ -514,6 +1199,19 @@ function apply(ctx, config) {
514
1199
  const currentVoice = () => vset.voice;
515
1200
  const currentRate = () => vset.rate;
516
1201
  const currentInterrupt = () => vset.interruptLevel;
1202
+ const yieldActiveSession = (expectedSid) => {
1203
+ ownerYieldTimer = null;
1204
+ const sid = activeVoiceSession;
1205
+ if (!sid) return;
1206
+ if (expectedSid !== void 0 && expectedSid !== sid) return;
1207
+ activeVoiceSession = null;
1208
+ activeTabId = null;
1209
+ queue.cancel(sid);
1210
+ asr.reset(sid);
1211
+ setTurn(sid, "idle");
1212
+ turnStates.delete(sid);
1213
+ broadcast("mode", { active: null, ownerTabId: activeTabId });
1214
+ };
517
1215
  ctx.on("system-prompt/assemble", (assembly, context, next) => {
518
1216
  if (!config.enabled || !vset.spokenFormat) return next();
519
1217
  const agentId = context.agent?.id;
@@ -526,7 +1224,17 @@ function apply(ctx, config) {
526
1224
  const sessionId = options.sessionId;
527
1225
  if (!config.enabled || sessionId === void 0 || options.purpose !== void 0) return next();
528
1226
  if (activeVoiceSession !== sessionId) return next();
529
- return tapActiveStream(sessionId, next(), queue, broadcast);
1227
+ const gen = (turnGen.get(sessionId) ?? 0) + 1;
1228
+ turnGen.set(sessionId, gen);
1229
+ return tapActiveStream(
1230
+ sessionId,
1231
+ next(),
1232
+ queue,
1233
+ broadcast,
1234
+ (state) => {
1235
+ if ((turnGen.get(sessionId) ?? 0) === gen) setTurn(sessionId, state);
1236
+ }
1237
+ );
530
1238
  });
531
1239
  const base = BASE_PATH;
532
1240
  ctx.effect(
@@ -534,16 +1242,12 @@ function apply(ctx, config) {
534
1242
  kind: "prefix",
535
1243
  path: base,
536
1244
  handler: (_req, res) => {
537
- res.statusCode = 200;
538
- res.setHeader("content-type", "application/json");
539
- res.end(
540
- JSON.stringify({
541
- ok: true,
542
- name: "dsh-voice-mode",
543
- enabled: config.enabled,
544
- active: activeVoiceSession
545
- })
546
- );
1245
+ respondJson2(res, 200, {
1246
+ ok: true,
1247
+ name: "dsh-voice-mode",
1248
+ enabled: config.enabled,
1249
+ active: activeVoiceSession
1250
+ });
547
1251
  }
548
1252
  })
549
1253
  );
@@ -552,23 +1256,23 @@ function apply(ctx, config) {
552
1256
  kind: "exact",
553
1257
  path: `${base}/config`,
554
1258
  handler: (_req, res) => {
555
- res.statusCode = 200;
556
- res.setHeader("content-type", "application/json");
557
- res.end(
558
- JSON.stringify({
559
- basePath: base,
560
- rate: currentRate(),
561
- voice: currentVoice(),
562
- interruptLevel: currentInterrupt(),
563
- silenceMs: vset.silenceMs,
564
- idleTimeoutMinutes: vset.idleTimeoutMinutes,
565
- modelHost: vset.modelHost,
566
- autoSend: vset.autoSend,
567
- mode: vset.mode,
568
- wakeWord: vset.wakeWord,
569
- cacheDir: config.cacheDir
570
- })
571
- );
1259
+ respondJson2(res, 200, {
1260
+ basePath: base,
1261
+ rate: currentRate(),
1262
+ voice: currentVoice(),
1263
+ senseVoice: vset.senseVoice,
1264
+ interruptLevel: currentInterrupt(),
1265
+ silenceMs: vset.silenceMs,
1266
+ idleTimeoutMinutes: vset.idleTimeoutMinutes,
1267
+ modelHost: vset.modelHost,
1268
+ autoSend: vset.autoSend,
1269
+ autoResume: vset.autoResume,
1270
+ mode: vset.mode,
1271
+ bargeInMode: vset.bargeInMode,
1272
+ echoGateDb: vset.echoGateDb,
1273
+ shortcut: vset.shortcut,
1274
+ cacheDir: config.cacheDir
1275
+ });
572
1276
  }
573
1277
  })
574
1278
  );
@@ -578,9 +1282,7 @@ function apply(ctx, config) {
578
1282
  path: `${base}/preview`,
579
1283
  handler: (req, res) => {
580
1284
  if (!config.enabled) {
581
- res.statusCode = 403;
582
- res.setHeader("content-type", "application/json");
583
- res.end(JSON.stringify({ error: "voice mode disabled" }));
1285
+ respondJson2(res, 403, { error: "voice mode disabled" });
584
1286
  return;
585
1287
  }
586
1288
  collectBody(req, res, MAX_JSON_BODY, async (body) => {
@@ -595,15 +1297,11 @@ function apply(ctx, config) {
595
1297
  } catch {
596
1298
  }
597
1299
  if (voice.length > 128) {
598
- res.statusCode = 400;
599
- res.setHeader("content-type", "application/json");
600
- res.end(JSON.stringify({ error: "voice too long" }));
1300
+ respondJson2(res, 400, { error: "voice too long" });
601
1301
  return;
602
1302
  }
603
1303
  if (!voice) {
604
- res.statusCode = 400;
605
- res.setHeader("content-type", "application/json");
606
- res.end(JSON.stringify({ error: "voice required" }));
1304
+ respondJson2(res, 400, { error: "voice required" });
607
1305
  return;
608
1306
  }
609
1307
  const sample = voice.startsWith("zh-") ? "\u4F60\u597D\uFF0C\u6B22\u8FCE\u4F7F\u7528\u8BED\u97F3\u6A21\u5F0F\u3002" : "Hello, welcome to voice mode.";
@@ -612,14 +1310,10 @@ function apply(ctx, config) {
612
1310
  buf = await queue.synthesize(sample, { voice, rate });
613
1311
  } catch (e) {
614
1312
  console.warn(`[dsh-voice-mode] preview synthesis failed: ${String(e)}`);
615
- res.statusCode = 502;
616
- res.setHeader("content-type", "application/json");
617
- res.end(JSON.stringify({ error: "\u9884\u89C8\u5408\u6210\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E" }));
1313
+ respondJson2(res, 502, { error: "\u9884\u89C8\u5408\u6210\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u7F51\u7EDC\u6216\u97F3\u8272\u540D\uFF08ShortName\uFF09\u662F\u5426\u6B63\u786E" });
618
1314
  return;
619
1315
  }
620
- res.statusCode = 200;
621
- res.setHeader("content-type", "audio/mpeg");
622
- res.setHeader("cache-control", "no-store");
1316
+ res.writeHead(200, { "content-type": "audio/mpeg", "cache-control": "no-store" });
623
1317
  res.end(buf);
624
1318
  });
625
1319
  }
@@ -633,36 +1327,99 @@ function apply(ctx, config) {
633
1327
  collectBody(req, res, MAX_JSON_BODY, (body) => {
634
1328
  let sessionId;
635
1329
  let on;
1330
+ let tabId;
636
1331
  try {
637
1332
  const parsed = JSON.parse(body || "{}");
638
1333
  sessionId = parsed.sessionId;
639
1334
  on = parsed.on;
1335
+ tabId = typeof parsed.tabId === "string" && parsed.tabId.length <= 64 ? parsed.tabId : void 0;
640
1336
  } catch {
641
1337
  }
642
1338
  if (!sessionId) {
643
- res.statusCode = 400;
644
- res.end(JSON.stringify({ error: "sessionId required" }));
1339
+ respondJson2(res, 400, { error: "sessionId required" });
1340
+ return;
1341
+ }
1342
+ if (on !== void 0 && typeof on !== "boolean") {
1343
+ respondJson2(res, 400, { error: "invalid on" });
645
1344
  return;
646
1345
  }
647
1346
  if (on === true) {
648
1347
  if (!config.enabled) {
649
- res.statusCode = 403;
650
- res.end(JSON.stringify({ error: "voice mode disabled" }));
1348
+ respondJson2(res, 403, { error: "voice mode disabled" });
651
1349
  return;
652
1350
  }
1351
+ asr.reset(sessionId);
1352
+ queue.cancel(sessionId);
653
1353
  const previous = activeVoiceSession;
654
1354
  activeVoiceSession = sessionId;
655
- if (previous && previous !== sessionId) queue.prune(previous);
656
- broadcast("mode", { active: activeVoiceSession });
1355
+ activeTabId = tabId ?? null;
1356
+ if (ownerYieldTimer) {
1357
+ clearTimeout(ownerYieldTimer);
1358
+ ownerYieldTimer = null;
1359
+ }
1360
+ if (previous && previous !== sessionId) {
1361
+ queue.cancel(previous);
1362
+ asr.reset(previous);
1363
+ setTurn(previous, "idle");
1364
+ turnStates.delete(previous);
1365
+ }
1366
+ broadcast("mode", { active: activeVoiceSession, ownerTabId: activeTabId });
657
1367
  } else {
658
1368
  if (activeVoiceSession === sessionId) {
659
1369
  activeVoiceSession = null;
660
- queue.prune(sessionId);
661
- broadcast("mode", { active: null });
1370
+ activeTabId = null;
1371
+ if (ownerYieldTimer) {
1372
+ clearTimeout(ownerYieldTimer);
1373
+ ownerYieldTimer = null;
1374
+ }
1375
+ queue.cancel(sessionId);
1376
+ asr.reset(sessionId);
1377
+ setTurn(sessionId, "idle");
1378
+ turnStates.delete(sessionId);
1379
+ broadcast("mode", { active: null, ownerTabId: null });
662
1380
  }
663
1381
  }
664
- res.setHeader("content-type", "application/json");
665
- res.end(JSON.stringify({ active: activeVoiceSession }));
1382
+ respondJson2(res, 200, { active: activeVoiceSession });
1383
+ });
1384
+ }
1385
+ })
1386
+ );
1387
+ ctx.effect(
1388
+ () => ctx.webServer.register({
1389
+ kind: "exact",
1390
+ path: `${base}/models/status`,
1391
+ handler: (_req, res) => {
1392
+ respondJson2(res, 200, asr.modelStatus());
1393
+ }
1394
+ })
1395
+ );
1396
+ ctx.effect(
1397
+ () => ctx.webServer.register({
1398
+ kind: "exact",
1399
+ path: `${base}/models/retry`,
1400
+ handler: (req, res) => {
1401
+ if (!config.enabled) {
1402
+ respondJson2(res, 403, { error: "voice mode disabled" });
1403
+ return;
1404
+ }
1405
+ collectBody(req, res, MAX_JSON_BODY, (body) => {
1406
+ let kind = "asr";
1407
+ try {
1408
+ const p = JSON.parse(body || "{}");
1409
+ if (p.kind === void 0) {
1410
+ } else if (p.kind === "vad" || p.kind === "sense" || p.kind === "asr") {
1411
+ kind = p.kind;
1412
+ } else {
1413
+ respondJson2(res, 400, { error: "invalid kind" });
1414
+ return;
1415
+ }
1416
+ } catch {
1417
+ respondJson2(res, 400, { error: "invalid json" });
1418
+ return;
1419
+ }
1420
+ void asr.retryModel(kind).then((done) => {
1421
+ respondJson2(res, 200, { ok: done, kind });
1422
+ });
666
1423
  });
667
1424
  }
668
1425
  })
@@ -672,6 +1429,14 @@ function apply(ctx, config) {
672
1429
  kind: "exact",
673
1430
  path: `${base}/asr`,
674
1431
  handler: (req, res) => {
1432
+ try {
1433
+ const url = new URL(req.url ?? "/", "http://localhost");
1434
+ const sid = url.searchParams.get("sessionId") ?? "";
1435
+ if (sid && sid === activeVoiceSession) {
1436
+ setTurn(sid, url.searchParams.get("final") === "1" ? "finalizing" : "listening");
1437
+ }
1438
+ } catch {
1439
+ }
675
1440
  handleAsrRequest(asr, activeVoiceSession, req, res);
676
1441
  }
677
1442
  })
@@ -683,17 +1448,18 @@ function apply(ctx, config) {
683
1448
  handler: (req, res) => {
684
1449
  collectBody(req, res, MAX_JSON_BODY, (body) => {
685
1450
  let sessionId;
1451
+ let keepAsr = false;
686
1452
  try {
687
1453
  const parsed = JSON.parse(body || "{}");
688
1454
  sessionId = parsed.sessionId;
1455
+ keepAsr = parsed.keepAsr === true;
689
1456
  } catch {
690
1457
  }
691
- if (sessionId) {
1458
+ if (sessionId && sessionId === activeVoiceSession) {
692
1459
  queue.cancel(sessionId);
693
- asr.reset(sessionId);
1460
+ if (!keepAsr) asr.reset(sessionId);
694
1461
  }
695
- res.setHeader("content-type", "application/json");
696
- res.end(JSON.stringify({ ok: true }));
1462
+ respondJson2(res, 200, { ok: true });
697
1463
  });
698
1464
  }
699
1465
  })
@@ -702,7 +1468,14 @@ function apply(ctx, config) {
702
1468
  () => ctx.webServer.register({
703
1469
  kind: "exact",
704
1470
  path: `${base}/stream`,
705
- handler: (_req, res) => {
1471
+ handler: (req, res) => {
1472
+ let tabId = null;
1473
+ try {
1474
+ const u = new URL(req.url ?? "/", "http://localhost");
1475
+ tabId = u.searchParams.get("tabId");
1476
+ } catch {
1477
+ }
1478
+ if (tabId !== null && tabId.length > 64) tabId = null;
706
1479
  res.writeHead(200, {
707
1480
  "content-type": "text/event-stream; charset=utf-8",
708
1481
  "cache-control": "no-cache, no-transform",
@@ -715,16 +1488,35 @@ data: ${JSON.stringify(payload)}
715
1488
 
716
1489
  `);
717
1490
  };
718
- sseClients.add(send);
719
- send("mode", { active: activeVoiceSession });
1491
+ const client = { tabId, send };
1492
+ sseClients.add(client);
1493
+ if (tabId !== null) latestConnByTab.set(tabId, client);
1494
+ if (tabId !== null && tabId === activeTabId && ownerYieldTimer) {
1495
+ clearTimeout(ownerYieldTimer);
1496
+ ownerYieldTimer = null;
1497
+ }
1498
+ send("mode", { active: activeVoiceSession, ownerTabId: activeTabId });
720
1499
  const heartbeat = setInterval(() => {
721
- res.write(": hb\n");
1500
+ try {
1501
+ res.write(": hb\n");
1502
+ } catch {
1503
+ }
722
1504
  }, 25e3);
1505
+ let cleaned = false;
723
1506
  const cleanup = () => {
1507
+ if (cleaned) return;
1508
+ cleaned = true;
724
1509
  clearInterval(heartbeat);
725
- sseClients.delete(send);
1510
+ sseClients.delete(client);
1511
+ if (tabId !== null && latestConnByTab.get(tabId) === client) {
1512
+ latestConnByTab.delete(tabId);
1513
+ if (tabId === activeTabId) {
1514
+ if (ownerYieldTimer) clearTimeout(ownerYieldTimer);
1515
+ ownerYieldTimer = setTimeout(() => yieldActiveSession(activeVoiceSession), 8e3);
1516
+ }
1517
+ }
726
1518
  };
727
- _req.on("close", cleanup);
1519
+ req.on("close", cleanup);
728
1520
  res.on("close", cleanup);
729
1521
  }
730
1522
  })
@@ -732,20 +1524,22 @@ data: ${JSON.stringify(payload)}
732
1524
  }
733
1525
  var MAX_JSON_BODY = 16 * 1024;
734
1526
  function collectBody(req, res, maxBytes, onBody) {
735
- let body = "";
1527
+ const chunks = [];
1528
+ let received = 0;
736
1529
  let tooLarge = false;
737
1530
  req.on("data", (c) => {
738
1531
  if (tooLarge) return;
739
- body += c;
740
- if (body.length > maxBytes) {
1532
+ received += c.length;
1533
+ if (received > maxBytes) {
741
1534
  tooLarge = true;
742
- res.statusCode = 413;
743
- res.setHeader("content-type", "application/json");
744
- res.end(JSON.stringify({ error: "request body too large" }));
1535
+ respondJson2(res, 413, { error: "request body too large" });
1536
+ return;
745
1537
  }
1538
+ chunks.push(c);
746
1539
  });
747
1540
  req.on("end", () => {
748
1541
  if (tooLarge) return;
1542
+ const body = Buffer.concat(chunks).toString("utf8");
749
1543
  try {
750
1544
  const r = onBody(body);
751
1545
  if (r && typeof r.then === "function") r.catch(() => {
@@ -756,8 +1550,10 @@ function collectBody(req, res, maxBytes, onBody) {
756
1550
  req.on("error", () => {
757
1551
  });
758
1552
  }
759
- async function* tapActiveStream(sessionId, inner, queue, broadcast) {
1553
+ async function* tapActiveStream(sessionId, inner, queue, broadcast, onTurn) {
760
1554
  const segmenter = new SentenceSegmenter();
1555
+ let firstTokenBroadcast = false;
1556
+ let firstSentenceBroadcast = false;
761
1557
  let flushed = false;
762
1558
  let finishReason = null;
763
1559
  const flushOnce = () => {
@@ -770,13 +1566,19 @@ async function* tapActiveStream(sessionId, inner, queue, broadcast) {
770
1566
  try {
771
1567
  for await (const chunk of inner) {
772
1568
  if (chunk.type === "text-delta" && chunk.text) {
1569
+ if (!firstTokenBroadcast) {
1570
+ firstTokenBroadcast = true;
1571
+ broadcast("latency", { sessionId, stage: "first-llm-token" });
1572
+ onTurn("agent-speaking");
1573
+ }
773
1574
  for (const s of segmenter.feed(chunk.text)) {
1575
+ if (!firstSentenceBroadcast) {
1576
+ firstSentenceBroadcast = true;
1577
+ broadcast("latency", { sessionId, stage: "first-sentence-text" });
1578
+ }
774
1579
  queue.enqueue(sessionId, s);
775
1580
  }
776
1581
  }
777
- if (chunk.type === "tool-call-delta" && chunk.name) {
778
- broadcast("tool", { sessionId, name: chunk.name });
779
- }
780
1582
  if (chunk.type === "finish") {
781
1583
  finishReason = chunk.reason;
782
1584
  }
@@ -785,6 +1587,7 @@ async function* tapActiveStream(sessionId, inner, queue, broadcast) {
785
1587
  } finally {
786
1588
  const aborted = finishReason !== null && typeof finishReason === "object" && finishReason.kind === "aborted";
787
1589
  if (!aborted) flushOnce();
1590
+ onTurn("listening");
788
1591
  }
789
1592
  }
790
1593
  export {