dsh-tingdong 0.5.2 → 0.7.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.md CHANGED
@@ -23,7 +23,10 @@ dsh plugin --profile desktop add github:AIcivilization/dsh-tingdong # 源码
23
23
 
24
24
  ## 它做什么
25
25
 
26
- 首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**文档转音频**(文章、手册、书)。
26
+ 首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**文档转音频**(文章、手册、书)。除了做成听力课,每一步的成品都能下载到本地:
27
+
28
+ - **英文音频 → 带时间的字幕 → 中文配音**:识别出每一句英文和它的起止时间,导出字幕(SRT / LRC / 录音稿);再按同样的时间生成另一种语言(中文)的音频(MP3 / FLAC)。
29
+ - **文档 / 文字 → 朗读音频 → 下载到本地**:PDF、Word、EPUB、TXT 或粘贴的文字,AI 朗读成 MP3,单课或整篇下载;同样能再导出带时间的字幕和中文配音。
27
30
 
28
31
  **文档转音频**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
29
32
 
@@ -54,9 +57,10 @@ dsh plugin --profile desktop add github:AIcivilization/dsh-tingdong # 源码
54
57
 
55
58
  **导出**(每课的「导出」页签):
56
59
 
57
- - **中文配音(音频)**:可以下载 **MP3** 或无损 **FLAC**(44.1 kHz / 16 位,和合成出来的原始音频同源,不是 MP3 转的)。把英文换成中文配音,只有中文、没有背景音,每句中文从对应英文开始的时刻开始说,总长和原音频一样(也可以对齐「原声·去空白」)。比空档长的句子自动读快一点(最快 1.6 倍),不会挤到下一句。两种语音可选:
60
+ - **中文配音(音频)**:可以下载 **MP3** 或无损 **FLAC**(44.1 kHz / 16 位,和合成出来的原始音频同源,不是 MP3 转的)。把英文换成中文配音,只有中文、没有背景音,每句中文从对应英文开始的时刻开始说,总长和原音频一样(也可以对齐「原声·去空白」)。比空档长的句子自动读快一点(最快 1.6 倍),不会挤到下一句。三种语音可选:
58
61
  - **本地 Kokoro**:离线,55 个普通话女声、45 个普通话男声,每个都能先试听;第一次用时下载约 147 MB 模型
