@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.
- package/LICENSE +1 -1
- package/README.md +192 -87
- package/README.ru.md +195 -0
- package/README.zh.md +190 -0
- package/lib/cache.js +6 -1
- package/lib/client/worklet.js +68 -1
- package/lib/client.js +427 -16
- package/lib/engines/f5.js +43 -2
- package/lib/engines/kokoro.js +68 -2
- package/lib/engines/manager.js +251 -0
- package/lib/engines/types.js +47 -2
- package/lib/index.js +230 -19
- package/lib/keys.js +9 -0
- package/lib/providers.js +34 -1
- package/lib/text.js +32 -3
- package/package.json +2 -2
package/lib/engines/kokoro.js
CHANGED
|
@@ -1,4 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
1
3
|
export class KokoroEngine {
|
|
2
|
-
constructor({modelPath}
|
|
3
|
-
|
|
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
|
+
}
|
package/lib/engines/types.js
CHANGED
|
@@ -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
|
|
3
|
-
|
|
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
|
}
|