dsh-voice-mode 0.2.3 → 0.5.0
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/README.en.md +28 -15
- package/README.md +69 -32
- package/assets/architecture.svg +1 -159
- package/assets/demo.gif +0 -0
- package/lib/client.js +2252 -359
- package/lib/index.js +1899 -245
- package/lib/sense-worker.mjs +128 -0
- package/lib/tts-vits-worker.cjs +147 -0
- package/package.json +18 -7
- package/scripts/bench-asr.mjs +289 -0
- package/scripts/prefetch.mjs +158 -37
- package/scripts/verify.mjs +34 -0
package/scripts/prefetch.mjs
CHANGED
|
@@ -1,24 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* dsh-voice-mode 模型预下载(prefetch):安装后、首次使用前预热
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - 缓存目录平台默认值:Windows = %LOCALAPPDATA%\dsh-voice-mode\models;
|
|
8
|
-
* 类 Unix = ~/.cache/dsh-voice-mode/models(src/index.ts defaultModelCacheDir)。
|
|
9
|
-
* - 模型清单与镜像回退(huggingface.co -> hf-mirror.com)与 src/asr-host.ts
|
|
10
|
-
* 的 MODEL_REPO / MODEL_FILES / HOST_PRIMARY / HOST_FALLBACK 保持一致。
|
|
3
|
+
* dsh-voice-mode 模型预下载(prefetch):安装后、首次使用前预热
|
|
4
|
+
* ASR(zipformer2 ~160MB / VAD ~2MB / SenseVoice ~228MB)与本地 TTS
|
|
5
|
+
* (vits-zh-ll ~130MB / kokoro int8 ~109MB)模型缓存,全部带 SHA256 校验
|
|
6
|
+
* (与运行时 src/asr-host.ts / src/tts-local.ts 清单一致)。
|
|
11
7
|
*
|
|
12
8
|
* 用法:node scripts/prefetch.mjs [--cache-dir <path>]
|
|
13
9
|
*/
|
|
14
|
-
import {
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { createWriteStream, createReadStream } from 'node:fs'
|
|
15
12
|
import { mkdir, rename, stat, unlink } from 'node:fs/promises'
|
|
16
13
|
import { homedir } from 'node:os'
|
|
17
14
|
import { join } from 'node:path'
|
|
18
15
|
|
|
19
|
-
// ---- 与 src/asr-host.ts
|
|
20
|
-
const
|
|
21
|
-
|
|
16
|
+
// ---- 与 src/asr-host.ts / src/tts-local.ts 同步的模型常量(改动时需一致)----
|
|
17
|
+
const MODELS = {
|
|
18
|
+
'csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30': {
|
|
19
|
+
'encoder.int8.onnx': '5ac51e27981bb4dab01bb9be4958453ba50c3b61c063ddda0eab23fd3671aa4f',
|
|
20
|
+
'decoder.onnx': '06522ad63cec0fdf6809f4e1db9bb4f7d710c34582e3b35db62ac60eccafac7e',
|
|
21
|
+
'joiner.int8.onnx': 'b34584dc6f561089e1d747fedebb3765f2caa72c927ef54d7ca55e5ae40a814b',
|
|
22
|
+
'tokens.txt': '6193c7ea1c96d0d9a1e9652789b40d13a8a913b434a5451e93158f5a09fd6652',
|
|
23
|
+
},
|
|
24
|
+
// VAD(Silero 端点检测)
|
|
25
|
+
'csukuangfj/vad': {
|
|
26
|
+
'silero_vad.onnx': 'a35ebf52fd3ce5f1469b2a36158dba761bc47b973ea3382b3186ca15b1f5af28',
|
|
27
|
+
},
|
|
28
|
+
// SenseVoice(定稿重译,带标点 + 数字归一化)
|
|
29
|
+
'csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17': {
|
|
30
|
+
'model.int8.onnx': 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
|
|
31
|
+
'tokens.txt': 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
|
|
32
|
+
},
|
|
33
|
+
'csukuangfj/sherpa-onnx-vits-zh-ll': {
|
|
34
|
+
'model.onnx': '6c349bdd73dc928234dd7bc86929748bba32cd5264d32d915bf7b7aa0595965b',
|
|
35
|
+
'lexicon.txt': 'b3a82f16b286c424953dea3686039e7ab465fa8e15d87ef8abd0ec69175beb21',
|
|
36
|
+
'tokens.txt': '34b035b9aeb070df6188b022f29c00e0e142c7ade9f25611ced65db5e9cc8402',
|
|
37
|
+
'G_multisperaker_latest.json': 'f31e4bf23827c3528fdf090fd7b6fb8e63333709b80670d40fa864f1fa9fadf3',
|
|
38
|
+
'date.fst': 'eb8aa079ae3cb81d8f4404992f39d61a0cb990947512b5b8d1e54d1f6980e718',
|
|
39
|
+
'phone.fst': '1ac2b6fa56b1442320c4de7db08353bab8963a2b57f365eebcdd3a2d3562f8d7',
|
|
40
|
+
'number.fst': '743f402181fcfebf76cc2f0546b71fa26476e626fbe4e460fb7b4c3a7a8bd5bd',
|
|
41
|
+
},
|
|
42
|
+
// 本地 TTS:Kokoro(中英混读,sherpa-onnx-node 原生,int8 约 109MB)
|
|
43
|
+
'csukuangfj/kokoro-int8-multi-lang-v1_1': {
|
|
44
|
+
'model.int8.onnx': 'bda15858163726a492d02a9a727bc263551b86ac77f90812c4b30ff41d380e26',
|
|
45
|
+
'voices.bin': 'e64a5a581d8c2a350d848f51c3121657cd83aa07ed6109172177345874a7244c',
|
|
46
|
+
'tokens.txt': '931ab2df2400cd65d580a22402024c2347ced8ae9ea300e545144b1aacc48e14',
|
|
47
|
+
'lexicon-us-en.txt': '7daaab53a181be9885b853a8582bf1838186317e5dadacbcef9c426d6fa0da14',
|
|
48
|
+
'lexicon-zh.txt': '11111d8cd695fba2ace1367a1d0a708b586e6ef5c1f9be91da5d7eef129b651c',
|
|
49
|
+
'espeak-ng-data/phontab': '886f3fa402cb0ba73d483aa8ad000af47a6b7cc06293c75a97913fba68a530f6',
|
|
50
|
+
'date-zh.fst': 'eb8aa079ae3cb81d8f4404992f39d61a0cb990947512b5b8d1e54d1f6980e718',
|
|
51
|
+
'number-zh.fst': '743f402181fcfebf76cc2f0546b71fa26476e626fbe4e460fb7b4c3a7a8bd5bd',
|
|
52
|
+
'phone-zh.fst': '1ac2b6fa56b1442320c4de7db08353bab8963a2b57f365eebcdd3a2d3562f8d7',
|
|
53
|
+
},
|
|
54
|
+
}
|
|
22
55
|
const HOST_PRIMARY = 'https://huggingface.co'
|
|
23
56
|
const HOST_FALLBACK = 'https://hf-mirror.com'
|
|
24
57
|
|
|
@@ -29,51 +62,71 @@ const defaultCacheDir = () =>
|
|
|
29
62
|
|
|
30
63
|
const argCache = process.argv.indexOf('--cache-dir')
|
|
31
64
|
const cacheDir = argCache !== -1 ? process.argv[argCache + 1] ?? defaultCacheDir() : defaultCacheDir()
|
|
32
|
-
const repoDir = join(cacheDir, MODEL_REPO)
|
|
33
65
|
|
|
34
|
-
|
|
66
|
+
function sha256OfFile(path) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const hash = createHash('sha256')
|
|
69
|
+
const stream = createReadStream(path)
|
|
70
|
+
stream.on('data', (c) => hash.update(c))
|
|
71
|
+
stream.on('error', reject)
|
|
72
|
+
stream.on('end', () => resolve(hash.digest('hex')))
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function downloadFile(repo, repoDir, file, expectedSha) {
|
|
35
77
|
const localPath = join(repoDir, file)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
78
|
+
const st = await stat(localPath).catch(() => null)
|
|
79
|
+
if (st?.isFile()) {
|
|
80
|
+
if ((await sha256OfFile(localPath)) === expectedSha) {
|
|
81
|
+
console.log(` ✓ ${file} 已存在(校验通过)`)
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
console.log(` ! ${file} 校验失败,重新下载`)
|
|
85
|
+
await unlink(localPath).catch(() => undefined)
|
|
39
86
|
}
|
|
40
87
|
const partPath = `${localPath}.part`
|
|
88
|
+
// 子目录文件(如 espeak-ng-data/phontab):确保父目录存在再写流。
|
|
89
|
+
await mkdir(join(repoDir, file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : ''), { recursive: true }).catch(() => undefined)
|
|
41
90
|
const partSt = await stat(partPath).catch(() => null)
|
|
42
91
|
for (const host of [HOST_PRIMARY, HOST_FALLBACK]) {
|
|
43
|
-
const url = `${host}/${
|
|
92
|
+
const url = `${host}/${repo}/resolve/main/${file}`
|
|
44
93
|
const headers = { 'user-agent': 'dsh-voice-mode/prefetch' }
|
|
45
94
|
const resumeFrom = partSt?.size ?? 0
|
|
46
95
|
if (resumeFrom > 0) headers.range = `bytes=${resumeFrom}-`
|
|
47
96
|
try {
|
|
48
97
|
const res = await fetch(url, { headers })
|
|
49
98
|
if (res.status === 416) {
|
|
50
|
-
await
|
|
51
|
-
|
|
52
|
-
|
|
99
|
+
if ((await sha256OfFile(partPath)) === expectedSha) {
|
|
100
|
+
await rename(partPath, localPath)
|
|
101
|
+
console.log(` ✓ ${file}(续传完成)`)
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
53
104
|
}
|
|
54
105
|
if (res.status !== 200 && res.status !== 206) continue
|
|
55
|
-
|
|
106
|
+
// 续传只在 206 成立;镜像/CDN 忽略 Range 返回 200 全量时必须从头重写。
|
|
107
|
+
const resume = res.status === 206 ? resumeFrom : 0
|
|
108
|
+
const total = Number(res.headers.get('content-length') ?? 0) + resume
|
|
56
109
|
const statusMax = file.length + 18
|
|
57
|
-
const sink = createWriteStream(partPath,
|
|
58
|
-
const reader = res.body
|
|
110
|
+
const sink = createWriteStream(partPath, resume > 0 ? { flags: 'a' } : {})
|
|
111
|
+
const reader = res.body?.getReader()
|
|
59
112
|
if (!reader) continue
|
|
60
|
-
let received =
|
|
61
|
-
let lastPct = -1
|
|
113
|
+
let received = resume
|
|
62
114
|
for (;;) {
|
|
63
115
|
const { done, value } = await reader.read()
|
|
64
116
|
if (done) break
|
|
65
117
|
received += value.byteLength
|
|
66
118
|
if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
|
|
67
119
|
const pct = total > 0 ? Math.min(100, Math.round((received / total) * 100)) : -1
|
|
68
|
-
if (pct >= 0 && pct
|
|
69
|
-
lastPct = pct
|
|
70
|
-
process.stdout.write(`\r ${file.padEnd(statusMax)} ${pct}%`)
|
|
71
|
-
}
|
|
120
|
+
if (pct >= 0 && pct % 10 === 0) process.stdout.write(`\r ${file.padEnd(statusMax)} ${pct}%`)
|
|
72
121
|
}
|
|
73
122
|
await new Promise((resolve, reject) => {
|
|
74
123
|
sink.end(resolve)
|
|
75
124
|
sink.on('error', reject)
|
|
76
125
|
})
|
|
126
|
+
if ((await sha256OfFile(partPath)) !== expectedSha) {
|
|
127
|
+
await unlink(partPath).catch(() => undefined)
|
|
128
|
+
continue // 校验失败换镜像
|
|
129
|
+
}
|
|
77
130
|
await rename(partPath, localPath)
|
|
78
131
|
process.stdout.write(`\r ${file.padEnd(statusMax)} 100%\n`)
|
|
79
132
|
console.log(` ✓ ${file} 完成(${(received / 1048576).toFixed(1)} MB)`)
|
|
@@ -83,15 +136,83 @@ async function downloadFile(repoDir, file) {
|
|
|
83
136
|
}
|
|
84
137
|
}
|
|
85
138
|
await unlink(partPath).catch(() => undefined)
|
|
86
|
-
console.error(` ✗ ${file}
|
|
139
|
+
console.error(` ✗ ${file} 下载失败(两个镜像均不可达或校验不过)`)
|
|
87
140
|
return false
|
|
88
141
|
}
|
|
89
142
|
|
|
90
|
-
console.log(`模型缓存目录:${repoDir}`)
|
|
91
|
-
await mkdir(repoDir, { recursive: true })
|
|
92
143
|
let ok = true
|
|
93
|
-
for (const
|
|
94
|
-
|
|
144
|
+
for (const [repo, files] of Object.entries(MODELS)) {
|
|
145
|
+
const repoDir = join(cacheDir, repo)
|
|
146
|
+
console.log(`模型仓库:${repo}(缓存 ${repoDir})`)
|
|
147
|
+
await mkdir(repoDir, { recursive: true })
|
|
148
|
+
for (const [file, sha] of Object.entries(files)) {
|
|
149
|
+
if (!(await downloadFile(repo, repoDir, file, sha))) ok = false
|
|
150
|
+
}
|
|
95
151
|
}
|
|
96
|
-
|
|
97
|
-
|
|
152
|
+
|
|
153
|
+
// --- Kokoro 中英朗读模型:文件多(含 espeak-ng-data 全目录),走 HF 树枚举批量下载 ---
|
|
154
|
+
{
|
|
155
|
+
const repo = 'csukuangfj/kokoro-int8-multi-lang-v1_1'
|
|
156
|
+
const repoDir = join(cacheDir, repo)
|
|
157
|
+
const SENTINELS = {
|
|
158
|
+
'model.int8.onnx': 'bda15858163726a492d02a9a727bc263551b86ac77f90812c4b30ff41d380e26',
|
|
159
|
+
'voices.bin': 'e64a5a581d8c2a350d848f51c3121657cd83aa07ed6109172177345874a7244c',
|
|
160
|
+
'tokens.txt': '931ab2df2400cd65d580a22402024c2347ced8ae9ea300e545144b1aacc48e14',
|
|
161
|
+
'lexicon-us-en.txt': '7daaab53a181be9885b853a8582bf1838186317e5dadacbcef9c426d6fa0da14',
|
|
162
|
+
'lexicon-zh.txt': '11111d8cd695fba2ace1367a1d0a708b586e6ef5c1f9be91da5d7eef129b651c',
|
|
163
|
+
'espeak-ng-data/phontab': '886f3fa402cb0ba73d483aa8ad000af47a6b7cc06293c75a97913fba68a530f6',
|
|
164
|
+
}
|
|
165
|
+
const sentinelOk = await Promise.all(Object.entries(SENTINELS).map(async ([f, sha]) => (await sha256OfFile(join(repoDir, f)).catch(() => '')) === sha))
|
|
166
|
+
if (!sentinelOk.every(Boolean)) {
|
|
167
|
+
console.log(`Kokoro 模型(${repo}):文件不全,走 HF 树枚举下载(约 400 个文件)`)
|
|
168
|
+
const treeRes = await fetch(`${HOST_FALLBACK}/api/models/${repo}/tree/main?recursive=true`, { headers: { 'user-agent': 'dsh-voice-mode/prefetch' } })
|
|
169
|
+
if (treeRes.ok) {
|
|
170
|
+
const tree = await treeRes.json()
|
|
171
|
+
const files = (tree ?? []).map((x) => x.path).filter((p) => !p.endsWith('/') && !['.gitattributes', 'README.md', 'LICENSE'].includes(p))
|
|
172
|
+
await mkdir(repoDir, { recursive: true })
|
|
173
|
+
let doneCount = 0
|
|
174
|
+
const queue = [...files]
|
|
175
|
+
async function worker() {
|
|
176
|
+
for (;;) {
|
|
177
|
+
const rel = queue.shift()
|
|
178
|
+
if (rel === undefined) return
|
|
179
|
+
const want = SENTINELS[rel]
|
|
180
|
+
const localPath = join(repoDir, rel)
|
|
181
|
+
const partPath = `${localPath}.part`
|
|
182
|
+
if (want && (await sha256OfFile(localPath).catch(() => '')) === want) { doneCount++; continue }
|
|
183
|
+
try {
|
|
184
|
+
await mkdir(localPath.replace(/[\\/][^\\/]*$/, ''), { recursive: true })
|
|
185
|
+
const res = await fetch(`${HOST_FALLBACK}/${repo}/resolve/main/${encodeURIComponent(rel)}`, { headers: { 'user-agent': 'dsh-voice-mode/prefetch' } })
|
|
186
|
+
if (res.status !== 200) { console.error(` ✗ ${rel} (status ${res.status})`); ok = false; continue }
|
|
187
|
+
const sink = createWriteStream(partPath)
|
|
188
|
+
const reader = res.body?.getReader()
|
|
189
|
+
if (!reader) continue
|
|
190
|
+
for (;;) {
|
|
191
|
+
const { done, value } = await reader.read()
|
|
192
|
+
if (done) break
|
|
193
|
+
if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
|
|
194
|
+
}
|
|
195
|
+
await new Promise((resolve, reject) => { sink.end(resolve); sink.on('error', reject) })
|
|
196
|
+
const got = await sha256OfFile(partPath)
|
|
197
|
+
if (want && got !== want) { console.error(` ✗ ${rel} 校验失败`); ok = false; await unlink(partPath).catch(() => undefined); continue }
|
|
198
|
+
await rename(partPath, localPath)
|
|
199
|
+
doneCount++
|
|
200
|
+
} catch {
|
|
201
|
+
console.error(` ✗ ${rel} 下载失败`)
|
|
202
|
+
ok = false
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await Promise.all(Array.from({ length: 6 }, () => worker()))
|
|
207
|
+
console.log(`Kokoro 下载完成 ${doneCount}/${files.length}`)
|
|
208
|
+
} else {
|
|
209
|
+
console.error('Kokoro 树枚举失败,请稍后重试')
|
|
210
|
+
ok = false
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
console.log(`Kokoro 模型(${repo}):已齐备(校验通过)`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log(ok ? '\n预下载完成,可直接进入语音模式(本地朗读亦可用)。' : '\n预下载未完成,可稍后重试(断点续传)。')
|
|
218
|
+
process.exitCode = ok ? 0 : 1
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 离线纯逻辑验证聚合(官方验证金字塔第 3 层)。
|
|
4
|
+
* 逐个执行无网络单测与清单自检;任一失败即非零退出。
|
|
5
|
+
* 用法:node scripts/verify.mjs(或 npm run verify)
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const root = join(here, '..')
|
|
13
|
+
const tests = [
|
|
14
|
+
['segmenter 单测', ['node', 'test/segmenter.test.mjs']],
|
|
15
|
+
['aec 单测', ['node', 'test/aec.test.mjs']],
|
|
16
|
+
['下载完整性 单测', ['node', 'test/download.test.mjs']],
|
|
17
|
+
['语义端点 单测', ['node', 'test/endpoint.test.mjs']],
|
|
18
|
+
['重采样 单测', ['node', 'test/resample.test.mjs']],
|
|
19
|
+
['SenseVoice worker 协议 单测', ['node', 'test/sense-worker.test.mjs']],
|
|
20
|
+
['打断检测路由 单测', ['node', 'test/detect-route.test.mjs']],
|
|
21
|
+
['清单/产物自检', ['node', 'test/verify-client.mjs']],
|
|
22
|
+
]
|
|
23
|
+
let failed = 0
|
|
24
|
+
for (const [name, cmd] of tests) {
|
|
25
|
+
const r = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: 'utf8' })
|
|
26
|
+
const tail = r.stdout.trim().split('\n').slice(-2).join(' | ')
|
|
27
|
+
console.log(`— ${name}: ${r.status === 0 ? 'PASS' : 'FAIL'}${tail ? `(${tail})` : ''}`)
|
|
28
|
+
if (r.status !== 0) {
|
|
29
|
+
console.error(r.stderr || r.stdout)
|
|
30
|
+
failed++
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
console.log(failed === 0 ? '\nverify: 全部通过' : `\nverify: ${failed} 项失败`)
|
|
34
|
+
process.exitCode = failed === 0 ? 0 : 1
|