59
62
  - **微软 Edge**:晓晓、云希、云扬等 14 种(含台湾、粤语、方言),要联网。插件调用开源工具 [edge-tts](https://github.com/rany2/edge-tts),点「安装 Edge 语音组件」会装进插件自己的 Python 虚拟环境(需要电脑上有 Python 3)。文字会发到微软的服务器;这不是微软公开的正式接口,可能失效
63
+ - **Mac 本机 Ming**:Ming-3.0-tts(inclusionAI,MIT)在 Apple 芯片上离线合成,13 种适合旁白的中文男女声(灵楷、灵阅川、灵朵儿、灵小雅……),不联网、不花钱;只支持 Apple 芯片、macOS 14 以上。和 [dsh-her](https://github.com/AIcivilization/dsh-her) 的「本机 Ming」是同一个:装过 dsh-her 的直接用它的;没装过就点「安装本机 Ming」,约 450 MB 程序(锁定版本和校验值)+ 1.6 GB 模型(逐个文件校验),装在 `~/.dsh/dsh-tingdong/ming/`。每句约 2 秒,生成时占约 2 GB 内存;数字会先写成汉字再念(「2个」念「两个」)
60
64
  - **文字**:双语 / 英文 / 中文字幕(SRT)、双语歌词 LRC(Walkman 等播放器滚动显示)、**录音稿**(每句的开始时间、英文时长、可用时长和中英对照,照着自己录中文配音)、表格 CSV。时间可按原音频或「原声·去空白」
61
65
 
62
66
  卡片右上角 ☆ 收藏,复习和磨耳朵里可以只练收藏的
@@ -134,7 +138,7 @@ node bin/dsh-tingdong.js voices all --track kokoro --voice am_michael # 批量
134
138
  ## 数据
135
139
 
136
140
  都在 `~/.dsh/dsh-tingdong/lessons/<课程>/`:`lesson.json`(句子、翻译、讲解、查过的词)、`progress.json`(复习进度、学到第几句、选的声音、收藏)、`audio.mp3`(压缩成 64 kbps 单声道,原文件所在的移动硬盘拔掉也能听)、`tracks/`(各种声音和它们的时间表)。
137
- 模型在 `~/.dsh/dsh-tingdong/models/`(Kokoro 英文、Kokoro 中文);Edge 语音组件在 `~/.dsh/dsh-tingdong/edge-tts/`;识别模型优先复用 DSH 语音输入下好的。
141
+ 模型在 `~/.dsh/dsh-tingdong/models/`(Kokoro 英文、Kokoro 中文);Edge 语音组件在 `~/.dsh/dsh-tingdong/edge-tts/`;本机 Ming 在 `~/.dsh/dsh-tingdong/ming/`(或复用 `~/.dsh-her/ming/`);识别模型优先复用 DSH 语音输入下好的。
138
142
  DSH 插件和命令行版共用这份数据。
139
143
 
140
144
  ## 实测
@@ -153,7 +157,7 @@ npm test
153
157
  - `lib/translate.js` 校对 + 翻译 + 生词 + 听音提示;缺句自动缩小批次重试,失败的留空,之后可「补全翻译」
154
158
  - `lib/tracks.js` 声音轨:原声按句截取拼接;朗读交给 `lib/tts-worker.js` 子进程(Kokoro / macOS say),`lib/pcm.js` 统一拼接编码并出时间表
155
159
  - `lib/srs.js` 间隔复习;`lib/store.js` 原子写入的 JSON 存储;`lib/today.js` 今日学习(按用户时区算日期)
156
- - `lib/doc.js` 文档:切句、分章、整理要求;`lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用
160
+ - `lib/doc.js` 文档:切句、分章、整理要求;`lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用;`lib/ming.js` 本机 Ming 的查找、安装、声音表,`python/ming_worker.py` 合成进程,`lib/spoken.js` 数字念法
157
161
  - `lib/drill.js` 磨耳朵音频;`lib/lookup.js` 点词查意思、重新讲解;`lib/vps.js` 经 SSH 发送到 VPS
158
162
  - `plugin/` DSH 宿主端(路由、AI 工具)和界面入口(右侧栏、设置);路由先过 DSH 的 `connection.requestRejection()` 登录校验
159
163
  - `web/` 学习面板(原生 JS 模块,无构建):`panel.js` 路由和首页 · `study.js` 精听、复习、今日学习 · `listen.js` 连续听、磨耳朵、词汇 · `edit.js` 改句子、查词 · `core.js` 播放器和公共状态
package/lib/ming.js ADDED
@@ -0,0 +1,212 @@
1
+ // lib/ming.js — Mac 本机 Ming 语音(中文配音用)
2
+ //
3
+ // Ming-3.0-tts-0.5B(inclusionAI,MIT)的 MLX 8 位版,经 mlx-audio 在 Apple 芯片上离线合成,有几十个中文男女声。
4
+ // 做法和 dsh-her 的「本机 Ming」一样(同一份锁定依赖、同一个模型版本、同样逐个文件校验):
5
+ // - 已经装过 dsh-her 的本机 Ming(~/.dsh-her/ming)就直接用它,不再下载;
6
+ // - 没有就装到 ~/.dsh/dsh-tingdong/ming:uv(固定版本、校验过)建 Python 3.12 环境,按锁定文件装 mlx-audio,
7
+ // 再下载约 1.6 GB 模型,每个文件核对大小和 SHA-256;下载中断下次接着下。删掉这个文件夹就是卸载。
8
+ // 只支持 Apple 芯片、macOS 14 以上。合成时一个常驻的 Python 进程(python/ming_worker.py)占约 2 GB 内存,做完就退出。
9
+
10
+ import { execFile } from 'node:child_process'
11
+ import { createHash } from 'node:crypto'
12
+ import { createWriteStream, existsSync } from 'node:fs'
13
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
14
+ import { homedir, release } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { Readable, Transform } from 'node:stream'
17
+ import { pipeline } from 'node:stream/promises'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { dataRoot } from './paths.js'
20
+
21
+ export const MING_WORKER = fileURLToPath(new URL('../python/ming_worker.py', import.meta.url))
22
+ const REQUIREMENTS = fileURLToPath(new URL('../python/ming-requirements.txt', import.meta.url))
23
+
24
+ /** 适合做旁白、对译的内置声音(官方声音表里的一部分);带「_中性」的是有几种情绪版本的声音 */
25
+ export const MING_VOICES = [
26
+ { id: '灵小雅', label: '灵小雅(女 · 甜美,新闻、有声书)', gender: 'f' },
27
+ { id: '灵朵儿', label: '灵朵儿(女 · 温暖知性,纪录片)', gender: 'f' },
28
+ { id: '灵小萌', label: '灵小萌(女 · 清亮,有声书、电台)', gender: 'f' },
29
+ { id: '灵小梨', label: '灵小梨(女 · 轻柔,有声书)', gender: 'f' },
30
+ { id: '灵小柔', label: '灵小柔(女 · 温柔礼貌)', gender: 'f' },
31
+ { id: '灵心恬_中性', label: '灵心恬(女 · 冷静客观,新闻)', gender: 'f' },
32
+ { id: '灵风信_中性', label: '灵风信(女 · 体贴温暖)', gender: 'f' },
33
+ { id: '灵楷', label: '灵楷(男 · 舒缓温暖,纪录片)', gender: 'm' },
34
+ { id: '灵阅川_中性', label: '灵阅川(男 · 浑厚,纪录片、历史)', gender: 'm' },
35
+ { id: '灵小锋', label: '灵小锋(男 · 温和稳重,讲解)', gender: 'm' },
36
+ { id: '灵旭', label: '灵旭(男 · 清亮温柔,有声书)', gender: 'm' },
37
+ { id: '灵江', label: '灵江(男 · 温暖,旁白)', gender: 'm' },
38
+ { id: '灵语尘_中性', label: '灵语尘(男 · 低沉,独白)', gender: 'm' },
39
+ ]
40
+ export const mingVoice = id => MING_VOICES.find(v => v.id === id) || MING_VOICES[0]
41
+
42
+ /** 模型:固定到一次提交;同样的文件也在 dsh-her 的 GitHub Release 上,先从那里下(Hugging Face 在有些地方很慢) */
43
+ const MODEL = {
44
+ repo: 'mlx-community/Ming-3.0-tts-0.5B-8bit',
45
+ revision: 'd0d1da6f627e4b3f20cc2db6ba4aee86d45343ad',
46
+ sources: [
47
+ 'https://github.com/AIcivilization/dsh-her/releases/download/ming-model-1/',
48
+ 'https://huggingface.co/mlx-community/Ming-3.0-tts-0.5B-8bit/resolve/d0d1da6f627e4b3f20cc2db6ba4aee86d45343ad/',
49
+ ],
50
+ files: [
51
+ { name: 'LICENSE', size: 1067, sha256: '2872f1918dfc04fdeb7f5d66d8d1b773594c9375e7c8f7bda39780bb97ad4fba' },
52
+ { name: 'README.md', size: 4360, sha256: '0af63591f3ab08d8e057330477928692ce8a4234618aacd4f2a0095c5d50bda9' },
53
+ { name: 'added_tokens.json', size: 1785, sha256: '34394b1e16d7da2db16618bd43a0f970902ade0075480bcd812fe62d559ec319' },
54
+ { name: 'campplus.safetensors', size: 27699096, sha256: 'a479586bd8f8f96cdb414eaa2fa07c6cd54d6d1636562e2d75a0211cb3b0a6fd' },
55
+ { name: 'config.json', size: 6087, sha256: '8ea3e685a870fd1a535d3b4a4c975fc20c4fca56e67acdef06fe40004064c73b' },
56
+ { name: 'merges.txt', size: 1671853, sha256: '8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5' },
57
+ { name: 'model.safetensors', size: 1573366591, sha256: 'e88a4b53cb5b705703e961389f3d7f0e8b72c240b4dad796bf9961cc018971fc' },
58
+ { name: 'model.safetensors.index.json', size: 201850, sha256: 'f59991abebed1a4f798a9488568af496d925e6eea507450aa8051a06f475a2ce' },
59
+ { name: 'special_tokens_map.json', size: 2726, sha256: '2c2ec51b9da5db654ac20de8310350d0c9c1a65918ae0181b80dacd2919b4fc3' },
60
+ { name: 'tokenizer.json', size: 11431389, sha256: '87d54ca3b44ea2f822ba39a0a45cb1b61644bd6606d9d3536dea75367bffb06d' },
61
+ { name: 'tokenizer_config.json', size: 16260, sha256: '12d6162c815edef24f8606e65b60d43fe0d92b0a4e150a0db8fa01c523aaf36a' },
62
+ { name: 'vocab.json', size: 2776833, sha256: 'ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910' },
63
+ ],
64
+ }
65
+ const MODEL_BYTES = MODEL.files.reduce((n, f) => n + f.size, 0)
66
+
67
+ /** uv:固定版本和校验值(只需要 Apple 芯片这一个) */
68
+ const UV = { version: '0.11.26', file: 'uv-aarch64-apple-darwin.tar.gz', sha256: '8f7fbf1708399b921857bce71e1d60f0d3ccf52a30caebc1c1a2f175dce13ab6' }
69
+
70
+ /** 为什么这台电脑用不了;能用就是 undefined */
71
+ export function mingUnsupported(platform = process.platform, arch = process.arch, darwin = release()) {
72
+ if (platform !== 'darwin' || arch !== 'arm64') return '本机 Ming 只支持 Apple 芯片的 Mac'
73
+ if (Number.parseInt(darwin, 10) < 23) return '本机 Ming 要 macOS 14 或更新'
74
+ return undefined
75
+ }
76
+
77
+ const ownRoot = () => join(dataRoot(), 'ming')
78
+ const pythonOf = root => join(root, 'env', 'bin', 'python3')
79
+ const installedAt = root => existsSync(pythonOf(root)) && existsSync(join(root, 'model', '.verified'))
80
+
81
+ /** 装好的 Ming 在哪:自己装的优先,其次 dsh-her 装的 */
82
+ export function findMing() {
83
+ // DSH_TINGDONG_NO_SHARED_MING=1:不用 dsh-her 的(测试自己安装时用)
84
+ const her = process.env.DSH_TINGDONG_NO_SHARED_MING === '1' ? [] : [join(homedir(), '.dsh-her', 'ming'), join(homedir(), '.dsh-her-dev', 'ming')]
85
+ for (const root of [ownRoot(), ...her]) {
86
+ if (installedAt(root)) return { root, python: pythonOf(root), model: join(root, 'model'), shared: root !== ownRoot() }
87
+ }
88
+ return null
89
+ }
90
+
91
+ let installing = null // {step, done?, total?}
92
+ let lastError = ''
93
+
94
+ /** 给界面看的状态 */
95
+ export function mingStatus() {
96
+ const why = mingUnsupported()
97
+ if (why) return { supported: false, reason: why, ready: false, voices: MING_VOICES }
98
+ const found = installing ? null : findMing()
99
+ return {
100
+ supported: true,
101
+ ready: Boolean(found),
102
+ shared: Boolean(found && found.shared), // 用的是 dsh-her 装好的
103
+ own: Boolean(found && !found.shared),
104
+ installing: installing ? { ...installing } : null,
105
+ error: installing || found ? '' : lastError,
106
+ voices: MING_VOICES,
107
+ }
108
+ }
109
+
110
+ function run(cmd, args, timeout, env) {
111
+ return new Promise((resolve, reject) => {
112
+ execFile(cmd, args, { timeout, maxBuffer: 8 << 20, env: { ...process.env, ...env } }, (err, stdout, stderr) => {
113
+ if (err) return reject(new Error(String(stderr || err.message).trim().split('\n').slice(-2).join(' ')))
114
+ resolve(String(stdout))
115
+ })
116
+ })
117
+ }
118
+
119
+ /** 下载一个文件到 target,大小和 SHA-256 都对才换上 */
120
+ async function download(url, target, want, onProgress) {
121
+ const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(3600000) })
122
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
123
+ const hash = createHash('sha256')
124
+ let got = 0
125
+ const meter = new Transform({ transform(c, _e, cb) { hash.update(c); got += c.length; onProgress(got); cb(null, c) } })
126
+ const part = `${target}.part`
127
+ await pipeline(Readable.fromWeb(res.body), meter, createWriteStream(part))
128
+ if (got !== want.size || hash.digest('hex') !== want.sha256) {
129
+ await rm(part, { force: true })
130
+ throw new Error('下载的文件校验不对')
131
+ }
132
+ await rename(part, target)
133
+ }
134
+
135
+ async function installUv(uvHome) {
136
+ const dir = join(uvHome, UV.version)
137
+ const found = [join(dir, 'uv'), join(dir, 'uv-aarch64-apple-darwin', 'uv')].find(p => existsSync(p))
138
+ if (found) return found
139
+ const res = await fetch(`https://github.com/astral-sh/uv/releases/download/${UV.version}/${UV.file}`, { redirect: 'follow', signal: AbortSignal.timeout(300000) })
140
+ if (!res.ok) throw new Error(`下载 uv 失败(${res.status})`)
141
+ const bytes = Buffer.from(await res.arrayBuffer())
142
+ if (createHash('sha256').update(bytes).digest('hex') !== UV.sha256) throw new Error('下载的 uv 校验不对,没有使用')
143
+ await mkdir(dir, { recursive: true })
144
+ const archive = join(dir, UV.file)
145
+ await writeFile(archive, bytes)
146
+ await run('tar', ['-xzf', archive, '-C', dir], 120000)
147
+ await rm(archive, { force: true })
148
+ const uv = [join(dir, 'uv'), join(dir, 'uv-aarch64-apple-darwin', 'uv')].find(p => existsSync(p))
149
+ if (!uv) throw new Error('uv 解压后找不到')
150
+ return uv
151
+ }
152
+
153
+ async function downloadModel(dir, onProgress) {
154
+ await mkdir(dir, { recursive: true })
155
+ if (existsSync(join(dir, '.verified'))) return
156
+ const checkedFile = join(dir, '.checked.json')
157
+ let checked = new Set()
158
+ try { checked = new Set(JSON.parse(await readFile(checkedFile, 'utf8'))) } catch { /* 第一次 */ }
159
+ let done = 0
160
+ for (const f of MODEL.files) {
161
+ const target = join(dir, f.name)
162
+ if (checked.has(f.sha256) && existsSync(target) && (await stat(target)).size === f.size) {
163
+ done += f.size
164
+ onProgress(done, MODEL_BYTES)
165
+ continue
166
+ }
167
+ const before = done
168
+ let last = null
169
+ for (const src of MODEL.sources) {
170
+ try {
171
+ await download(src + f.name, target, f, got => onProgress(before + got, MODEL_BYTES))
172
+ last = null
173
+ break
174
+ } catch (e) { last = e }
175
+ }
176
+ if (last) throw new Error(`下载 ${f.name} 失败:${last.message}`)
177
+ done = before + f.size
178
+ checked.add(f.sha256)
179
+ await writeFile(checkedFile, JSON.stringify([...checked]))
180
+ }
181
+ await writeFile(join(dir, '.verified'), `${MODEL.repo}@${MODEL.revision}\n`)
182
+ await rm(checkedFile, { force: true })
183
+ }
184
+
185
+ /** 后台安装(已经在装就什么也不做)。进度看 mingStatus().installing */
186
+ export function startMingInstall() {
187
+ const why = mingUnsupported()
188
+ if (why) throw Object.assign(new Error(why), { status: 400 })
189
+ if (installing || findMing()) return
190
+ installing = { step: '准备 Python(第一次约 1 分钟)' }
191
+ lastError = ''
192
+ doInstall().catch(e => { lastError = `安装本机 Ming 失败:${e.message}` }).finally(() => { installing = null })
193
+ }
194
+
195
+ async function doInstall() {
196
+ const root = ownRoot()
197
+ const uvHome = join(dataRoot(), 'uv')
198
+ const env = { UV_PYTHON_INSTALL_DIR: join(uvHome, 'python'), UV_CACHE_DIR: join(uvHome, 'cache'), UV_NO_CONFIG: '1' }
199
+ const uv = await installUv(uvHome)
200
+ if (!existsSync(pythonOf(root))) await run(uv, ['venv', '--quiet', '--python', '3.12', join(root, 'env')], 600000, env)
201
+ installing = { step: '安装语音程序(约 450 MB)' }
202
+ await run(uv, ['pip', 'install', '--quiet', '--require-hashes', '--python', pythonOf(root), '-r', REQUIREMENTS], 1200000, env)
203
+ await rm(env.UV_CACHE_DIR, { recursive: true, force: true }) // 装好了,下载缓存不要了
204
+ await downloadModel(join(root, 'model'), (done, total) => { installing = { step: '下载声音模型(约 1.6 GB)', done, total } })
205
+ }
206
+
207
+ /** 卸载自己装的(dsh-her 的不动) */
208
+ export async function uninstallMing() {
209
+ if (installing) throw Object.assign(new Error('正在安装,等装完再卸载'), { status: 409 })
210
+ await rm(ownRoot(), { recursive: true, force: true })
211
+ await rm(join(dataRoot(), 'uv'), { recursive: true, force: true })
212
+ }
package/lib/server.js CHANGED
@@ -31,6 +31,7 @@
31
31
  // GET <prefix>/update[?force=1] · POST /update · POST /uninstall 插件自己的版本、更新、卸载(只在 DSH 里)
