@goodandready/dsh-tts 0.4.28 → 0.4.29

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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.29
4
+
5
+ ### Fixed
6
+ - **Settings & Revision Management** (#151, #152):
7
+ - Added robust `extractRevision` supporting nested Schemastery namespaces and entries array.
8
+ - Return revision in `GET /dsh-tts/config` and send it in `PUT /dsh-tts/config`.
9
+ - Eliminated stale `savedConfig` caching in `live()`, enabling direct live Schemastery volatile box evaluation.
10
+ - **Audio Pipeline, Tool Schema & Dedup** (#156, #157, #166, #173, #175, #190):
11
+ - Enforced speech preparation, maxChars, and summarizeReply before sentence splitting in `speakAsItGoes`.
12
+ - Added `id: { type: 'string' }` to `speak_text` tool output schema under `additionalProperties: false` preventing core ToolOutputError.
13
+ - Included credential slot reference in cache and flight fingerprints avoiding cross-credential collisions.
14
+ - In-memory index for synthesis cache avoiding O(N) fs scans.
15
+ - **Stream Hub & Web Player** (#158, #159, #160, #161, #174, #176, #186):
16
+ - `skipCurrent()` now cancels active browser SpeechSynthesis and clears busy state.
17
+ - `playAudio` resolves promise on NotAllowedError and detaches gesture listeners cleanly.
18
+ - Disconnect slow SSE subscribers exceeding backpressure buffer threshold.
19
+ - Registered updater routes inside ctx.effect with clean disposers.
20
+ - **Providers & Local Engines** (#165, #167, #168, #169, #170, #171, #172, #177, #178, #179, #180):
21
+ - Nested integrations parser in UI and gated /speak on disabled channels.
22
+ - Canonical EN defaults for Edge and eSpeak.
23
+ - 100% key parity for EN/ZH model hints.
24
+ - Google TTS validates MIME and wraps raw PCM in valid WAV container.
25
+ - Azure SSML includes required xmlns/mstts attributes.
26
+ - Robust model download locking, cancellation awaiting writer cleanup, and HTML error page rejection.
27
+ - **Release Hygiene & Sanitization** (#183, #184, #185):
28
+ - Removed outdated marketing claims from READMEs.
29
+ - Restored internal DEV docs while verifying exclusion from npm pack and GitHub mirror.
30
+ - Cleaned up publish-github script to handle negation patterns.
31
+
3
32
  ## 0.4.28
4
33
 
5
34
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- <h3>Multi-Provider Text-to-Speech Voice Synthesis with Local Neural Engines, Sub-300ms Streaming, IT Dictionary & Messenger Integration for DeepSeek Harness</h3>
5
+ <h3>Multi-Provider Text-to-Speech Voice Synthesis with Local Neural Engines, Real-time Streaming, IT Dictionary & Messenger Integration for DeepSeek Harness</h3>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/@goodandready/dsh-tts"><img src="https://img.shields.io/npm/v/@goodandready/dsh-tts.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -49,7 +49,7 @@ graph LR
49
49
 
50
50
  subgraph Stream [Low-Latency Streaming]
51
51
  Scrub --> SSE[SSE /dsh-tts/stream]
52
- SSE --> Worklet[AudioWorklet PCM Processor]
52
+ SSE --> Player[Web Audio & HTML Audio Player]
53
53
  end
54
54
 
55
55
  subgraph Cache [Performance Layer]
@@ -59,7 +59,7 @@ graph LR
59
59
 
60
60
  subgraph Fallback [TTS Provider Fallback Chain]
61
61
  LRU -->|Cache Miss| Chain{Active Chain}
62
- Chain -->|Offline| P1[Edge TTS / Piper / eSpeak]
62
+ Chain -->|Online / Local| P1[Edge TTS / Piper / eSpeak]
63
63
  Chain -.->|Cloud Neural| P2[OpenAI / ElevenLabs / Google / Azure / Groq]
64
64
  Chain -.->|OpenAI-compatible| P3[SiliconFlow / DeepInfra / Fireworks / OpenRouter]
65
65
  Chain -.->|Other| P4[MiMo / MiniMax / Custom]
@@ -85,13 +85,14 @@ graph LR
85
85
 
86
86
  ## 🚀 Key Features
87
87
 
88
- ### 1. 📴 Offline system engines + optional future neural runtimes
89
- * **Edge TTS / Piper / eSpeak**: fully offline or free local/system synthesis without cloud API keys (Edge needs the `edge-tts` CLI).
88
+ ### 1. 📴 Local and system engines + optional future neural runtimes
89
+ * **Piper / eSpeak**: fully offline local synthesis without cloud API keys.
90
+ * **Edge TTS**: free online cloud synthesis without API keys (requires network connection and the `edge-tts` CLI).
90
91
  * **Kokoro-82M / F5-TTS**: weight download and status UI only. **Neural inference is not bundled** in this package — those providers fail with a clear reason and the fallback chain continues. Do not enable them expecting speech until a supported runtime is wired.
91
92
  * **ModelManager UI**: Direct manual installation in settings with real-time download progress bar, SHA-256 validation, and deletion. No silent or automatic multi-gigabyte downloads.
92
93
 
93
94
  ### 2. ⚡ Real-Time Streaming Audio (< 300 ms Latency)
94
- * **AudioWorklet (`TTSWorklet`)**: High-performance Web Audio Worklet processor playing seamless Float32Array PCM chunks at 24 kHz without audible clicks or buffer underruns.
95
+ * **HTML Audio & Web Audio Player**: Seamless queued audio playback with prebuffering, barge-in support, playback rate control, and instant browser fallback.
95
96
  * **Server-Sent Events (SSE)**: Dedicated `/dsh-tts/stream` route delivering synthesized chunks to connected browsers instantly.
96
97
 
97
98
  ### 3. 🎙️ Voice Duplex & VAD Barge-In (with `@goodandready/dsh-voice`)
@@ -173,7 +174,7 @@ dsh plugin --profile web add @goodandready/dsh-tts
173
174
 
174
175
  ---
175
176
 
176
- ## ⚙️ Configuration Recipes (`settings.yaml`)
177
+ ## ⚙️ Configuration Recipes (DSH Settings UI)
177
178
 
178
179
  ```yaml
179
180
  dsh-tts:
@@ -212,7 +213,7 @@ The plugin settings card (**Settings → Plugins → Plugin settings**) exposes
212
213
 
213
214
  Provider **API keys are never stored in plugin settings**. Paste them in the chain editor; values go to the DSH credential store via `PUT /dsh-tts/credential`.
214
215
 
215
- Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). They only rename the credential slot the plugin looks up. Change them in `settings.yaml` if you must rebind a key name; the defaults match the usual environment variable names.
216
+ Config-only (not in the card): `*KeyEnv` fields (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). They only rename the credential slot the plugin looks up. Change them in the profile settings if you must rebind a key name; the defaults match the usual environment variable names.
216
217
 
217
218
 
218
219
  ## 🤖 HTTP Endpoints Reference
package/README.ru.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- <h3>Многопровайдерная озвучка ответов агента: облачные и системные offline-движки, потоковый звук, IT-словарь и интеграция с мессенджерами для DeepSeek Harness</h3>
5
+ <h3>Многопровайдерная озвучка ответов агента: облачные, локальные и системные движки, потоковый звук, IT-словарь и интеграция с мессенджерами для DeepSeek Harness</h3>
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/@goodandready/dsh-tts"><img src="https://img.shields.io/npm/v/@goodandready/dsh-tts.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -37,11 +37,11 @@
37
37
 
38
38
  ## ⚡ Обзор
39
39
 
40
- > **Честно про локальные нейросети:** Kokoro/F5 — только статус и загрузка весов. Inference **не входит** в пакет; эти провайдеры падают с понятной причиной, цепочка идёт дальше. Рабочий offline: Edge TTS, Piper, eSpeak.
40
+ > **Честно про локальные нейросети:** Kokoro/F5 — только статус и загрузка весов. Inference **не входит** в пакет; эти провайдеры падают с понятной причиной, цепочка идёт дальше. Рабочий offline: Piper, eSpeak (Edge TTS — бесплатный онлайн-сервис без ключей).
41
41
 
42
42
  **`dsh-tts`** обеспечивает качественное голосовое озвучивание ответов ассистента в веб-интерфейсе **DeepSeek Harness**. При включённой опции **«Читать ответы агента»**, каждая готовая реплика или потоковый фрагмент синтезируется на хосте и мгновенно воспроизводится в браузере.
43
43
 
44
- API-ключи никогда не передаются в браузер: синтез аудио выполняется на стороне хоста через **независимые цепочки отказоустойчивости (фолбеков)**, включая системные offline-движки (Edge TTS, Piper, eSpeak). Kokoro/F5 — только загрузка весов; inference не входит в пакет.
44
+ API-ключи никогда не передаются в браузер: синтез аудио выполняется на стороне хоста через **независимые цепочки отказоустойчивости (фолбеков)**, включая локальные и системные движки (Edge TTS — онлайн без ключей, Piper и eSpeak — offline). Kokoro/F5 — только загрузка весов; inference не входит в пакет.
45
45
 
46
46
  ```mermaid
47
47
  graph LR
@@ -51,7 +51,7 @@ graph LR
51
51
 
52
52
  subgraph Stream [Потоковый звук < 300 мс]
53
53
  Scrub --> SSE[SSE /dsh-tts/stream]
54
- SSE --> Worklet[AudioWorklet PCM процессор]
54
+ SSE --> Player[Web Audio & HTML Audio плеер]
55
55
  end
56
56
 
57
57
  subgraph Cache [Слой производительности]
@@ -61,7 +61,7 @@ graph LR
61
61
 
62
62
  subgraph Fallback [Цепочка фолбеков TTS]
63
63
  LRU -->|Промах кэша| Chain{Активная цепочка}
64
- Chain -->|Offline| P1[Edge TTS / Piper / eSpeak]
64
+ Chain -->|Online / Local| P1[Edge TTS / Piper / eSpeak]
65
65
  Chain -.->|Cloud| P2[OpenAI / ElevenLabs / Google / Azure / Groq]
66
66
  Chain -.->|Бесплатные облака| P3[EdgeTTS / SiliconFlow]
67
67
  Chain -.->|Системный фолбек| P4[Локальный Piper / eSpeak NG]
@@ -87,13 +87,14 @@ graph LR
87
87
 
88
88
  ## 🚀 Ключевые возможности
89
89
 
90
- ### 1. 📴 Offline-системные движки и честный статус нейросетей
91
- * **Edge TTS / Piper / eSpeak**: рабочий offline/системный синтез без облачных API-ключей.
90
+ ### 1. 📴 Локальные движки и честный статус нейросетей
91
+ * **Piper / eSpeak**: полностью автономный локальный оффлайн-синтез.
92
+ * **Edge TTS**: бесплатный онлайн-синтез через Microsoft без API-ключей (требует интернет и CLI `edge-tts`).
92
93
  * **Kokoro-82M / F5-TTS**: только UI и загрузка весов. **Inference не входит в пакет** — провайдер падает с понятной причиной, цепочка идёт дальше.
93
94
  * **ModelManager в UI**: Ручная загрузка моделей прямо из настроек плагина с отображением процентов и прогресс-бара, проверкой SHA-256 и кнопкой удаления. Никаких скрытых или автозагрузок гигабайтных весов.
94
95
 
95
96
  ### 2. ⚡ Потоковое воспроизведение звука с задержкой < 300 мс
96
- * **AudioWorklet (`TTSWorklet`)**: Высокопроизводительный процессор веб-аудио с бесшовным кольцевым буфером для воспроизведения PCM-чанков (24 кГц) без щелчков и пауз.
97
+ * **HTML Audio & Web Audio плеер**: Бесшовное воспроизведение аудио-очереди с предбуферизацией, поддержкой перебивания речи (barge-in), регулировкой скорости и отказоустойчивым fallback на синтез браузера.
97
98
  * **Server-Sent Events (SSE)**: Роут `/dsh-tts/stream` передаёт синтезированные куски речи сразу в браузер, исключая задержки традиционного полинга.
98
99
 
99
100
  ### 3. 🎙️ Голосовой диалог и VAD Barge-In (в связке с `@goodandready/dsh-voice`)
@@ -156,7 +157,7 @@ dsh plugin --profile web add @goodandready/dsh-tts
156
157
 
157
158
  ---
158
159
 
159
- ## ⚙️ Пример конфигурации (`settings.yaml`)
160
+ ## ⚙️ Пример конфигурации (Интерфейс настроек DSH)
160
161
 
161
162
  ```yaml
162
163
  dsh-tts:
@@ -195,7 +196,7 @@ dsh-tts:
195
196
 
196
197
  **API-ключи не хранятся в настройках плагина.** Вставляйте их в редакторе цепочки — значение уходит в credential store DSH через `PUT /dsh-tts/credential`.
197
198
 
198
- Только в конфиге (нет в карточке): поля `*KeyEnv` (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). Они задают **имя** credential-слота; по умолчанию совпадают с привычными именами env-переменных. Меняйте в `settings.yaml`, если нужно перевязать имя ключа.
199
+ Только в конфиге (нет в карточке): поля `*KeyEnv` (`openaiKeyEnv`, `elevenlabsKeyEnv`, …). Они задают **имя** credential-слота; по умолчанию совпадают с привычными именами env-переменных. Меняйте в настройках профиля, если нужно перевязать имя ключа.
199
200
 
200
201
 
201
202
  ## 🤖 HTTP роуты API
package/README.zh.md CHANGED
@@ -47,9 +47,9 @@ graph LR
47
47
  Reply[💬 智能体生成文本] --> Scrub[智能过滤 & IT术语词典]
48
48
  end
49
49
 
50
- subgraph Stream [低延迟流式音频 < 300ms]
50
+ subgraph Stream [实时流式音频播放]
51
51
  Scrub --> SSE[SSE /dsh-tts/stream]
52
- SSE --> Worklet[AudioWorklet PCM 处理器]
52
+ SSE --> Player[Web Audio & HTML Audio 播放器]
53
53
  end
54
54
 
55
55
  subgraph Cache [性能缓存层]
@@ -90,8 +90,8 @@ graph LR
90
90
  * **Kokoro-82M / F5-TTS**:仅权重下载与状态展示。**推理未捆绑在本包中**,provider 会明确失败并继续 fallback。
91
91
  * **ModelManager 管理界面**:在设置中手动安装模型,实时显示下载进度条、SHA-256 校验和一键删除。无任何静默或自动下载。
92
92
 
93
- ### 2. ⚡ 实时流式音频播放(延迟 < 300ms)
94
- * **AudioWorklet (`TTSWorklet`)**:高性能 Web Audio Worklet 处理器,以 24kHz 采样率无缝播放 Float32Array PCM 数据块,无可感知的爆音或缓冲区欠载。
93
+ ### 2. ⚡ 实时流式音频播放
94
+ * **HTML Audio & Web Audio 播放器**:无缝队列音频播放,支持预缓冲、麦克风打断响应 (barge-in)、播放倍速控制以及浏览器原生语音故障回退。
95
95
  * **Server-Sent Events (SSE)**:专用 `/dsh-tts/stream` 路由将合成音频片段即时推送至浏览器。
96
96
 
97
97
  ### 3. 🎙️ 语音双工对话与 VAD 打断(配合 `@goodandready/dsh-voice`)
@@ -165,7 +165,7 @@ dsh plugin --profile web add @goodandready/dsh-tts
165
165
  API 密钥不写入插件设置,只在链编辑器粘贴,经 `PUT /dsh-tts/credential` 写入 DSH credential store。
166
166
 
167
167
 
168
- ## ⚙️ 配置示例(`settings.yaml`)
168
+ ## ⚙️ 配置说明(DSH 设置面板)
169
169
 
170
170
  ```yaml
171
171
  dsh-tts:
package/lib/client.js CHANGED
@@ -51,6 +51,14 @@ window.__ModuleLoader__.load({
51
51
  }
52
52
 
53
53
 
54
+ function getModelHint(provider, t) {
55
+ if (typeof t === 'function') {
56
+ const loc = t('modelHint_' + provider)
57
+ if (loc && !loc.startsWith('modelHint_')) return loc
58
+ }
59
+ return MODEL_HINT[provider] || ''
60
+ }
61
+
54
62
  function createErrorBoundary() {
55
63
  if (!React || typeof React.Component !== 'function') {
56
64
  return function NoopBoundary(props) { return (props && props.children) || null }
@@ -74,7 +82,7 @@ window.__ModuleLoader__.load({
74
82
  className: 'dts-alert dts-alert-err',
75
83
  style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
76
84
  },
77
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ TTS UI Error:'),
85
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, (typeof fallbackDockText === 'function' ? fallbackDockText('ttsUiError') : '⚠️ TTS UI Error:')),
78
86
  React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
79
87
  React.createElement(
80
88
  'button',
@@ -165,7 +173,16 @@ window.__ModuleLoader__.load({
165
173
  }
166
174
  }
167
175
 
176
+ let clearGestureListeners = null
177
+ function removeGestureListeners() {
178
+ if (typeof clearGestureListeners === 'function') {
179
+ clearGestureListeners()
180
+ clearGestureListeners = null
181
+ }
182
+ }
183
+
168
184
  function unlockAudio() {
185
+ removeGestureListeners()
169
186
  try {
170
187
  const AC = window.AudioContext || window.webkitAudioContext
171
188
  if (AC) {
@@ -330,16 +347,21 @@ window.__ModuleLoader__.load({
330
347
  player.busy = false
331
348
  playerChanged()
332
349
  if (typeof window !== 'undefined') {
350
+ removeGestureListeners()
333
351
  const onUserGesture = () => {
334
- window.removeEventListener('click', onUserGesture)
335
- window.removeEventListener('keydown', onUserGesture)
336
- window.removeEventListener('touchstart', onUserGesture)
352
+ removeGestureListeners()
337
353
  unlockAudio()
338
354
  }
339
355
  window.addEventListener('click', onUserGesture, { once: true })
340
356
  window.addEventListener('keydown', onUserGesture, { once: true })
341
357
  window.addEventListener('touchstart', onUserGesture, { once: true })
358
+ clearGestureListeners = () => {
359
+ window.removeEventListener('click', onUserGesture)
360
+ window.removeEventListener('keydown', onUserGesture)
361
+ window.removeEventListener('touchstart', onUserGesture)
362
+ }
342
363
  }
364
+ resolve()
343
365
  return
344
366
  }
345
367
  done()
@@ -370,6 +392,9 @@ window.__ModuleLoader__.load({
370
392
  }
371
393
 
372
394
  function stopPlayback() {
395
+ removeGestureListeners()
396
+ player.blocked = false
397
+ setAutoplayBlocked(false)
373
398
  for (const item of player.queue) {
374
399
  if (item && item.prepared && item.prepared.url) {
375
400
  try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
@@ -402,16 +427,22 @@ window.__ModuleLoader__.load({
402
427
  }
403
428
 
404
429
  function skipCurrent() {
430
+ try {
431
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
432
+ window.speechSynthesis.cancel()
433
+ }
434
+ } catch (_err) { /* no speechSynthesis */ }
405
435
  if (player.audio) {
406
436
  try {
407
437
  player.audio.pause()
408
438
  player.audio.currentTime = 0
409
- } catch { /* ignore pause error */ }
439
+ } catch (_err) { /* ignore pause error */ }
410
440
  }
411
441
  if (currentAudioResolve) {
412
442
  currentAudioResolve()
413
443
  } else {
414
444
  player.audio = null
445
+ player.busy = false
415
446
  playerChanged()
416
447
  if (player.queue.length) drainQueue()
417
448
  }
@@ -469,13 +500,13 @@ window.__ModuleLoader__.load({
469
500
  if (player.enabled === false) return
470
501
  player.queue.push(item)
471
502
  drainQueue()
472
- } catch {}
503
+ } catch (_err) { /* ignore malformed chime payload */ }
473
504
  }
474
505
 
475
506
  const connect = () => {
476
507
  if (sseDisposed) return
477
508
  if (activeSse) {
478
- try { activeSse.close() } catch {}
509
+ try { activeSse.close() } catch (_err) { /* active sse already closed */ }
479
510
  activeSse = null
480
511
  }
481
512
  try {
@@ -486,7 +517,7 @@ window.__ModuleLoader__.load({
486
517
  es.addEventListener('chime', onChime)
487
518
  es.onerror = () => {
488
519
  if (activeSse === es) {
489
- try { es.close() } catch {}
520
+ try { es.close() } catch (_err) { /* eventsource already closed */ }
490
521
  activeSse = null
491
522
  }
492
523
  if (sseDisposed) return
@@ -504,7 +535,7 @@ window.__ModuleLoader__.load({
504
535
  sseDisposed = true
505
536
  if (activeSseTimer) clearTimeout(activeSseTimer)
506
537
  if (activeSse) {
507
- try { activeSse.close() } catch {}
538
+ try { activeSse.close() } catch (_err) { /* active sse already closed on dispose */ }
508
539
  activeSse = null
509
540
  }
510
541
  }
@@ -655,6 +686,25 @@ window.__ModuleLoader__.load({
655
686
  return (map && map[provider]) || { configured: false, writable: true, ref: '' }
656
687
  }
657
688
  const en = {
689
+ 'ttsUiError': '⚠️ TTS UI Error:',
690
+ 'modelHint_openai': 'provider default',
691
+ 'modelHint_elevenlabs': 'provider default',
692
+ 'modelHint_google': 'provider default',
693
+ 'modelHint_azure': 'region voice name',
694
+ 'modelHint_groq': 'provider default',
695
+ 'modelHint_deepgram': 'provider default (English-only voices)',
696
+ 'modelHint_openrouter': 'provider/model id',
697
+ 'modelHint_siliconflow': 'model id (e.g. FunAudioLLM/CosyVoice2-0.5B)',
698
+ 'modelHint_deepinfra': 'model id (e.g. hexgrad/Kokoro-82M)',
699
+ 'modelHint_fireworks': 'model id (e.g. kokoro)',
700
+ 'modelHint_mimo': 'model id (e.g. mimo-v2.5-tts)',
701
+ 'modelHint_custom': 'custom model',
702
+ 'modelHint_minimax': 'model id',
703
+ 'modelHint_edge': 'BCP-47 voice, e.g. en-US-AriaNeural',
704
+ 'modelHint_piper': 'path to .onnx',
705
+ 'modelHint_espeak': 'language code, e.g. en',
706
+ 'modelHint_kokoro': 'not bundled — offline neural runtime unavailable',
707
+ 'modelHint_f5': 'not bundled — offline neural runtime unavailable',
658
708
  'dockLabel': 'Speech',
659
709
  'dockPause': 'Pause',
660
710
  'dockResume': 'Resume speech',
@@ -862,6 +912,25 @@ window.__ModuleLoader__.load({
862
912
  }
863
913
 
864
914
  const zh = {
915
+ 'ttsUiError': '⚠️ TTS 界面错误:',
916
+ 'modelHint_openai': '服务商默认',
917
+ 'modelHint_elevenlabs': '服务商默认',
918
+ 'modelHint_google': '服务商默认',
919
+ 'modelHint_azure': '区域语音名称',
920
+ 'modelHint_groq': '服务商默认',
921
+ 'modelHint_deepgram': '服务商默认 (仅英语语音)',
922
+ 'modelHint_openrouter': '服务商/模型 ID',
923
+ 'modelHint_siliconflow': '模型 ID (如 FunAudioLLM/CosyVoice2-0.5B)',
924
+ 'modelHint_deepinfra': '模型 ID (如 hexgrad/Kokoro-82M)',
925
+ 'modelHint_fireworks': '模型 ID (如 kokoro)',
926
+ 'modelHint_mimo': '模型 ID (如 mimo-v2.5-tts)',
927
+ 'modelHint_custom': '自定义模型',
928
+ 'modelHint_minimax': '模型 ID',
929
+ 'modelHint_edge': 'BCP-47 语音名称,如 en-US-AriaNeural',
930
+ 'modelHint_piper': '.onnx 模型文件路径',
931
+ 'modelHint_espeak': '语言代码,如 en',
932
+ 'modelHint_kokoro': '未内置 — 离线神经语音运行时不可用',
933
+ 'modelHint_f5': '未内置 — 离线神经语音运行时不可用',
865
934
  'dockLabel': '语音朗读',
866
935
  'dockPause': '暂停朗读',
867
936
  'dockResume': '继续朗读',
@@ -1100,7 +1169,7 @@ window.__ModuleLoader__.load({
1100
1169
  }, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
1101
1170
  React.createElement('input', {
1102
1171
  className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
1103
- placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
1172
+ placeholder: (typeof getModelHint === 'function' ? getModelHint(row.provider, t) : (MODEL_HINT[row.provider] || '')), onChange: (e) => change(i, { model: e.target.value }),
1104
1173
  }),
1105
1174
  React.createElement('input', {
1106
1175
  className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
@@ -1668,6 +1737,7 @@ window.__ModuleLoader__.load({
1668
1737
  // looks like a working empty form.
1669
1738
  // Snapshot status contract (#163):
1670
1739
  // Without valid scope, or when loading/unavailable, editing is disabled.
1740
+ const [revision, setRevision] = React.useState(null)
1671
1741
  const ready = !!scope && !!snap && snap.status === 'ready'
1672
1742
  const writable = ready && snap.writable !== false
1673
1743
 
@@ -1675,6 +1745,7 @@ window.__ModuleLoader__.load({
1675
1745
  const cfg = data && data.config ? data.config : {}
1676
1746
  setDraft(JSON.parse(JSON.stringify(cfg)))
1677
1747
  setCredentials(data && data.credentials ? data.credentials : {})
1748
+ if (data && data.revision !== undefined) setRevision(data.revision)
1678
1749
  player.enabled = !!cfg.speakReplies
1679
1750
  }
1680
1751
 
@@ -1687,7 +1758,7 @@ window.__ModuleLoader__.load({
1687
1758
 
1688
1759
  fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1689
1760
  if (!alive) return
1690
- if (data && data.ok) setIntegrations(data)
1761
+ if (data && data.ok) setIntegrations(data.integrations || data)
1691
1762
  }).catch(() => {})
1692
1763
 
1693
1764
  const loadTelemetry = () => {
@@ -1768,10 +1839,14 @@ window.__ModuleLoader__.load({
1768
1839
  const v = String(keyDrafts[p] || '').trim()
1769
1840
  if (v) keys[p] = v
1770
1841
  })
1842
+ const bodyPayload = Object.assign({}, draft, { keys: keys })
1843
+ if (revision !== null && revision !== undefined) {
1844
+ bodyPayload.revision = revision
1845
+ }
1771
1846
  const res = await fetch('/dsh-tts/config', {
1772
1847
  method: 'PUT',
1773
1848
  headers: { 'Content-Type': 'application/json' },
1774
- body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
1849
+ body: JSON.stringify(bodyPayload),
1775
1850
  })
1776
1851
  const data = await res.json().catch(() => ({ /* parse error */ }))
1777
1852
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1885,12 +1960,12 @@ window.__ModuleLoader__.load({
1885
1960
  React.createElement(VoiceDuplexEditor, {
1886
1961
  t: t, writable: writable, draft: draft,
1887
1962
  boolField: boolField, setTop: setTop,
1888
- installed: !!(integrations && integrations.voice && integrations.voice.installed),
1963
+ installed: !!(integrations && (integrations.voice?.installed ?? integrations.integrations?.voice?.installed ?? integrations.voiceInstalled)),
1889
1964
  }),
1890
1965
  React.createElement(MessengerIntegrationEditor, {
1891
1966
  t: t, writable: writable, draft: draft,
1892
1967
  boolField: boolField, setTop: setTop,
1893
- installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
1968
+ installed: !!(integrations && (integrations.messenger?.installed ?? integrations.integrations?.messenger?.installed ?? integrations.messengerInstalled)),
1894
1969
  }),
1895
1970
  React.createElement('div', { className: 'dts-block' },
1896
1971
  React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
@@ -11,12 +11,12 @@ export const MODEL_SPECS = {
11
11
  files: [
12
12
  {
13
13
  name: 'kokoro-v0_19.onnx',
14
- url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/kokoro-v0_19.onnx',
14
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/f905c3175b9f7a77d4c1b9be894ff99ff7e9a8f2/kokoro-v0_19.onnx',
15
15
  minBytes: 1024,
16
16
  },
17
17
  {
18
18
  name: 'voices.json',
19
- url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/voices.json',
19
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/f905c3175b9f7a77d4c1b9be894ff99ff7e9a8f2/voices.json',
20
20
  minBytes: 1024,
21
21
  },
22
22
  ],
@@ -45,13 +45,26 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
45
45
  return path.join(baseDir, engineId)
46
46
  }
47
47
 
48
- async function checkFileExists(filePath, minBytes = 1024) {
49
- try {
50
- const st = await fs.promises.stat(filePath)
51
- return st.isFile() && st.size >= minBytes
52
- } catch {
48
+ async function validateFileIntegrity(filePath, minBytes = 1024) {
49
+ try {
50
+ const st = await fs.promises.stat(filePath)
51
+ if (!st.isFile() || st.size < minBytes) return false
52
+ const fd = await fs.promises.open(filePath, 'r')
53
+ const buf = Buffer.alloc(Math.min(st.size, 1024))
54
+ await fd.read(buf, 0, buf.length, 0)
55
+ await fd.close()
56
+ const str = buf.toString('utf8').trim().toLowerCase()
57
+ if (str.startsWith('<!doctype') || str.startsWith('<html') || str.startsWith('<head') || str.includes('<html')) {
53
58
  return false
54
59
  }
60
+ return true
61
+ } catch (_err) {
62
+ return false
63
+ }
64
+ }
65
+
66
+ async function checkFileExists(filePath, minBytes = 1024) {
67
+ return await validateFileIntegrity(filePath, minBytes)
55
68
  }
56
69
 
57
70
  async function getDirSize(dirPath) {
@@ -236,6 +249,10 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
236
249
  if (st.size < minExpected) {
237
250
  throw new Error(`File ${f.name} truncated: got ${st.size} bytes, expected at least ${minExpected}`)
238
251
  }
252
+ const valid = await validateFileIntegrity(tmpFile, minExpected)
253
+ if (!valid) {
254
+ throw new Error(`File ${f.name} failed integrity check: invalid format or HTML error`)
255
+ }
239
256
 
240
257
  await fs.promises.rename(tmpFile, targetFile)
241
258
  const idx = createdTmpFiles.indexOf(tmpFile)
@@ -255,7 +272,9 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
255
272
  lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
256
273
  throw err
257
274
  } finally {
258
- activeDownloads.delete(engineId)
275
+ if (activeDownloads.get(engineId) === downloadState) {
276
+ activeDownloads.delete(engineId)
277
+ }
259
278
  }
260
279
  }
261
280
 
@@ -269,7 +288,16 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
269
288
  const active = activeDownloads.get(engineId)
270
289
  if (active && active.controller) {
271
290
  active.controller.abort()
272
- activeDownloads.delete(engineId)
291
+ if (active.promise) {
292
+ try {
293
+ await active.promise
294
+ } catch (_err) {
295
+ // aborted download rejected as expected
296
+ }
297
+ }
298
+ if (activeDownloads.get(engineId) === active) {
299
+ activeDownloads.delete(engineId)
300
+ }
273
301
  lastErrors.delete(engineId)
274
302
  return true
275
303
  }
package/lib/index.js CHANGED
@@ -163,9 +163,9 @@ export const Config = z.object({
163
163
  .description('Longer replies are truncated before synthesis.'),
164
164
  chain: z.array(ChainEntry).volatile()
165
165
  .default([
166
- { provider: 'edge', model: '', voice: 'ru-RU-SvetlanaNeural' },
166
+ { provider: 'edge', model: '', voice: 'en-US-JennyNeural' },
167
167
  { provider: 'piper', model: '', voice: '' },
168
- { provider: 'espeak', model: '', voice: 'ru' },
168
+ { provider: 'espeak', model: '', voice: 'en' },
169
169
  ])
170
170
  .description('Fallback chain. Order is the order of attempts. A provider without a key is skipped.'),
171
171
  openaiKeyEnv: z.string().volatile().default('OPENAI_API_KEY'),
@@ -198,23 +198,56 @@ export const Config = z.object({
198
198
  maxQueue: z.number().step(1).min(0).volatile().default(8),
199
199
  })
200
200
 
201
+ export function extractRevision(desc, targetNs = NS) {
202
+ if (!desc) return undefined
203
+ if (typeof desc.revision === 'number' || typeof desc.revision === 'string') {
204
+ return desc.revision
205
+ }
206
+ let list = null
207
+ if (Array.isArray(desc)) {
208
+ list = desc
209
+ } else if (Array.isArray(desc.namespaces)) {
210
+ list = desc.namespaces
211
+ } else if (Array.isArray(desc.entries)) {
212
+ list = desc.entries
213
+ }
214
+ if (list) {
215
+ const match = list.find((item) => {
216
+ if (!item || typeof item !== 'object') return false
217
+ const name = item.name || item.id || item.ns || item.namespace || item.key
218
+ return name === targetNs || name === 'goodandready/dsh-tts' || name === 'dsh-tts' || name === '@goodandready/dsh-tts'
219
+ })
220
+ if (match && (typeof match.revision === 'number' || typeof match.revision === 'string')) {
221
+ return match.revision
222
+ }
223
+ if (list.length === 1 && (typeof list[0].revision === 'number' || typeof list[0].revision === 'string')) {
224
+ return list[0].revision
225
+ }
226
+ }
227
+ if (typeof desc === 'object') {
228
+ for (const k of [targetNs, 'goodandready/dsh-tts', 'dsh-tts', '@goodandready/dsh-tts']) {
229
+ if (desc[k] && (typeof desc[k].revision === 'number' || typeof desc[k].revision === 'string')) {
230
+ return desc[k].revision
231
+ }
232
+ }
233
+ }
234
+ return undefined
235
+ }
236
+
201
237
  let utteranceSeq = 0
202
238
 
203
239
  export function apply(ctx, config) {
204
240
  const providerBreaker = createProviderBreaker()
205
241
 
206
- let savedConfig = null
207
242
  const live = () => {
208
243
  const fromBoxes = plainConfig(config)
209
- const merged = savedConfig ? { ...fromBoxes, ...savedConfig } : fromBoxes
210
- return plainConfig(Config(merged))
244
+ return plainConfig(Config(fromBoxes))
211
245
  }
212
246
 
213
247
  const settingsApi = {
214
248
  get: () => live(),
215
249
  replace: async (next, expectedRevision) => {
216
250
  const plain = plainConfig(next)
217
- const prevSaved = savedConfig
218
251
  const svc = ctx?.get?.('settings') ?? ctx?.settings ?? ctx?.get?.('settingsForms') ?? ctx?.settingsForms
219
252
  if (!svc) {
220
253
  throw new Error('Settings service is unavailable')
@@ -223,28 +256,33 @@ export function apply(ctx, config) {
223
256
  if (rev === undefined && typeof svc.describe === 'function') {
224
257
  try {
225
258
  const desc = await svc.describe(NS)
226
- if (desc && typeof desc.revision === 'number') {
227
- rev = desc.revision
228
- } else if (desc && Array.isArray(desc.namespaces)) {
229
- const entry = desc.namespaces.find((n) => n.ns === NS)
230
- if (entry && typeof entry.revision === 'number') rev = entry.revision
231
- }
232
- } catch {
259
+ rev = extractRevision(desc, NS)
260
+ } catch (_err) {
233
261
  // ignore describe error
234
262
  }
235
263
  }
236
- try {
237
- if (typeof svc.replace === 'function') {
238
- await svc.replace(NS, plain, rev)
239
- } else if (typeof svc.update === 'function') {
240
- await svc.update(NS, plain, rev)
241
- } else {
242
- throw new Error('Settings service does not support replace or update')
264
+ if (typeof svc.replace === 'function') {
265
+ await svc.replace(NS, plain, rev)
266
+ } else if (typeof svc.update === 'function') {
267
+ await svc.update(NS, plain, rev)
268
+ } else {
269
+ throw new Error('Settings service does not support replace or update')
270
+ }
271
+
272
+ // Mutate volatile boxes directly so runtime dynamically reflects new settings (#152)
273
+ if (config && typeof config === 'object') {
274
+ for (const [k, v] of Object.entries(plain)) {
275
+ if (config[k] && typeof config[k] === 'object') {
276
+ const sym = Object.getOwnPropertySymbols(config[k]).find((s) => String(s).includes('volatile.write'))
277
+ if (sym && typeof config[k][sym] === 'function') {
278
+ try { config[k][sym](v) } catch (_wErr) { /* best effort */ }
279
+ } else if ('value' in config[k]) {
280
+ config[k].value = v
281
+ }
282
+ } else if (config[k] !== undefined) {
283
+ config[k] = v
284
+ }
243
285
  }
244
- savedConfig = plain
245
- } catch (err) {
246
- savedConfig = prevSaved
247
- throw err
248
286
  }
249
287
  return plain
250
288
  },
@@ -265,7 +303,7 @@ export function apply(ctx, config) {
265
303
  try {
266
304
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
267
305
  if (resolved && resolved.value) return resolved.value
268
- } catch { /* fall through to env */ }
306
+ } catch (_err) { /* fall through to env */ }
269
307
  return process.env[ref] || ''
270
308
  }
271
309
 
@@ -285,7 +323,7 @@ export function apply(ctx, config) {
285
323
  }
286
324
  const resolved = await ctx.credentials.resolve(credentialRef(ref))
287
325
  return { provider, ref, configured: !!(resolved && resolved.value), writable: true }
288
- } catch {
326
+ } catch (_err) {
289
327
  return base
290
328
  }
291
329
  }
@@ -327,10 +365,21 @@ export function apply(ctx, config) {
327
365
  }
328
366
 
329
367
  async function configResponse() {
368
+ let revision = null
369
+ const svc = ctx?.get?.('settings') ?? ctx?.settings ?? ctx?.get?.('settingsForms') ?? ctx?.settingsForms
370
+ if (svc && typeof svc.describe === 'function') {
371
+ try {
372
+ const desc = await svc.describe(NS)
373
+ revision = extractRevision(desc, NS) ?? null
374
+ } catch (_err) {
375
+ // best effort describe
376
+ }
377
+ }
330
378
  return {
331
379
  ok: true,
332
380
  config: publicConfig(live()),
333
381
  credentials: await credentialsView(),
382
+ revision,
334
383
  }
335
384
  }
336
385
 
@@ -381,7 +430,7 @@ export function apply(ctx, config) {
381
430
  if (retold) {
382
431
  return speechPhrases(cfg.language).summaryIntro + ' ' + retold
383
432
  }
384
- } catch { /* quiet fallback to truncation */ }
433
+ } catch (_err) { /* quiet fallback to truncation */ }
385
434
  }
386
435
  return text.slice(0, maxChars)
387
436
  }
@@ -389,7 +438,8 @@ export function apply(ctx, config) {
389
438
  }
390
439
 
391
440
  function synthesisFingerprint(text, provider, model, voice, role, ssmlStyle, cfg, lang) {
392
- const parts = [text, provider, model || '', voice || '', role || '', ssmlStyle || '', lang || '']
441
+ const credRef = needsApiKey(provider) ? (keyEnvName(cfg, provider) || '') : ''
442
+ const parts = [text, provider, model || '', voice || '', role || '', ssmlStyle || '', lang || '', credRef]
393
443
  if (provider === 'custom') parts.push(cfg.customBaseUrl || '')
394
444
  else if (provider === 'openai') parts.push(cfg.openaiBaseUrl || '')
395
445
  else if (provider === 'mimo') parts.push(cfg.mimoBaseUrl || '', cfg.mimoFormat || '')
@@ -402,12 +452,13 @@ export function apply(ctx, config) {
402
452
 
403
453
  function flightFingerprint(text, order, models, voices, role, ssmlStyle, cfg, lang) {
404
454
  const details = order.map((p) => {
455
+ const credRef = needsApiKey(p) ? (keyEnvName(cfg, p) || '') : ''
405
456
  const extra = p === 'custom' ? (cfg.customBaseUrl || '') :
406
457
  p === 'openai' ? (cfg.openaiBaseUrl || '') :
407
458
  p === 'mimo' ? `${cfg.mimoBaseUrl || ''}:${cfg.mimoFormat || ''}` :
408
459
  p === 'azure' ? (cfg.azureRegion || '') :
409
460
  p === 'piper' ? `${cfg.piperModel || ''}:${cfg.piperBin || ''}` : ''
410
- return `${p}:${models[p] || ''}:${voices[p] || ''}:${extra}`
461
+ return `${p}:${models[p] || ''}:${voices[p] || ''}:${credRef}:${extra}`
411
462
  }).join(';')
412
463
  return cacheKey([text, details, role, ssmlStyle, lang])
413
464
  }
@@ -656,7 +707,7 @@ export function apply(ctx, config) {
656
707
  if (p && p.id && activeReservations.has(p.id)) {
657
708
  const resv = activeReservations.get(p.id)
658
709
  resv.dropped = true
659
- try { resv.controller.abort() } catch {}
710
+ try { resv.controller.abort() } catch (_abortErr) { /* ignore aborted controller */ }
660
711
  clearTimeout(resv.timer)
661
712
  activeReservations.delete(p.id)
662
713
  }
@@ -671,7 +722,7 @@ export function apply(ctx, config) {
671
722
  const resv = activeReservations.get(dropped.id)
672
723
  if (resv) {
673
724
  resv.dropped = true
674
- try { resv.controller.abort() } catch {}
725
+ try { resv.controller.abort() } catch (_abortErr) { /* ignore aborted controller */ }
675
726
  clearTimeout(resv.timer)
676
727
  activeReservations.delete(dropped.id)
677
728
  }
@@ -720,8 +771,10 @@ export function apply(ctx, config) {
720
771
  if (!cfg.speakReplies) return
721
772
  const sid = session && session.id
722
773
  if (!sid) return
723
- const isMessenger = session && (session.kind === 'im' || session.kind === 'messenger' || session.channel === 'messenger' || session.type === 'im')
724
- if (isMessenger && cfg.messengerTtsEnabled === false) return
774
+ const isMessenger = session && (session.kind === 'im' || session.kind === 'messenger' || session.channel === 'messenger' || session.type === 'im' || session.source === 'messenger')
775
+ if (isMessenger && !cfg.messengerTtsEnabled) return
776
+ const isVoice = session && (session.kind === 'voice' || session.channel === 'voice' || session.type === 'voice' || session.mode === 'voice')
777
+ if (isVoice && !cfg.voiceDuplexEnabled) return
725
778
  // The agent is waiting for human approval — worth speaking aloud
726
779
  // so the user notices without watching the screen.
727
780
  if (event.type === 'approval/asked') {
@@ -737,7 +790,7 @@ export function apply(ctx, config) {
737
790
  }
738
791
 
739
792
  if (event.type === 'assistant/message') {
740
- const text = assistantText(event.data && event.data.message)
793
+ const text = assistantText(event.data && (event.data.message ?? event.data.text ?? event.data))
741
794
  const agentName = (event.data && (
742
795
  event.data.subagent ||
743
796
  event.data.agent ||
@@ -750,8 +803,12 @@ export function apply(ctx, config) {
750
803
  // in sentence pieces; waiting for turn end means silence while the agent works.
751
804
 
752
805
  if (cfg.speakAsItGoes) {
753
- const ready = cleanText(text, cfg, 0)
754
- for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
806
+ prepareSpeechText(text, cfg).then((ready) => {
807
+ if (!ready) return
808
+ for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
809
+ }).catch((_err) => {
810
+ // best-effort speech fallback
811
+ })
755
812
  return
756
813
  }
757
814
  const cur = collectors.get(sid) || { parts: [], role: activeRole }
@@ -816,6 +873,7 @@ export function apply(ctx, config) {
816
873
  additionalProperties: false,
817
874
  properties: {
818
875
  ok: { type: 'boolean' },
876
+ id: { type: 'string' },
819
877
  provider: { type: 'string' },
820
878
  mime: { type: 'string' },
821
879
  bytes: { type: 'number' },
@@ -192,7 +192,15 @@ export function makeCloudProviders(deps, req, withCloudTimeout) {
192
192
  const m = mimeType.match(/rate=(\d+)/i)
193
193
  if (m) sampleRate = parseInt(m[1], 10)
194
194
  const isWavAlready = raw.length >= 4 && raw.toString('ascii', 0, 4) === 'RIFF'
195
- const audio = isWavAlready ? raw : pcmToWav(raw, sampleRate, 1)
195
+ let audio = null
196
+ const lowerMime = mimeType.toLowerCase()
197
+ if (isWavAlready || lowerMime.includes('wav')) {
198
+ audio = raw
199
+ } else if (!lowerMime || lowerMime.includes('pcm') || lowerMime.includes('raw') || lowerMime.includes('l16')) {
200
+ audio = pcmToWav(raw, sampleRate, 1)
201
+ } else {
202
+ return { ok: false, provider: 'google', reason: `unsupported Google TTS MIME: ${mimeType}` }
203
+ }
196
204
  return { ok: audio.length > 0, provider: 'google', audio, mime: 'audio/wav', reason: audio.length ? '' : 'empty audio' }
197
205
  } finally {
198
206
  cleanup()
package/lib/providers.js CHANGED
@@ -29,7 +29,7 @@ export function makeProviders(deps, req) {
29
29
  cleanup: () => {
30
30
  clearTimeout(timer)
31
31
  if (parentSignal && onParentAbort) {
32
- try { parentSignal.removeEventListener('abort', onParentAbort) } catch {}
32
+ try { parentSignal.removeEventListener('abort', onParentAbort) } catch (_err) { /* parent abort listener already removed */ }
33
33
  }
34
34
  },
35
35
  }
package/lib/routes.js CHANGED
@@ -119,7 +119,7 @@ export function registerHttpRoutes(ctx, api) {
119
119
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
120
120
  return
121
121
  }
122
- if (!MODEL_SPECS[engine]) {
122
+ if (!Object.hasOwn(MODEL_SPECS, engine)) {
123
123
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'unknown engine: ' + engine } })
124
124
  return
125
125
  }
@@ -160,7 +160,7 @@ export function registerHttpRoutes(ctx, api) {
160
160
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
161
161
  return
162
162
  }
163
- if (!MODEL_SPECS[engine]) {
163
+ if (!Object.hasOwn(MODEL_SPECS, engine)) {
164
164
  writeJson(res, 400, { ok: false, error: { code: 'param', message: 'unknown engine: ' + engine } })
165
165
  return
166
166
  }
@@ -187,7 +187,7 @@ export function registerHttpRoutes(ctx, api) {
187
187
  try {
188
188
  if (typeof ctx.get === 'function' && ctx.get(id)) return true
189
189
  if (ctx[id]) return true
190
- } catch {}
190
+ } catch (_err) { /* service check fallback */ }
191
191
  try {
192
192
  const reg = ctx.registry || ctx.root?.registry
193
193
  if (reg && typeof reg.entries === 'function') {
@@ -197,7 +197,7 @@ export function registerHttpRoutes(ctx, api) {
197
197
  if (id === 'messenger' && (name.includes('messenger') || name.includes('dsh-im'))) return true
198
198
  }
199
199
  }
200
- } catch {}
200
+ } catch (_err) { /* registry check fallback */ }
201
201
  try {
202
202
  if (ctx.webServer) {
203
203
  if (typeof ctx.webServer.hasRoute === 'function') {
@@ -207,7 +207,7 @@ export function registerHttpRoutes(ctx, api) {
207
207
  if (ctx.webServer.routes.some((r) => r && r.path && r.path.includes(id))) return true
208
208
  }
209
209
  }
210
- } catch {}
210
+ } catch (_err) { /* webServer route check fallback */ }
211
211
  return false
212
212
  }
213
213
 
@@ -467,6 +467,11 @@ export function registerHttpRoutes(ctx, api) {
467
467
  const rawText = String(payload.text || '')
468
468
  if (!rawText.trim()) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
469
469
  const cfg = live()
470
+ const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
471
+ if (isMessenger && !cfg.messengerTtsEnabled) {
472
+ writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
473
+ return
474
+ }
470
475
  const controller = new AbortController()
471
476
  const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
472
477
  try {
package/lib/stream-hub.js CHANGED
@@ -20,25 +20,22 @@ function detach(res) {
20
20
  }
21
21
 
22
22
  function safeWrite(res, payload) {
23
- // If subscriber is already backpressured, check buffered limit
24
- if (res.__dtsBackpressured) {
25
- const currentBuffered = typeof res.writableLength === 'number'
26
- ? res.writableLength
27
- : (res.__dtsBufferedBytes || 0)
28
- const payloadBytes = Buffer.byteLength(payload)
29
- if (currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) {
30
- // Disconnect slow subscriber exceeding memory buffer limit (#186)
31
- subscribers.delete(res)
32
- detach(res)
33
- try { res.end() } catch {}
34
- return false
35
- }
23
+ const payloadBytes = Buffer.byteLength(payload)
24
+ const currentBuffered = typeof res.writableLength === 'number'
25
+ ? res.writableLength
26
+ : (res.__dtsBufferedBytes || 0)
27
+
28
+ if (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) {
29
+ subscribers.delete(res)
30
+ detach(res)
31
+ try { res.end() } catch (_err) { /* already ended */ }
32
+ return false
36
33
  }
37
34
 
38
35
  let writeOk = true
39
36
  try {
40
37
  writeOk = res.write(payload)
41
- } catch {
38
+ } catch (_err) {
42
39
  subscribers.delete(res)
43
40
  detach(res)
44
41
  return false
@@ -46,7 +43,13 @@ function safeWrite(res, payload) {
46
43
 
47
44
  if (writeOk === false) {
48
45
  res.__dtsBackpressured = true
49
- res.__dtsBufferedBytes = (res.__dtsBufferedBytes || 0) + Buffer.byteLength(payload)
46
+ res.__dtsBufferedBytes = (res.__dtsBufferedBytes || 0) + payloadBytes
47
+ if (res.__dtsBufferedBytes > MAX_SUBSCRIBER_BUFFER) {
48
+ subscribers.delete(res)
49
+ detach(res)
50
+ try { res.end() } catch (_err) { /* already ended */ }
51
+ return false
52
+ }
50
53
  if (!res.__dtsOnDrain) {
51
54
  const onDrain = () => {
52
55
  res.__dtsBackpressured = false
@@ -55,7 +58,7 @@ function safeWrite(res, payload) {
55
58
  res.__dtsOnDrain = onDrain
56
59
  try {
57
60
  if (typeof res.on === 'function') res.on('drain', onDrain)
58
- } catch { /* non-eventemitter mock */ }
61
+ } catch (_err) { /* non-eventemitter mock */ }
59
62
  }
60
63
  } else if (res.__dtsBackpressured && (!res.writableLength || res.writableLength === 0)) {
61
64
  res.__dtsBackpressured = false
package/lib/text.js CHANGED
@@ -138,7 +138,13 @@ export function stripForSpeech(raw, maxChars, opts) {
138
138
 
139
139
  export function assistantText(message) {
140
140
  if (!message) return ''
141
- return blocksToText(message.content)
141
+ if (typeof message === 'string') return message
142
+ if (typeof message === 'object') {
143
+ if (message.content !== undefined) return blocksToText(message.content)
144
+ if (typeof message.text === 'string') return message.text
145
+ if (typeof message.message === 'string') return message.message
146
+ }
147
+ return ''
142
148
  }
143
149
 
144
150
  // Protect common English abbreviations from false sentence splits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",