dsh-live-voice 0.2.1 → 0.2.3

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/client.js CHANGED
@@ -114,7 +114,9 @@ var defaultSettings = Object.freeze({
114
114
  recognitionProcessLocally: true,
115
115
  recognitionAutoInstall: true,
116
116
  voiceDetectionPreset: "natural",
117
+ recognitionMaxUtteranceSeconds: 60,
117
118
  microphoneEnabled: true,
119
+ holdToTalkEnabled: true,
118
120
  announceAssistantMessages: true,
119
121
  interruptSpeechOnUserMessage: false,
120
122
  sendingMode: "manual",
@@ -150,7 +152,9 @@ function normalizeSettings(value) {
150
152
  recognitionProcessLocally: typeof source.recognitionProcessLocally === "boolean" ? source.recognitionProcessLocally : defaultSettings.recognitionProcessLocally,
151
153
  recognitionAutoInstall: typeof source.recognitionAutoInstall === "boolean" ? source.recognitionAutoInstall : defaultSettings.recognitionAutoInstall,
152
154
  voiceDetectionPreset: Object.hasOwn(voiceDetectionPresets, source.voiceDetectionPreset) ? source.voiceDetectionPreset : defaultSettings.voiceDetectionPreset,
155
+ recognitionMaxUtteranceSeconds: Number.isInteger(source.recognitionMaxUtteranceSeconds) && source.recognitionMaxUtteranceSeconds >= 10 && source.recognitionMaxUtteranceSeconds <= 300 ? source.recognitionMaxUtteranceSeconds : defaultSettings.recognitionMaxUtteranceSeconds,
153
156
  microphoneEnabled: typeof source.microphoneEnabled === "boolean" ? source.microphoneEnabled : defaultSettings.microphoneEnabled,
157
+ holdToTalkEnabled: typeof source.holdToTalkEnabled === "boolean" ? source.holdToTalkEnabled : defaultSettings.holdToTalkEnabled,
154
158
  announceAssistantMessages: typeof source.announceAssistantMessages === "boolean" ? source.announceAssistantMessages : defaultSettings.announceAssistantMessages,
155
159
  interruptSpeechOnUserMessage: typeof source.interruptSpeechOnUserMessage === "boolean" ? source.interruptSpeechOnUserMessage : defaultSettings.interruptSpeechOnUserMessage,
156
160
  sendingMode: source.sendingMode === "automatic" ? "queue" : ["manual", "queue", "steer"].includes(source.sendingMode) ? source.sendingMode : defaultSettings.sendingMode,
@@ -273,6 +277,7 @@ var VoiceCoordinator = class {
273
277
  conversation: false,
274
278
  listening: false,
275
279
  recognizing: false,
280
+ pendingTranscriptions: 0,
276
281
  muted: normalizeSettings(settings).microphoneEnabled === false,
277
282
  speaking: false,
278
283
  paused: false,
@@ -287,6 +292,7 @@ var VoiceCoordinator = class {
287
292
  };
288
293
  this.autoSendTimer = null;
289
294
  this.autoSendDraft = null;
295
+ this.holdToTalkRelease = false;
290
296
  this.interruptionTimer = null;
291
297
  this.interruptionTranscriptConfirmed = false;
292
298
  this.interruptionPausedSpeech = false;
@@ -404,6 +410,27 @@ var VoiceCoordinator = class {
404
410
  startDictation() {
405
411
  return this.startListening(false);
406
412
  }
413
+ startHoldToTalk() {
414
+ this.holdToTalkRelease = false;
415
+ return this.startListening(false);
416
+ }
417
+ async releaseHoldToTalk() {
418
+ if (this.disposed || !this.snapshot.listening && !this.snapshot.starting) return;
419
+ this.holdToTalkRelease = true;
420
+ if (this.snapshot.starting) return;
421
+ await this.recognition.finish?.();
422
+ if (this.snapshot.pendingTranscriptions === 0) this._finishHoldToTalk();
423
+ }
424
+ _finishHoldToTalk() {
425
+ if (!this.holdToTalkRelease) return;
426
+ this.holdToTalkRelease = false;
427
+ const draft = this.composer.getDraft();
428
+ if (!draft.trim()) {
429
+ void this.stopListening();
430
+ return;
431
+ }
432
+ this.scheduleAutoSend(draft, { force: true, stopAfter: true });
433
+ }
407
434
  startConversation() {
408
435
  return this.startListening(true);
409
436
  }
@@ -483,6 +510,14 @@ var VoiceCoordinator = class {
483
510
  onResult: (result) => {
484
511
  if (valid()) this.onResult(result);
485
512
  },
513
+ onProcessingChange: ({ pending = 0 } = {}) => {
514
+ if (!valid()) return;
515
+ const count = Number.isSafeInteger(pending) && pending >= 0 ? pending : 0;
516
+ this.patch({ pendingTranscriptions: count });
517
+ if (count > 0) this.cancelAutoSend();
518
+ else if (this.holdToTalkRelease) this._finishHoldToTalk();
519
+ else this.maybeScheduleAutoSend();
520
+ },
486
521
  onActivity: (active) => {
487
522
  if (!valid()) return;
488
523
  if (active) {
@@ -507,6 +542,7 @@ var VoiceCoordinator = class {
507
542
  return;
508
543
  }
509
544
  this.patch({ listening: true, starting: false });
545
+ if (this.holdToTalkRelease) void this.releaseHoldToTalk();
510
546
  this._drain();
511
547
  } catch (error) {
512
548
  try {
@@ -619,7 +655,7 @@ var VoiceCoordinator = class {
619
655
  }
620
656
  const next = this.transcript.update(this.composer.getDraft(), final, true);
621
657
  this.composer.setDraft(next);
622
- if (this.snapshot.settings.sendingMode !== "manual") this.scheduleAutoSend(next);
658
+ this.maybeScheduleAutoSend(next);
623
659
  }
624
660
  if (interim) {
625
661
  this.cancelAutoSend();
@@ -630,7 +666,12 @@ var VoiceCoordinator = class {
630
666
  this.patch({ recognizing: !!interim });
631
667
  this._drain();
632
668
  }
633
- scheduleAutoSend(draft) {
669
+ maybeScheduleAutoSend(draft = this.composer.getDraft()) {
670
+ if (this.snapshot.settings.sendingMode === "manual" || this.snapshot.recognizing || this.snapshot.pendingTranscriptions > 0)
671
+ return;
672
+ this.scheduleAutoSend(draft);
673
+ }
674
+ scheduleAutoSend(draft, { force = false, stopAfter = false } = {}) {
634
675
  this.cancelAutoSend();
635
676
  if (!draft.trim() || typeof this.composer.submit !== "function") return;
636
677
  const delay = this.snapshot.settings.autoSendDelaySeconds * 1e3;
@@ -641,11 +682,14 @@ var VoiceCoordinator = class {
641
682
  const expected = this.autoSendDraft;
642
683
  this.autoSendDraft = null;
643
684
  this.patch({ autoSendAt: null });
644
- if (!this.disposed && this.snapshot.settings.sendingMode !== "manual" && expected === this.composer.getDraft()) {
685
+ if (!this.disposed && (force || this.snapshot.settings.sendingMode !== "manual") && expected === this.composer.getDraft()) {
645
686
  try {
646
- this.composer.submit(this.snapshot.settings.sendingMode === "steer" ? "steer" : "queue");
687
+ this.composer.submit(
688
+ this.snapshot.settings.sendingMode === "steer" && !force ? "steer" : "queue"
689
+ );
647
690
  this.transcript.reset();
648
691
  this.recognition.reset?.();
692
+ if (stopAfter) void this.stopListening();
649
693
  } catch (error) {
650
694
  this.patch({ error: message(error) });
651
695
  }
@@ -662,10 +706,11 @@ var VoiceCoordinator = class {
662
706
  if (this.autoSendDraft !== null && draft !== this.autoSendDraft) this.cancelAutoSend();
663
707
  }
664
708
  stopListening() {
709
+ this.holdToTalkRelease = false;
665
710
  this.cancelAutoSend();
666
711
  ++this.epoch;
667
712
  this.inputController?.abort();
668
- this.patch({ listening: false, recognizing: false, starting: false });
713
+ this.patch({ listening: false, recognizing: false, pendingTranscriptions: 0, starting: false });
669
714
  return this._input(async () => {
670
715
  try {
671
716
  await this._releaseInput();
@@ -1389,7 +1434,10 @@ var BrowserRecognitionEngine = class {
1389
1434
  restarts: 0,
1390
1435
  timer: null,
1391
1436
  recognition: null,
1392
- speaking: false
1437
+ speaking: false,
1438
+ finishing: false,
1439
+ finishPromise: null,
1440
+ resolveFinish: null
1393
1441
  };
1394
1442
  this.session = session;
1395
1443
  const cancelled3 = new Promise((resolve, reject) => {
@@ -1487,6 +1535,12 @@ var BrowserRecognitionEngine = class {
1487
1535
  session.recognition = null;
1488
1536
  this._activity(session, false);
1489
1537
  if (this.session !== session) return;
1538
+ if (session.finishing) {
1539
+ this.session = null;
1540
+ session.signal?.removeEventListener("abort", session.cancel);
1541
+ session.resolveFinish?.();
1542
+ return;
1543
+ }
1490
1544
  if (session.restarts >= this.maxRestarts) {
1491
1545
  this._fail(
1492
1546
  session,
@@ -1515,6 +1569,33 @@ var BrowserRecognitionEngine = class {
1515
1569
  };
1516
1570
  recognition.start();
1517
1571
  }
1572
+ finish() {
1573
+ const session = this.session;
1574
+ if (!session) return Promise.resolve();
1575
+ if (session.finishPromise) return session.finishPromise;
1576
+ session.finishing = true;
1577
+ session.finishPromise = new Promise((resolve) => {
1578
+ session.resolveFinish = resolve;
1579
+ });
1580
+ if (session.timer !== null) {
1581
+ (this.globals.clearTimeout ?? globalThis.clearTimeout)(session.timer);
1582
+ session.timer = null;
1583
+ this.session = null;
1584
+ session.resolveFinish();
1585
+ return session.finishPromise;
1586
+ }
1587
+ try {
1588
+ session.recognition?.stop();
1589
+ if (!session.recognition) {
1590
+ this.session = null;
1591
+ session.resolveFinish();
1592
+ }
1593
+ } catch {
1594
+ this.session = null;
1595
+ session.resolveFinish();
1596
+ }
1597
+ return session.finishPromise;
1598
+ }
1518
1599
  reset() {
1519
1600
  const session = this.session;
1520
1601
  const recognition = session?.recognition;
@@ -1549,6 +1630,7 @@ var BrowserRecognitionEngine = class {
1549
1630
  } catch {
1550
1631
  }
1551
1632
  this._activity(session, false);
1633
+ session.resolveFinish?.();
1552
1634
  }
1553
1635
  };
1554
1636
 
@@ -1585,12 +1667,18 @@ function encodeMonoPcm16Wav(samples, inputRate) {
1585
1667
  return buffer;
1586
1668
  }
1587
1669
  var WhisperHttpRecognitionEngine = class {
1588
- constructor({ globals = globalThis, meter, voiceDetectionPreset = "natural" } = {}) {
1670
+ constructor({
1671
+ globals = globalThis,
1672
+ meter,
1673
+ voiceDetectionPreset = "natural",
1674
+ maxUtteranceSeconds = 60
1675
+ } = {}) {
1589
1676
  this.g = globals;
1590
1677
  this.meter = meter;
1591
1678
  this.session = null;
1592
1679
  this.lang = "pt-BR";
1593
1680
  this.voiceDetectionPreset = voiceDetectionPreset;
1681
+ this.maxUtteranceSeconds = maxUtteranceSeconds;
1594
1682
  this.route = ROUTE;
1595
1683
  }
1596
1684
  get segmentation() {
@@ -1616,7 +1704,14 @@ var WhisperHttpRecognitionEngine = class {
1616
1704
  };
1617
1705
  }
1618
1706
  }
1619
- async start({ lang = this.lang, signal, onResult, onActivity, onError } = {}) {
1707
+ async start({
1708
+ lang = this.lang,
1709
+ signal,
1710
+ onResult,
1711
+ onActivity,
1712
+ onError,
1713
+ onProcessingChange
1714
+ } = {}) {
1620
1715
  await this.stop();
1621
1716
  if (signal?.aborted) throw abortError3();
1622
1717
  const context = this.meter?.context, source = this.meter?.source;
@@ -1636,16 +1731,24 @@ var WhisperHttpRecognitionEngine = class {
1636
1731
  samples: 0,
1637
1732
  voiced: false,
1638
1733
  silence: 0,
1639
- inflight: /* @__PURE__ */ new Set(),
1734
+ transcriptionQueue: [],
1735
+ activeRequest: null,
1736
+ draining: false,
1640
1737
  onResult,
1641
1738
  onActivity,
1642
1739
  onError,
1740
+ onProcessingChange,
1643
1741
  lang,
1644
1742
  signal
1645
1743
  };
1646
1744
  this.session = session;
1647
1745
  const valid = () => this.session === session && !signal?.aborted;
1648
- const submit = async () => {
1746
+ const notifyProcessing = () => session.onProcessingChange?.({
1747
+ queued: session.transcriptionQueue.length,
1748
+ active: !!session.activeRequest,
1749
+ pending: session.transcriptionQueue.length + (session.activeRequest ? 1 : 0)
1750
+ });
1751
+ const enqueue = () => {
1649
1752
  if (!session.voiced || session.samples < context.sampleRate * 0.25) {
1650
1753
  session.chunks = [];
1651
1754
  session.samples = 0;
@@ -1663,29 +1766,46 @@ var WhisperHttpRecognitionEngine = class {
1663
1766
  session.samples = 0;
1664
1767
  session.voiced = false;
1665
1768
  session.silence = 0;
1666
- const request = new AbortController();
1667
- session.inflight.add(request);
1769
+ session.transcriptionQueue.push(samples);
1770
+ notifyProcessing();
1771
+ void drain();
1772
+ };
1773
+ const drain = async () => {
1774
+ if (session.draining || !valid()) return;
1775
+ session.draining = true;
1668
1776
  try {
1669
- const response = await this.g.fetch(this.route + "/transcribe", {
1670
- method: "POST",
1671
- credentials: "same-origin",
1672
- headers: {
1673
- "content-type": "audio/wav",
1674
- "x-dlv-client-id": session.operation,
1675
- "x-dlv-operation-id": id(),
1676
- "x-dlv-language": lang
1677
- },
1678
- body: encodeMonoPcm16Wav(samples, context.sampleRate),
1679
- signal: request.signal
1680
- });
1681
- const json = await response.json();
1682
- if (!response.ok || !json?.ok)
1683
- throw new Error(json?.error?.message || "HTTP transcription failed.");
1684
- if (valid() && json.value.text) onResult?.({ final: json.value.text, interim: "" });
1685
- } catch (error) {
1686
- if (error.name !== "AbortError" && valid()) onError?.(error);
1777
+ while (valid() && session.transcriptionQueue.length) {
1778
+ const samples = session.transcriptionQueue.shift();
1779
+ const request = new AbortController();
1780
+ session.activeRequest = request;
1781
+ notifyProcessing();
1782
+ try {
1783
+ const response = await this.g.fetch(this.route + "/transcribe", {
1784
+ method: "POST",
1785
+ credentials: "same-origin",
1786
+ headers: {
1787
+ "content-type": "audio/wav",
1788
+ "x-dlv-client-id": session.operation,
1789
+ "x-dlv-operation-id": id(),
1790
+ "x-dlv-language": lang
1791
+ },
1792
+ body: encodeMonoPcm16Wav(samples, context.sampleRate),
1793
+ signal: request.signal
1794
+ });
1795
+ const json = await response.json();
1796
+ if (!response.ok || !json?.ok)
1797
+ throw new Error(json?.error?.message || "HTTP transcription failed.");
1798
+ if (valid() && json.value.text) onResult?.({ final: json.value.text, interim: "" });
1799
+ } catch (error) {
1800
+ if (error.name !== "AbortError" && valid()) onError?.(error);
1801
+ } finally {
1802
+ if (session.activeRequest === request) session.activeRequest = null;
1803
+ notifyProcessing();
1804
+ }
1805
+ }
1687
1806
  } finally {
1688
- session.inflight.delete(request);
1807
+ session.draining = false;
1808
+ notifyProcessing();
1689
1809
  }
1690
1810
  };
1691
1811
  processor.onaudioprocess = (event) => {
@@ -1701,13 +1821,24 @@ var WhisperHttpRecognitionEngine = class {
1701
1821
  }
1702
1822
  session.chunks.push(data);
1703
1823
  session.samples += data.length;
1704
- if (session.voiced && session.silence > context.sampleRate * (this.segmentation.silenceMs / 1e3) || session.samples > context.sampleRate * 20)
1705
- void submit();
1824
+ if (session.voiced && session.silence > context.sampleRate * (this.segmentation.silenceMs / 1e3) || session.samples > context.sampleRate * this.maxUtteranceSeconds)
1825
+ enqueue();
1706
1826
  };
1707
1827
  source.connect(processor);
1828
+ session.finish = enqueue;
1708
1829
  session.abort = () => this.stop();
1709
1830
  signal?.addEventListener("abort", session.abort, { once: true });
1710
1831
  }
1832
+ finish() {
1833
+ const session = this.session;
1834
+ if (!session) return;
1835
+ session.processor.onaudioprocess = null;
1836
+ try {
1837
+ this.meter?.source?.disconnect(session.processor);
1838
+ } catch {
1839
+ }
1840
+ session.finish?.();
1841
+ }
1711
1842
  async stop() {
1712
1843
  const session = this.session;
1713
1844
  if (!session) return;
@@ -1723,8 +1854,10 @@ var WhisperHttpRecognitionEngine = class {
1723
1854
  session.gain?.disconnect();
1724
1855
  } catch {
1725
1856
  }
1726
- for (const request of session.inflight) request.abort();
1727
- session.inflight.clear();
1857
+ session.transcriptionQueue = [];
1858
+ session.activeRequest?.abort();
1859
+ session.activeRequest = null;
1860
+ session.onProcessingChange?.({ queued: 0, active: false, pending: 0 });
1728
1861
  }
1729
1862
  };
1730
1863
 
@@ -2150,6 +2283,100 @@ function createQwenSettings(React2) {
2150
2283
  };
2151
2284
  }
2152
2285
 
2286
+ // src/client/releases.ts
2287
+ var CURRENT_VERSION = "0.2.3";
2288
+ var TESTED_DSH_VERSION = "0.1.6-alpha.2";
2289
+ var REPOSITORY_URL = "https://github.com/victorwads/dsh-live-voice";
2290
+ var RELEASES_URL = `${REPOSITORY_URL}/releases`;
2291
+ var LATEST_RELEASE_API_URL = "https://api.github.com/repos/victorwads/dsh-live-voice/releases/latest";
2292
+ var RELEASE_CHECK_STORAGE_KEY = "dsh-live-voice.latest-release-check";
2293
+ var RELEASE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2294
+ var LIVE_VOICE_BADGE_URL = "https://cdn.simpleicons.org/npm/white";
2295
+ var DSH_BADGE_URL = "https://cdn.simpleicons.org/deepseek/white";
2296
+ var TESTED_DSH_RELEASE_URL = `https://github.com/deepseek-ai/deepseek-harness/releases/tag/v${TESTED_DSH_VERSION}`;
2297
+ function versionParts(version) {
2298
+ const normalized = String(version || "").trim().replace(/^v/i, "").split("+", 1)[0];
2299
+ const [core = "", prerelease = ""] = normalized.split("-", 2);
2300
+ const numbers = core.split(".").map((part) => {
2301
+ const match = part.match(/^\d+/);
2302
+ return match ? Number(match[0]) : 0;
2303
+ });
2304
+ while (numbers.length < 3) numbers.push(0);
2305
+ return { numbers, prerelease: prerelease ? prerelease.split(".") : [] };
2306
+ }
2307
+ function compareIdentifier(left, right) {
2308
+ const leftNumber = /^\d+$/.test(left) ? Number(left) : null;
2309
+ const rightNumber = /^\d+$/.test(right) ? Number(right) : null;
2310
+ if (leftNumber !== null && rightNumber !== null) return Math.sign(leftNumber - rightNumber);
2311
+ if (leftNumber !== null) return -1;
2312
+ if (rightNumber !== null) return 1;
2313
+ return left.localeCompare(right);
2314
+ }
2315
+ function compareVersions(left, right) {
2316
+ const a = versionParts(left);
2317
+ const b = versionParts(right);
2318
+ for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index++) {
2319
+ const difference = (a.numbers[index] || 0) - (b.numbers[index] || 0);
2320
+ if (difference) return Math.sign(difference);
2321
+ }
2322
+ if (!a.prerelease.length && !b.prerelease.length) return 0;
2323
+ if (!a.prerelease.length) return 1;
2324
+ if (!b.prerelease.length) return -1;
2325
+ for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) {
2326
+ if (a.prerelease[index] === void 0) return -1;
2327
+ if (b.prerelease[index] === void 0) return 1;
2328
+ const difference = compareIdentifier(a.prerelease[index], b.prerelease[index]);
2329
+ if (difference) return difference;
2330
+ }
2331
+ return 0;
2332
+ }
2333
+ function readCached(storage, now) {
2334
+ try {
2335
+ const cached = JSON.parse(storage?.getItem(RELEASE_CHECK_STORAGE_KEY) || "null");
2336
+ if (cached && Number.isFinite(cached.checkedAt) && now - cached.checkedAt >= 0 && now - cached.checkedAt < RELEASE_CHECK_INTERVAL_MS)
2337
+ return cached;
2338
+ } catch {
2339
+ }
2340
+ return null;
2341
+ }
2342
+ function writeCached(storage, value) {
2343
+ try {
2344
+ storage?.setItem(RELEASE_CHECK_STORAGE_KEY, JSON.stringify(value));
2345
+ } catch {
2346
+ }
2347
+ }
2348
+ async function checkLatestRelease({
2349
+ fetchImpl = globalThis.window?.fetch?.bind(globalThis.window),
2350
+ storage = globalThis.window?.localStorage,
2351
+ now = Date.now()
2352
+ } = {}) {
2353
+ const cached = readCached(storage, now);
2354
+ if (cached) return cached;
2355
+ const attempted = { checkedAt: now, release: null };
2356
+ writeCached(storage, attempted);
2357
+ if (typeof fetchImpl !== "function") return attempted;
2358
+ try {
2359
+ const response = await fetchImpl(LATEST_RELEASE_API_URL, {
2360
+ headers: { Accept: "application/vnd.github+json" }
2361
+ });
2362
+ if (!response.ok) return attempted;
2363
+ const body = await response.json();
2364
+ if (typeof body?.tag_name !== "string" || !body.tag_name.trim()) return attempted;
2365
+ const release = {
2366
+ tag: body.tag_name.trim(),
2367
+ url: typeof body.html_url === "string" && body.html_url.startsWith("https://github.com/") ? body.html_url : RELEASES_URL
2368
+ };
2369
+ const result = { checkedAt: now, release };
2370
+ writeCached(storage, result);
2371
+ return result;
2372
+ } catch {
2373
+ return attempted;
2374
+ }
2375
+ }
2376
+ function hasNewerRelease(release, currentVersion = CURRENT_VERSION) {
2377
+ return Boolean(release?.tag && compareVersions(release.tag, currentVersion) > 0);
2378
+ }
2379
+
2153
2380
  // src/client/components.ts
2154
2381
  function createComponents(React2) {
2155
2382
  const h = React2.createElement;
@@ -2443,6 +2670,17 @@ function createComponents(React2) {
2443
2670
  const settings = state.settings || {};
2444
2671
  const capabilities = state.capabilities || {};
2445
2672
  const tabsId = React2.useId();
2673
+ const [latestRelease, setLatestRelease] = React2.useState(null);
2674
+ React2.useEffect(() => {
2675
+ let active = true;
2676
+ void checkLatestRelease().then((result) => {
2677
+ if (active) setLatestRelease(result.release);
2678
+ });
2679
+ return () => {
2680
+ active = false;
2681
+ };
2682
+ }, []);
2683
+ const updateAvailable = hasNewerRelease(latestRelease);
2446
2684
  const tabs = [
2447
2685
  { id: "conversation", label: "Conversation" },
2448
2686
  { id: "speech", label: "Speech" },
@@ -2535,7 +2773,69 @@ function createComponents(React2) {
2535
2773
  return h(
2536
2774
  "section",
2537
2775
  { className: "dlv-settings", "aria-label": "Live Voice settings" },
2538
- h("h3", null, "Live Voice"),
2776
+ h(
2777
+ "div",
2778
+ { className: "dlv-settings-heading" },
2779
+ h("h3", null, "Live Voice"),
2780
+ h(
2781
+ "div",
2782
+ { className: "dlv-version-badges", "aria-label": "Version information" },
2783
+ h(
2784
+ "a",
2785
+ {
2786
+ className: "dlv-shields-badge",
2787
+ href: RELEASES_URL,
2788
+ target: "_blank",
2789
+ rel: "noreferrer",
2790
+ "aria-label": `DSH Live Voice v${CURRENT_VERSION}. Open releases`,
2791
+ title: `DSH Live Voice v${CURRENT_VERSION}`
2792
+ },
2793
+ h("img", { src: LIVE_VOICE_BADGE_URL, alt: "" }),
2794
+ h("span", null, `v${CURRENT_VERSION}`)
2795
+ ),
2796
+ h(
2797
+ "a",
2798
+ {
2799
+ className: "dlv-shields-badge",
2800
+ href: TESTED_DSH_RELEASE_URL,
2801
+ target: "_blank",
2802
+ rel: "noreferrer",
2803
+ "aria-label": `Compatible with DSH v${TESTED_DSH_VERSION}. Open release`,
2804
+ title: `Compatible with DSH v${TESTED_DSH_VERSION}`
2805
+ },
2806
+ h("img", { src: DSH_BADGE_URL, alt: "" }),
2807
+ h("span", null, `v${TESTED_DSH_VERSION}`)
2808
+ )
2809
+ ),
2810
+ h("span", { className: "dlv-heading-divider", "aria-hidden": true }),
2811
+ updateAvailable ? h(
2812
+ "a",
2813
+ {
2814
+ className: "dlv-version-badge dlv-update-badge",
2815
+ href: latestRelease.url,
2816
+ target: "_blank",
2817
+ rel: "noreferrer",
2818
+ "aria-label": `Update available: ${latestRelease.tag}. Open release`,
2819
+ title: `Update available: ${latestRelease.tag}`
2820
+ },
2821
+ h("span", { className: "dlv-update-icon", "aria-hidden": true }, "\u2191"),
2822
+ "Update available"
2823
+ ) : null,
2824
+ updateAvailable ? h("span", { className: "dlv-heading-divider", "aria-hidden": true }) : null,
2825
+ h(
2826
+ "a",
2827
+ {
2828
+ className: "dlv-version-badge dlv-star-badge",
2829
+ href: REPOSITORY_URL,
2830
+ target: "_blank",
2831
+ rel: "noreferrer",
2832
+ "aria-label": "Star DSH Live Voice on GitHub",
2833
+ title: "Star DSH Live Voice on GitHub"
2834
+ },
2835
+ h("span", { className: "dlv-star-icon", "aria-hidden": true }, "\u2605"),
2836
+ "Star Us on GitHub"
2837
+ )
2838
+ ),
2539
2839
  onClose ? h(Button, { label: "Close voice settings", icon: "close", onClick: onClose }) : null,
2540
2840
  h(
2541
2841
  "div",
@@ -2744,11 +3044,28 @@ function createComponents(React2) {
2744
3044
  event.target.value === "browser" && settings.recognitionLang === "auto" ? { recognitionEngine: "browser", recognitionLang: "pt-BR" } : { recognitionEngine: event.target.value }
2745
3045
  )
2746
3046
  },
2747
- h("option", { value: "qwen-http" }, "Qwen3 ASR \u2014 local MLX server"),
2748
- h("option", { value: "browser" }, "Browser SpeechRecognition"),
2749
- h("option", { value: "whisper-http" }, "Whisper HTTP \u2014 DSH host")
3047
+ h("option", { value: "browser" }, "Browser SpeechRecognition \u2014 Default option"),
3048
+ h("option", { value: "qwen-http" }, "Qwen3 ASR \u2014 HTTP API"),
3049
+ h("option", { value: "whisper-http" }, "Whisper \u2014 HTTP API"),
3050
+ h(
3051
+ "optgroup",
3052
+ { label: "Coming Soon \u2014 vote on repo issues" },
3053
+ h("option", { value: "webgpu", disabled: true }, "Browser WebGPU Inference \u2014 Soon"),
3054
+ h(
3055
+ "option",
3056
+ { value: "sherpa-onnx", disabled: true },
3057
+ "sherpa-onnx Streaming \u2014 Soon"
3058
+ ),
3059
+ h("option", { value: "parakeet", disabled: true }, "NVIDIA Parakeet \u2014 Soon"),
3060
+ h("option", { value: "voxtral", disabled: true }, "Voxtral Realtime \u2014 Soon")
3061
+ )
2750
3062
  )
2751
3063
  ),
3064
+ h(
3065
+ "small",
3066
+ { className: "dlv-recognition-engine-description" },
3067
+ settings.recognitionEngine === "qwen-http" ? "HTTP API at the configured base URL (default: http://127.0.0.1:8080/). Compatible with POST /v1/audio/transcriptions." : settings.recognitionEngine === "whisper-http" ? "HTTP API at the configured DSH host URL (default: http://127.0.0.1:8080/inference). Audio uses the authenticated DSH host transcription route." : "Uses the browser SpeechRecognition API. This is the default option."
3068
+ ),
2752
3069
  field("Input device", "inputDeviceId", deviceOptions("audioinput", "Microphone")),
2753
3070
  settings.recognitionEngine === "browser" ? h(
2754
3071
  "small",
@@ -2894,6 +3211,28 @@ function createComponents(React2) {
2894
3211
  null,
2895
3212
  "Controls how long a pause must last before captured speech is sent for recognition."
2896
3213
  ),
3214
+ h(
3215
+ "label",
3216
+ { className: "dlv-setting-field" },
3217
+ "Maximum continuous speech (seconds)",
3218
+ h("input", {
3219
+ type: "number",
3220
+ min: 10,
3221
+ max: 300,
3222
+ step: 1,
3223
+ value: settings.recognitionMaxUtteranceSeconds ?? 60,
3224
+ onChange: (event) => {
3225
+ const value = Number(event.target.value);
3226
+ if (Number.isInteger(value) && value >= 10 && value <= 300)
3227
+ invoke("updateSettings", { recognitionMaxUtteranceSeconds: value });
3228
+ }
3229
+ }),
3230
+ h(
3231
+ "small",
3232
+ null,
3233
+ "If speech never pauses, start a new transcription chunk after this duration. Default: 60 seconds."
3234
+ )
3235
+ ),
2897
3236
  h(
2898
3237
  "div",
2899
3238
  {
@@ -2961,6 +3300,21 @@ function createComponents(React2) {
2961
3300
  { key: "interrupt-message-description", className: "dlv-setting-description" },
2962
3301
  settings.interruptSpeechOnUserMessage ? "Sending or steering a new user message stops current or paused assistant speech." : "Sending another message does not stop the assistant audio you are already hearing."
2963
3302
  ),
3303
+ h(
3304
+ "label",
3305
+ { key: "hold-to-talk", className: "dlv-check" },
3306
+ h("input", {
3307
+ type: "checkbox",
3308
+ checked: settings.holdToTalkEnabled !== false,
3309
+ onChange: (event) => invoke("updateSettings", { holdToTalkEnabled: event.target.checked })
3310
+ }),
3311
+ " Hold Control to talk"
3312
+ ),
3313
+ h(
3314
+ "p",
3315
+ { key: "hold-to-talk-description", className: "dlv-setting-description" },
3316
+ "While a composer is open, hold Control anywhere on the page to capture speech. Release it to flush queued transcription, wait the configured send delay, queue the message, and close voice capture. Press Escape while holding to cancel."
3317
+ ),
2964
3318
  field("Listening mode", "mode", [
2965
3319
  { value: "speaker", label: "Speakers \u2014 gated listening" },
2966
3320
  { value: "headphones", label: "Headphones \u2014 open microphone" }
@@ -3066,13 +3420,15 @@ var styles = `
3066
3420
  .dlv-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:13px;max-width:720px;margin:8px auto}
3067
3421
  .dlv-settings{box-sizing:border-box;padding:18px;width:100%;display:grid;gap:16px;max-width:640px;color:var(--dsw-alias-label-primary)}
3068
3422
  .dlv-settings h3,.dlv-settings p{margin:0}
3423
+ .dlv-settings-heading{display:flex;align-items:center;gap:8px;min-width:0}.dlv-settings-heading h3{white-space:nowrap}.dlv-heading-divider{height:1px;min-width:12px;flex:1;background:var(--dsw-alias-border-l1)}.dlv-settings-heading .dlv-star-badge{flex:none}
3424
+ .dlv-version-badges{display:flex;flex:none;gap:5px;align-items:center}.dlv-shields-badge{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;height:20px;gap:4px;padding:3px 5px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:650;line-height:12px;text-decoration:none}.dlv-shields-badge img{display:block;width:12px;height:12px;max-width:100%}.dlv-shields-badge:focus-visible{border-radius:4px;outline:2px solid var(--dsw-alias-label-primary);outline-offset:3px}.dlv-version-badge{display:inline-flex;align-items:center;min-height:24px;box-sizing:border-box;padding:3px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;line-height:16px;text-decoration:none}.dlv-version-badges a.dlv-version-badge:hover{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}.dlv-star-badge{padding:4px 10px;color:var(--dsw-alias-label-primary)}.dlv-update-icon{margin-right:4px;font-size:14px;line-height:1}.dlv-star-icon{margin-right:4px;color:#f59e0b;font-size:14px;line-height:1;text-shadow:0 0 8px rgba(245,158,11,.28)}.dlv-version-badges a.dlv-star-badge:hover{border-color:#f59e0b;background:rgba(245,158,11,.08)}.dlv-update-badge{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}
3069
3425
  .dlv-settings-tabs{border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;align-items:flex-end;gap:22px;overflow-x:auto}.dlv-settings-tab{position:relative;flex:none;padding:7px 1px 9px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}.dlv-settings-tab:hover,.dlv-settings-tab[data-active=true]{color:var(--dsw-alias-label-primary)}.dlv-settings-tab[data-active=true]:after,.dlv-settings-tab:focus-visible:after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:2px;border-radius:2px 2px 0 0;background:var(--dsw-alias-label-primary)}.dlv-settings-tab:focus-visible{border-radius:2px;outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;color:var(--dsw-alias-label-primary)}.dlv-settings-panel{min-width:0;padding-top:2px}.dlv-settings-panel[hidden]{display:none}.dlv-settings-group{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;min-width:0;display:grid;gap:14px;padding:16px}.dlv-settings-group legend{padding:0 6px;font-weight:600;color:var(--dsw-alias-label-primary)}.dlv-settings-card-body{display:grid;gap:12px;padding:12px 0}.dlv-settings-subcard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;min-width:0}.dlv-settings-subcard>summary{cursor:pointer;font-weight:650;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:10px}.dlv-settings-subcard>summary::-webkit-details-marker{display:none}.dlv-settings-subcard>summary:after{content:"\u203A";transform:rotate(90deg);transition:transform .15s}.dlv-settings-subcard:not([open])>summary:after{transform:rotate(0)}.dlv-settings-subcard-body{display:grid;gap:10px;padding:0 10px 10px}
3070
3426
  .dlv-settings label{display:grid;gap:6px;font-size:14px}.dlv-settings label.dlv-check{display:flex;align-items:center;gap:8px}.dlv-settings label.dlv-check input{width:auto}
3071
3427
  .dlv-settings :is(input,select,textarea){box-sizing:border-box;width:100%;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit}
3072
3428
  .dlv-settings small,.dlv-settings p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}
3073
3429
  .dlv-preset-group{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dlv-settings label.dlv-preset{display:flex;align-items:flex-start;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;cursor:pointer}.dlv-settings label.dlv-preset:has(input:checked){border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}.dlv-settings label.dlv-preset input{width:auto;margin-top:3px}.dlv-preset span{display:grid;gap:3px}.dlv-vad-summary{font-weight:500}
3074
3430
  .dlv-settings-actions{display:flex;flex-wrap:wrap;gap:8px}.dlv-settings-actions button{padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;cursor:pointer}.dlv-settings-actions button:disabled{opacity:.45;cursor:default}
3075
- @media(max-width:480px){.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
3431
+ @media(max-width:480px){.dlv-settings-heading{gap:6px}.dlv-heading-divider{min-width:4px}.dlv-settings-heading .dlv-star-badge{padding:3px 6px;font-size:10px}.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
3076
3432
  `;
3077
3433
 
3078
3434
  // src/client/chat.ts
@@ -3120,12 +3476,22 @@ function apply(ctx) {
3120
3476
  let disposed = false;
3121
3477
  let voiceModeActive = false;
3122
3478
  const ownership = new VoiceOwnership();
3479
+ const recognitionSettingKeys = /* @__PURE__ */ new Set([
3480
+ "recognitionEngine",
3481
+ "recognitionProcessLocally",
3482
+ "recognitionAutoInstall",
3483
+ "voiceDetectionPreset",
3484
+ "recognitionMaxUtteranceSeconds"
3485
+ ]);
3486
+ const changesRecognition = (next) => Object.keys(next).some((key) => recognitionSettingKeys.has(key));
3123
3487
  const recognitionFor = (settings, meter) => settings.recognitionEngine === "qwen-http" ? new QwenHttpRecognitionEngine({
3124
3488
  meter,
3125
- voiceDetectionPreset: settings.voiceDetectionPreset
3489
+ voiceDetectionPreset: settings.voiceDetectionPreset,
3490
+ maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds
3126
3491
  }) : settings.recognitionEngine === "whisper-http" ? new WhisperHttpRecognitionEngine({
3127
3492
  meter,
3128
- voiceDetectionPreset: settings.voiceDetectionPreset
3493
+ voiceDetectionPreset: settings.voiceDetectionPreset,
3494
+ maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds
3129
3495
  }) : new BrowserRecognitionEngine({
3130
3496
  processLocally: settings.recognitionProcessLocally,
3131
3497
  autoInstallLocalPack: settings.recognitionAutoInstall
@@ -3202,7 +3568,8 @@ function apply(ctx) {
3202
3568
  if (capture.index < capture.interaction.questions.length) {
3203
3569
  capture.answering = false;
3204
3570
  const text = pendingQuestionSpeech(capture.interaction, capture.index);
3205
- if (text) run(entry.controller, entry.controller.speak(text, capture.interaction.key));
3571
+ if (text)
3572
+ run(entry.controller, entry.controller.speak(text, capture.interaction.key));
3206
3573
  } else {
3207
3574
  entry.questionCapture = null;
3208
3575
  entry.controller.patch({ answeringQuestion: false });
@@ -3303,7 +3670,7 @@ function apply(ctx) {
3303
3670
  return original(...args);
3304
3671
  };
3305
3672
  }
3306
- for (const method of ["startDictation", "startConversation", "speak"]) {
3673
+ for (const method of ["startDictation", "startHoldToTalk", "startConversation", "speak"]) {
3307
3674
  const original = controller[method].bind(controller);
3308
3675
  controller[method] = (...args) => {
3309
3676
  if (disposed || entry.closed || !entry.refs) return Promise.resolve();
@@ -3414,13 +3781,11 @@ function apply(ctx) {
3414
3781
  let settingsRevision = 0;
3415
3782
  c.updateSettings = async (next) => {
3416
3783
  const revision = ++settingsRevision;
3417
- const previousEngine = c.getSnapshot().settings.recognitionEngine;
3418
3784
  update(next);
3419
3785
  browser.lang = c.getSnapshot().settings.lang;
3420
3786
  qwen.lang = c.getSnapshot().settings.lang;
3421
3787
  const settings2 = c.getSnapshot().settings;
3422
- if (previousEngine !== settings2.recognitionEngine)
3423
- c.replaceRecognition(recognitionFor(settings2, c.meter));
3788
+ if (changesRecognition(next)) c.replaceRecognition(recognitionFor(settings2, c.meter));
3424
3789
  localStorage.setItem("dsh-live-voice.settings", JSON.stringify(settings2));
3425
3790
  run(c, c.refreshCapabilities());
3426
3791
  const active = [...controllers.values()];
@@ -3431,7 +3796,7 @@ function apply(ctx) {
3431
3796
  if (revision !== settingsRevision || disposed || c.disposed) return;
3432
3797
  for (const entry of controllers.values()) {
3433
3798
  if (entry.closed) continue;
3434
- if (entry.controller.getSnapshot().settings.recognitionEngine !== settings2.recognitionEngine)
3799
+ if (changesRecognition(next))
3435
3800
  entry.controller.replaceRecognition(recognitionFor(settings2, entry.controller.meter));
3436
3801
  entry.applySettings(settings2);
3437
3802
  run(entry.controller, entry.controller.refreshCapabilities());
@@ -3548,19 +3913,47 @@ function apply(ctx) {
3548
3913
  for (const entry of controllers.values())
3549
3914
  run(entry.controller, entry.controller.endConversation());
3550
3915
  };
3551
- const onKey = (event) => {
3552
- if (disposed || event.defaultPrevented || !event.ctrlKey || !event.shiftKey || event.code !== "Space" || event.repeat)
3553
- return;
3916
+ let holdToTalk = null;
3917
+ const candidate = () => {
3554
3918
  const candidates = [...controllers.values()].filter(
3555
3919
  (entry) => entry.buttons > 0 && entry.composers.size > 0
3556
3920
  );
3557
- if (candidates.length !== 1) return;
3558
- event.preventDefault();
3559
- const c = candidates[0].controller;
3560
- run(
3561
- c,
3562
- c.getSnapshot().listening || c.getSnapshot().starting ? c.stopListening() : c.startDictation()
3563
- );
3921
+ return candidates.length === 1 ? candidates[0] : null;
3922
+ };
3923
+ const releaseHoldToTalk = () => {
3924
+ const entry = holdToTalk;
3925
+ holdToTalk = null;
3926
+ if (!entry || entry.closed) return;
3927
+ run(entry.controller, entry.controller.releaseHoldToTalk());
3928
+ };
3929
+ const onKeyDown = (event) => {
3930
+ if (disposed || event.defaultPrevented || event.repeat) return;
3931
+ if (event.key === "Escape" && holdToTalk) {
3932
+ const entry2 = holdToTalk;
3933
+ holdToTalk = null;
3934
+ event.preventDefault();
3935
+ run(entry2.controller, entry2.controller.cancelDictation());
3936
+ return;
3937
+ }
3938
+ if (event.ctrlKey && event.shiftKey && event.code === "Space") {
3939
+ const entry2 = candidate();
3940
+ if (!entry2) return;
3941
+ event.preventDefault();
3942
+ const c = entry2.controller;
3943
+ run(
3944
+ c,
3945
+ c.getSnapshot().listening || c.getSnapshot().starting ? c.stopListening() : c.startDictation()
3946
+ );
3947
+ return;
3948
+ }
3949
+ if (event.key !== "Control" || event.altKey || event.metaKey || event.shiftKey) return;
3950
+ const entry = candidate();
3951
+ if (!entry || !entry.controller.getSnapshot().settings.holdToTalkEnabled) return;
3952
+ holdToTalk = entry;
3953
+ run(entry.controller, entry.controller.startHoldToTalk());
3954
+ };
3955
+ const onKeyUp = (event) => {
3956
+ if (event.key === "Control") releaseHoldToTalk();
3564
3957
  };
3565
3958
  const refreshCapabilities = () => {
3566
3959
  for (const entry of controllers.values())
@@ -3573,7 +3966,9 @@ function apply(ctx) {
3573
3966
  window.speechSynthesis?.addEventListener?.("voiceschanged", refreshCapabilities);
3574
3967
  navigator.mediaDevices?.addEventListener?.("devicechange", refreshCapabilities);
3575
3968
  document.addEventListener("visibilitychange", visibilityChanged);
3576
- document.addEventListener("keydown", onKey);
3969
+ document.addEventListener("keydown", onKeyDown);
3970
+ document.addEventListener("keyup", onKeyUp);
3971
+ window.addEventListener("blur", releaseHoldToTalk);
3577
3972
  window.addEventListener("pagehide", stop);
3578
3973
  return () => {
3579
3974
  disposed = true;
@@ -3581,7 +3976,9 @@ function apply(ctx) {
3581
3976
  window.speechSynthesis?.removeEventListener?.("voiceschanged", refreshCapabilities);
3582
3977
  navigator.mediaDevices?.removeEventListener?.("devicechange", refreshCapabilities);
3583
3978
  document.removeEventListener("visibilitychange", visibilityChanged);
3584
- document.removeEventListener("keydown", onKey);
3979
+ document.removeEventListener("keydown", onKeyDown);
3980
+ document.removeEventListener("keyup", onKeyUp);
3981
+ window.removeEventListener("blur", releaseHoldToTalk);
3585
3982
  window.removeEventListener("pagehide", stop);
3586
3983
  for (const entry of controllers.values()) retire(entry);
3587
3984
  };