@goodandready/dsh-tts 0.3.16 → 0.3.21

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.
@@ -1,4 +1,70 @@
1
+ import fs from 'node:fs'
2
+
1
3
  export class KokoroEngine {
2
- constructor({modelPath}){ this.modelPath=modelPath }
3
- async *synthesize(text, voice){ yield new Float32Array(24000) }
4
+ constructor({ modelPath = '' } = {}) {
5
+ this.modelPath = modelPath
6
+ this.sampleRate = 24000
7
+ }
8
+
9
+ isInstalled() {
10
+ return !!(this.modelPath && fs.existsSync(this.modelPath))
11
+ }
12
+
13
+ /**
14
+ * Synthesizes audio stream yielding Float32Array PCM chunks (24kHz).
15
+ * @param {string} text
16
+ * @param {string} voice
17
+ * @returns {AsyncGenerator<Float32Array>}
18
+ */
19
+ async *synthesize(text, voice = 'af_bella') {
20
+ if (!text || !text.trim()) return
21
+
22
+ // Generates 1s chunk of PCM frames (24000 samples for test/stub)
23
+ const samples = Math.max(2400, Math.min(24000, text.length * 800))
24
+ const chunk = new Float32Array(samples)
25
+ for (let i = 0; i < samples; i++) {
26
+ // Soft synthetic sine wave envelope so it does not pop
27
+ chunk[i] = Math.sin((i * 440 * 2 * Math.PI) / this.sampleRate) * 0.2 * (1 - i / samples)
28
+ }
29
+ yield chunk
30
+ }
31
+
32
+ async synthesizeWav(text, voice = 'af_bella') {
33
+ const chunks = []
34
+ for await (const chunk of this.synthesize(text, voice)) {
35
+ chunks.push(chunk)
36
+ }
37
+ let totalLen = 0
38
+ for (const c of chunks) totalLen += c.length
39
+ const merged = new Float32Array(totalLen)
40
+ let offset = 0
41
+ for (const c of chunks) {
42
+ merged.set(c, offset)
43
+ offset += c.length
44
+ }
45
+ return pcmFloat32ToWav(merged, this.sampleRate)
46
+ }
47
+ }
48
+
49
+ export function pcmFloat32ToWav(samples, sampleRate = 24000) {
50
+ const numSamples = samples.length
51
+ const buffer = Buffer.alloc(44 + numSamples * 2)
52
+ buffer.write('RIFF', 0)
53
+ buffer.writeUInt32LE(36 + numSamples * 2, 4)
54
+ buffer.write('WAVE', 8)
55
+ buffer.write('fmt ', 12)
56
+ buffer.writeUInt32LE(16, 16)
57
+ buffer.writeUInt16LE(1, 20)
58
+ buffer.writeUInt16LE(1, 22)
59
+ buffer.writeUInt32LE(sampleRate, 24)
60
+ buffer.writeUInt32LE(sampleRate * 2, 28)
61
+ buffer.writeUInt16LE(2, 32)
62
+ buffer.writeUInt16LE(16, 34)
63
+ buffer.write('data', 36)
64
+ buffer.writeUInt32LE(numSamples * 2, 40)
65
+ for (let i = 0; i < numSamples; i++) {
66
+ const s = Math.max(-1, Math.min(1, samples[i]))
67
+ buffer.writeInt16LE(s < 0 ? s * 0x8000 : s * 0x7fff, 44 + i * 2)
68
+ }
69
+ return buffer
4
70
  }
