dsh-voice-mode 0.2.2 → 0.3.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 +40 -32
- package/README.md +51 -28
- package/assets/architecture.svg +1 -1
- package/lib/client.js +1640 -333
- package/lib/index.js +950 -147
- package/lib/sense-worker.mjs +128 -0
- package/package.json +6 -2
- package/scripts/bench-asr.mjs +289 -0
- package/scripts/prefetch.mjs +20 -7
- package/scripts/verify.mjs +34 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/sense-worker.ts
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
function createSenseWorkerClient(worker) {
|
|
4
|
+
let counter = 0;
|
|
5
|
+
const pending = /* @__PURE__ */ new Map();
|
|
6
|
+
let dead = false;
|
|
7
|
+
const deathFns = /* @__PURE__ */ new Set();
|
|
8
|
+
const die = () => {
|
|
9
|
+
if (dead) return;
|
|
10
|
+
dead = true;
|
|
11
|
+
for (const fn of deathFns) {
|
|
12
|
+
try {
|
|
13
|
+
fn();
|
|
14
|
+
} catch {
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
worker.on?.("message", (msg) => {
|
|
19
|
+
const p = pending.get(msg?.id);
|
|
20
|
+
if (!p) return;
|
|
21
|
+
pending.delete(msg.id);
|
|
22
|
+
if (!msg.ok) {
|
|
23
|
+
p.resolve(null);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
p.resolve(p.op === "create" ? true : msg.text ?? "");
|
|
27
|
+
});
|
|
28
|
+
worker.on?.("error", (e) => {
|
|
29
|
+
die();
|
|
30
|
+
const err = new Error("sense worker error: " + String(e?.message ?? e));
|
|
31
|
+
for (const [, p] of pending) p.reject(err);
|
|
32
|
+
pending.clear();
|
|
33
|
+
});
|
|
34
|
+
worker.on?.("exit", () => {
|
|
35
|
+
die();
|
|
36
|
+
const err = new Error("sense worker exited");
|
|
37
|
+
for (const [, p] of pending) p.reject(err);
|
|
38
|
+
pending.clear();
|
|
39
|
+
});
|
|
40
|
+
const request = (op, samples) => {
|
|
41
|
+
if (dead) return Promise.reject(new Error("sense worker dead"));
|
|
42
|
+
const id = counter++;
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
pending.set(id, { op, resolve, reject });
|
|
45
|
+
const msg = { id, op };
|
|
46
|
+
if (samples) msg.samples = samples;
|
|
47
|
+
try {
|
|
48
|
+
worker.postMessage(msg);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
pending.delete(id);
|
|
51
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
request,
|
|
57
|
+
onDeath(fn) {
|
|
58
|
+
deathFns.add(fn);
|
|
59
|
+
},
|
|
60
|
+
terminate: async () => {
|
|
61
|
+
dead = true;
|
|
62
|
+
const err = new Error("sense worker terminated");
|
|
63
|
+
for (const [, p] of pending) p.reject(err);
|
|
64
|
+
pending.clear();
|
|
65
|
+
try {
|
|
66
|
+
await worker.terminate?.();
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function startSenseWorker(data) {
|
|
73
|
+
const port = parentPort;
|
|
74
|
+
if (!port) return;
|
|
75
|
+
let recognizer = null;
|
|
76
|
+
let sherpa = null;
|
|
77
|
+
port.on("message", async (msg) => {
|
|
78
|
+
try {
|
|
79
|
+
if (msg.op === "create" || msg.op === "decode") {
|
|
80
|
+
if (!sherpa) {
|
|
81
|
+
sherpa = await import(data.sherpaModule);
|
|
82
|
+
}
|
|
83
|
+
if (!recognizer) {
|
|
84
|
+
recognizer = sherpa.createOfflineRecognizer({
|
|
85
|
+
featConfig: { sampleRate: 16e3, featureDim: 80 },
|
|
86
|
+
modelConfig: {
|
|
87
|
+
senseVoice: {
|
|
88
|
+
model: data.modelDir + "/model.int8.onnx",
|
|
89
|
+
language: "auto",
|
|
90
|
+
useInverseTextNormalization: 1
|
|
91
|
+
},
|
|
92
|
+
tokens: data.modelDir + "/tokens.txt",
|
|
93
|
+
provider: "cpu",
|
|
94
|
+
debug: 0
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (msg.op === "decode" && msg.samples) {
|
|
99
|
+
const stream = recognizer.createStream();
|
|
100
|
+
try {
|
|
101
|
+
stream.acceptWaveform(16e3, msg.samples);
|
|
102
|
+
recognizer.decode(stream);
|
|
103
|
+
const text = recognizer.getResult(stream).text.trim();
|
|
104
|
+
port.postMessage({ id: msg.id, ok: true, text });
|
|
105
|
+
} finally {
|
|
106
|
+
try {
|
|
107
|
+
stream.free();
|
|
108
|
+
} catch {
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
port.postMessage({ id: msg.id, ok: true, text: "" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
port.postMessage({ id: msg.id, ok: false, error: "unknown op: " + msg.op });
|
|
117
|
+
} catch (e) {
|
|
118
|
+
port.postMessage({ id: msg.id, ok: false, error: String(e) });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (parentPort) {
|
|
123
|
+
startSenseWorker(workerData);
|
|
124
|
+
}
|
|
125
|
+
export {
|
|
126
|
+
createSenseWorkerClient,
|
|
127
|
+
startSenseWorker
|
|
128
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-voice-mode",
|
|
3
3
|
"description": "Full-duplex voice mode for DeepSeek Harness: zipformer2 streaming ASR → editable draft, Edge TTS sentence-by-sentence read-aloud with live captions, true barge-in — on-device ASR, no API key. · DSH 语音双工对话:流式识别入草稿、按句朗读+实时字幕、开口即打断,识别本地推理、无需 API Key",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"repository": {
|
|
@@ -24,11 +24,14 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"lib/index.js",
|
|
26
26
|
"lib/client.js",
|
|
27
|
+
"lib/sense-worker.mjs",
|
|
27
28
|
"cordis.patch.yml",
|
|
28
29
|
"assets/architecture.svg",
|
|
29
30
|
"assets/demo.gif",
|
|
30
31
|
"scripts/prefetch.mjs",
|
|
31
32
|
"scripts/list-voices.mjs",
|
|
33
|
+
"scripts/bench-asr.mjs",
|
|
34
|
+
"scripts/verify.mjs",
|
|
32
35
|
"README.md",
|
|
33
36
|
"README.en.md",
|
|
34
37
|
"LICENSE"
|
|
@@ -36,10 +39,11 @@
|
|
|
36
39
|
"scripts": {
|
|
37
40
|
"build": "node build.mjs",
|
|
38
41
|
"prepack": "node build.mjs",
|
|
39
|
-
"test": "node test/segmenter.test.mjs && node test/
|
|
42
|
+
"test": "node test/segmenter.test.mjs && node test/aec.test.mjs && node test/download.test.mjs && node test/endpoint.test.mjs && node test/resample.test.mjs && node test/sense-worker.test.mjs && node test/detect-route.test.mjs && node test/verify-client.mjs",
|
|
40
43
|
"verify:client": "node test/verify-client.mjs",
|
|
41
44
|
"prefetch": "node scripts/prefetch.mjs",
|
|
42
45
|
"list-voices": "node scripts/list-voices.mjs",
|
|
46
|
+
"bench-asr": "node scripts/bench-asr.mjs",
|
|
43
47
|
"verify": "node scripts/verify.mjs",
|
|
44
48
|
"typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.client.json --noEmit"
|
|
45
49
|
},
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* P4-2 在线 ASR 换型离线对照评测(CER/段延迟/体积)。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node scripts/bench-asr.mjs --dir <测试集目录>
|
|
7
|
+
*
|
|
8
|
+
* 测试集目录约定:*.wav(16k 单声道 16bit PCM)+ 同名 *.txt(参考文本,UTF-8)。
|
|
9
|
+
* 模型自动懒下载至平台缓存目录(与插件同一约定:Linux/macOS
|
|
10
|
+
* ~/.cache/dsh-voice-mode/models/,Windows %LOCALAPPDATA%\dsh-voice-mode\models),
|
|
11
|
+
* .part 断点续传,huggingface.co ↗ hf-mirror.com 回退(--host 可指定镜像)。
|
|
12
|
+
*
|
|
13
|
+
* 输出:Markdown 表格(model | CER% | 平均段延迟 ms | 模型体积 MB),
|
|
14
|
+
* 「数据说话」支撑 P4 换型决策(plan.md §3-P4)。
|
|
15
|
+
*/
|
|
16
|
+
import { createWriteStream, readdirSync, readFileSync, statSync, mkdirSync, renameSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import sherpa_onnx from 'sherpa-onnx'
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const root = join(here, '..')
|
|
24
|
+
|
|
25
|
+
// ---------- 模型清单(repo/文件清单/设备配置) ----------
|
|
26
|
+
const MODELS = [
|
|
27
|
+
{
|
|
28
|
+
id: 'zipformer-zh-int8',
|
|
29
|
+
repo: 'csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30',
|
|
30
|
+
files: ['encoder.int8.onnx', 'decoder.onnx', 'joiner.int8.onnx', 'tokens.txt'],
|
|
31
|
+
make: (t) => ({
|
|
32
|
+
modelConfig: {
|
|
33
|
+
transducer: { encoder: t('encoder.int8.onnx'), decoder: t('decoder.onnx'), joiner: t('joiner.int8.onnx') },
|
|
34
|
+
tokens: t('tokens.txt'), numThreads: 4, provider: 'cpu', debug: 0,
|
|
35
|
+
},
|
|
36
|
+
decodingMethod: 'greedy_search',
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'zipformer-zh-xlarge-int8',
|
|
41
|
+
repo: 'csukuangfj/sherpa-onnx-streaming-zipformer-zh-xlarge-int8-2025-06-30',
|
|
42
|
+
files: ['encoder.int8.onnx', 'decoder.onnx', 'joiner.int8.onnx', 'tokens.txt'],
|
|
43
|
+
make: (t) => ({
|
|
44
|
+
modelConfig: {
|
|
45
|
+
transducer: { encoder: t('encoder.int8.onnx'), decoder: t('decoder.onnx'), joiner: t('joiner.int8.onnx') },
|
|
46
|
+
tokens: t('tokens.txt'), numThreads: 4, provider: 'cpu', debug: 0,
|
|
47
|
+
},
|
|
48
|
+
decodingMethod: 'greedy_search',
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'zipformer-small-ctc-zh-int8',
|
|
53
|
+
repo: 'csukuangfj/sherpa-onnx-streaming-zipformer-small-ctc-zh-int8-2025-04-01',
|
|
54
|
+
files: ['model.int8.onnx', 'tokens.txt'],
|
|
55
|
+
make: (t) => ({
|
|
56
|
+
modelConfig: {
|
|
57
|
+
zipformer2Ctc: { model: t('model.int8.onnx') },
|
|
58
|
+
tokens: t('tokens.txt'), numThreads: 4, provider: 'cpu', debug: 0,
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'paraformer-bilingual-zh-en',
|
|
64
|
+
repo: 'csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en',
|
|
65
|
+
files: ['encoder.int8.onnx', 'decoder.int8.onnx', 'tokens.txt'],
|
|
66
|
+
make: (t) => ({
|
|
67
|
+
modelConfig: {
|
|
68
|
+
paraformer: { encoder: t('encoder.int8.onnx'), decoder: t('decoder.int8.onnx') },
|
|
69
|
+
tokens: t('tokens.txt'), numThreads: 4, provider: 'cpu', debug: 0,
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
// ---------- 参数 ----------
|
|
76
|
+
function parseArgs(argv) {
|
|
77
|
+
const args = { dir: null, host: 'https://huggingface.co' }
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
if (argv[i] === '--dir') args.dir = argv[i + 1]
|
|
80
|
+
else if (argv[i] === '--host') args.host = argv[i + 1]
|
|
81
|
+
}
|
|
82
|
+
return args
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------- 平台缓存目录 ----------
|
|
86
|
+
function cacheDir() {
|
|
87
|
+
return process.platform === 'win32'
|
|
88
|
+
? join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'dsh-voice-mode', 'models')
|
|
89
|
+
: join(homedir(), '.cache', 'dsh-voice-mode', 'models')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- 懒下载(.part 续传 + host 回退,与插件 ensureFile 同构) ----------
|
|
93
|
+
async function ensureFile(repoDir, file, hosts) {
|
|
94
|
+
const localPath = join(repoDir, file)
|
|
95
|
+
try {
|
|
96
|
+
if (statSync(localPath).isFile()) return true
|
|
97
|
+
} catch {
|
|
98
|
+
// 缺失
|
|
99
|
+
}
|
|
100
|
+
if (file === 'tokens.txt') console.log(` 下载 ${file}…`)
|
|
101
|
+
else console.log(` 下载 ${file}(可能较大)…`)
|
|
102
|
+
mkdirSync(repoDir, { recursive: true })
|
|
103
|
+
const partPath = localPath + '.part'
|
|
104
|
+
let partSize = 0
|
|
105
|
+
try {
|
|
106
|
+
partSize = statSync(partPath).size
|
|
107
|
+
} catch {
|
|
108
|
+
// 无 .part
|
|
109
|
+
}
|
|
110
|
+
for (const host of hosts) {
|
|
111
|
+
try {
|
|
112
|
+
const url = `${host}/${repoDir.split(/[\\/]/).pop()}/resolve/main/${file}`
|
|
113
|
+
const headers = { 'user-agent': 'dsh-voice-mode-bench' }
|
|
114
|
+
if (partSize > 0) headers.range = `bytes=${partSize}-`
|
|
115
|
+
const res = await fetch(url, { headers })
|
|
116
|
+
if (res.status === 416) {
|
|
117
|
+
renameSync(partPath, localPath)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
if (res.status !== 200 && res.status !== 206) continue
|
|
121
|
+
const sink = createWriteStream(partPath, partSize > 0 ? { flags: 'a' } : {})
|
|
122
|
+
const reader = res.body.getReader()
|
|
123
|
+
for (;;) {
|
|
124
|
+
const { done, value } = await reader.read()
|
|
125
|
+
if (done) break
|
|
126
|
+
if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
|
|
127
|
+
}
|
|
128
|
+
await new Promise((resolve, reject) => {
|
|
129
|
+
sink.end(() => resolve())
|
|
130
|
+
sink.on('error', reject)
|
|
131
|
+
})
|
|
132
|
+
renameSync(partPath, localPath)
|
|
133
|
+
return true
|
|
134
|
+
} catch {
|
|
135
|
+
partSize = 0 // 换 host 重来
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async function ensureModels(cache, repo, files, hosts) {
|
|
143
|
+
const repoDir = join(cache, repo)
|
|
144
|
+
for (const f of files) {
|
|
145
|
+
if (!(await ensureFile(repoDir, f, hosts))) {
|
|
146
|
+
console.error(` 模型下载失败: ${repo}/${f}`)
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------- WAV 读取(readWaveFromBinaryData → {samples, sampleRate}) ----------
|
|
154
|
+
async function loadTestSet(dir) {
|
|
155
|
+
const entries = readdirSync(dir)
|
|
156
|
+
const wavs = entries.filter((n) => n.endsWith('.wav')).sort()
|
|
157
|
+
const cases = []
|
|
158
|
+
for (const w of wavs) {
|
|
159
|
+
const txt = w.replace(/\.wav$/, '.txt')
|
|
160
|
+
if (!entries.includes(txt)) {
|
|
161
|
+
console.warn(`跳过 ${w}:无同名 ${txt} 参考文本`)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
const buf = readFileSync(join(dir, w))
|
|
165
|
+
let wav
|
|
166
|
+
try {
|
|
167
|
+
wav = sherpa_onnx.readWaveFromBinaryData(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.warn(`跳过 ${w}:无法解析 WAV(${String(e).slice(0, 80)})`)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (wav.sampleRate !== 16000) {
|
|
173
|
+
console.warn(`跳过 ${w}:采样率 ${wav.sampleRate}Hz ≠ 16k(请先重采样)`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
const ref = readFileSync(join(dir, txt), 'utf8').trim()
|
|
177
|
+
cases.push({ name: w, samples: wav.samples, ref })
|
|
178
|
+
}
|
|
179
|
+
return cases
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------- 编辑距离(字符级,中文按字) ----------
|
|
183
|
+
function cer(hyp, ref) {
|
|
184
|
+
const a = [...hyp]
|
|
185
|
+
const b = [...ref]
|
|
186
|
+
const m = a.length
|
|
187
|
+
const n = b.length
|
|
188
|
+
const dp = new Uint32Array((m + 1) * (n + 1))
|
|
189
|
+
for (let i = 0; i <= m; i++) dp[i * (n + 1)] = i
|
|
190
|
+
for (let j = 0; j <= n; j++) dp[j] = j
|
|
191
|
+
for (let i = 1; i <= m; i++) {
|
|
192
|
+
for (let j = 1; j <= n; j++) {
|
|
193
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
194
|
+
dp[i * (n + 1) + j] = Math.min(
|
|
195
|
+
dp[(i - 1) * (n + 1) + j] + 1,
|
|
196
|
+
dp[i * (n + 1) + j - 1] + 1,
|
|
197
|
+
dp[(i - 1) * (n + 1) + j - 1] + cost,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const dist = dp[m * (n + 1) + n]
|
|
202
|
+
const denom = Math.max(n, 1)
|
|
203
|
+
return (dist / denom) * 100
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
// ---------- 单模型评测 ----------
|
|
209
|
+
async function evalModel(model, cases, hosts, cache) {
|
|
210
|
+
const repoDir = join(cache, model.repo)
|
|
211
|
+
if (!(await ensureModels(cache, model.repo, model.files, hosts))) {
|
|
212
|
+
return { id: model.id, cer: null, ms: null, mb: null, error: '模型下载失败' }
|
|
213
|
+
}
|
|
214
|
+
const t = (f) => join(repoDir, f)
|
|
215
|
+
const rec = sherpa_onnx.createOnlineRecognizer(model.make(t))
|
|
216
|
+
let distSum = 0
|
|
217
|
+
let lenSum = 0
|
|
218
|
+
const timings = []
|
|
219
|
+
for (const c of cases) {
|
|
220
|
+
const stream = rec.createStream()
|
|
221
|
+
const t0 = performance.now()
|
|
222
|
+
stream.acceptWaveform(16000, c.samples)
|
|
223
|
+
while (rec.isReady(stream)) rec.decode(stream)
|
|
224
|
+
const text = rec.getResult(stream).text
|
|
225
|
+
timings.push(performance.now() - t0)
|
|
226
|
+
distSum += cer(text, c.ref) * Math.max(c.ref.length, 1)
|
|
227
|
+
lenSum += Math.max(c.ref.length, 1)
|
|
228
|
+
stream.free?.()
|
|
229
|
+
}
|
|
230
|
+
rec.free?.()
|
|
231
|
+
let bytes = 0
|
|
232
|
+
for (const f of model.files) {
|
|
233
|
+
try {
|
|
234
|
+
bytes += statSync(join(repoDir, f)).size
|
|
235
|
+
} catch {
|
|
236
|
+
// ignore
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
id: model.id,
|
|
241
|
+
cer: lenSum > 0 ? distSum / lenSum : null,
|
|
242
|
+
ms: timings.length > 0 ? timings.reduce((a, b) => a + b, 0) / timings.length : null,
|
|
243
|
+
mb: bytes / (1024 * 1024),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function main() {
|
|
248
|
+
const args = parseArgs(process.argv.slice(2))
|
|
249
|
+
if (!args.dir) {
|
|
250
|
+
console.error('用法: node scripts/bench-asr.mjs --dir <测试集目录> [--host 镜像]')
|
|
251
|
+
process.exit(1)
|
|
252
|
+
}
|
|
253
|
+
const cache = cacheDir()
|
|
254
|
+
const hosts = [...new Set([args.host, 'https://huggingface.co', 'https://hf-mirror.com'].filter(Boolean))]
|
|
255
|
+
const cases = await loadTestSet(args.dir)
|
|
256
|
+
if (cases.length === 0) {
|
|
257
|
+
console.error(`测试集为空:${args.dir}(需 16k 单声道 16bit PCM .wav + 同名 .txt)`)
|
|
258
|
+
process.exit(1)
|
|
259
|
+
}
|
|
260
|
+
console.log(`测试集 ${args.dir}:${cases.length} 段(合计约 ${Math.round(cases.reduce((a, c) => a + c.samples.length, 0) / 16000)}s 音频)`)
|
|
261
|
+
console.log('')
|
|
262
|
+
const rows = []
|
|
263
|
+
for (const m of MODELS) {
|
|
264
|
+
const r = await evalModel(m, cases, hosts, cache)
|
|
265
|
+
rows.push(r)
|
|
266
|
+
console.log(
|
|
267
|
+
r.error
|
|
268
|
+
? `- ${m.id}: ${r.error}`
|
|
269
|
+
: `- ${m.id}: CER ${r.cer.toFixed(2)}% · 平均段延迟 ${r.ms.toFixed(0)}ms · 体积 ${r.mb.toFixed(0)}MB`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
console.log('')
|
|
273
|
+
console.log('| 模型 | CER% | 平均段延迟 ms | 体积 MB |')
|
|
274
|
+
console.log('| --- | --- | --- | --- |')
|
|
275
|
+
for (const r of rows) {
|
|
276
|
+
console.log(
|
|
277
|
+
r.error || r.cer === null
|
|
278
|
+
? `| ${r.id} | — | — | — (${r.error ?? '无数据'}) |`
|
|
279
|
+
: `| ${r.id} | ${r.cer.toFixed(2)} | ${r.ms.toFixed(0)} | ${r.mb.toFixed(0)} |`,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
console.log('')
|
|
283
|
+
console.log('说明:CER = 字符编辑距离/参考长度;段延迟 = 整段喂入到 getResult 的墙钟;体积 = 模型文件磁盘占用。')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
main().catch((e) => {
|
|
287
|
+
console.error('bench failed:', e)
|
|
288
|
+
process.exit(1)
|
|
289
|
+
})
|
package/scripts/prefetch.mjs
CHANGED
|
@@ -38,26 +38,30 @@ async function downloadFile(repoDir, file) {
|
|
|
38
38
|
return true
|
|
39
39
|
}
|
|
40
40
|
const partPath = `${localPath}.part`
|
|
41
|
-
const partSt = await stat(partPath).catch(() => null)
|
|
42
41
|
for (const host of [HOST_PRIMARY, HOST_FALLBACK]) {
|
|
43
42
|
const url = `${host}/${MODEL_REPO}/resolve/main/${file}`
|
|
44
43
|
const headers = { 'user-agent': 'dsh-voice-mode/prefetch' }
|
|
45
|
-
|
|
44
|
+
// 续传基准每 host 重读:前一 host 可能已追加过 .part,复用过期大小会拼坏文件。
|
|
45
|
+
const resumeFrom = (await stat(partPath).catch(() => null))?.size ?? 0
|
|
46
46
|
if (resumeFrom > 0) headers.range = `bytes=${resumeFrom}-`
|
|
47
47
|
try {
|
|
48
|
-
|
|
48
|
+
// 与 src/asr-host.ts 一致:15 分钟超时(60s 会掐断 161MB encoder 大模型)+ 完整性核对。
|
|
49
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(900000) })
|
|
49
50
|
if (res.status === 416) {
|
|
50
51
|
await rename(partPath, localPath).catch(() => undefined)
|
|
51
52
|
console.log(` ✓ ${file}(续传完成)`)
|
|
52
53
|
return true
|
|
53
54
|
}
|
|
54
55
|
if (res.status !== 200 && res.status !== 206) continue
|
|
55
|
-
|
|
56
|
+
// 仅 206 才续传;带 .part 却回 200 全量(CDN 忽略 Range)必须从头重写,
|
|
57
|
+
// 否则在旧字节上追加全量 → 半旧半新损坏(与 asr-host.ts 同款修复)。
|
|
58
|
+
const resume = res.status === 206 ? resumeFrom : 0
|
|
59
|
+
const total = Number(res.headers.get('content-length') ?? 0) + resume
|
|
56
60
|
const statusMax = file.length + 18
|
|
57
|
-
const sink = createWriteStream(partPath,
|
|
61
|
+
const sink = createWriteStream(partPath, resume > 0 ? { flags: 'a' } : {})
|
|
58
62
|
const reader = res.body
|
|
59
63
|
if (!reader) continue
|
|
60
|
-
let received =
|
|
64
|
+
let received = resume
|
|
61
65
|
let lastPct = -1
|
|
62
66
|
for (;;) {
|
|
63
67
|
const { done, value } = await reader.read()
|
|
@@ -71,8 +75,17 @@ async function downloadFile(repoDir, file) {
|
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
77
|
await new Promise((resolve, reject) => {
|
|
74
|
-
|
|
78
|
+
// 仅当字节数与声明一致(或无声明且收到 EOF)才算成功:截断即失败换 host。
|
|
79
|
+
sink.on('finish', () => {
|
|
80
|
+
if (total > 0 && received < total) {
|
|
81
|
+
sink.destroy(new Error('download truncated'))
|
|
82
|
+
reject(new Error('download truncated'))
|
|
83
|
+
} else {
|
|
84
|
+
resolve(true)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
75
87
|
sink.on('error', reject)
|
|
88
|
+
sink.end()
|
|
76
89
|
})
|
|
77
90
|
await rename(partPath, localPath)
|
|
78
91
|
process.stdout.write(`\r ${file.padEnd(statusMax)} 100%\n`)
|
|
@@ -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
|