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.
@@ -1,24 +1,57 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * dsh-voice-mode 模型预下载(prefetch):安装后、首次使用前预热 zipformer2
4
- * 模型缓存,避免第一次进入语音模式等待下载(约 160MB)。
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 { createWriteStream } from 'node:fs'
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 MODEL_REPO = 'csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30'
21
- const MODEL_FILES = ['encoder.int8.onnx', 'decoder.onnx', 'joiner.int8.onnx', 'tokens.txt']
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
- async function downloadFile(repoDir, file) {
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
- if ((await stat(localPath).catch(() => null))?.isFile()) {
37
- console.log(` ✓ ${file} 已存在`)
38
- return true
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}/${MODEL_REPO}/resolve/main/${file}`
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 rename(partPath, localPath).catch(() => undefined)
51
- console.log(` ✓ ${file}(续传完成)`)
52
- return true
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
- const total = Number(res.headers.get('content-length') ?? 0) + resumeFrom
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, resumeFrom > 0 ? { flags: 'a' } : {})
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 = resumeFrom
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 !== lastPct && pct % 10 === 0) {
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 f of MODEL_FILES) {
94
- if (!(await downloadFile(repoDir, f))) ok = false
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
- console.log(ok ? '\n预下载完成,可直接进入语音模式。' : '\n预下载未完成,可稍后重试(断点续传)。')
97
- process.exitCode = ok ? 0 : 1
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