dsh-live-voice 0.0.1-developing → 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/server.js ADDED
@@ -0,0 +1,1006 @@
1
+ // src/engines/speaking/say.ts
2
+ import { spawn as nodeSpawn } from "node:child_process";
3
+ import * as nodeFs from "node:fs/promises";
4
+ import { constants } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ function failure(message, code, cause) {
8
+ return Object.assign(new Error(message, cause === void 0 ? void 0 : { cause }), { code });
9
+ }
10
+ function aborted(reason) {
11
+ return Object.assign(
12
+ new Error("Speech cancelled", reason === void 0 ? void 0 : { cause: reason }),
13
+ {
14
+ name: "AbortError",
15
+ code: "ABORT_ERR"
16
+ }
17
+ );
18
+ }
19
+ var SayEngine = class {
20
+ #spawn;
21
+ #fs;
22
+ #platform;
23
+ #tempRoot;
24
+ #killAfterMs;
25
+ #closeAfterMs;
26
+ #tail = Promise.resolve();
27
+ #requests = /* @__PURE__ */ new Set();
28
+ #active = null;
29
+ #unclosed = null;
30
+ #state = "idle";
31
+ #lastError = null;
32
+ constructor({
33
+ spawn = nodeSpawn,
34
+ fs = nodeFs,
35
+ platform = process.platform,
36
+ tempRoot = tmpdir(),
37
+ killAfterMs = 250,
38
+ closeAfterMs = 1e3
39
+ } = {}) {
40
+ for (const value of [killAfterMs, closeAfterMs]) {
41
+ if (!Number.isFinite(value) || value < 0 || value > 2147483647) {
42
+ throw new TypeError("Cancellation deadlines must be finite nonnegative milliseconds");
43
+ }
44
+ }
45
+ this.#spawn = spawn;
46
+ this.#fs = fs;
47
+ this.#platform = platform;
48
+ this.#tempRoot = tempRoot;
49
+ this.#killAfterMs = killAfterMs;
50
+ this.#closeAfterMs = closeAfterMs;
51
+ }
52
+ get state() {
53
+ return this.#state;
54
+ }
55
+ get lastError() {
56
+ return this.#lastError;
57
+ }
58
+ /** Checks the host executable, not browser support or installed voices. */
59
+ async getCapabilities() {
60
+ if (this.#platform !== "darwin") {
61
+ return { supported: false, pause: false, resume: false, reason: "unsupported-platform" };
62
+ }
63
+ try {
64
+ await this.#fs.access("/usr/bin/say", constants.X_OK);
65
+ return { supported: true, pause: true, resume: true, reason: null };
66
+ } catch {
67
+ return { supported: false, pause: false, resume: false, reason: "executable-unavailable" };
68
+ }
69
+ }
70
+ speak(text, { voice, rate, signal } = {}) {
71
+ if (typeof text !== "string") return Promise.reject(new TypeError("text must be a string"));
72
+ if (voice !== void 0 && (typeof voice !== "string" || !voice.trim() || voice.includes("\0"))) {
73
+ return Promise.reject(new TypeError("voice must be a nonempty string without NUL"));
74
+ }
75
+ if (rate !== void 0 && (!Number.isFinite(rate) || rate <= 0)) {
76
+ return Promise.reject(new TypeError("rate must be a positive finite number"));
77
+ }
78
+ if (signal !== void 0 && (signal === null || typeof signal.addEventListener !== "function" || typeof signal.removeEventListener !== "function" || typeof signal.aborted !== "boolean")) {
79
+ return Promise.reject(new TypeError("signal must be an AbortSignal"));
80
+ }
81
+ for (const request2 of this.#requests) this.#cancel(request2);
82
+ const request = { cancelled: false, reason: void 0, cancelChild: null };
83
+ const onAbort = () => this.#cancel(request, signal.reason);
84
+ this.#requests.add(request);
85
+ signal?.addEventListener("abort", onAbort, { once: true });
86
+ if (signal?.aborted) onAbort();
87
+ const result = this.#tail.then(() => this.#run(request, text, voice, rate));
88
+ const settled = result.finally(() => {
89
+ signal?.removeEventListener("abort", onAbort);
90
+ this.#requests.delete(request);
91
+ });
92
+ this.#tail = settled.catch(() => {
93
+ });
94
+ return settled;
95
+ }
96
+ /** Resolves after queued requests and cleanup; rejects teardown failures. */
97
+ async stop() {
98
+ for (const request of this.#requests) this.#cancel(request);
99
+ const pending = this.#tail;
100
+ await pending;
101
+ if (this.#unclosed || this.#state === "error") throw this.#lastError;
102
+ }
103
+ pause() {
104
+ return this.#control("speaking", "paused", "SIGSTOP");
105
+ }
106
+ resume() {
107
+ return this.#control("paused", "speaking", "SIGCONT");
108
+ }
109
+ #control(from, to, signal) {
110
+ if (this.#platform !== "darwin" || this.#state !== from || !this.#active?.child || this.#active.cancelled)
111
+ return false;
112
+ try {
113
+ if (!this.#active.child.kill(signal))
114
+ throw failure("Unable to signal speech process", "SAY_SIGNAL_FAILED");
115
+ this.#state = to;
116
+ return true;
117
+ } catch (error) {
118
+ this.#lastError = error;
119
+ return false;
120
+ }
121
+ }
122
+ #cancel(request, reason) {
123
+ if (request.cancelled) return;
124
+ request.cancelled = true;
125
+ request.reason = reason;
126
+ request.cancelChild?.();
127
+ }
128
+ async #run(request, text, voice, rate) {
129
+ let directory;
130
+ let error;
131
+ const check = () => {
132
+ if (request.cancelled) throw aborted(request.reason);
133
+ };
134
+ try {
135
+ check();
136
+ if (this.#unclosed)
137
+ throw failure("Previous speech process has not closed", "SAY_PROCESS_UNCLOSED");
138
+ this.#active = request;
139
+ this.#state = "preparing";
140
+ this.#lastError = null;
141
+ const capability = await this.getCapabilities();
142
+ check();
143
+ if (!capability.supported) throw failure("macOS say is unavailable", "SAY_UNAVAILABLE");
144
+ directory = await this.#fs.mkdtemp(join(this.#tempRoot, "dsh-live-voice-say-"));
145
+ check();
146
+ await this.#fs.chmod(directory, 448);
147
+ const file = join(directory, "speech.txt");
148
+ await this.#fs.writeFile(file, text, { encoding: "utf8", mode: 384, flag: "wx" });
149
+ await this.#fs.chmod(file, 384);
150
+ check();
151
+ const args = ["-f", file];
152
+ if (voice !== void 0) args.push("-v", voice);
153
+ if (rate !== void 0) args.push("-r", String(rate));
154
+ const child = this.#spawn("/usr/bin/say", args, { shell: false, stdio: "ignore" });
155
+ request.child = child;
156
+ this.#state = "speaking";
157
+ await this.#waitForClose(request, child);
158
+ check();
159
+ } catch (caught) {
160
+ error = caught;
161
+ } finally {
162
+ request.cancelChild = null;
163
+ if (directory) {
164
+ try {
165
+ await this.#fs.rm(directory, { recursive: true, force: true });
166
+ } catch (cleanupError) {
167
+ error = failure(
168
+ "Speech temporary-file cleanup failed",
169
+ "SAY_CLEANUP_FAILED",
170
+ error ? new AggregateError([error, cleanupError]) : cleanupError
171
+ );
172
+ }
173
+ }
174
+ if (!error && request.cancelled) error = aborted(request.reason);
175
+ if (this.#active === request) this.#active = null;
176
+ if (error && error.name !== "AbortError") {
177
+ this.#state = "error";
178
+ this.#lastError = error;
179
+ } else if (!this.#unclosed && this.#state !== "error") this.#state = "idle";
180
+ }
181
+ if (error) throw error;
182
+ }
183
+ #waitForClose(request, child) {
184
+ return new Promise((resolve, reject) => {
185
+ let closed = false;
186
+ let processError;
187
+ let killTimer;
188
+ let closeTimer;
189
+ const send = (signal) => {
190
+ try {
191
+ if (!child.kill(signal))
192
+ processError ??= failure("Unable to signal speech process", "SAY_SIGNAL_FAILED");
193
+ } catch (error) {
194
+ processError ??= error;
195
+ }
196
+ };
197
+ const onError = (error) => {
198
+ processError ??= error;
199
+ };
200
+ child.on("error", onError);
201
+ child.once("close", (code, signal) => {
202
+ closed = true;
203
+ clearTimeout(killTimer);
204
+ clearTimeout(closeTimer);
205
+ child.removeListener("error", onError);
206
+ if (this.#unclosed === child) this.#unclosed = null;
207
+ if (request.cancelled) reject(aborted(request.reason));
208
+ else if (processError) reject(processError);
209
+ else if (code !== 0)
210
+ reject(
211
+ failure("Speech process exited unsuccessfully", "SAY_EXIT_FAILED", { code, signal })
212
+ );
213
+ else resolve();
214
+ });
215
+ request.cancelChild = () => {
216
+ if (closed) return;
217
+ const paused = this.#state === "paused";
218
+ this.#state = "stopping";
219
+ if (paused) send("SIGCONT");
220
+ send("SIGTERM");
221
+ if (closed) return;
222
+ killTimer = setTimeout(() => {
223
+ send("SIGKILL");
224
+ if (closed) return;
225
+ closeTimer = setTimeout(() => {
226
+ if (closed) return;
227
+ this.#unclosed = child;
228
+ reject(
229
+ failure(
230
+ "Speech process did not close after cancellation",
231
+ "SAY_STOP_TIMEOUT",
232
+ processError
233
+ )
234
+ );
235
+ }, this.#closeAfterMs);
236
+ }, this.#killAfterMs);
237
+ };
238
+ if (request.cancelled) request.cancelChild();
239
+ });
240
+ }
241
+ };
242
+
243
+ // src/engines/recognition/whisper-http-host.ts
244
+ import { readFile, mkdir, writeFile, rename, rm } from "node:fs/promises";
245
+ import { homedir } from "node:os";
246
+ import { dirname, join as join2 } from "node:path";
247
+ import { randomUUID } from "node:crypto";
248
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
249
+ function validateWhisperConfig(value) {
250
+ if (!value || typeof value.url !== "string" || typeof value.healthUrl !== "string")
251
+ throw new Error("Endpoint URL and health URL/path are required.");
252
+ const url = resolveWhisperUrl(value.url.trim());
253
+ const health = value.healthUrl.trim();
254
+ if (!health) throw new Error("Health URL/path is required.");
255
+ resolveWhisperUrl(new URL(health, url).href);
256
+ if (!Number.isInteger(value.timeoutMs) || value.timeoutMs < 100 || value.timeoutMs > 3e5)
257
+ throw new Error("Request timeout must be an integer between 100 and 300000 ms.");
258
+ return { url: url.href, healthUrl: health, timeoutMs: value.timeoutMs };
259
+ }
260
+ function createWhisperConfigStore(path = join2(homedir(), ".dsh", "dsh-live-voice-whisper.json")) {
261
+ return {
262
+ async load() {
263
+ try {
264
+ return JSON.parse(await readFile(path, "utf8"));
265
+ } catch (error) {
266
+ if (error.code === "ENOENT") return null;
267
+ throw new Error("Cannot read persisted Whisper settings: " + error.message);
268
+ }
269
+ },
270
+ async save(config) {
271
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
272
+ const temporary = path + "." + randomUUID() + ".tmp";
273
+ try {
274
+ await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
275
+ mode: 384,
276
+ flag: "wx"
277
+ });
278
+ await rename(temporary, path);
279
+ } finally {
280
+ await rm(temporary, { force: true });
281
+ }
282
+ }
283
+ };
284
+ }
285
+ var clean = (value) => String(value ?? "").trim();
286
+ function resolveWhisperUrl(value = process.env.DSH_LIVE_VOICE_WHISPER_URL || "http://127.0.0.1:8080/inference") {
287
+ const url = new URL(value);
288
+ if (url.protocol !== "http:" || !LOOPBACK.has(url.hostname) || url.username || url.password || url.hash)
289
+ throw new Error("Whisper HTTP URL must be an unauthenticated loopback http URL.");
290
+ return url;
291
+ }
292
+ function validateMonoPcm16Wav(input, { maxBytes = 2e6 } = {}) {
293
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
294
+ if (bytes.byteLength < 44 || bytes.byteLength > maxBytes)
295
+ throw new Error("Invalid or oversized WAV recording.");
296
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), ascii = (at2, n) => String.fromCharCode(...bytes.subarray(at2, at2 + n));
297
+ if (ascii(0, 4) !== "RIFF" || ascii(8, 4) !== "WAVE" || view.getUint32(4, true) + 8 !== bytes.byteLength)
298
+ throw new Error("Invalid WAV container.");
299
+ let at = 12, fmt = null, data = null;
300
+ while (at + 8 <= bytes.byteLength) {
301
+ const id = ascii(at, 4), size = view.getUint32(at + 4, true), start = at + 8, end = start + size;
302
+ if (end > bytes.byteLength) throw new Error("Invalid WAV chunk.");
303
+ if (id === "fmt ") fmt = { start, size };
304
+ if (id === "data") {
305
+ if (data) throw new Error("Multiple WAV data chunks are unsupported.");
306
+ data = { start, size };
307
+ }
308
+ at = end + (size & 1);
309
+ }
310
+ if (!fmt || fmt.size < 16 || !data || at !== bytes.byteLength)
311
+ throw new Error("Incomplete WAV recording.");
312
+ const f = fmt.start;
313
+ if (view.getUint16(f, true) !== 1 || view.getUint16(f + 2, true) !== 1 || view.getUint32(f + 4, true) !== 16e3 || view.getUint32(f + 8, true) !== 32e3 || view.getUint16(f + 12, true) !== 2 || view.getUint16(f + 14, true) !== 16 || data.size < 2 || data.size % 2)
314
+ throw new Error("WAV must be mono 16 kHz PCM16.");
315
+ return bytes;
316
+ }
317
+ var WhisperHttpHost = class {
318
+ constructor({
319
+ url,
320
+ healthUrl = "/health",
321
+ timeoutMs = 3e4,
322
+ fetchImpl = globalThis.fetch,
323
+ maxBytes = 2e6,
324
+ store
325
+ } = {}) {
326
+ this.config = validateWhisperConfig({ url: resolveWhisperUrl(url).href, healthUrl, timeoutMs });
327
+ this.fetch = fetchImpl;
328
+ this.maxBytes = maxBytes;
329
+ this.store = store;
330
+ this.active = new AbortController();
331
+ this.queue = Promise.resolve();
332
+ this.ready = Promise.resolve().then(async () => {
333
+ const saved = await store?.load();
334
+ if (saved) this.config = validateWhisperConfig(saved);
335
+ }).catch((error) => {
336
+ this.loadError = error;
337
+ });
338
+ }
339
+ get url() {
340
+ return new URL(this.config.url);
341
+ }
342
+ async getConfig() {
343
+ await this.ready;
344
+ if (this.loadError) throw this.loadError;
345
+ return { ...this.config };
346
+ }
347
+ replaceConfig(value) {
348
+ const next = validateWhisperConfig(value);
349
+ const operation = this.queue.then(async () => {
350
+ await this.ready;
351
+ await this.store?.save(next);
352
+ this.active.abort();
353
+ this.active = new AbortController();
354
+ this.config = next;
355
+ this.loadError = null;
356
+ return { ...next };
357
+ });
358
+ this.queue = operation.catch(() => {
359
+ });
360
+ return operation;
361
+ }
362
+ dispose() {
363
+ this.active.abort();
364
+ }
365
+ async request(url, options, config, consume = (response) => response) {
366
+ const timeout = AbortSignal.timeout(config.timeoutMs);
367
+ const signal = AbortSignal.any([
368
+ timeout,
369
+ this.active.signal,
370
+ ...options.signal ? [options.signal] : []
371
+ ]);
372
+ const response = await this.fetch(url, { ...options, signal, redirect: "error" });
373
+ const value = await consume(response);
374
+ signal.throwIfAborted();
375
+ return value;
376
+ }
377
+ async capability(signal, configuration) {
378
+ try {
379
+ const config = configuration ? validateWhisperConfig(configuration) : await this.getConfig();
380
+ const response = await this.request(
381
+ new URL(config.healthUrl, config.url),
382
+ { signal },
383
+ config
384
+ );
385
+ return response.ok ? { supported: true, local: true, streaming: false, maxBytes: this.maxBytes } : {
386
+ supported: false,
387
+ local: true,
388
+ streaming: false,
389
+ reason: "Whisper HTTP health check failed (" + response.status + ")."
390
+ };
391
+ } catch (error) {
392
+ return {
393
+ supported: false,
394
+ local: true,
395
+ streaming: false,
396
+ reason: "Whisper HTTP server is unreachable: " + (error?.message || error)
397
+ };
398
+ }
399
+ }
400
+ async transcribe(input, { lang = "auto", signal } = {}) {
401
+ const bytes = validateMonoPcm16Wav(input, { maxBytes: this.maxBytes }), form = new FormData();
402
+ form.append("file", new Blob([bytes], { type: "audio/wav" }), "utterance.wav");
403
+ form.append("response_format", "json");
404
+ form.append("language", lang.startsWith("pt") ? "pt" : lang.startsWith("en") ? "en" : "auto");
405
+ const config = await this.getConfig();
406
+ return this.request(
407
+ new URL(config.url),
408
+ { method: "POST", body: form, signal },
409
+ config,
410
+ async (response) => {
411
+ if (!response.ok)
412
+ throw new Error("Whisper HTTP transcription failed (" + response.status + ").");
413
+ const json = await response.json();
414
+ return { text: clean(json?.text) };
415
+ }
416
+ );
417
+ }
418
+ };
419
+
420
+ // src/engines/qwen-http-host.ts
421
+ import { readFile as readFile2, mkdir as mkdir2, writeFile as writeFile2, rename as rename2, rm as rm2 } from "node:fs/promises";
422
+ import { homedir as homedir2 } from "node:os";
423
+ import { dirname as dirname2, join as join3 } from "node:path";
424
+ import { randomUUID as randomUUID2 } from "node:crypto";
425
+
426
+ // src/core/settings.ts
427
+ var voiceDetectionPresets = Object.freeze({
428
+ short: Object.freeze({
429
+ silenceMs: 900,
430
+ label: "Short",
431
+ description: "Send quickly after a short pause."
432
+ }),
433
+ natural: Object.freeze({
434
+ silenceMs: 1500,
435
+ label: "Natural",
436
+ description: "Allow normal pauses between phrases."
437
+ }),
438
+ long: Object.freeze({
439
+ silenceMs: 2200,
440
+ label: "Long",
441
+ description: "Wait through longer thinking pauses."
442
+ })
443
+ });
444
+ var qwenVoices = Object.freeze([
445
+ Object.freeze({ value: "aiden", label: "Aiden \u2014 male, American English" }),
446
+ Object.freeze({ value: "ryan", label: "Ryan \u2014 male, English" }),
447
+ Object.freeze({ value: "uncle_fu", label: "Uncle Fu \u2014 male, Chinese" }),
448
+ Object.freeze({ value: "dylan", label: "Dylan \u2014 male, Beijing Chinese" }),
449
+ Object.freeze({ value: "eric", label: "Eric \u2014 male, Sichuan Chinese" }),
450
+ Object.freeze({ value: "vivian", label: "Vivian \u2014 female, Chinese" }),
451
+ Object.freeze({ value: "serena", label: "Serena \u2014 female, Chinese" }),
452
+ Object.freeze({ value: "ono_anna", label: "Ono Anna \u2014 female, Japanese" }),
453
+ Object.freeze({ value: "sohee", label: "Sohee \u2014 female, Korean" })
454
+ ]);
455
+ var defaultQwenVoice = qwenVoices[0].value;
456
+ var isQwenVoice = (value) => qwenVoices.some((voice) => voice.value === value);
457
+ var defaultSettings = Object.freeze({
458
+ engine: "browser",
459
+ recognitionEngine: "browser",
460
+ recognitionProcessLocally: true,
461
+ recognitionAutoInstall: true,
462
+ voiceDetectionPreset: "natural",
463
+ announceAssistantMessages: true,
464
+ interruptSpeechOnUserMessage: false,
465
+ sendingMode: "manual",
466
+ autoSendDelaySeconds: 4,
467
+ assistantSpeechDelaySeconds: 3,
468
+ mode: "speaker",
469
+ lang: "pt-BR",
470
+ recognitionLang: "pt-BR",
471
+ voice: "",
472
+ rate: 1
473
+ });
474
+
475
+ // src/engines/qwen-http-host.ts
476
+ var clean2 = (value) => String(value ?? "").trim();
477
+ function resolveQwenBaseUrl(value = process.env.DSH_LIVE_VOICE_QWEN_URL || "http://127.0.0.1:8080/") {
478
+ const url = new URL(value);
479
+ if (!["http:", "https:"].includes(url.protocol))
480
+ throw new Error("Qwen API URL must use HTTP or HTTPS.");
481
+ url.pathname = url.pathname.replace(/\/*$/, "/");
482
+ url.search = "";
483
+ url.hash = "";
484
+ return url;
485
+ }
486
+ function validateQwenConfig(value) {
487
+ if (!value || typeof value.baseUrl !== "string")
488
+ throw new Error("Qwen API base URL is required.");
489
+ const baseUrl = resolveQwenBaseUrl(value.baseUrl.trim());
490
+ if (!Number.isInteger(value.timeoutMs) || value.timeoutMs < 1e3 || value.timeoutMs > 6e5)
491
+ throw new Error("Request timeout must be an integer between 1000 and 600000 ms.");
492
+ return { baseUrl: baseUrl.href, timeoutMs: value.timeoutMs };
493
+ }
494
+ function createQwenConfigStore(path = join3(homedir2(), ".dsh", "dsh-live-voice-qwen.json")) {
495
+ return {
496
+ async load() {
497
+ try {
498
+ return JSON.parse(await readFile2(path, "utf8"));
499
+ } catch (error) {
500
+ if (error.code === "ENOENT") return null;
501
+ throw new Error("Cannot read persisted Qwen settings: " + error.message);
502
+ }
503
+ },
504
+ async save(config) {
505
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
506
+ const temporary = path + "." + randomUUID2() + ".tmp";
507
+ try {
508
+ await writeFile2(temporary, JSON.stringify(config, null, 2) + "\n", {
509
+ mode: 384,
510
+ flag: "wx"
511
+ });
512
+ await rename2(temporary, path);
513
+ } finally {
514
+ await rm2(temporary, { force: true });
515
+ }
516
+ }
517
+ };
518
+ }
519
+ var language = (value) => value === "auto" ? void 0 : value?.toLowerCase().startsWith("pt") ? "portuguese" : value?.toLowerCase().startsWith("en") ? "english" : value;
520
+ var QwenHttpHost = class {
521
+ constructor({
522
+ baseUrl,
523
+ timeoutMs = 3e5,
524
+ fetchImpl = globalThis.fetch,
525
+ maxBytes = 2e6,
526
+ maxSpeechBytes = 5e7,
527
+ store
528
+ } = {}) {
529
+ this.config = validateQwenConfig({ baseUrl: resolveQwenBaseUrl(baseUrl).href, timeoutMs });
530
+ this.fetch = fetchImpl;
531
+ this.maxBytes = maxBytes;
532
+ this.maxSpeechBytes = maxSpeechBytes;
533
+ this.store = store;
534
+ this.active = new AbortController();
535
+ this.queue = Promise.resolve();
536
+ this.ready = Promise.resolve().then(async () => {
537
+ const saved = await store?.load();
538
+ if (saved) this.config = validateQwenConfig(saved);
539
+ }).catch((error) => {
540
+ this.loadError = error;
541
+ });
542
+ }
543
+ async getConfig() {
544
+ await this.ready;
545
+ if (this.loadError) throw this.loadError;
546
+ return { ...this.config };
547
+ }
548
+ replaceConfig(value) {
549
+ const next = validateQwenConfig(value);
550
+ const operation = this.queue.then(async () => {
551
+ await this.ready;
552
+ await this.store?.save(next);
553
+ this.active.abort();
554
+ this.active = new AbortController();
555
+ this.config = next;
556
+ this.loadError = null;
557
+ return { ...next };
558
+ });
559
+ this.queue = operation.catch(() => {
560
+ });
561
+ return operation;
562
+ }
563
+ dispose() {
564
+ this.active.abort();
565
+ }
566
+ async request(path, options, configuration, consume = (response) => response) {
567
+ const config = configuration ? validateQwenConfig(configuration) : await this.getConfig();
568
+ const timeout = AbortSignal.timeout(config.timeoutMs);
569
+ const signal = AbortSignal.any([
570
+ timeout,
571
+ this.active.signal,
572
+ ...options.signal ? [options.signal] : []
573
+ ]);
574
+ const response = await this.fetch(new URL(path, config.baseUrl), {
575
+ ...options,
576
+ signal,
577
+ redirect: "error"
578
+ });
579
+ const value = await consume(response);
580
+ signal.throwIfAborted();
581
+ return value;
582
+ }
583
+ async serverInfo(signal, configuration) {
584
+ const response = await this.request("health", { signal }, configuration, async (response2) => ({
585
+ ok: response2.ok,
586
+ status: response2.status,
587
+ body: response2.ok ? await response2.json() : null
588
+ }));
589
+ return { ...response, ominix: response.body?.service === "ominix-api" };
590
+ }
591
+ async capability(signal, configuration, kind = "both") {
592
+ try {
593
+ const config = configuration ? validateQwenConfig(configuration) : await this.getConfig();
594
+ const health = await this.serverInfo(signal, config);
595
+ let models = health.body?.models;
596
+ if (health.ominix && health.ok) {
597
+ const status = await this.request(
598
+ "v1/models/status",
599
+ { signal },
600
+ config,
601
+ async (response) => ({
602
+ ok: response.ok,
603
+ status: response.status,
604
+ body: response.ok ? await response.json() : null
605
+ })
606
+ );
607
+ models = {
608
+ asr: status.body?.models?.asr === "qwen3-asr",
609
+ tts: status.body?.models?.qwen3_tts === "customvoice"
610
+ };
611
+ }
612
+ const ready = kind === "asr" ? models?.asr === true : kind === "tts" ? models?.tts === true : models?.asr === true && models?.tts === true;
613
+ return health.ok && ready ? { supported: true, local: true, location: "host", streaming: false, models } : {
614
+ supported: false,
615
+ local: true,
616
+ location: "host",
617
+ streaming: false,
618
+ reason: `Qwen health check did not report ${kind === "both" ? "both ASR and TTS" : kind.toUpperCase()} ready (${health.status}).`
619
+ };
620
+ } catch (error) {
621
+ return {
622
+ supported: false,
623
+ local: true,
624
+ location: "host",
625
+ streaming: false,
626
+ reason: "Qwen speech server is unreachable: " + (error?.message || error)
627
+ };
628
+ }
629
+ }
630
+ async transcribe(input, { lang = "pt-BR", signal } = {}) {
631
+ const bytes = validateMonoPcm16Wav(input, { maxBytes: this.maxBytes }), resolved = language(lang), health = await this.serverInfo(signal);
632
+ let body, headers;
633
+ if (health.ominix) {
634
+ headers = { "content-type": "application/json" };
635
+ body = JSON.stringify({
636
+ file: Buffer.from(bytes).toString("base64"),
637
+ language: resolved,
638
+ response_format: "json"
639
+ });
640
+ } else {
641
+ const form = new FormData();
642
+ form.append("file", new Blob([bytes], { type: "audio/wav" }), "utterance.wav");
643
+ form.append("response_format", "json");
644
+ if (resolved) form.append("language", resolved);
645
+ body = form;
646
+ }
647
+ return this.request(
648
+ "v1/audio/transcriptions",
649
+ { method: "POST", headers, body, signal },
650
+ void 0,
651
+ async (response) => {
652
+ if (!response.ok) throw new Error("Qwen transcription failed (" + response.status + ").");
653
+ const json = await response.json();
654
+ return { text: clean2(json?.text) };
655
+ }
656
+ );
657
+ }
658
+ async synthesize(text, { lang = "pt-BR", signal, voice = defaultQwenVoice } = {}) {
659
+ if (typeof text !== "string" || !text.trim() || text.length > 1e5 || text.includes("\0"))
660
+ throw new Error("Speech text must contain 1\u2013100000 characters without NUL.");
661
+ if (!isQwenVoice(voice)) throw new Error("Unsupported Qwen voice.");
662
+ return this.request(
663
+ "v1/audio/speech",
664
+ {
665
+ method: "POST",
666
+ headers: { "content-type": "application/json" },
667
+ body: JSON.stringify({
668
+ model: "qwen3-tts",
669
+ input: text,
670
+ voice,
671
+ language: language(lang) || "portuguese",
672
+ response_format: "wav"
673
+ }),
674
+ signal
675
+ },
676
+ void 0,
677
+ async (response) => {
678
+ if (!response.ok) throw new Error("Qwen synthesis failed (" + response.status + ").");
679
+ const length = Number(response.headers.get("content-length") || 0);
680
+ if (length > this.maxSpeechBytes) throw new Error("Qwen speech response is too large.");
681
+ const bytes = await response.arrayBuffer();
682
+ if (bytes.byteLength < 44 || bytes.byteLength > this.maxSpeechBytes)
683
+ throw new Error("Qwen returned invalid or oversized speech audio.");
684
+ return bytes;
685
+ }
686
+ );
687
+ }
688
+ };
689
+
690
+ // src/server.ts
691
+ var name = "dsh-live-voice";
692
+ var inject = ["connection"];
693
+ var SAY_CHANNEL = "/api/dsh-live-voice";
694
+ var ok = (value) => ({ ok: true, value });
695
+ var fail = (code, message) => ({ ok: false, error: { code, message, details: {} } });
696
+ var identity = (value) => typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value);
697
+ function createSayHost({ engine = new SayEngine() } = {}) {
698
+ let active = null;
699
+ let disposed = false;
700
+ async function handle(endpoint, payload, signal) {
701
+ if (disposed) return fail("disposed", "Speech service is closed.");
702
+ if (signal?.aborted) return fail("cancelled", "Speech was cancelled.");
703
+ try {
704
+ if (endpoint === "capabilities") return ok(await engine.getCapabilities());
705
+ if (!["speak", "stop", "pause", "resume"].includes(endpoint))
706
+ return fail("not-found", "Unknown speech endpoint.");
707
+ if (!payload || !identity(payload.clientId) || !identity(payload.operationId)) {
708
+ return fail("invalid-request", "Valid client and operation IDs are required.");
709
+ }
710
+ const owns = active?.clientId === payload.clientId && active?.operationId === payload.operationId;
711
+ if (endpoint !== "speak") {
712
+ if (!owns) return ok({ applied: false });
713
+ const operation2 = active;
714
+ if (endpoint === "stop") {
715
+ operation2.abort.abort();
716
+ await operation2.done;
717
+ return ok({ applied: true });
718
+ }
719
+ return ok({ applied: Boolean(engine[endpoint]()) });
720
+ }
721
+ if (typeof payload.text !== "string" || !payload.text.trim() || payload.text.length > 1e5 || payload.text.includes("\0"))
722
+ return fail("invalid-request", "Speech text must contain 1\u2013100000 characters without NUL.");
723
+ if (payload.voice !== void 0 && (typeof payload.voice !== "string" || !payload.voice.trim() || payload.voice.length > 200 || payload.voice.includes("\0")))
724
+ return fail("invalid-request", "Invalid voice.");
725
+ if (!Number.isFinite(payload.rate) || payload.rate < 18 || payload.rate > 1750)
726
+ return fail("invalid-request", "Invalid speech rate.");
727
+ if (active && active.clientId !== payload.clientId)
728
+ return fail("busy", "Another voice client owns the host speaker.");
729
+ if (owns) return fail("duplicate-operation", "The speech operation is already active.");
730
+ active?.abort.abort();
731
+ const operation = {
732
+ clientId: payload.clientId,
733
+ operationId: payload.operationId,
734
+ abort: new AbortController(),
735
+ done: null
736
+ };
737
+ active = operation;
738
+ const cancel = () => operation.abort.abort();
739
+ signal?.addEventListener("abort", cancel, { once: true });
740
+ if (signal?.aborted) cancel();
741
+ operation.done = Promise.resolve().then(
742
+ () => engine.speak(payload.text, {
743
+ voice: payload.voice,
744
+ rate: payload.rate,
745
+ signal: operation.abort.signal
746
+ })
747
+ ).then(
748
+ () => ok({ completed: true }),
749
+ (error) => error?.name === "AbortError" ? fail("cancelled", "Speech was cancelled.") : fail("speech-failed", "Local speech failed.")
750
+ );
751
+ try {
752
+ return await operation.done;
753
+ } finally {
754
+ signal?.removeEventListener("abort", cancel);
755
+ if (active === operation) active = null;
756
+ }
757
+ } catch {
758
+ return fail("speech-failed", "Local speech service failed.");
759
+ }
760
+ }
761
+ async function dispose() {
762
+ disposed = true;
763
+ active?.abort.abort();
764
+ await engine.stop();
765
+ active = null;
766
+ }
767
+ return { handle, dispose };
768
+ }
769
+ function apply(ctx, {
770
+ whisperStore = createWhisperConfigStore(),
771
+ whisperFetch = globalThis.fetch,
772
+ qwenStore = createQwenConfigStore(),
773
+ qwenFetch = globalThis.fetch
774
+ } = {}) {
775
+ const host = createSayHost();
776
+ const whisper = new WhisperHttpHost({ store: whisperStore, fetchImpl: whisperFetch });
777
+ const qwen = new QwenHttpHost({ store: qwenStore, fetchImpl: qwenFetch });
778
+ for (const endpoint of ["config", "test"]) {
779
+ const dispose = ctx.connection.fetch.register({
780
+ path: SAY_CHANNEL + "/whisper/" + endpoint,
781
+ methods: endpoint === "config" ? ["GET", "PUT"] : ["POST"],
782
+ requestBody: "buffered",
783
+ fetch: async (request) => {
784
+ try {
785
+ if (request.method === "GET") return Response.json(ok(await whisper.getConfig()));
786
+ let config;
787
+ try {
788
+ config = validateWhisperConfig(await request.json());
789
+ } catch (error) {
790
+ return Response.json(fail("invalid-config", error.message), { status: 400 });
791
+ }
792
+ const value = endpoint === "test" ? await whisper.capability(request.signal, config) : await whisper.replaceConfig(config);
793
+ return Response.json(ok(value));
794
+ } catch (error) {
795
+ return Response.json(
796
+ fail("config-failed", error.message || "Whisper settings could not be saved."),
797
+ { status: 500 }
798
+ );
799
+ }
800
+ }
801
+ });
802
+ ctx.effect(() => () => dispose(), "dsh-live-voice: remove Whisper " + endpoint + " route");
803
+ }
804
+ ctx.effect(() => () => whisper.dispose(), "dsh-live-voice: cancel Whisper requests");
805
+ const whisperCapability = ctx.connection.fetch.register({
806
+ path: SAY_CHANNEL + "/whisper/capabilities",
807
+ methods: ["GET"],
808
+ requestBody: "buffered",
809
+ fetch: async (request) => Response.json(ok(await whisper.capability(request.signal)))
810
+ });
811
+ const whisperTranscribe = ctx.connection.fetch.register({
812
+ path: SAY_CHANNEL + "/whisper/transcribe",
813
+ methods: ["POST"],
814
+ requestBody: "buffered",
815
+ fetch: async (request) => {
816
+ try {
817
+ const clientId = request.headers.get("x-dlv-client-id"), operationId = request.headers.get("x-dlv-operation-id");
818
+ if (!identity(clientId) || !identity(operationId))
819
+ return Response.json(
820
+ fail("invalid-request", "Valid client and operation IDs are required."),
821
+ { status: 400 }
822
+ );
823
+ if (request.headers.get("content-type")?.split(";")[0] !== "audio/wav")
824
+ return Response.json(
825
+ fail("invalid-audio", "A mono 16 kHz PCM WAV recording is required."),
826
+ { status: 400 }
827
+ );
828
+ const length = Number(request.headers.get("content-length") || 0);
829
+ if (length > whisper.maxBytes)
830
+ return Response.json(
831
+ fail("audio-too-large", "The recording exceeds the configured Whisper limit."),
832
+ { status: 413 }
833
+ );
834
+ const value = await whisper.transcribe(await request.arrayBuffer(), {
835
+ lang: request.headers.get("x-dlv-language") || "auto",
836
+ signal: request.signal
837
+ });
838
+ return Response.json(ok(value));
839
+ } catch (error) {
840
+ if (error?.name === "AbortError")
841
+ return Response.json(fail("cancelled", "Whisper transcription was cancelled."), {
842
+ status: 499
843
+ });
844
+ return Response.json(
845
+ fail("transcription-failed", error?.message || "Whisper transcription failed."),
846
+ { status: 502 }
847
+ );
848
+ }
849
+ }
850
+ });
851
+ ctx.effect(
852
+ () => async () => {
853
+ await whisperCapability();
854
+ await whisperTranscribe();
855
+ },
856
+ "dsh-live-voice: remove Whisper routes"
857
+ );
858
+ for (const endpoint of ["config", "test"]) {
859
+ const dispose = ctx.connection.fetch.register({
860
+ path: SAY_CHANNEL + "/qwen/" + endpoint,
861
+ methods: endpoint === "config" ? ["GET", "PUT"] : ["POST"],
862
+ requestBody: "buffered",
863
+ fetch: async (request) => {
864
+ try {
865
+ if (request.method === "GET") return Response.json(ok(await qwen.getConfig()));
866
+ let config;
867
+ try {
868
+ config = validateQwenConfig(await request.json());
869
+ } catch (error) {
870
+ return Response.json(fail("invalid-config", error.message), { status: 400 });
871
+ }
872
+ const value = endpoint === "test" ? await qwen.capability(request.signal, config) : await qwen.replaceConfig(config);
873
+ return Response.json(ok(value));
874
+ } catch (error) {
875
+ return Response.json(
876
+ fail("config-failed", error.message || "Qwen settings could not be saved."),
877
+ { status: 500 }
878
+ );
879
+ }
880
+ }
881
+ });
882
+ ctx.effect(() => () => dispose(), "dsh-live-voice: remove Qwen " + endpoint + " route");
883
+ }
884
+ ctx.effect(() => () => qwen.dispose(), "dsh-live-voice: cancel Qwen requests");
885
+ const qwenCapability = ctx.connection.fetch.register({
886
+ path: SAY_CHANNEL + "/qwen/capabilities",
887
+ methods: ["GET"],
888
+ requestBody: "buffered",
889
+ fetch: async (request) => {
890
+ const kind = new URL(request.url).searchParams.get("kind");
891
+ return Response.json(
892
+ ok(
893
+ await qwen.capability(
894
+ request.signal,
895
+ void 0,
896
+ kind === "asr" || kind === "tts" ? kind : "both"
897
+ )
898
+ )
899
+ );
900
+ }
901
+ });
902
+ const qwenTranscribe = ctx.connection.fetch.register({
903
+ path: SAY_CHANNEL + "/qwen/transcribe",
904
+ methods: ["POST"],
905
+ requestBody: "buffered",
906
+ fetch: async (request) => {
907
+ try {
908
+ const clientId = request.headers.get("x-dlv-client-id"), operationId = request.headers.get("x-dlv-operation-id");
909
+ if (!identity(clientId) || !identity(operationId))
910
+ return Response.json(
911
+ fail("invalid-request", "Valid client and operation IDs are required."),
912
+ { status: 400 }
913
+ );
914
+ if (request.headers.get("content-type")?.split(";")[0] !== "audio/wav")
915
+ return Response.json(
916
+ fail("invalid-audio", "A mono 16 kHz PCM WAV recording is required."),
917
+ { status: 400 }
918
+ );
919
+ const length = Number(request.headers.get("content-length") || 0);
920
+ if (length > qwen.maxBytes)
921
+ return Response.json(
922
+ fail("audio-too-large", "The recording exceeds the configured Qwen limit."),
923
+ { status: 413 }
924
+ );
925
+ return Response.json(
926
+ ok(
927
+ await qwen.transcribe(await request.arrayBuffer(), {
928
+ lang: request.headers.get("x-dlv-language") || "pt-BR",
929
+ signal: request.signal
930
+ })
931
+ )
932
+ );
933
+ } catch (error) {
934
+ if (error?.name === "AbortError")
935
+ return Response.json(fail("cancelled", "Qwen transcription was cancelled."), {
936
+ status: 499
937
+ });
938
+ return Response.json(
939
+ fail("transcription-failed", error?.message || "Qwen transcription failed."),
940
+ { status: 502 }
941
+ );
942
+ }
943
+ }
944
+ });
945
+ const qwenSpeech = ctx.connection.fetch.register({
946
+ path: SAY_CHANNEL + "/qwen/speech",
947
+ methods: ["POST"],
948
+ requestBody: "buffered",
949
+ fetch: async (request) => {
950
+ try {
951
+ const body = await request.json();
952
+ const bytes = await qwen.synthesize(body?.text, {
953
+ lang: body?.lang || "pt-BR",
954
+ voice: body?.voice,
955
+ signal: request.signal
956
+ });
957
+ return new Response(bytes, {
958
+ status: 200,
959
+ headers: { "content-type": "audio/wav", "cache-control": "no-store" }
960
+ });
961
+ } catch (error) {
962
+ if (error?.name === "AbortError")
963
+ return Response.json(fail("cancelled", "Qwen synthesis was cancelled."), { status: 499 });
964
+ return Response.json(fail("synthesis-failed", error?.message || "Qwen synthesis failed."), {
965
+ status: 502
966
+ });
967
+ }
968
+ }
969
+ });
970
+ ctx.effect(
971
+ () => async () => {
972
+ await qwenCapability();
973
+ await qwenTranscribe();
974
+ await qwenSpeech();
975
+ },
976
+ "dsh-live-voice: remove Qwen routes"
977
+ );
978
+ for (const endpoint of ["capabilities", "speak", "stop", "pause", "resume"]) {
979
+ const dispose = ctx.connection.fetch.register({
980
+ path: `${SAY_CHANNEL}/${endpoint}`,
981
+ methods: ["POST"],
982
+ requestBody: "buffered",
983
+ fetch: async (request) => {
984
+ let body;
985
+ try {
986
+ body = await request.json();
987
+ } catch {
988
+ return new Response("Invalid JSON", { status: 400 });
989
+ }
990
+ if (body?.type !== "client-request" || typeof body.rpcId !== "string" || body.rpcId.length > 128 || body.method !== `dsh-live-voice/${endpoint}`)
991
+ return new Response("Invalid RPC envelope", { status: 400 });
992
+ const result = await host.handle(endpoint, body.payload, request.signal);
993
+ return Response.json({ type: "server-response", rpcId: body.rpcId, result });
994
+ }
995
+ });
996
+ ctx.effect(() => () => dispose(), `dsh-live-voice: remove ${endpoint} route`);
997
+ }
998
+ ctx.effect(() => () => host.dispose(), "dsh-live-voice: stop host speech");
999
+ }
1000
+ export {
1001
+ SAY_CHANNEL,
1002
+ apply,
1003
+ createSayHost,
1004
+ inject,
1005
+ name
1006
+ };