@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 +29 -0
- package/README.md +9 -8
- package/README.ru.md +11 -10
- package/README.zh.md +5 -5
- package/lib/client.js +89 -14
- package/lib/engines/manager.js +37 -9
- package/lib/index.js +94 -36
- package/lib/providers/cloud.js +9 -1
- package/lib/providers.js +1 -1
- package/lib/routes.js +10 -5
- package/lib/stream-hub.js +19 -16
- package/lib/text.js +7 -1
- package/package.json +1 -1
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,
|
|
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 -->
|
|
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 -->|
|
|
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. 📴
|
|
89
|
-
* **
|
|
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
|
-
* **
|
|
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 (
|
|
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
|
|
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>Многопровайдерная озвучка ответов агента:
|
|
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
|
|
40
|
+
> **Честно про локальные нейросети:** Kokoro/F5 — только статус и загрузка весов. Inference **не входит** в пакет; эти провайдеры падают с понятной причиной, цепочка идёт дальше. Рабочий offline: Piper, eSpeak (Edge TTS — бесплатный онлайн-сервис без ключей).
|
|
41
41
|
|
|
42
42
|
**`dsh-tts`** обеспечивает качественное голосовое озвучивание ответов ассистента в веб-интерфейсе **DeepSeek Harness**. При включённой опции **«Читать ответы агента»**, каждая готовая реплика или потоковый фрагмент синтезируется на хосте и мгновенно воспроизводится в браузере.
|
|
43
43
|
|
|
44
|
-
API-ключи никогда не передаются в браузер: синтез аудио выполняется на стороне хоста через **независимые цепочки отказоустойчивости (фолбеков)**, включая системные
|
|
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 -->
|
|
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 -->|
|
|
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. 📴
|
|
91
|
-
* **
|
|
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
|
-
* **
|
|
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
|
-
## ⚙️ Пример конфигурации (
|
|
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-переменных. Меняйте в
|
|
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 [
|
|
50
|
+
subgraph Stream [实时流式音频播放]
|
|
51
51
|
Scrub --> SSE[SSE /dsh-tts/stream]
|
|
52
|
-
SSE -->
|
|
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. ⚡
|
|
94
|
-
* **
|
|
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
|
-
## ⚙️
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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')),
|
package/lib/engines/manager.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
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
|
-
|
|
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: '
|
|
166
|
+
{ provider: 'edge', model: '', voice: 'en-US-JennyNeural' },
|
|
167
167
|
{ provider: 'piper', model: '', voice: '' },
|
|
168
|
-
{ provider: 'espeak', model: '', voice: '
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
754
|
-
|
|
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' },
|
package/lib/providers/cloud.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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) +
|
|
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
|
-
|
|
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.
|
|
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",
|