@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 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
- const proc = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
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 { /* gone */ }
18
+ try { proc.kill('SIGKILL') } catch (_err) { /* process already exited */ }
13
19
  reject(new Error(`${bin} timed out`))
14
20
  }, timeoutMs || 60000)
15
- proc.stdout.on('data', (c) => out.push(c))
16
- proc.stderr.on('data', (c) => err.push(c))
17
- proc.on('error', (e) => {
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
- clearTimeout(timer)
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.on('error', () => {})
33
- proc.stdin.end(Buffer.from(String(stdinText), 'utf8'))
56
+ proc.stdin?.on('error', () => {})
57
+ proc.stdin?.end(Buffer.from(String(stdinText), 'utf8'))
34
58
  } else {
35
- proc.stdin.end()
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, { bin, voice, timeoutMs }) {
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
- await spawnCollect(bin || 'edge-tts', [
72
- '--voice', voice || 'ru-RU-SvetlanaNeural',
73
- '--text', text,
74
- '--write-media', outFile,
75
- ], { timeoutMs })
76
- const bytes = await readFile(outFile)
77
- if (!bytes || bytes.length < 32) throw new Error('edge-tts produced empty audio')
78
- return { audio: bytes, mime: 'audio/mpeg' }
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
+ }
@@ -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 out = await edgeSpeak(text, { bin: cfg.edgeBin, voice, timeoutMs: cfg.timeoutMs })
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.35",
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",