32
32
  // GET <prefix>/export?id=&format=&align= 导出文字:字幕 SRT、歌词 LRC、录音稿、表格
33
33
  // POST <prefix>/voice-sample {engine, voice} 试听一个中文音色 · POST /edge/install 装 / 更新 Edge 语音组件
34
+ // POST <prefix>/ming/install | /ming/uninstall 装(后台,进度看 /voices 的 ming)/ 卸载 Mac 本机 Ming
34
35
  //
35
36
  // 会改东西的请求都要带 x-tingdong 头:别的网站发跨站请求带不了自定义头(要预检),挡住 CSRF。
36
37
 
@@ -47,6 +48,7 @@ import { explainSentences, lookupWord } from './lookup.js'
47
48
  import { today } from './today.js'
48
49
  import { exportText, FORMATS } from './export.js'
49
50
  import { EDGE_EN_VOICES, EDGE_ZH_VOICES, edgeStatus, installEdge } from './edge.js'
51
+ import { mingStatus, startMingInstall, uninstallMing } from './ming.js'
50
52
  import { MAX_CHARS, PREP_PROMPT } from './doc.js'
51
53
  import { DOC_EXT } from './extract.js'
52
54
  import { wholeDocAudio } from './docaudio.js'
@@ -176,6 +178,7 @@ export function createHandler(o) {
176
178
  kokoro: { ready: await kokoroReady(), voices: KOKORO_VOICES },
177
179
  zh: { ready: await kokoroZhReady(), voices: ZH_VOICES.map(({ id, label, gender }) => ({ id, label, gender })) },
178
180
  edge: { ...(await edgeStatus()), voices: EDGE_ZH_VOICES, en: EDGE_EN_VOICES },
181
+ ming: mingStatus(),
179
182
  })
