dsh-voice-mode 0.1.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/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "dsh-voice-mode",
3
+ "description": "Full-duplex voice conversation mode for DeepSeek Harness: streamed zipformer2 ASR to draft, Edge TTS sentence-by-sentence reading of final replies, voice barge-in · DSH 语音双工对话模式",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/qishuilalala/dsh-voice-mode.git"
10
+ },
11
+ "homepage": "https://github.com/qishuilalala/dsh-voice-mode#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/qishuilalala/dsh-voice-mode/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "exports": {
19
+ ".": "./lib/index.js",
20
+ "./client": "./lib/client.js",
21
+ "./cordis.patch.yml": "./cordis.patch.yml",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/client.js",
27
+ "cordis.patch.yml",
28
+ "assets/architecture.svg",
29
+ "assets/demo.gif",
30
+ "scripts/prefetch.mjs",
31
+ "scripts/list-voices.mjs",
32
+ "README.md",
33
+ "README.en.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "build": "node build.mjs",
38
+ "prepack": "node build.mjs",
39
+ "test": "node test/segmenter.test.mjs && node test/wakeword.test.mjs && node test/verify-client.mjs",
40
+ "verify:client": "node test/verify-client.mjs",
41
+ "prefetch": "node scripts/prefetch.mjs",
42
+ "list-voices": "node scripts/list-voices.mjs",
43
+ "verify": "node scripts/verify.mjs",
44
+ "typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.client.json --noEmit"
45
+ },
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "platform": "web",
52
+ "inject": [
53
+ "@deepseek-ai/dsh-client-runtime",
54
+ "@deepseek-ai/dsh-client-ui-slots"
55
+ ]
56
+ }
57
+ },
58
+ "dependencies": {
59
+ "msedge-tts": "^2.0.7",
60
+ "sherpa-onnx": "^1.13.6",
61
+ "@deepseek-ai/schemastery": "^3.18.1"
62
+ },
63
+ "peerDependencies": {
64
+ "@deepseek-ai/cordis": "^4.0.1",
65
+ "@deepseek-ai/dsh-web": "^0.1.0-rc.6 || ^0.1.1-rc.0",
66
+ "react": "^18.2.0"
67
+ },
68
+ "devDependencies": {
69
+ "esbuild": "^0.25.0",
70
+ "typescript": "^5.9.2",
71
+ "@types/node": "^26.0.0",
72
+ "@types/react": "^19.0.0"
73
+ },
74
+ "keywords": [
75
+ "dsh",
76
+ "dsh-plugin",
77
+ "voice",
78
+ "speech",
79
+ "asr",
80
+ "tts",
81
+ "duplex",
82
+ "zipformer2",
83
+ "msedge-tts"
84
+ ],
85
+ "publishConfig": {
86
+ "access": "public"
87
+ },
88
+ "license": "MIT"
89
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 打印 msedge-tts 可用的音色清单(README 音色参考表的权威来源)。
4
+ *
5
+ * 用法:
6
+ * node scripts/list-voices.mjs # 全部音色
7
+ * node scripts/list-voices.mjs zh # 仅中文(zh-CN / zh-HK / zh-TW)
8
+ * node scripts/list-voices.mjs zh,en # 多区域过滤(逗号分隔前缀)
9
+ */
10
+ import { MsEdgeTTS } from 'msedge-tts'
11
+
12
+ const filter = (process.argv[2] ?? '').split(',').filter(Boolean).map((s) => s.trim().toLowerCase())
13
+
14
+ const tts = new MsEdgeTTS()
15
+ const voices = await tts.getVoices()
16
+ const rows = voices
17
+ .filter((v) => filter.length === 0 || filter.some((f) => v.Locale.toLowerCase().startsWith(f)))
18
+ .sort((a, b) => a.Locale.localeCompare(b.Locale) || a.ShortName.localeCompare(b.ShortName))
19
+ .map((v) => `${v.ShortName}\t${v.Locale}\t${v.Gender}\t${v.FriendlyName}`)
20
+
21
+ if (rows.length === 0) {
22
+ console.error('未获取到音色(网络不可达或服务变更),请检查后重试。')
23
+ process.exitCode = 1
24
+ } else {
25
+ console.log(`共 ${rows.length} 个音色(ShortName 即配置 voice 的取值):`)
26
+ console.log('ShortName\tLocale\tGender\tFriendlyName')
27
+ console.log(rows.join('\n'))
28
+ }
29
+ await tts.close()
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
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 保持一致。
11
+ *
12
+ * 用法:node scripts/prefetch.mjs [--cache-dir <path>]
13
+ */
14
+ import { createWriteStream } from 'node:fs'
15
+ import { mkdir, rename, stat, unlink } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+
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']
22
+ const HOST_PRIMARY = 'https://huggingface.co'
23
+ const HOST_FALLBACK = 'https://hf-mirror.com'
24
+
25
+ const defaultCacheDir = () =>
26
+ process.platform === 'win32'
27
+ ? join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'dsh-voice-mode', 'models')
28
+ : join(homedir(), '.cache', 'dsh-voice-mode', 'models')
29
+
30
+ const argCache = process.argv.indexOf('--cache-dir')
31
+ const cacheDir = argCache !== -1 ? process.argv[argCache + 1] ?? defaultCacheDir() : defaultCacheDir()
32
+ const repoDir = join(cacheDir, MODEL_REPO)
33
+
34
+ async function downloadFile(repoDir, file) {
35
+ const localPath = join(repoDir, file)
36
+ if ((await stat(localPath).catch(() => null))?.isFile()) {
37
+ console.log(` ✓ ${file} 已存在`)
38
+ return true
39
+ }
40
+ const partPath = `${localPath}.part`
41
+ const partSt = await stat(partPath).catch(() => null)
42
+ for (const host of [HOST_PRIMARY, HOST_FALLBACK]) {
43
+ const url = `${host}/${MODEL_REPO}/resolve/main/${file}`
44
+ const headers = { 'user-agent': 'dsh-voice-mode/prefetch' }
45
+ const resumeFrom = partSt?.size ?? 0
46
+ if (resumeFrom > 0) headers.range = `bytes=${resumeFrom}-`
47
+ try {
48
+ const res = await fetch(url, { headers })
49
+ if (res.status === 416) {
50
+ await rename(partPath, localPath).catch(() => undefined)
51
+ console.log(` ✓ ${file}(续传完成)`)
52
+ return true
53
+ }
54
+ if (res.status !== 200 && res.status !== 206) continue
55
+ const total = Number(res.headers.get('content-length') ?? 0) + resumeFrom
56
+ const statusMax = file.length + 18
57
+ const sink = createWriteStream(partPath, resumeFrom > 0 ? { flags: 'a' } : {})
58
+ const reader = res.body
59
+ if (!reader) continue
60
+ let received = resumeFrom
61
+ let lastPct = -1
62
+ for (;;) {
63
+ const { done, value } = await reader.read()
64
+ if (done) break
65
+ received += value.byteLength
66
+ if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
67
+ 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
+ }
72
+ }
73
+ await new Promise((resolve, reject) => {
74
+ sink.end(resolve)
75
+ sink.on('error', reject)
76
+ })
77
+ await rename(partPath, localPath)
78
+ process.stdout.write(`\r ${file.padEnd(statusMax)} 100%\n`)
79
+ console.log(` ✓ ${file} 完成(${(received / 1048576).toFixed(1)} MB)`)
80
+ return true
81
+ } catch {
82
+ // 换下一个 host
83
+ }
84
+ }
85
+ await unlink(partPath).catch(() => undefined)
86
+ console.error(` ✗ ${file} 下载失败(两个镜像均不可达)`)
87
+ return false
88
+ }
89
+
90
+ console.log(`模型缓存目录:${repoDir}`)
91
+ await mkdir(repoDir, { recursive: true })
92
+ let ok = true
93
+ for (const f of MODEL_FILES) {
94
+ if (!(await downloadFile(repoDir, f))) ok = false
95
+ }
96
+ console.log(ok ? '\n预下载完成,可直接进入语音模式。' : '\n预下载未完成,可稍后重试(断点续传)。')
97
+ process.exitCode = ok ? 0 : 1