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/README.en.md +40 -32
- package/README.md +51 -28
- package/assets/architecture.svg +1 -1
- package/lib/client.js +1640 -333
- package/lib/index.js +950 -147
- package/lib/sense-worker.mjs +128 -0
- package/package.json +6 -2
- package/scripts/bench-asr.mjs +289 -0
- package/scripts/prefetch.mjs +20 -7
- package/scripts/verify.mjs +34 -0
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
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
|
|
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}/${
|
|
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
|
|
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,
|
|
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 =
|
|
723
|
+
let received = resume;
|
|
137
724
|
const done = new Promise((resolve, reject) => {
|
|
138
725
|
sink.on("error", (e) => reject(e));
|
|
139
|
-
sink.on("finish", () =>
|
|
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
|
|
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
|
|
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
|
|
804
|
+
respondJson(res, 200, { ok: true });
|
|
195
805
|
return;
|
|
196
806
|
}
|
|
197
|
-
const
|
|
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
|
|
200
|
-
res.end(JSON.stringify({ error: "invalid pcm payload" }));
|
|
810
|
+
respondJson(res, 400, { error: "invalid pcm payload" });
|
|
201
811
|
return;
|
|
202
812
|
}
|
|
203
|
-
|
|
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
|
|
206
|
-
res.end(JSON.stringify({ loading: true }));
|
|
823
|
+
respondJson(res, 202, { loading: true });
|
|
207
824
|
return;
|
|
208
825
|
}
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
1010
|
+
let chunkId = 0;
|
|
1011
|
+
let bytes = 0;
|
|
376
1012
|
for await (const chunk of audioStream) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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:
|
|
1097
|
+
silenceMs: 700,
|
|
434
1098
|
idleTimeoutMinutes: 10,
|
|
435
1099
|
modelHost: "",
|
|
436
1100
|
autoSend: true,
|
|
1101
|
+
autoResume: false,
|
|
437
1102
|
mode: "toggle",
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
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
|
-
|
|
455
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
644
|
-
|
|
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
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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
|
|
665
|
-
|
|
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
|
|
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: (
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1527
|
+
const chunks = [];
|
|
1528
|
+
let received = 0;
|
|
736
1529
|
let tooLarge = false;
|
|
737
1530
|
req.on("data", (c) => {
|
|
738
1531
|
if (tooLarge) return;
|
|
739
|
-
|
|
740
|
-
if (
|
|
1532
|
+
received += c.length;
|
|
1533
|
+
if (received > maxBytes) {
|
|
741
1534
|
tooLarge = true;
|
|
742
|
-
res
|
|
743
|
-
|
|
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 {
|