@@ -0,0 +1,251 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ export const MODEL_SPECS = {
6
+ kokoro: {
7
+ id: 'kokoro',
8
+ name: 'Kokoro-82M (CPU)',
9
+ approxMb: 350,
10
+ requiresGpu: false,
11
+ files: [
12
+ {
13
+ name: 'kokoro-v0_19.onnx',
14
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/kokoro-v0_19.onnx',
15
+ },
16
+ {
17
+ name: 'voices.json',
18
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/voices.json',
19
+ },
20
+ ],
21
+ },
22
+ f5: {
23
+ id: 'f5',
24
+ name: 'F5-TTS (GPU)',
25
+ approxMb: 2100,
26
+ requiresGpu: true,
27
+ files: [
28
+ {
29
+ name: 'model_1200000.safetensors',
30
+ url: 'https://huggingface.co/SWivid/F5-TTS/resolve/main/F5TTS_Base/model_1200000.safetensors',
31
+ },
32
+ ],
33
+ },
34
+ }
35
+
36
+ export function createModelManager({ root = process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), fetchImpl = fetch } = {}) {
37
+ const baseDir = path.join(String(root), 'data', 'dsh-tts', 'models')
38
+ const activeDownloads = new Map()
39
+
40
+ function getModelPath(engineId) {
41
+ return path.join(baseDir, engineId)
42
+ }
43
+
44
+ async function checkFileExists(filePath) {
45
+ try {
46
+ const st = await fs.promises.stat(filePath)
47
+ return st.isFile() && st.size > 0
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ async function getDirSize(dirPath) {
54
+ try {
55
+ let total = 0
56
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
57
+ for (const entry of entries) {
58
+ const full = path.join(dirPath, entry.name)
59
+ if (entry.isFile()) {
60
+ const st = await fs.promises.stat(full)
61
+ total += st.size
62
+ }
63
+ }
64
+ return total
65
+ } catch {
66
+ return 0
67
+ }
68
+ }
69
+
70
+ async function getStatus(engineId) {
71
+ const spec = MODEL_SPECS[engineId]
72
+ if (!spec) {
73
+ return { id: engineId, installed: false, error: 'unknown engine' }
74
+ }
75
+
76
+ const dir = getModelPath(engineId)
77
+ const active = activeDownloads.get(engineId)
78
+
79
+ if (active) {
80
+ return {
81
+ id: engineId,
82
+ name: spec.name,
83
+ approxMb: spec.approxMb,
84
+ requiresGpu: spec.requiresGpu,
85
+ installed: false,
86
+ downloading: true,
87
+ progress: active.progress || 0,
88
+ downloadedBytes: active.downloadedBytes || 0,
89
+ totalBytes: active.totalBytes || 0,
90
+ path: dir,
91
+ }
92
+ }
93
+
94
+ let allFilesExist = true
95
+ for (const f of spec.files) {
96
+ const p = path.join(dir, f.name)
97
+ if (!(await checkFileExists(p))) {
98
+ allFilesExist = false
99
+ break
100
+ }
101
+ }
102
+
103
+ const sizeBytes = allFilesExist ? await getDirSize(dir) : 0
104
+
105
+ return {
106
+ id: engineId,
107
+ name: spec.name,
108
+ approxMb: spec.approxMb,
109
+ requiresGpu: spec.requiresGpu,
110
+ installed: allFilesExist,
111
+ downloading: false,
112
+ progress: allFilesExist ? 100 : 0,
113
+ sizeBytes,
114
+ path: dir,
115
+ }
116
+ }
117
+
118
+ async function listStatus() {
119
+ const out = {}
120
+ for (const id of Object.keys(MODEL_SPECS)) {
121
+ out[id] = await getStatus(id)
122
+ }
123
+ return out
124
+ }
125
+
126
+ async function installModel(engineId, options = {}) {
127
+ const spec = MODEL_SPECS[engineId]
128
+ if (!spec) throw new Error(`Unknown engine: ${engineId}`)
129
+
130
+ if (activeDownloads.has(engineId)) {
131
+ throw new Error(`Download already in progress for ${engineId}`)
132
+ }
133
+
134
+ const customFetch = options.fetchImpl || fetchImpl
135
+ const dir = getModelPath(engineId)
136
+ await fs.promises.mkdir(dir, { recursive: true })
137
+
138
+ const controller = new AbortController()
139
+ if (options.signal) {
140
+ if (options.signal.aborted) controller.abort()
141
+ else options.signal.addEventListener('abort', () => controller.abort(), { once: true })
142
+ }
143
+
144
+ const downloadState = {
145
+ progress: 0,
146
+ downloadedBytes: 0,
147
+ totalBytes: spec.approxMb * 1024 * 1024,
148
+ controller,
149
+ }
150
+ activeDownloads.set(engineId, downloadState)
151
+ let totalDownloaded = 0
152
+
153
+ try {
154
+ for (const f of spec.files) {
155
+ if (options.signal?.aborted || controller.signal.aborted) {
156
+ throw new Error('Download aborted')
157
+ }
158
+
159
+ const targetFile = path.join(dir, f.name)
160
+ const tmpFile = path.join(dir, `${f.name}.tmp`)
161
+
162
+ const res = await customFetch(f.url, { signal: controller.signal })
163
+ if (!res.ok) {
164
+ throw new Error(`Failed to download ${f.name}: HTTP ${res.status}`)
165
+ }
166
+
167
+ if (res.body && typeof res.body.getReader === 'function') {
168
+ const reader = res.body.getReader()
169
+ const ws = fs.createWriteStream(tmpFile)
170
+ try {
171
+ while (true) {
172
+ const { done, value } = await reader.read()
173
+ if (done) break
174
+ await new Promise((resolve, reject) => {
175
+ if (!ws.write(value)) {
176
+ ws.once('drain', resolve)
177
+ } else {
178
+ process.nextTick(resolve)
179
+ }
180
+ ws.once('error', reject)
181
+ })
182
+ totalDownloaded += value.length
183
+ downloadState.downloadedBytes = totalDownloaded
184
+ downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
185
+ if (typeof options.onProgress === 'function') {
186
+ options.onProgress(downloadState)
187
+ }
188
+ }
189
+ } finally {
190
+ await new Promise((resolve) => ws.end(resolve))
191
+ }
192
+ } else if (res.arrayBuffer) {
193
+ const buf = Buffer.from(await res.arrayBuffer())
194
+ totalDownloaded += buf.length
195
+ downloadState.downloadedBytes = totalDownloaded
196
+ downloadState.progress = Math.min(100, Math.round((totalDownloaded / (downloadState.totalBytes || 1)) * 100))
197
+ await fs.promises.writeFile(tmpFile, buf)
198
+ } else if (typeof res.text === 'function') {
199
+ const txt = await res.text()
200
+ const buf = Buffer.from(txt, 'utf8')
201
+ totalDownloaded += buf.length
202
+ downloadState.downloadedBytes = totalDownloaded
203
+ await fs.promises.writeFile(tmpFile, buf)
204
+ }
205
+
206
+ await fs.promises.rename(tmpFile, targetFile)
207
+ }
208
+
209
+ downloadState.progress = 100
210
+ activeDownloads.delete(engineId)
211
+ return await getStatus(engineId)
212
+ } catch (err) {
213
+ // Clean up tmp files
214
+ for (const f of spec.files) {
215
+ const tmpFile = path.join(dir, `${f.name}.tmp`)
216
+ await fs.promises.rm(tmpFile, { force: true }).catch(() => {})
217
+ }
218
+ throw err
219
+ } finally {
220
+ activeDownloads.delete(engineId)
221
+ }
222
+ }
223
+
224
+ async function cancelDownload(engineId) {
225
+ const active = activeDownloads.get(engineId)
226
+ if (active && active.controller) {
227
+ active.controller.abort()
228
+ activeDownloads.delete(engineId)
229
+ return true
230
+ }
231
+ return false
232
+ }
233
+
234
+ async function deleteModel(engineId) {
235
+ const spec = MODEL_SPECS[engineId]
236
+ if (!spec) throw new Error(`Unknown engine: ${engineId}`)
237
+ await cancelDownload(engineId)
238
+ const dir = getModelPath(engineId)
239
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
240
+ return await getStatus(engineId)
241
+ }
242
+
243
+ return {
244
+ getModelPath,
245
+ getStatus,
246
+ listStatus,
247
+ installModel,
248
+ cancelDownload,
249
+ deleteModel,
250
+ }
251
+ }
@@ -1,4 +1,49 @@
1
+ import { execSync } from 'node:child_process'
2
+
3
+ export function detectGPU() {
4
+ try {
5
+ const out = execSync('nvidia-smi --query-gpu=name --format=csv,noheader', {
6
+ stdio: ['ignore', 'pipe', 'ignore'],
7
+ timeout: 2000,
8
+ encoding: 'utf8',
9
+ })
10
+ return !!(out && out.trim())
11
+ } catch {
12
+ return false
13
+ }
14
+ }
15
+
1
16
  export class EngineRegistry {
2
- constructor({gpu, installed}) { this.gpu=gpu; this.installed=installed }
3
- list(){ return this.installed.map(id=>({id, supports:()=>true})) }
17
+ constructor({ gpu, installed, manager } = {}) {
18
+ this.gpu = typeof gpu === 'boolean' ? gpu : detectGPU()
19
+ this.installed = Array.isArray(installed) ? installed : []
20
+ this.manager = manager || null
21
+ }
22
+
23
+ isInstalled(engineId) {
24
+ return this.installed.includes(engineId)
25
+ }
26
+
27
+ list() {
28
+ const list = []
29
+ // Kokoro works on CPU everywhere
30
+ if (this.installed.includes('kokoro')) {
31
+ list.push({
32
+ id: 'kokoro',
33
+ name: 'Kokoro-82M',
34
+ type: 'local-cpu',
35
+ supports: (lang) => !lang || ['en', 'ru', 'ja', 'zh'].includes(String(lang).slice(0, 2).toLowerCase()),
36
+ })
37
+ }
38
+ // F5-TTS requires GPU
39
+ if (this.gpu && this.installed.includes('f5')) {
40
+ list.push({
41
+ id: 'f5',
42
+ name: 'F5-TTS',
43
+ type: 'local-gpu',
44
+ supports: () => true,
45
+ })
46
+ }
47
+ return list
48
+ }
4
49
  }