@goodandready/dsh-tts 0.4.35 → 0.4.36
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 +9 -0
- package/lib/local.js +103 -21
- package/lib/providers/local.js +14 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.36 (2026-10-23)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Edge TTS Transient Error Retry** (#212, GitHub #4):
|
|
7
|
+
- Retries transient `edge_tts.exceptions.NoAudioReceived` errors and empty audio payloads (`< 32` bytes) up to 3 times before failing or tripping circuit breaker.
|
|
8
|
+
- Automatically cleans up corrupt/empty audio artifacts between retry attempts.
|
|
9
|
+
- Honors `req.signal` (AbortSignal) cancellation during backoff delays without hanging.
|
|
10
|
+
- Fails fast on `ENOENT` / missing binary without wasting retry cycles.
|
|
11
|
+
|
|
3
12
|
## 0.4.35 (2026-10-20)
|
|
4
13
|
|
|
5
14
|
### Fixed
|
package/lib/local.js
CHANGED
|
@@ -3,23 +3,47 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
|
|
6
|
-
function spawnCollect(bin, args, { stdinText, timeoutMs } = {}) {
|
|
6
|
+
function spawnCollect(bin, args, { stdinText, timeoutMs, signal, spawnImpl = spawn } = {}) {
|
|
7
7
|
return new Promise((resolve, reject) => {
|
|
8
|
-
|
|
8
|
+
if (signal?.aborted) {
|
|
9
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
const proc = spawnImpl === spawn
|
|
13
|
+
? spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
14
|
+
: spawnImpl(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
9
15
|
const out = []
|
|
10
16
|
const err = []
|
|
11
17
|
const timer = setTimeout(() => {
|
|
12
|
-
try { proc.kill('SIGKILL') } catch { /*
|
|
18
|
+
try { proc.kill('SIGKILL') } catch (_err) { /* process already exited */ }
|
|
13
19
|
reject(new Error(`${bin} timed out`))
|
|
14
20
|
}, timeoutMs || 60000)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
|
|
22
|
+
let onAbort = null
|
|
23
|
+
if (signal) {
|
|
24
|
+
onAbort = () => {
|
|
25
|
+
clearTimeout(timer)
|
|
26
|
+
try { proc.kill('SIGKILL') } catch (_err) { /* process already exited */ }
|
|
27
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
|
|
28
|
+
}
|
|
29
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cleanup = () => {
|
|
18
33
|
clearTimeout(timer)
|
|
34
|
+
if (signal && onAbort) {
|
|
35
|
+
try { signal.removeEventListener('abort', onAbort) } catch (_err) { /* abort listener already removed */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
proc.stdout?.on('data', (c) => out.push(c))
|
|
40
|
+
proc.stderr?.on('data', (c) => err.push(c))
|
|
41
|
+
proc.on('error', (e) => {
|
|
42
|
+
cleanup()
|
|
19
43
|
reject(new Error(`${bin} unavailable: ${e.message}`))
|
|
20
44
|
})
|
|
21
45
|
proc.on('close', (code) => {
|
|
22
|
-
|
|
46
|
+
cleanup()
|
|
23
47
|
const stdout = Buffer.concat(out)
|
|
24
48
|
const stderr = Buffer.concat(err).toString('utf8').slice(0, 240)
|
|
25
49
|
if (code !== 0) {
|
|
@@ -29,10 +53,10 @@ function spawnCollect(bin, args, { stdinText, timeoutMs } = {}) {
|
|
|
29
53
|
resolve(stdout)
|
|
30
54
|
})
|
|
31
55
|
if (stdinText != null) {
|
|
32
|
-
proc.stdin
|
|
33
|
-
proc.stdin
|
|
56
|
+
proc.stdin?.on('error', () => {})
|
|
57
|
+
proc.stdin?.end(Buffer.from(String(stdinText), 'utf8'))
|
|
34
58
|
} else {
|
|
35
|
-
proc.stdin
|
|
59
|
+
proc.stdin?.end?.()
|
|
36
60
|
}
|
|
37
61
|
})
|
|
38
62
|
}
|
|
@@ -64,24 +88,82 @@ export async function piperSpeak(text, { bin, model, timeoutMs }) {
|
|
|
64
88
|
}
|
|
65
89
|
}
|
|
66
90
|
|
|
67
|
-
export async function edgeSpeak(text,
|
|
91
|
+
export async function edgeSpeak(text, options = {}) {
|
|
92
|
+
const {
|
|
93
|
+
bin,
|
|
94
|
+
voice,
|
|
95
|
+
timeoutMs,
|
|
96
|
+
retries = 3,
|
|
97
|
+
retryDelayMs = 350,
|
|
98
|
+
signal,
|
|
99
|
+
spawnImpl = spawn,
|
|
100
|
+
} = options
|
|
101
|
+
|
|
102
|
+
const maxAttempts = Math.max(1, Number(retries) || 1)
|
|
68
103
|
const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-tts-edge-'))
|
|
69
104
|
const outFile = path.join(dir, 'out.mp3')
|
|
105
|
+
let lastError = null
|
|
106
|
+
|
|
70
107
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
108
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
109
|
+
if (signal?.aborted) {
|
|
110
|
+
throw signal.reason instanceof Error ? signal.reason : new Error('aborted')
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await rm(outFile, { force: true })
|
|
114
|
+
} catch (_err) { /* ignore cleanup error */ }
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await spawnCollect(bin || 'edge-tts', [
|
|
118
|
+
'--voice', voice || 'ru-RU-SvetlanaNeural',
|
|
119
|
+
'--text', text,
|
|
120
|
+
'--write-media', outFile,
|
|
121
|
+
], { timeoutMs, signal, spawnImpl })
|
|
122
|
+
|
|
123
|
+
let bytes = null
|
|
124
|
+
try {
|
|
125
|
+
bytes = await readFile(outFile)
|
|
126
|
+
} catch (_readErr) {
|
|
127
|
+
throw new Error('edge-tts produced no output file')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!bytes || bytes.length < 32) {
|
|
131
|
+
throw new Error('edge-tts produced empty audio')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { audio: bytes, mime: 'audio/mpeg' }
|
|
135
|
+
} catch (err) {
|
|
136
|
+
lastError = err
|
|
137
|
+
if (signal?.aborted) throw err
|
|
138
|
+
|
|
139
|
+
const errMsg = String((err && err.message) || err)
|
|
140
|
+
if (/ENOENT|not found/i.test(errMsg)) {
|
|
141
|
+
throw err
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (attempt < maxAttempts) {
|
|
145
|
+
const delay = typeof retryDelayMs === 'number' ? retryDelayMs * attempt : 350
|
|
146
|
+
if (delay > 0) {
|
|
147
|
+
await new Promise((resolve, reject) => {
|
|
148
|
+
const timer = setTimeout(resolve, delay)
|
|
149
|
+
if (signal) {
|
|
150
|
+
const onAbort = () => {
|
|
151
|
+
clearTimeout(timer)
|
|
152
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
|
|
153
|
+
}
|
|
154
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
throw lastError || new Error('edge-tts failed after retries')
|
|
79
162
|
} finally {
|
|
80
163
|
await rm(dir, { recursive: true, force: true })
|
|
81
164
|
}
|
|
82
165
|
}
|
|
83
166
|
|
|
84
|
-
|
|
85
167
|
/**
|
|
86
168
|
* MiniMax via their official CLI utility.
|
|
87
169
|
*
|
|
@@ -105,4 +187,4 @@ export async function minimaxSpeak(text, { bin, voice, model, timeoutMs }) {
|
|
|
105
187
|
} finally {
|
|
106
188
|
await rm(dir, { recursive: true, force: true })
|
|
107
189
|
}
|
|
108
|
-
}
|
|
190
|
+
}
|
package/lib/providers/local.js
CHANGED
|
@@ -12,10 +12,21 @@ export function makeLocalProviders(deps, req) {
|
|
|
12
12
|
const { cfg, modelManager } = deps
|
|
13
13
|
const { text, lang, models, voices } = req
|
|
14
14
|
|
|
15
|
-
async function edge() {
|
|
15
|
+
async function edge(extraOptions = {}) {
|
|
16
16
|
try {
|
|
17
17
|
const voice = pick(DEFAULT_VOICES, voices, 'edge') || pick(DEFAULT_MODELS, models, 'edge')
|
|
18
|
-
const
|
|
18
|
+
const retries = typeof extraOptions.retries === 'number'
|
|
19
|
+
? extraOptions.retries
|
|
20
|
+
: (typeof cfg.edgeRetries === 'number' ? cfg.edgeRetries : 3)
|
|
21
|
+
const out = await edgeSpeak(text, {
|
|
22
|
+
bin: cfg.edgeBin,
|
|
23
|
+
voice,
|
|
24
|
+
timeoutMs: cfg.timeoutMs,
|
|
25
|
+
retries,
|
|
26
|
+
retryDelayMs: extraOptions.retryDelayMs,
|
|
27
|
+
signal: req.signal,
|
|
28
|
+
spawnImpl: extraOptions.spawnImpl || req.edgeSpawnImpl,
|
|
29
|
+
})
|
|
19
30
|
return { ok: true, provider: 'edge', audio: out.audio, mime: out.mime }
|
|
20
31
|
} catch (e) {
|
|
21
32
|
return { ok: false, provider: 'edge', reason: String(e && e.message || e) }
|
|
@@ -96,4 +107,4 @@ export function makeLocalProviders(deps, req) {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
return { kokoro, f5, edge, piper, espeak, minimax }
|
|
99
|
-
}
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.36",
|
|
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",
|