@goodandready/dsh-tts 0.4.27 → 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.
@@ -11,11 +11,13 @@ 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
+ minBytes: 1024,
15
16
  },
16
17
  {
17
18
  name: 'voices.json',
18
- url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/voices.json',
19
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/f905c3175b9f7a77d4c1b9be894ff99ff7e9a8f2/voices.json',
20
+ minBytes: 1024,
19
21
  },
20
22
  ],
21
23
  },
@@ -28,6 +30,7 @@ export const MODEL_SPECS = {
28
30
  {
29
31
  name: 'model_1200000.safetensors',
30
32
  url: 'https://huggingface.co/SWivid/F5-TTS/resolve/main/F5TTS_Base/model_1200000.safetensors',
33
+ minBytes: 1024,
31
34
  },
32
35
  ],
33
36
  },
@@ -42,13 +45,26 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
42
45
  return path.join(baseDir, engineId)
43
46
  }
44
47
 
45
- async function checkFileExists(filePath) {
46
- try {
47
- const st = await fs.promises.stat(filePath)
48
- return st.isFile() && st.size > 0
49
- } 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')) {
50
58
  return false
51
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)
52
68
  }
53
69
 
54
70
  async function getDirSize(dirPath) {
@@ -98,7 +114,7 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
98
114
  let allFilesExist = true
99
115
  for (const f of spec.files) {
100
116
  const p = path.join(dir, f.name)
101
- if (!(await checkFileExists(p))) {
117
+ if (!(await checkFileExists(p, f.minBytes || 1024))) {
102
118
  allFilesExist = false
103
119
  break
104
120
  }
@@ -132,14 +148,14 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
132
148
  const spec = MODEL_SPECS[engineId]
133
149
  if (!spec) throw new Error(`Unknown engine: ${engineId}`)
134
150
 
135
- if (activeDownloads.has(engineId)) {
136
- throw new Error(`Download already in progress for ${engineId}`)
151
+ const existing = activeDownloads.get(engineId)
152
+ if (existing && existing.promise) {
153
+ return await existing.promise
137
154
  }
138
155
  lastErrors.delete(engineId)
139
156
 
140
157
  const customFetch = options.fetchImpl || fetchImpl
141
158
  const dir = getModelPath(engineId)
142
- await fs.promises.mkdir(dir, { recursive: true })
143
159
 
144
160
  const controller = new AbortController()
145
161
  if (options.signal) {
@@ -152,90 +168,136 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
152
168
  downloadedBytes: 0,
153
169
  totalBytes: spec.approxMb * 1024 * 1024,
154
170
  controller,
171
+ promise: null,
155
172
  }
156
- activeDownloads.set(engineId, downloadState)
157
- let totalDownloaded = 0
158
173
 
159
- try {
160
- for (const f of spec.files) {
161
- if (options.signal?.aborted || controller.signal.aborted) {
162
- throw new Error('Download aborted')
163
- }
174
+ const createdTmpFiles = []
164
175
 
165
- const targetFile = path.join(dir, f.name)
166
- const tmpFile = path.join(dir, `${f.name}.tmp`)
176
+ const execute = async () => {
177
+ await fs.promises.mkdir(dir, { recursive: true })
178
+ let totalDownloaded = 0
179
+ try {
180
+ for (const f of spec.files) {
181
+ if (options.signal?.aborted || controller.signal.aborted) {
182
+ throw new Error('Download aborted')
183
+ }
167
184
 
168
- const res = await customFetch(f.url, { signal: controller.signal })
169
- if (!res.ok) {
170
- throw new Error(`Failed to download ${f.name}: HTTP ${res.status}`)
171
- }
185
+ const targetFile = path.join(dir, f.name)
186
+ const nonce = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
187
+ const tmpFile = path.join(dir, `${f.name}.${nonce}.tmp`)
188
+ createdTmpFiles.push(tmpFile)
189
+
190
+ const res = await customFetch(f.url, { signal: controller.signal })
191
+ if (!res.ok) {
192
+ throw new Error(`Failed to download ${f.name}: HTTP ${res.status}`)
193
+ }
194
+
195
+ if (res.body && typeof res.body.getReader === 'function') {
196
+ const reader = res.body.getReader()
197
+ const ws = fs.createWriteStream(tmpFile)
198
+ let streamError = null
199
+ const onStreamError = (err) => { streamError = err }
200
+ ws.once('error', onStreamError)
201
+ try {
202
+ while (true) {
203
+ if (streamError) throw streamError
204
+ const { done, value } = await reader.read()
205
+ if (done) break
206
+ if (streamError) throw streamError
172
207
 
173
- if (res.body && typeof res.body.getReader === 'function') {
174
- const reader = res.body.getReader()
175
- const ws = fs.createWriteStream(tmpFile)
176
- try {
177
- while (true) {
178
- const { done, value } = await reader.read()
179
- if (done) break
180
- await new Promise((resolve, reject) => {
181
208
  if (!ws.write(value)) {
182
- ws.once('drain', resolve)
183
- } else {
184
- process.nextTick(resolve)
209
+ await new Promise((resolve, reject) => {
210
+ const onDrain = () => { cleanup(); resolve() }
211
+ const onError = (e) => { cleanup(); reject(e) }
212
+ const cleanup = () => {
213
+ ws.removeListener('drain', onDrain)
214
+ ws.removeListener('error', onError)
215
+ }
216
+ ws.once('drain', onDrain)
217
+ ws.once('error', onError)
218
+ })
219
+ }
220
+ totalDownloaded += value.length
221
+ downloadState.downloadedBytes = totalDownloaded
222
+ downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
223
+ if (typeof options.onProgress === 'function') {
224
+ options.onProgress(downloadState)
185
225
  }
186
- ws.once('error', reject)
187
- })
188
- totalDownloaded += value.length
189
- downloadState.downloadedBytes = totalDownloaded
190
- downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
191
- if (typeof options.onProgress === 'function') {
192
- options.onProgress(downloadState)
193
226
  }
227
+ } finally {
228
+ ws.removeListener('error', onStreamError)
229
+ await new Promise((resolve, reject) => {
230
+ ws.end((err) => (err ? reject(err) : resolve()))
231
+ })
194
232
  }
195
- } finally {
196
- await new Promise((resolve) => ws.end(resolve))
233
+ } else if (res.arrayBuffer) {
234
+ const buf = Buffer.from(await res.arrayBuffer())
235
+ totalDownloaded += buf.length
236
+ downloadState.downloadedBytes = totalDownloaded
237
+ downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
238
+ await fs.promises.writeFile(tmpFile, buf)
239
+ } else if (typeof res.text === 'function') {
240
+ const txt = await res.text()
241
+ const buf = Buffer.from(txt, 'utf8')
242
+ totalDownloaded += buf.length
243
+ downloadState.downloadedBytes = totalDownloaded
244
+ await fs.promises.writeFile(tmpFile, buf)
197
245
  }
198
- } else if (res.arrayBuffer) {
199
- const buf = Buffer.from(await res.arrayBuffer())
200
- totalDownloaded += buf.length
201
- downloadState.downloadedBytes = totalDownloaded
202
- downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
203
- await fs.promises.writeFile(tmpFile, buf)
204
- } else if (typeof res.text === 'function') {
205
- const txt = await res.text()
206
- const buf = Buffer.from(txt, 'utf8')
207
- totalDownloaded += buf.length
208
- downloadState.downloadedBytes = totalDownloaded
209
- await fs.promises.writeFile(tmpFile, buf)
210
- }
211
246
 
212
- await fs.promises.rename(tmpFile, targetFile)
213
- }
247
+ const minExpected = f.minBytes || 1024
248
+ const st = await fs.promises.stat(tmpFile)
249
+ if (st.size < minExpected) {
250
+ throw new Error(`File ${f.name} truncated: got ${st.size} bytes, expected at least ${minExpected}`)
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
+ }
214
256
 
215
- downloadState.progress = 100
216
- activeDownloads.delete(engineId)
217
- lastErrors.delete(engineId)
218
- return await getStatus(engineId)
219
- } catch (err) {
220
- // Clean up tmp files
221
- for (const f of spec.files) {
222
- const tmpFile = path.join(dir, `${f.name}.tmp`)
223
- await fs.promises.rm(tmpFile, { force: true }).catch(() => {})
257
+ await fs.promises.rename(tmpFile, targetFile)
258
+ const idx = createdTmpFiles.indexOf(tmpFile)
259
+ if (idx !== -1) createdTmpFiles.splice(idx, 1)
260
+ }
261
+
262
+ downloadState.progress = 100
263
+ lastErrors.delete(engineId)
264
+ activeDownloads.delete(engineId)
265
+ return await getStatus(engineId)
266
+ } catch (err) {
267
+ for (const tmp of createdTmpFiles) {
268
+ await fs.promises.rm(tmp, { force: true }).catch(() => {})
269
+ }
270
+ const rawMsg = String(err?.message || err || 'Installation failed')
271
+ const sanitized = rawMsg.replace(/https?:\/\/[^\s]+/g, '[url]')
272
+ lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
273
+ throw err
274
+ } finally {
275
+ if (activeDownloads.get(engineId) === downloadState) {
276
+ activeDownloads.delete(engineId)
277
+ }
224
278
  }
225
- const rawMsg = String(err?.message || err || 'Installation failed')
226
- const sanitized = rawMsg.replace(/https?:\/\/[^\s]+/g, '[url]')
227
- lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
228
- throw err
229
- } finally {
230
- activeDownloads.delete(engineId)
231
279
  }
280
+
281
+ const promise = execute()
282
+ downloadState.promise = promise
283
+ activeDownloads.set(engineId, downloadState)
284
+ return await promise
232
285
  }
233
286
 
234
287
  async function cancelDownload(engineId) {
235
288
  const active = activeDownloads.get(engineId)
236
289
  if (active && active.controller) {
237
290
  active.controller.abort()
238
- 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
+ }
239
301
  lastErrors.delete(engineId)
240
302
  return true
241
303
  }