180
183
  }
181
184
  if (path === '/export') {
@@ -329,7 +332,7 @@ export function createHandler(o) {
329
332
  voice: typeof body.voice === 'string' && body.voice.length < 80 ? body.voice : undefined,
330
333
  speed: Number.isFinite(speed) && speed >= 0.5 && speed <= 1.5 ? speed : undefined,
331
334
  align: body.align === 'clean' ? 'clean' : 'full',
332
- engine: body.engine === 'edge' ? 'edge' : 'kokoro',
335
+ engine: ['edge', 'ming'].includes(body.engine) ? body.engine : 'kokoro',
333
336
  })
334
337
  return send(res, 200, { ok: true })
335
338
  }
@@ -360,12 +363,14 @@ export function createHandler(o) {
360
363
  }
361
364
  if (path === '/voice-sample') {
362
365
  // 试听中文音色(第一次会先下载中文语音模型)
363
- const file = await zhSample(String(body.voice || ''), { ffmpeg: opts().ffmpeg, engine: body.engine === 'edge' ? 'edge' : 'kokoro' })
366
+ const file = await zhSample(String(body.voice || ''), { ffmpeg: opts().ffmpeg, engine: ['edge', 'ming'].includes(body.engine) ? body.engine : 'kokoro' })
364
367
  return serveAudio(req, res, file)
365
368
  }
