dsh-voice-mode 0.1.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 ADDED
@@ -0,0 +1,792 @@
1
+ // src/index.ts
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { join as join2 } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ // src/asr-host.ts
7
+ import { createWriteStream } from "node:fs";
8
+ import { mkdir, rename, stat, unlink } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import sherpa_onnx from "sherpa-onnx";
11
+ var { createOnlineRecognizer } = sherpa_onnx;
12
+ var MODEL_REPO = "csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30";
13
+ var MODEL_FILES = ["encoder.int8.onnx", "decoder.onnx", "joiner.int8.onnx", "tokens.txt"];
14
+ function pcmToSamples(buf) {
15
+ if (buf.length % 4 !== 0 || buf.length === 0) return null;
16
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
17
+ }
18
+ var MAX_ASR_BYTES = 4 * 1024 * 1024;
19
+ function createAsrRuntime(options) {
20
+ const { cacheDir, modelHost, broadcast } = options;
21
+ const repoDir = join(cacheDir, MODEL_REPO);
22
+ const segments = /* @__PURE__ */ new Map();
23
+ let recognizer = null;
24
+ let modelsReady = false;
25
+ let modelsLoading = null;
26
+ const haveAllModels = async () => {
27
+ for (const f of MODEL_FILES) {
28
+ const st = await stat(join(repoDir, f)).catch(() => null);
29
+ if (!st?.isFile()) return false;
30
+ }
31
+ return true;
32
+ };
33
+ const ensureModels = async () => {
34
+ if (modelsReady) return true;
35
+ if (!modelsLoading) {
36
+ modelsLoading = (async () => {
37
+ if (!await haveAllModels()) {
38
+ for (const f of MODEL_FILES) {
39
+ if (!await ensureFile(repoDir, f, modelHost(), broadcast)) {
40
+ broadcast("asr-error", { file: f });
41
+ return false;
42
+ }
43
+ }
44
+ }
45
+ modelsReady = true;
46
+ broadcast("asr-ready", {});
47
+ return true;
48
+ })().finally(() => {
49
+ modelsLoading = null;
50
+ });
51
+ }
52
+ return modelsLoading;
53
+ };
54
+ const getRecognizer = async () => {
55
+ if (!await ensureModels()) return null;
56
+ if (recognizer) return recognizer;
57
+ const t = (f) => join(repoDir, f);
58
+ recognizer = createOnlineRecognizer({
59
+ modelConfig: {
60
+ transducer: {
61
+ encoder: t("encoder.int8.onnx"),
62
+ decoder: t("decoder.onnx"),
63
+ joiner: t("joiner.int8.onnx")
64
+ },
65
+ tokens: t("tokens.txt"),
66
+ numThreads: 4,
67
+ provider: "cpu",
68
+ debug: 0
69
+ },
70
+ decodingMethod: "greedy_search"
71
+ });
72
+ return recognizer;
73
+ };
74
+ const feed = async (sessionId, samples, final) => {
75
+ const rec = await getRecognizer();
76
+ if (!rec) return { text: "", loading: true };
77
+ let seg = segments.get(sessionId);
78
+ if (!seg) {
79
+ if (samples.length === 0 && final) return { text: "" };
80
+ seg = { stream: rec.createStream(), fed: 0 };
81
+ segments.set(sessionId, seg);
82
+ }
83
+ if (samples.length > seg.fed) {
84
+ seg.stream.acceptWaveform(rec.config.featConfig.sampleRate, samples.subarray(seg.fed));
85
+ seg.fed = samples.length;
86
+ while (rec.isReady(seg.stream)) rec.decode(seg.stream);
87
+ }
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
+ };
98
+ return { feed, reset: (sessionId) => segments.delete(sessionId) };
99
+ }
100
+ async function ensureFile(repoDir, file, primaryHost, broadcast) {
101
+ const localPath = join(repoDir, file);
102
+ const st = await stat(localPath).catch(() => null);
103
+ if (st?.isFile()) return true;
104
+ await mkdir(repoDir, { recursive: true }).catch(() => void 0);
105
+ 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
+ for (const host of hosts) {
109
+ try {
110
+ const ok = await download(host, repoDir, file, partSt?.size ?? 0, broadcast);
111
+ if (ok) {
112
+ await rename(partPath, localPath).catch(() => void 0);
113
+ if ((await stat(localPath).catch(() => null))?.isFile()) return true;
114
+ }
115
+ } catch {
116
+ }
117
+ }
118
+ await unlink(partPath).catch(() => void 0);
119
+ return false;
120
+ }
121
+ var HOST_PRIMARY = "https://huggingface.co";
122
+ 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}`;
125
+ const headers = { "user-agent": "dsh-voice-mode" };
126
+ if (resumeFrom > 0) headers.range = `bytes=${resumeFrom}-`;
127
+ const res = await fetch(url, { headers });
128
+ if (res.status === 416) return true;
129
+ if (res.status !== 200 && res.status !== 206) return false;
130
+ const total = Number(res.headers.get("content-length") ?? 0) + resumeFrom;
131
+ const partPath = join(repoDir, `${file}.part`);
132
+ const sink = createWriteStream(partPath, resumeFrom > 0 ? { flags: "a" } : {});
133
+ const src = res.body;
134
+ if (!src) return false;
135
+ const reader = src.getReader();
136
+ let received = resumeFrom;
137
+ const done = new Promise((resolve, reject) => {
138
+ sink.on("error", (e) => reject(e));
139
+ sink.on("finish", () => resolve(true));
140
+ (async () => {
141
+ try {
142
+ for (; ; ) {
143
+ const { done: d, value } = await reader.read();
144
+ if (d) break;
145
+ received += value.byteLength;
146
+ if (!sink.write(value)) {
147
+ await new Promise((r) => sink.once("drain", r));
148
+ }
149
+ if (total > 0) {
150
+ broadcast("asr-progress", {
151
+ file,
152
+ percent: Math.min(100, Math.round(received / total * 100))
153
+ });
154
+ }
155
+ }
156
+ sink.end();
157
+ } catch (e) {
158
+ sink.destroy(e);
159
+ reject(e);
160
+ }
161
+ })();
162
+ });
163
+ return done.catch(() => false);
164
+ }
165
+ function handleAsrRequest(asr, activeSessionId, req, res) {
166
+ const chunks = [];
167
+ let received = 0;
168
+ let tooLarge = false;
169
+ req.on("data", (c) => {
170
+ if (tooLarge) return;
171
+ received += c.length;
172
+ if (received > MAX_ASR_BYTES) {
173
+ tooLarge = true;
174
+ res.statusCode = 413;
175
+ res.end(JSON.stringify({ error: "pcm payload too large" }));
176
+ return;
177
+ }
178
+ chunks.push(c);
179
+ });
180
+ req.on("end", () => {
181
+ if (tooLarge) return;
182
+ res.setHeader("content-type", "application/json");
183
+ const url = new URL(req.url ?? "/", "http://localhost");
184
+ const sessionId = url.searchParams.get("sessionId") ?? "";
185
+ const final = url.searchParams.get("final") === "1";
186
+ const reset = url.searchParams.get("reset") === "1";
187
+ if (!sessionId || sessionId !== activeSessionId) {
188
+ res.statusCode = 403;
189
+ res.end(JSON.stringify({ error: "not the active voice session" }));
190
+ return;
191
+ }
192
+ if (reset) {
193
+ asr.reset(sessionId);
194
+ res.end(JSON.stringify({ ok: true }));
195
+ return;
196
+ }
197
+ const samples = pcmToSamples(Buffer.concat(chunks));
198
+ if (!samples) {
199
+ res.statusCode = 400;
200
+ res.end(JSON.stringify({ error: "invalid pcm payload" }));
201
+ return;
202
+ }
203
+ void asr.feed(sessionId, samples, final).then((out) => {
204
+ if (out.loading) {
205
+ res.statusCode = 202;
206
+ res.end(JSON.stringify({ loading: true }));
207
+ return;
208
+ }
209
+ res.end(JSON.stringify({ text: out.text }));
210
+ }).catch((e) => {
211
+ res.statusCode = 500;
212
+ res.end(JSON.stringify({ error: String(e) }));
213
+ });
214
+ });
215
+ }
216
+
217
+ // src/segmenter.ts
218
+ var SKIP_PREFIX = /^[\s.,,、::;;!?!?)\])"'”’〉》】]+$/;
219
+ function plainText(text) {
220
+ return String(text).replace(/```[\s\S]*?```/g, " ").replace(/`([^`]*)`/g, "$1").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/^[-*+]\s+/gm, "").replace(/^\d+\.\s+/gm, "").replace(/<\/?[a-zA-Z][^>]*>/g, " ");
221
+ }
222
+ function splitSentences(chunk) {
223
+ const sentences = [];
224
+ let start = 0;
225
+ const re = /[。!?!?;;…\n]+|\.(?=\s|$)/g;
226
+ let m;
227
+ let lastEnd = 0;
228
+ while ((m = re.exec(chunk)) !== null) {
229
+ const end = m.index + m[0].length;
230
+ sentences.push(chunk.slice(start, end));
231
+ start = end;
232
+ lastEnd = end;
233
+ }
234
+ return { sentences, tail: chunk.slice(lastEnd) };
235
+ }
236
+ var SentenceSegmenter = class {
237
+ buffer = "";
238
+ maxChars;
239
+ constructor(options = {}) {
240
+ this.maxChars = options.maxSentenceChars ?? 200;
241
+ }
242
+ /** 喂入一段 raw delta,返回它补全的完整句子。 */
243
+ feed(chunk) {
244
+ const cleaned = plainText(chunk);
245
+ if (!cleaned) return [];
246
+ this.buffer += cleaned;
247
+ const { sentences, tail } = splitSentences(this.buffer);
248
+ this.buffer = tail;
249
+ const out = [];
250
+ for (const s of sentences) {
251
+ const t = s.trim();
252
+ if (t && !SKIP_PREFIX.test(t)) out.push(t);
253
+ }
254
+ if (this.buffer.length > this.maxChars) {
255
+ const cut = this.buffer.search(/[,,、\s]/);
256
+ const idx = cut > 0 ? cut : Math.floor(this.maxChars / 2);
257
+ const head = this.buffer.slice(0, idx).trim();
258
+ this.buffer = this.buffer.slice(idx);
259
+ if (head) out.push(head);
260
+ }
261
+ return out;
262
+ }
263
+ /** 收尾:flush 剩余缓冲(流结束)。 */
264
+ flush() {
265
+ const t = this.buffer.trim();
266
+ this.buffer = "";
267
+ if (t && !SKIP_PREFIX.test(t)) return [t];
268
+ return [];
269
+ }
270
+ };
271
+
272
+ // src/tts-queue.ts
273
+ import { MsEdgeTTS, OUTPUT_FORMAT } from "msedge-tts";
274
+ var MP3_MAGIC = 255;
275
+ var TTS_METADATA = { wordBoundaryEnabled: false, sentenceBoundaryEnabled: false };
276
+ function prosodyFromRate(rate) {
277
+ if (rate !== void 0 && rate > 0 && rate !== 1) return { rate };
278
+ return void 0;
279
+ }
280
+ function isValidMp3(buf) {
281
+ return buf.length > 0 && buf[0] === MP3_MAGIC;
282
+ }
283
+ var TtsQueue = class {
284
+ tts = new MsEdgeTTS();
285
+ queues = /* @__PURE__ */ new Map();
286
+ listeners = /* @__PURE__ */ new Set();
287
+ voice;
288
+ prosody;
289
+ ready = null;
290
+ /** TTS 全体不可达通知(每会话去重,成功后复位)。 */
291
+ onError;
292
+ constructor(options = {}) {
293
+ this.voice = options.voice ?? "zh-CN-XiaoxiaoNeural";
294
+ this.prosody = prosodyFromRate(options.rate);
295
+ this.onError = options.onError;
296
+ }
297
+ /** 动态更换音色/语速(Q15 设置即时生效;正在合成的句子不受影响)。 */
298
+ updateVoice(voice, rate) {
299
+ const nextProsody = prosodyFromRate(rate);
300
+ if (voice === this.voice && nextProsody?.rate === this.prosody?.rate) return;
301
+ this.voice = voice;
302
+ this.prosody = nextProsody;
303
+ this.ready = null;
304
+ }
305
+ /**
306
+ * 一次性合成(设置卡「试听」用):独立连接,不干扰朗读队列的在途合成;
307
+ * 音色/语速可指定,缺省用当前队列参数。失败(含非法 ShortName)抛错。
308
+ */
309
+ async synthesize(text, options = {}) {
310
+ const tts = new MsEdgeTTS();
311
+ try {
312
+ await tts.setMetadata(options.voice ?? this.voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3, TTS_METADATA);
313
+ const { audioStream } = tts.toStream(text, prosodyFromRate(options.rate));
314
+ const chunks = [];
315
+ for await (const chunk of audioStream) chunks.push(chunk);
316
+ const buf = Buffer.concat(chunks);
317
+ if (!isValidMp3(buf)) throw new Error("empty or invalid audio");
318
+ return buf;
319
+ } finally {
320
+ try {
321
+ await tts.close();
322
+ } catch {
323
+ }
324
+ }
325
+ }
326
+ /** 初始化 Edge TTS WebSocket(懒执行,close 后可重来)。 */
327
+ async ensureReady() {
328
+ if (this.ready) return this.ready;
329
+ this.ready = this.tts.setMetadata(this.voice, OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3, TTS_METADATA).catch((e) => {
330
+ this.ready = null;
331
+ throw e;
332
+ });
333
+ return this.ready;
334
+ }
335
+ subscribe(listener) {
336
+ this.listeners.add(listener);
337
+ return () => {
338
+ this.listeners.delete(listener);
339
+ };
340
+ }
341
+ /** 为某会话入队一句;若泵空闲则启动。 */
342
+ enqueue(sessionId, text) {
343
+ let q = this.queues.get(sessionId);
344
+ if (!q) {
345
+ q = { pending: [], busy: false, seq: 0, epoch: 0, errorNotified: false };
346
+ this.queues.set(sessionId, q);
347
+ }
348
+ q.pending.push({ text, epoch: q.epoch });
349
+ void this.pump(sessionId, q);
350
+ }
351
+ /**
352
+ * 弃掉某会话的所有积压并作废正在合成的句子(打断)。之后入队的句子
353
+ * 获得新 epoch 正常播放。
354
+ */
355
+ cancel(sessionId) {
356
+ const q = this.queues.get(sessionId);
357
+ if (q) {
358
+ q.epoch++;
359
+ q.pending.length = 0;
360
+ }
361
+ }
362
+ /** 会话退出/被抢占时彻底清理其队列(防止 Map 长期累积)。 */
363
+ prune(sessionId) {
364
+ this.queues.delete(sessionId);
365
+ }
366
+ async pump(sessionId, q) {
367
+ if (q.busy) return;
368
+ q.busy = true;
369
+ try {
370
+ await this.ensureReady();
371
+ while (q.pending.length > 0) {
372
+ const item = q.pending.shift();
373
+ try {
374
+ const { audioStream } = this.tts.toStream(item.text, this.prosody);
375
+ const chunks = [];
376
+ 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 {
393
+ }
394
+ }
395
+ } catch (e) {
396
+ console.warn(`[dsh-voice-mode] synthesis failed: ${String(e)}`);
397
+ }
398
+ }
399
+ } catch (e) {
400
+ console.warn(`[dsh-voice-mode] TTS unavailable: ${String(e)}`);
401
+ if (!q.errorNotified) {
402
+ q.errorNotified = true;
403
+ this.onError?.(sessionId);
404
+ }
405
+ } finally {
406
+ q.busy = false;
407
+ if (q.pending.length > 0) void this.pump(sessionId, q);
408
+ }
409
+ }
410
+ async close() {
411
+ await this.tts.close();
412
+ this.ready = null;
413
+ }
414
+ };
415
+
416
+ // src/index.ts
417
+ var name = "voice-mode";
418
+ var NS_VOICE_MODE = "voice-mode";
419
+ var BASE_PATH = "/voice-mode";
420
+ 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";
421
+ var VOICE_SPOKEN_SECTION = "voice-mode:spoken-format";
422
+ var inject = ["webServer", "settings"];
423
+ var defaultModelCacheDir = () => process.platform === "win32" ? join2(process.env.LOCALAPPDATA ?? join2(homedir(), "AppData", "Local"), "dsh-voice-mode", "models") : join2(homedir(), ".cache", "dsh-voice-mode", "models");
424
+ var VOICE_SETTINGS_DEFAULTS = {
425
+ voice: "zh-CN-XiaoxiaoNeural",
426
+ rate: 1,
427
+ interruptLevel: 0,
428
+ silenceMs: 2e3,
429
+ idleTimeoutMinutes: 10,
430
+ modelHost: "",
431
+ autoSend: true,
432
+ mode: "toggle",
433
+ wakeWord: "",
434
+ spokenFormat: false
435
+ };
436
+ function createVoiceSettingsSchema(defs) {
437
+ const d = { ...VOICE_SETTINGS_DEFAULTS, ...defs };
438
+ return z.object({
439
+ voice: z.string().default(d.voice).description(
440
+ "Edge TTS \u97F3\u8272\uFF08\u5927\u9646\u81EA\u7136\u97F3\uFF1Azh-CN-XiaoxiaoNeural \u6653\u6653\xB7\u5973 / zh-CN-XiaoyiNeural \u6653\u4F0A\xB7\u5973 / zh-CN-YunxiNeural \u4E91\u5E0C\xB7\u7537 / zh-CN-YunjianNeural \u4E91\u5065\xB7\u7537 / zh-CN-YunyangNeural \u4E91\u626C\xB7\u7537 / zh-CN-YunxiaNeural \u4E91\u590F\xB7\u7537\uFF1B\u65B9\u8A00\uFF1A\u4E1C\u5317-\u5C0F\u5317 / \u9655\u897F-\u5C0F\u59AE\uFF1B\u7CA4\u8BED\uFF1AHiuGaai/HiuMaan/WanLung\uFF1B\u53F0\u6E7E\uFF1AHsiaoChen/HsiaoYu/YunJhe\uFF1B\u5B8C\u6574\u6E05\u5355\u89C1 scripts/list-voices.mjs\uFF09"
441
+ ),
442
+ rate: z.number().default(d.rate).description("\u6717\u8BFB\u8BED\u901F\u500D\u7387\uFF080.5 = \u6162\u901F\uFF0C2.0 = \u5FEB\u901F\uFF0C1.0 = \u6B63\u5E38\uFF09"),
443
+ 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"),
444
+ silenceMs: z.number().default(d.silenceMs).description("\u8BF4\u5B8C\u6574\u4E00\u53E5\u7684\u9759\u97F3\u505C\u987F\u6BEB\u79D2\u6570\uFF08\u9ED8\u8BA4 2000 = 2 \u79D2\uFF09"),
445
+ idleTimeoutMinutes: z.number().default(d.idleTimeoutMinutes).description("\u65E0\u6D3B\u52A8\u81EA\u52A8\u9000\u51FA\u8BED\u97F3\u6A21\u5F0F\u7684\u5206\u949F\u6570\uFF08\u9ED8\u8BA4 10\uFF09"),
446
+ 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"),
447
+ 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"),
448
+ 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"),
449
+ 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"),
450
+ 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")
451
+ });
452
+ }
453
+ var VoiceSettingsSchema = createVoiceSettingsSchema();
454
+ var Config = z.object({
455
+ enabled: z.boolean().default(true),
456
+ cacheDir: z.string().default(defaultModelCacheDir()),
457
+ modelHost: z.string().default("https://huggingface.co"),
458
+ voice: z.string().default("zh-CN-XiaoxiaoNeural"),
459
+ rate: z.number().default(1),
460
+ interruptLevel: z.union([z.const(0), z.const(1), z.const(2)]).default(0),
461
+ silenceMs: z.number().default(2e3),
462
+ idleTimeoutMinutes: z.number().default(10)
463
+ });
464
+ function apply(ctx, config) {
465
+ let activeVoiceSession = null;
466
+ const sseClients = /* @__PURE__ */ new Set();
467
+ const broadcast = (event, payload) => {
468
+ for (const send of sseClients) {
469
+ try {
470
+ send(event, payload);
471
+ } catch {
472
+ }
473
+ }
474
+ };
475
+ const settingsScope = ctx.settings.register(
476
+ NS_VOICE_MODE,
477
+ createVoiceSettingsSchema(),
478
+ {
479
+ base: {
480
+ voice: config.voice,
481
+ rate: config.rate,
482
+ interruptLevel: config.interruptLevel,
483
+ silenceMs: config.silenceMs,
484
+ idleTimeoutMinutes: config.idleTimeoutMinutes,
485
+ modelHost: config.modelHost
486
+ }
487
+ }
488
+ );
489
+ let vset = settingsScope.get();
490
+ const asr = createAsrRuntime({
491
+ cacheDir: config.cacheDir,
492
+ modelHost: () => vset.modelHost,
493
+ broadcast
494
+ });
495
+ const queue = new TtsQueue({
496
+ voice: vset.voice,
497
+ rate: vset.rate,
498
+ onError: (sessionId) => broadcast("tts-error", { sessionId })
499
+ });
500
+ const unsubscribe = queue.subscribe((frame) => broadcast("audio", frame));
501
+ ctx.effect(() => unsubscribe);
502
+ ctx.effect(() => () => void queue.close());
503
+ ctx.effect(
504
+ () => settingsScope.watch((next) => {
505
+ vset = next;
506
+ queue.updateVoice(next.voice, next.rate);
507
+ })
508
+ );
509
+ const currentVoice = () => vset.voice;
510
+ const currentRate = () => vset.rate;
511
+ const currentInterrupt = () => vset.interruptLevel;
512
+ ctx.on("system-prompt/assemble", (assembly, context, next) => {
513
+ if (!config.enabled || !vset.spokenFormat) return next();
514
+ const agentId = context.agent?.id;
515
+ if (agentId !== void 0 && agentId === activeVoiceSession) {
516
+ assembly.sections.push({ name: VOICE_SPOKEN_SECTION, text: VOICE_SPOKEN_PROMPT });
517
+ }
518
+ return next();
519
+ });
520
+ ctx.on("llm/stream", (options, next) => {
521
+ const sessionId = options.sessionId;
522
+ if (!config.enabled || sessionId === void 0 || options.purpose !== void 0) return next();
523
+ if (activeVoiceSession !== sessionId) return next();
524
+ return tapActiveStream(sessionId, next(), queue, broadcast);
525
+ });
526
+ const base = BASE_PATH;
527
+ ctx.effect(
528
+ () => ctx.webServer.register({
529
+ kind: "prefix",
530
+ path: base,
531
+ handler: (_req, res) => {
532
+ res.statusCode = 200;
533
+ res.setHeader("content-type", "application/json");
534
+ res.end(
535
+ JSON.stringify({
536
+ ok: true,
537
+ name: "dsh-voice-mode",
538
+ enabled: config.enabled,
539
+ active: activeVoiceSession
540
+ })
541
+ );
542
+ }
543
+ })
544
+ );
545
+ ctx.effect(
546
+ () => ctx.webServer.register({
547
+ kind: "exact",
548
+ path: `${base}/config`,
549
+ handler: (_req, res) => {
550
+ res.statusCode = 200;
551
+ res.setHeader("content-type", "application/json");
552
+ res.end(
553
+ JSON.stringify({
554
+ basePath: base,
555
+ rate: currentRate(),
556
+ voice: currentVoice(),
557
+ interruptLevel: currentInterrupt(),
558
+ silenceMs: vset.silenceMs,
559
+ idleTimeoutMinutes: vset.idleTimeoutMinutes,
560
+ modelHost: vset.modelHost,
561
+ autoSend: vset.autoSend,
562
+ mode: vset.mode,
563
+ wakeWord: vset.wakeWord,
564
+ cacheDir: config.cacheDir
565
+ })
566
+ );
567
+ }
568
+ })
569
+ );
570
+ ctx.effect(
571
+ () => ctx.webServer.register({
572
+ kind: "exact",
573
+ path: `${base}/preview`,
574
+ handler: (req, res) => {
575
+ if (!config.enabled) {
576
+ res.statusCode = 403;
577
+ res.setHeader("content-type", "application/json");
578
+ res.end(JSON.stringify({ error: "voice mode disabled" }));
579
+ return;
580
+ }
581
+ collectBody(req, res, MAX_JSON_BODY, async (body) => {
582
+ let voice = "";
583
+ let rate;
584
+ try {
585
+ const parsed = JSON.parse(body || "{}");
586
+ voice = String(parsed.voice ?? "").trim();
587
+ if (typeof parsed.rate === "number" && Number.isFinite(parsed.rate)) {
588
+ rate = Math.min(2, Math.max(0.5, parsed.rate));
589
+ }
590
+ } catch {
591
+ }
592
+ if (voice.length > 128) {
593
+ res.statusCode = 400;
594
+ res.setHeader("content-type", "application/json");
595
+ res.end(JSON.stringify({ error: "voice too long" }));
596
+ return;
597
+ }
598
+ if (!voice) {
599
+ res.statusCode = 400;
600
+ res.setHeader("content-type", "application/json");
601
+ res.end(JSON.stringify({ error: "voice required" }));
602
+ return;
603
+ }
604
+ const sample = voice.startsWith("zh-") ? "\u4F60\u597D\uFF0C\u6B22\u8FCE\u4F7F\u7528\u8BED\u97F3\u6A21\u5F0F\u3002" : "Hello, welcome to voice mode.";
605
+ let buf;
606
+ try {
607
+ buf = await queue.synthesize(sample, { voice, rate });
608
+ } catch (e) {
609
+ console.warn(`[dsh-voice-mode] preview synthesis failed: ${String(e)}`);
610
+ res.statusCode = 502;
611
+ res.setHeader("content-type", "application/json");
612
+ 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" }));
613
+ return;
614
+ }
615
+ res.statusCode = 200;
616
+ res.setHeader("content-type", "audio/mpeg");
617
+ res.setHeader("cache-control", "no-store");
618
+ res.end(buf);
619
+ });
620
+ }
621
+ })
622
+ );
623
+ ctx.effect(
624
+ () => ctx.webServer.register({
625
+ kind: "exact",
626
+ path: `${base}/toggle`,
627
+ handler: (req, res) => {
628
+ collectBody(req, res, MAX_JSON_BODY, (body) => {
629
+ let sessionId;
630
+ let on;
631
+ try {
632
+ const parsed = JSON.parse(body || "{}");
633
+ sessionId = parsed.sessionId;
634
+ on = parsed.on;
635
+ } catch {
636
+ }
637
+ if (!sessionId) {
638
+ res.statusCode = 400;
639
+ res.end(JSON.stringify({ error: "sessionId required" }));
640
+ return;
641
+ }
642
+ if (on === true) {
643
+ if (!config.enabled) {
644
+ res.statusCode = 403;
645
+ res.end(JSON.stringify({ error: "voice mode disabled" }));
646
+ return;
647
+ }
648
+ const previous = activeVoiceSession;
649
+ activeVoiceSession = sessionId;
650
+ if (previous && previous !== sessionId) queue.prune(previous);
651
+ broadcast("mode", { active: activeVoiceSession });
652
+ } else {
653
+ if (activeVoiceSession === sessionId) {
654
+ activeVoiceSession = null;
655
+ queue.prune(sessionId);
656
+ broadcast("mode", { active: null });
657
+ }
658
+ }
659
+ res.setHeader("content-type", "application/json");
660
+ res.end(JSON.stringify({ active: activeVoiceSession }));
661
+ });
662
+ }
663
+ })
664
+ );
665
+ ctx.effect(
666
+ () => ctx.webServer.register({
667
+ kind: "exact",
668
+ path: `${base}/asr`,
669
+ handler: (req, res) => {
670
+ handleAsrRequest(asr, activeVoiceSession, req, res);
671
+ }
672
+ })
673
+ );
674
+ ctx.effect(
675
+ () => ctx.webServer.register({
676
+ kind: "exact",
677
+ path: `${base}/cancel`,
678
+ handler: (req, res) => {
679
+ collectBody(req, res, MAX_JSON_BODY, (body) => {
680
+ let sessionId;
681
+ try {
682
+ const parsed = JSON.parse(body || "{}");
683
+ sessionId = parsed.sessionId;
684
+ } catch {
685
+ }
686
+ if (sessionId) {
687
+ queue.cancel(sessionId);
688
+ asr.reset(sessionId);
689
+ }
690
+ res.setHeader("content-type", "application/json");
691
+ res.end(JSON.stringify({ ok: true }));
692
+ });
693
+ }
694
+ })
695
+ );
696
+ ctx.effect(
697
+ () => ctx.webServer.register({
698
+ kind: "exact",
699
+ path: `${base}/stream`,
700
+ handler: (_req, res) => {
701
+ res.writeHead(200, {
702
+ "content-type": "text/event-stream; charset=utf-8",
703
+ "cache-control": "no-cache, no-transform",
704
+ connection: "keep-alive"
705
+ });
706
+ res.write("retry: 3000\n\n");
707
+ const send = (event, payload) => {
708
+ res.write(`event: ${event}
709
+ data: ${JSON.stringify(payload)}
710
+
711
+ `);
712
+ };
713
+ sseClients.add(send);
714
+ send("mode", { active: activeVoiceSession });
715
+ const heartbeat = setInterval(() => {
716
+ res.write(": hb\n");
717
+ }, 25e3);
718
+ const cleanup = () => {
719
+ clearInterval(heartbeat);
720
+ sseClients.delete(send);
721
+ };
722
+ _req.on("close", cleanup);
723
+ res.on("close", cleanup);
724
+ }
725
+ })
726
+ );
727
+ }
728
+ var MAX_JSON_BODY = 16 * 1024;
729
+ function collectBody(req, res, maxBytes, onBody) {
730
+ let body = "";
731
+ let tooLarge = false;
732
+ req.on("data", (c) => {
733
+ if (tooLarge) return;
734
+ body += c;
735
+ if (body.length > maxBytes) {
736
+ tooLarge = true;
737
+ res.statusCode = 413;
738
+ res.setHeader("content-type", "application/json");
739
+ res.end(JSON.stringify({ error: "request body too large" }));
740
+ }
741
+ });
742
+ req.on("end", () => {
743
+ if (tooLarge) return;
744
+ try {
745
+ const r = onBody(body);
746
+ if (r && typeof r.then === "function") r.catch(() => {
747
+ });
748
+ } catch {
749
+ }
750
+ });
751
+ req.on("error", () => {
752
+ });
753
+ }
754
+ async function* tapActiveStream(sessionId, inner, queue, broadcast) {
755
+ const segmenter = new SentenceSegmenter();
756
+ let flushed = false;
757
+ let finishReason = null;
758
+ const flushOnce = () => {
759
+ if (flushed) return;
760
+ flushed = true;
761
+ for (const s of segmenter.flush()) {
762
+ queue.enqueue(sessionId, s);
763
+ }
764
+ };
765
+ try {
766
+ for await (const chunk of inner) {
767
+ if (chunk.type === "text-delta" && chunk.text) {
768
+ for (const s of segmenter.feed(chunk.text)) {
769
+ queue.enqueue(sessionId, s);
770
+ }
771
+ }
772
+ if (chunk.type === "tool-call-delta" && chunk.name) {
773
+ broadcast("tool", { sessionId, name: chunk.name });
774
+ }
775
+ if (chunk.type === "finish") {
776
+ finishReason = chunk.reason;
777
+ }
778
+ yield chunk;
779
+ }
780
+ } finally {
781
+ const aborted = finishReason !== null && typeof finishReason === "object" && finishReason.kind === "aborted";
782
+ if (!aborted) flushOnce();
783
+ }
784
+ }
785
+ export {
786
+ Config,
787
+ VoiceSettingsSchema,
788
+ apply,
789
+ createVoiceSettingsSchema,
790
+ inject,
791
+ name
792
+ };