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/LICENSE +23 -0
- package/README.en.md +267 -0
- package/README.md +271 -0
- package/assets/architecture.svg +159 -0
- package/assets/demo.gif +0 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +1750 -0
- package/lib/index.js +792 -0
- package/package.json +89 -0
- package/scripts/list-voices.mjs +29 -0
- package/scripts/prefetch.mjs +97 -0
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
|