366
369
  if (path === '/update' && o.manage) return send(res, 200, await o.manage.update())
367
370
  if (path === '/uninstall' && o.manage) return send(res, 200, await o.manage.uninstall())
368
371
  if (path === '/edge/install') return send(res, 200, { ok: true, ...(await installEdge()) })
372
+ if (path === '/ming/install') { startMingInstall(); return send(res, 200, { ok: true, ming: mingStatus() }) }
373
+ if (path === '/ming/uninstall') { await uninstallMing(); return send(res, 200, { ok: true, ming: mingStatus() }) }
369
374
  if (path === '/vps/detect') return send(res, 200, { ok: true, ...(await detect(String(body.host || ''))) })
370
375
  if (path === '/vps/config') {
371
376
  const cfg = await saveVpsConfig(body)
package/lib/spoken.js ADDED
@@ -0,0 +1,75 @@
1
+ // lib/spoken.js — 数字和几个符号先写成汉字,给本机 Ming 念(它的 MLX 版没有文字规范化,「2个」会念成「二个」)
2
+ // 和 dsh-her 的 core/spoken-numbers.ts 一样:个数、时间、日期、年份、百分比、小数、长串号码、范围、度数。
3
+
4
+ const DIGITS = '零一二三四五六七八九'
5
+ const SMALL_UNITS = ['', '十', '百', '千']
6
+ const BIG_UNITS = ['', '万', '亿']
7
+
8
+ /** 量词前面的 2 念「两」(两个、两点、两次……) */
9
+ const MEASURE = /^(?:个|位|点|次|天|只|本|件|张|条|岁|种|遍|杯|分钟|小时|周|斤|块|台|辆|家|句|下|口|双|对|份|层|片|场|首|部|回|趟|步|顿|碗|瓶|盒|袋|根|颗|粒|间|座|所|名|人|样|批|年|星期|礼拜|倍)/
10
+ /** 前面是这些词的数字一位一位念 */
11
+ const DIGIT_CONTEXT = /(?:验证码|号码|电话|手机|编号|尾号|房间|门牌|邮编|工号|学号|密码|单号)[是为::\s]*$/
12
+
13
+ /** "2026" → 二零二六 */
14
+ export const readDigits = digits => [...digits].map(d => DIGITS[Number(d)]).join('')
15
+
16
+ /** "1205" → 一千二百零五;"12" → 十二;"20000" → 两万。超过 12 位一位一位念 */
17
+ export function readInteger(number) {
18
+ const digits = number.replace(/^0+(?=\d)/, '')
19
+ if (digits.length > 12) return readDigits(digits)
20
+ if (/^0+$/.test(digits)) return '零'
21
+ const groups = []
22
+ for (let end = digits.length; end > 0; end -= 4) groups.unshift(Number(digits.slice(Math.max(0, end - 4), end)))
23
+ let out = ''
24
+ let zero = false
25
+ groups.forEach((group, i) => {
26
+ const big = BIG_UNITS[groups.length - 1 - i]
27
+ if (group === 0) { zero = out !== ''; return }
28
+ if (out !== '' && (zero || group < 1000)) out += '零'
29
+ zero = false
30
+ out += readGroup(group) + big
31
+ })
32
+ return out.replace(/^一十/, '十').replace(/^二(?=[百千万亿])/, '两')
33
+ }
34
+
35
+ function readGroup(group) {
36
+ let out = ''
37
+ let zero = false
38
+ for (let pos = 3; pos >= 0; pos--) {
39
+ const d = Math.floor(group / 10 ** pos) % 10
40
+ if (d === 0) { zero = out !== ''; continue }
41
+ if (zero) out += '零'
42
+ zero = false
43
+ out += DIGITS[d] + SMALL_UNITS[pos]
44
+ }
45
+ return out
46
+ }
47
+
48
+ const readNumber = n => {
49
+ const [i, f] = n.split('.')
50
+ return f === undefined ? readInteger(i) : `${readInteger(i)}点${readDigits(f)}`
51
+ }
52
+ const hour = h => (Number(h) === 2 ? '两' : readInteger(h))
53
+
54
+ export function spokenNumbers(text) {
55
+ return String(text)
56
+ // 30% → 百分之三十
57
+ .replace(/(\d+(?:\.\d+)?)\s*[%%]/g, (_w, n) => `百分之${readNumber(n)}`)
58
+ // 15:30 → 十五点三十分;9:05 → 九点零五分;8:00 → 八点
59
+ .replace(/(\d{1,2})[::](\d{2})(?!\d)/g, (_w, h, m) => `${hour(h)}点${m === '00' ? '' : `${m.startsWith('0') ? '零' : ''}${readInteger(m)}分`}`)
60
+ // 2026-10-31 → 二零二六年十月三十一日
61
+ .replace(/(?<!\d)(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?!\d)/g, (_w, y, m, d) => `${readDigits(y)}年${readInteger(m)}月${readInteger(d)}日`)
62
+ // 2026年 → 二零二六年
63
+ .replace(/(\d{4})(?=\s*年)/g, (_w, y) => readDigits(y))
64
+ // 20~25度、3-5个 → 到
65
+ .replace(/(?<![\d-])(\d{1,3})\s*[~~—–-]\s*(\d{1,3})(?![\d-])/g, '$1到$2')
66
+ .replace(/\s*(?:℃|°C|°)/g, '度')
67
+ .replace(/(\d+)\.(\d+)/g, (_w, i, f) => `${readInteger(i)}点${readDigits(f)}`)
68
+ .replace(/\d+/g, (n, offset, whole) => {
69
+ const before = whole.slice(0, offset)
70
+ const after = whole.slice(offset + n.length)
71
+ if (n.length >= 7 || (n.length > 1 && n.startsWith('0')) || DIGIT_CONTEXT.test(before)) return readDigits(n)
72
+ if (n === '2' && MEASURE.test(after)) return '两'
73
+ return readInteger(n)
74
+ })
75
+ }
package/lib/tracks.js CHANGED
@@ -18,6 +18,27 @@ import { createEncoder, RATE } from './pcm.js'
18
18
  import { dataRoot, findFfmpeg, FFMPEG_HINT, lessonDir } from './paths.js'
19
19
  import { ensureKokoro, ensureKokoroZh, sayVoices, zhVoice } from './voices.js'
20
20
  import { edgeBin, edgeEnVoice, edgeVoice } from './edge.js'
21
+ import { findMing, mingStatus, mingVoice } from './ming.js'
22
+
23
+ /** 中文配音用哪种语音:'kokoro'(离线,默认)、'edge'(微软在线)、'ming'(Mac 本机 Ming) */
24
+ const zhEngineOf = e => (e === 'edge' || e === 'ming' ? e : 'kokoro')
25
+ const ZH_ENGINE_LABEL = { kokoro: '', edge: 'Edge ', ming: 'Ming ' }
26
+
27
+ /** 这种中文语音的声音和要传给朗读子进程的参数;没装好就报错 */
28
+ function zhSetup(engine, voiceId) {
29
+ if (engine === 'edge') {
30
+ if (!edgeBin()) throw Object.assign(new Error('还没装 Edge 语音组件:在「导出 → 中文配音」里点「安装 Edge 语音组件」'), { status: 400 })
31
+ return { voice: edgeVoice(voiceId), extra: {} }
32
+ }
33
+ if (engine === 'ming') {
34
+ const st = mingStatus()
35
+ if (!st.supported) throw Object.assign(new Error(st.reason), { status: 400 })
36
+ const ming = findMing()
37
+ if (!ming) throw Object.assign(new Error('还没装本机 Ming:在「导出 → 中文配音」里点「安装本机 Ming」'), { status: 400 })
38
+ return { voice: mingVoice(voiceId), extra: { ming: { python: ming.python, model: ming.model } } }
39
+ }
40
+ return { voice: zhVoice(voiceId), extra: {} }
41
+ }
21
42
 
22
43
  const WORKER = fileURLToPath(new URL('./tts-worker.js', import.meta.url))
23
44
 
@@ -183,9 +204,8 @@ function runWorker(args, o, stage) {
183
204
  async function buildZh(lesson, o) {
184
205
  const ffmpeg = findFfmpeg(o.ffmpeg)
185
206
  if (!ffmpeg) throw new Error(FFMPEG_HINT)
186
- const engine = o.engine === 'edge' ? 'edge' : 'kokoro'
187
- const voice = engine === 'edge' ? edgeVoice(o.voice) : zhVoice(o.voice)
188
- if (engine === 'edge' && !edgeBin()) throw Object.assign(new Error('还没装 Edge 语音组件:在「导出 → 中文配音」里点「安装 Edge 语音组件」'), { status: 400 })
207
+ const engine = zhEngineOf(o.engine)
208
+ const { voice, extra } = zhSetup(engine, o.voice)
189
209
  const align = o.align === 'clean' ? 'clean' : 'full'
190
210
  const talk = lesson.sentences.filter(s => !s.skip).sort((a, b) => a.start - b.start)
191
211
  let at
@@ -209,13 +229,13 @@ async function buildZh(lesson, o) {
209
229
  : undefined
210
230
  const out = join(tracksDir(lesson.id), 'zh.part.mp3')
211
231
  const flac = join(tracksDir(lesson.id), 'zh.part.flac')
212
- const args = { flac, engine: `${engine}-zh`, sid: voice.sid, voice: voice.id, speed: o.speed || 1, sentences, total, kokoroDir, ffmpeg, out, threads: o.threads || 2, bitrate: '48k' }
232
+ const args = { flac, engine: `${engine}-zh`, sid: voice.sid, voice: voice.id, speed: o.speed || 1, sentences, total, kokoroDir, ffmpeg, out, threads: o.threads || 2, bitrate: '48k', ...extra }
213
233
  o.onStage && o.onStage(`中文对译(${voice.label})`, 0, sentences.length, '句')
214
234
  let res
215
235
  try { res = await runWorker(args, o, `中文对译(${voice.label})`) } catch (e) { await rm(flac, { force: true }); throw e }
216
236
  await rename(out, trackAudio(lesson.id, 'zh'))
217
237
  await rename(flac, trackFlac(lesson.id, 'zh'))
218
- return { flac: true, items: res.items, duration: res.duration, engine, voice: voice.id, voiceLabel: `${engine === 'edge' ? 'Edge ' : ''}${voice.label}`, speed: args.speed, align, squeezed: res.squeezed || 0 }
238
+ return { flac: true, items: res.items, duration: res.duration, engine, voice: voice.id, voiceLabel: `${ZH_ENGINE_LABEL[engine]}${voice.label}`, speed: args.speed, align, squeezed: res.squeezed || 0 }
219
239
  }
220
240
 
221
241
  /**
@@ -258,18 +278,17 @@ export async function buildDocAudio(lesson, o = {}) {
258
278
  /** 试听一个中文音色:同一句话,生成一次缓存起来 */
259
279
  const ZH_SAMPLE = '现在进入你的准备流程。首先,用你的能量转换盒,接着做共振调音。'
260
280
  export async function zhSample(voiceId, o = {}) {
261
- const engine = o.engine === 'edge' ? 'edge' : 'kokoro'
262
- const voice = engine === 'edge' ? edgeVoice(voiceId) : zhVoice(voiceId)
281
+ const engine = zhEngineOf(o.engine)
263
282
  const ffmpeg = findFfmpeg(o.ffmpeg)
264
283
  if (!ffmpeg) throw new Error(FFMPEG_HINT)
265
- if (engine === 'edge' && !edgeBin()) throw Object.assign(new Error('还没装 Edge 语音组件'), { status: 400 })
284
+ const { voice, extra } = zhSetup(engine, voiceId)
266
285
  const dir = join(dataRoot(), 'samples')
267
286
  const file = join(dir, `zh-${engine}-${voice.id}.mp3`)
268
287
  if (existsSync(file)) return file
269
288
  await mkdir(dir, { recursive: true })
270
289
  const kokoroDir = engine === 'kokoro' ? await ensureKokoroZh({ signal: o.signal }) : undefined
271
290
  const out = join(dir, `zh-${engine}-${voice.id}.part.mp3`)
272
- await runWorker({ engine: `${engine}-zh`, sid: voice.sid, voice: voice.id, speed: 1, sentences: [{ i: 0, text: ZH_SAMPLE, start: 0 }], total: 0, kokoroDir, ffmpeg, out, threads: 2, bitrate: '64k' }, o, '试听')
291
+ await runWorker({ engine: `${engine}-zh`, sid: voice.sid, voice: voice.id, speed: 1, sentences: [{ i: 0, text: ZH_SAMPLE, start: 0 }], total: 0, kokoroDir, ffmpeg, out, threads: 2, bitrate: '64k', ...extra }, o, '试听')
273
292
  await rename(out, file)
274
293
  return file
275
294
  }
package/lib/tts-worker.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // lib/tts-worker.js — 朗读子进程:把每句英文读出来,拼成一条声音轨
2
2
  //
3
3
  // Kokoro(sherpa-onnx)是同步推理,和识别一样放在单独进程里;macOS 的 say 也在这里一句一句调。
4
- // 输入:argv[2] 的 JSON { engine:'kokoro'|'say'|'kokoro-zh', voice, sid, speed, sentences:[{i,text,start?,slot?}], kokoroDir, ffmpeg, out, gap, total? }
4
+ // Mac 本机 Ming 是 Python(mlx-audio),由这里再起一个 Python 进程。
5
+ // 输入:argv[2] 的 JSON { engine:'kokoro'|'say'|'kokoro-zh'|'edge-zh'|'ming-zh'|'edge-en', voice, sid, speed, sentences:[{i,text,start?,slot?}], kokoroDir, ming?, ffmpeg, out, gap, total? }
5
6
  // 有 total 时按时间表放:每句放在它的 start,其余是静音,总长 total(中文对译用);没有就一句接一句、句间停 gap 秒。
6
7
  // 输出:stdout 每行一个 JSON:{type:'progress',done,total} / {type:'done',items,duration} / {type:'error',message}
7
8
 
@@ -11,8 +12,11 @@ import { createRequire } from 'node:module'
11
12
  import { tmpdir } from 'node:os'
12
13
  import { join } from 'node:path'
13
14
  import { createEncoder, parseWav, RATE, trimSilence } from './pcm.js'
15
+ import { createInterface } from 'node:readline'
14
16
  import { KOKORO_IDS } from './voices.js'
15
17
  import { edgeSpeak } from './edge.js'
18
+ import { MING_WORKER } from './ming.js'
19
+ import { spokenNumbers } from './spoken.js'
16
20
 
17
21
  const emit = obj => process.stdout.write(JSON.stringify(obj) + '\n')
18
22
 
@@ -104,6 +108,85 @@ function edgeEngine(opt) {
104
108
  }
105
109
  }
106
110
 
111
+ /** 喂给 ffmpeg 一段声音,拿回 24 kHz 单声道 float;filter 可以是 atempo 之类 */
112
+ function ffPipe(ffmpeg, input, inArgs, filter) {
113
+ return new Promise((resolve, reject) => {
114
+ const args = ['-hide_banner', ...inArgs, '-i', 'pipe:0', ...(filter ? ['-af', filter] : []), '-ac', '1', '-ar', String(RATE), '-f', 'f32le', 'pipe:1']
115
+ const ff = spawn(ffmpeg, args, { stdio: ['pipe', 'pipe', 'pipe'] })
116
+ const chunks = []
117
+ let err = ''
118
+ ff.stdout.on('data', c => chunks.push(c))
119
+ ff.stderr.on('data', d => { err = (err + d).slice(-1000) })
120
+ ff.stdin.on('error', () => {}) // ffmpeg 先退出时别把整个进程带崩,错误从 close 里报
121
+ ff.on('error', reject)
122
+ ff.on('close', code => {
123
+ if (code !== 0) return reject(new Error(`ffmpeg 处理声音失败(${code}):${err.trim().split('\n').pop()}`))
124
+ const b = Buffer.concat(chunks)
125
+ const f = new Float32Array(Math.floor(b.length / 4))
126
+ new Uint8Array(f.buffer).set(b.subarray(0, f.length * 4))
127
+ resolve(f)
128
+ })
129
+ ff.stdin.end(input)
130
+ })
131
+ }
132
+
133
+ /**
134
+ * Mac 本机 Ming(mlx-audio):一个 Python 进程加载一次模型,一句一句合成(44.1 kHz),转成 24 kHz。
135
+ * Ming 只有「慢速 / 快速」两档,配音要的是刚好塞进空档:变速一律用 ffmpeg 的 atempo(不变调),
136
+ * 同一句先合成一次,要读快就在这份声音上加速,不重新合成。
137
+ */
138
+ function mingEngine(opt) {
139
+ const py = spawn(opt.ming.python, ['-I', MING_WORKER], {
140
+ stdio: ['pipe', 'pipe', 'pipe'],
141
+ env: { ...process.env, HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1', TOKENIZERS_PARALLELISM: 'false', PYTHONUNBUFFERED: '1' },
142
+ })
143
+ const waiting = new Map()
144
+ let nextId = 1
145
+ let tail = ''
146
+ let dead = null
147
+ py.stdin.on('error', () => {})
148
+ py.stderr.on('data', d => { tail = (tail + d).slice(-1500) })
149
+ createInterface({ input: py.stdout }).on('line', line => {
150
+ let r
151
+ try { r = JSON.parse(line) } catch { return }
152
+ const w = waiting.get(r.id)
153
+ if (!w) return
154
+ waiting.delete(r.id)
155
+ if (r.ok) w.resolve(r)
156
+ else w.reject(new Error(`本机 Ming 合成失败:${r.error}`))
157
+ })
158
+ const fail = why => {
159
+ dead = new Error(`本机 Ming 退出了(${why}):${tail.trim().split('\n').slice(-2).join(' ')}`)
160
+ for (const w of waiting.values()) w.reject(dead)
161
+ waiting.clear()
162
+ }
163
+ py.on('error', e => fail(e.message))
164
+ py.on('exit', code => fail(`退出码 ${code}`))
165
+ const send = msg => {
166
+ if (dead) return Promise.reject(dead)
167
+ const id = nextId++
168
+ return new Promise((resolve, reject) => {
169
+ waiting.set(id, { resolve, reject })
170
+ py.stdin.write(JSON.stringify({ ...msg, id }) + '\n')
171
+ })
172
+ }
173
+ const loaded = send({ op: 'load', model: opt.ming.model })
174
+ loaded.catch(() => {})
175
+ let last = { text: null, pcm: null } // 只留上一句:读快一点要用同一份声音
176
+ return async (text, speed) => {
177
+ await loaded
178
+ if (last.text !== text) {
179
+ const r = await send({ op: 'speak', text: spokenNumbers(text), voice: opt.voice, steps: 10 })
180
+ const pcm = trimSilence(await ffPipe(opt.ffmpeg, Buffer.from(r.pcm16, 'base64'), ['-f', 's16le', '-ar', String(r.sampleRate), '-ac', '1']))
181
+ last = { text, pcm }
182
+ }
183
+ const t = Math.min(2, Math.max(0.5, speed || 1))
184
+ if (Math.abs(t - 1) < 0.01) return last.pcm
185
+ const raw = Buffer.from(last.pcm.buffer, last.pcm.byteOffset, last.pcm.byteLength)
186
+ return ffPipe(opt.ffmpeg, raw, ['-f', 'f32le', '-ar', String(RATE), '-ac', '1'], `atempo=${t.toFixed(3)}`)
187
+ }
188
+ }
189
+
107
190
  function sayEngine(opt) {
108
191
  const wpm = String(Math.round(175 * (opt.speed || 0.9)))
109
192
  let n = 0
@@ -179,6 +262,7 @@ async function main() {
179
262
  const opt = JSON.parse(process.argv[2] || '{}')
180
263
  if (opt.engine === 'kokoro-zh') return timeline(opt, kokoroZhEngine(opt))
181
264
  if (opt.engine === 'edge-zh') return timeline(opt, edgeEngine(opt))
265
+ if (opt.engine === 'ming-zh') return timeline(opt, mingEngine(opt))
182
266
  if (opt.engine === 'edge-en') return reading(opt, edgeEngine(opt))
183
267
  if (opt.mode === 'reading') return reading(opt, kokoroEngine(opt))
184
268
  const speak = opt.engine === 'kokoro' ? kokoroEngine(opt) : sayEngine(opt)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tingdong",
3
- "version": "0.5.2",
4
- "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
3
+ "version": "0.7.0",
4
+ "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课,练到「一听就懂」。英文音频 → 带时间的字幕 → 时间对齐的中文配音;文档 / 文字 → AI 朗读音频 → 下载到本地。DeepSeek Harness (dsh) 插件。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
7
7
  "dsh-plugins",
@@ -43,6 +43,7 @@
43
43
  "bin",
44
44
  "lib",
45
45
  "plugin",
46
+ "python",
46
47
  "web",
47
48
  "README.md",
48
49
  "LICENSE"