dsh-tingdong 0.3.0 → 0.4.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 +17 -5
- package/bin/dsh-tingdong.js +28 -0
- package/lib/doc.js +132 -0
- package/lib/edge.js +19 -0
- package/lib/importer.js +54 -5
- package/lib/pcm.js +6 -3
- package/lib/server.js +35 -8
- package/lib/store.js +2 -0
- package/lib/tracks.js +46 -4
- package/lib/translate.js +8 -6
- package/lib/tts-worker.js +26 -1
- package/package.json +2 -2
- package/plugin/client.js +102 -7
- package/plugin/index.js +14 -0
- package/plugin/manage.js +129 -0
- package/plugin/tools.js +29 -0
- package/web/core.js +3 -1
- package/web/listen.js +3 -3
- package/web/panel.css +32 -0
- package/web/panel.html +1 -0
- package/web/panel.js +187 -26
package/README.md
CHANGED
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
|
|
6
6
|
## 它做什么
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**导入文档**(文章、手册、书)。
|
|
9
|
+
|
|
10
|
+
**导入文档**:把整理好的英文纯文本粘贴进来,或选 .txt / .md 文件。格式很简单——每章一行 `# 章节标题`(每章做成一课),`## 小标题` 也会读出来,段落之间空一行。
|
|
11
|
+
PDF、电子书、网页先交给别的 AI(ChatGPT、DeepSeek、Kimi……)整理:界面上有现成的「整理要求」可以一键复制,连同材料一起发过去。
|
|
12
|
+
在 DSH 对话里更省事:把 PDF 发给 AI,说「按听懂的格式整理后导入听懂」,它会整理好直接导入(AI 工具 `tingdong_import_text`)。
|
|
13
|
+
导入后用 **微软 Edge** 的英文语音朗读(默认 Andrew,14 种美式 / 英式男女声,可调语速;也可选离线的 Kokoro),句与句之间停 0.6 秒、换段 1.2 秒,再逐句翻译讲解。之后和音频课一样学:精听、复习、磨耳朵、连续听、导出中文配音和字幕。
|
|
14
|
+
|
|
15
|
+
**导入音频**:
|
|
16
|
+
|
|
17
|
+
1. **识别**:选一个 MP3(或整个文件夹),本机识别出每一句英文和它的起止时间。
|
|
9
18
|
识别用的是 DSH 自带语音输入的同一套模型(SenseVoice + Silero VAD),音频不上传;用过 DSH 语音输入就不用再下载。
|
|
10
19
|
2. **讲解**:用你在 DSH 里选的大模型逐句校对英文(修正识别错误,如 "resna tuning" → "resonant tuning")、
|
|
11
20
|
翻成口语化中文、挑出生词(带音标)、写一句「听音提示」(哪里连读、弱读、吞音),再写全篇概述和关键术语表。
|
|
@@ -20,9 +29,9 @@
|
|
|
20
29
|
| 连续听 | 一句接一句不停地播,英文 / 中文字幕分别开关;最后关掉字幕整篇听 |
|
|
21
30
|
| 词汇 | 全篇关键术语、每句的生词、你查过的词,点一下听原句 |
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
**导出**(每课的「导出」页签):
|
|
24
33
|
|
|
25
|
-
-
|
|
34
|
+
- **中文配音(音频)**:可以下载 **MP3** 或无损 **FLAC**(44.1 kHz / 16 位,和合成出来的原始音频同源,不是 MP3 转的)。把英文换成中文配音,只有中文、没有背景音,每句中文从对应英文开始的时刻开始说,总长和原音频一样(也可以对齐「原声·去空白」)。比空档长的句子自动读快一点(最快 1.6 倍),不会挤到下一句。两种语音可选:
|
|
26
35
|
- **本地 Kokoro**:离线,55 个普通话女声、45 个普通话男声,每个都能先试听;第一次用时下载约 147 MB 模型
|
|
27
36
|
- **微软 Edge**:晓晓、云希、云扬等 14 种(含台湾、粤语、方言),要联网。插件调用开源工具 [edge-tts](https://github.com/rany2/edge-tts),点「安装 Edge 语音组件」会装进插件自己的 Python 虚拟环境(需要电脑上有 Python 3)。文字会发到微软的服务器;这不是微软公开的正式接口,可能失效
|
|
28
37
|
- **文字**:双语 / 英文 / 中文字幕(SRT)、双语歌词 LRC(Walkman 等播放器滚动显示)、**录音稿**(每句的开始时间、英文时长、可用时长和中英对照,照着自己录中文配音)、表格 CSV。时间可按原音频或「原声·去空白」
|
|
@@ -46,7 +55,9 @@
|
|
|
46
55
|
|
|
47
56
|
电脑快捷键:空格 重听 · 回车 下一步 · 1/2/3 评分 · ←/→ 上一句/下一句 · S 变速(1× / 0.85× / 0.7×)· L 单句循环
|
|
48
57
|
|
|
49
|
-
在 DSH 对话里也可以直接说:「把 /Volumes/…/CD4
|
|
58
|
+
在 DSH 对话里也可以直接说:「把 /Volumes/…/CD4 导入听懂」「把这份 PDF 整理后导入听懂」「我这课哪些句子总听不懂?给我讲讲」——插件给 AI 注册了 `tingdong_import`、`tingdong_import_text`、`tingdong_lessons`、`tingdong_sentences` 四个工具。
|
|
59
|
+
|
|
60
|
+
**更新 / 卸载**:在 DSH 的「设置 → 听懂」里。打开时自动查 npm 上有没有新版本;有就出现「更新到 vX」,点一下由 DSH 的插件管理器装好(和插件市场同一套机制),重启 DSH 生效。「卸载」会先确认,只移除插件,课程和学习进度留在 `~/.dsh/dsh-tingdong`。学习面板首页标题旁显示版本号,有新版本时会提醒。
|
|
50
61
|
|
|
51
62
|
## 安装
|
|
52
63
|
|
|
@@ -90,6 +101,7 @@ dsh plugin --profile desktop add /path/to/dsh-go
|
|
|
90
101
|
```bash
|
|
91
102
|
node bin/dsh-tingdong.js import "/Volumes/kuozhan/门罗/CD4" # 整个文件夹,每个音频一课
|
|
92
103
|
node bin/dsh-tingdong.js serve # 打开 http://127.0.0.1:4810
|
|
104
|
+
node bin/dsh-tingdong.js import-doc 手册.md --voice en-US-AndrewNeural # 导入整理好的英文文档
|
|
93
105
|
node bin/dsh-tingdong.js list
|
|
94
106
|
node bin/dsh-tingdong.js voices all --track kokoro --voice am_michael # 批量生成声音轨
|
|
95
107
|
```
|
|
@@ -118,7 +130,7 @@ npm test
|
|
|
118
130
|
- `lib/translate.js` 校对 + 翻译 + 生词 + 听音提示;缺句自动缩小批次重试,失败的留空,之后可「补全翻译」
|
|
119
131
|
- `lib/tracks.js` 声音轨:原声按句截取拼接;朗读交给 `lib/tts-worker.js` 子进程(Kokoro / macOS say),`lib/pcm.js` 统一拼接编码并出时间表
|
|
120
132
|
- `lib/srs.js` 间隔复习;`lib/store.js` 原子写入的 JSON 存储;`lib/today.js` 今日学习(按用户时区算日期)
|
|
121
|
-
- `lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用
|
|
133
|
+
- `lib/doc.js` 文档:切句、分章、整理要求;`lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用
|
|
122
134
|
- `lib/drill.js` 磨耳朵音频;`lib/lookup.js` 点词查意思、重新讲解;`lib/vps.js` 经 SSH 发送到 VPS
|
|
123
135
|
- `plugin/` DSH 宿主端(路由、AI 工具)和界面入口(右侧栏、设置);路由先过 DSH 的 `connection.requestRejection()` 登录校验
|
|
124
136
|
- `web/` 学习面板(原生 JS 模块,无构建):`panel.js` 路由和首页 · `study.js` 精听、复习、今日学习 · `listen.js` 连续听、磨耳朵、词汇 · `edit.js` 改句子、查词 · `core.js` 播放器和公共状态
|
package/bin/dsh-tingdong.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// dsh-tingdong import <文件或文件夹> [--title 标题] 识别 + 翻译,做成课
|
|
5
5
|
// dsh-tingdong serve [--port 4810] 在浏览器里打开学习面板
|
|
6
6
|
// dsh-tingdong list 看有哪些课
|
|
7
|
+
// dsh-tingdong import-doc <整理好的 .txt / .md> 导入英文文档(# 章节 分课),Edge 朗读 + 翻译
|
|
7
8
|
// dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro] [--voice 名字] [--speed 0.9]
|
|
8
9
|
// 生成声音轨(去空白原声 / 压背景 / 系统朗读 / Kokoro)
|
|
9
10
|
//
|
|
@@ -50,6 +51,32 @@ async function main() {
|
|
|
50
51
|
return
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
if (cmd === 'import-doc') {
|
|
55
|
+
if (!rest[0]) throw new Error('用法:dsh-tingdong import-doc <整理好的 .txt / .md> [--title 标题] [--voice en-US-AndrewNeural] [--engine edge|kokoro]')
|
|
56
|
+
const { readFile } = await import('node:fs/promises')
|
|
57
|
+
const text = await readFile(rest[0], 'utf8')
|
|
58
|
+
const llm = await cliLlm(flags)
|
|
59
|
+
console.log(llm ? `翻译模型:${llm.label()}(${llm.via})` : '没有可用的大模型:只朗读英文')
|
|
60
|
+
const importer = new Importer({ llm: () => llm, options, log: m => console.error(m) })
|
|
61
|
+
let last = ''
|
|
62
|
+
importer.onChange(() => {
|
|
63
|
+
for (const j of importer.list()) {
|
|
64
|
+
if (j.state !== 'running') continue
|
|
65
|
+
const key = `${j.id}|${j.stage}|${j.total ? Math.floor(j.done / j.total * 4) : 0}`
|
|
66
|
+
if (key !== last) { last = key; console.log(`${j.title}:${j.stage}${j.total ? ` ${j.done}/${j.total}` : ''}`) }
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
const r = await importer.importDoc(text, { title: typeof flags.title === 'string' ? flags.title : '', engine: flags.engine === 'kokoro' ? 'kokoro' : 'edge', voice: typeof flags.voice === 'string' ? flags.voice : undefined })
|
|
70
|
+
console.log(`《${r.title}》分成 ${r.items.length} 课:${r.items.map(x => `${x.title}(${x.sentences || '已有'} 句)`).join('、')}`)
|
|
71
|
+
await new Promise(resolve => {
|
|
72
|
+
const check = () => { if (!importer.running && !importer.jobs.some(j => j.state === 'queued')) resolve() }
|
|
73
|
+
importer.onChange(() => setImmediate(check))
|
|
74
|
+
check()
|
|
75
|
+
})
|
|
76
|
+
for (const j of importer.list()) console.log(`${j.state === 'done' ? '✓' : '✗'} ${j.title}${j.error ? ':' + j.error : ''}`)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
if (cmd === 'voices') {
|
|
54
81
|
const which = rest[0]
|
|
55
82
|
if (!which) throw new Error('用法:dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro]')
|
|
@@ -132,6 +159,7 @@ async function main() {
|
|
|
132
159
|
dsh-tingdong import <文件或文件夹> [--title 标题]
|
|
133
160
|
dsh-tingdong serve [--port 4810]
|
|
134
161
|
dsh-tingdong list
|
|
162
|
+
dsh-tingdong import-doc <整理好的 .txt / .md> [--title 标题] [--voice en-US-AndrewNeural]
|
|
135
163
|
dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro] [--voice 名字] [--speed 0.9]
|
|
136
164
|
|
|
137
165
|
模型:--base-url URL --api-key KEY --model ID(或 TINGDONG_BASE_URL / TINGDONG_API_KEY / TINGDONG_MODEL)
|
package/lib/doc.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// lib/doc.js — 导入英文文档:整理好的纯文本 → 一章一课,每课是一句一句的英文
|
|
2
|
+
//
|
|
3
|
+
// 约定的格式(让别的 AI 先整理好,界面上有现成的整理指令可以复制):
|
|
4
|
+
// # 章节标题 一行「# 」开头:新的一章,做成一课
|
|
5
|
+
// ## 小标题 小标题也读出来,算一句
|
|
6
|
+
// 段落之间空一行;段落内部的换行会被接起来(硬换行、断词的连字符都能处理)
|
|
7
|
+
// 没有任何「# 」时整篇算一章;一章太长(超过 MAX_SENTENCES 句)就按段落拆成几课。
|
|
8
|
+
|
|
9
|
+
export const MAX_CHARS = 400000
|
|
10
|
+
export const MAX_SENTENCES = 160
|
|
11
|
+
|
|
12
|
+
// 句点后面不算句子结束的缩写
|
|
13
|
+
const ABBR = new Set(['mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'vs', 'etc', 'e.g', 'i.e', 'fig', 'no', 'vol', 'p', 'pp', 'ch', 'u.s', 'u.k', 'a.m', 'p.m', 'inc', 'ltd', 'co', 'approx', 'dept', 'est', 'mt', 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec'])
|
|
14
|
+
|
|
15
|
+
/** 文本清理:统一换行、去掉不可见字符、接上断词 */
|
|
16
|
+
export function normalize(text) {
|
|
17
|
+
return String(text || '')
|
|
18
|
+
.replace(/\r\n?/g, '\n')
|
|
19
|
+
.replace(/[]/g, '')
|
|
20
|
+
.replace(/‐|‑/g, '-')
|
|
21
|
+
.replace(/(\w)-\n(\w)/g, '$1$2') // 行尾断词
|
|
22
|
+
.replace(/[ \t]+/g, ' ')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 一段文字切成句子 */
|
|
26
|
+
export function splitSentences(paragraph) {
|
|
27
|
+
const p = paragraph.replace(/\s*\n\s*/g, ' ').replace(/\s+/g, ' ').trim()
|
|
28
|
+
if (!p) return []
|
|
29
|
+
const out = []
|
|
30
|
+
let start = 0
|
|
31
|
+
const re = /([.!?…]+)(["'”’)\]]*)(\s+)(?=["'“‘(\[]?[A-Z0-9])/g
|
|
32
|
+
let m
|
|
33
|
+
while ((m = re.exec(p))) {
|
|
34
|
+
const end = m.index + m[1].length + m[2].length
|
|
35
|
+
const before = p.slice(start, m.index)
|
|
36
|
+
const lastWord = (/([A-Za-z.]+)$/.exec(before) || [])[1] || ''
|
|
37
|
+
// 缩写(Mr. / e.g.)和单个大写字母(A. Monroe)后面的句点不断句
|
|
38
|
+
if (m[1] === '.' && (ABBR.has(lastWord.toLowerCase().replace(/\.$/, '')) || /^[A-Z]$/.test(lastWord))) continue
|
|
39
|
+
out.push(p.slice(start, end).trim())
|
|
40
|
+
start = end + m[3].length
|
|
41
|
+
}
|
|
42
|
+
out.push(p.slice(start).trim())
|
|
43
|
+
return out.filter(Boolean).flatMap(splitLong)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 太长的句子听起来累:超过 220 个字符的,在分号、冒号、破折号处拆开 */
|
|
47
|
+
function splitLong(s) {
|
|
48
|
+
if (s.length <= 220) return [s]
|
|
49
|
+
const parts = s.split(/(?<=[;:])\s+|\s+[—–]\s+|(?<=—)/).map(x => x.trim()).filter(Boolean)
|
|
50
|
+
if (parts.length < 2) return [s]
|
|
51
|
+
// 拆得太碎的再并回去
|
|
52
|
+
const merged = []
|
|
53
|
+
for (const x of parts) {
|
|
54
|
+
const last = merged[merged.length - 1]
|
|
55
|
+
if (last && (last.length < 60 || x.length < 40)) merged[merged.length - 1] = `${last} ${x}`
|
|
56
|
+
else merged.push(x)
|
|
57
|
+
}
|
|
58
|
+
return merged
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {{title:string, chapters:{title:string, sentences:{text:string, heading?:boolean, para:number}[]}[]}}
|
|
63
|
+
*/
|
|
64
|
+
export function parseDoc(text, { title = '' } = {}) {
|
|
65
|
+
const src = normalize(text)
|
|
66
|
+
if (!src.trim()) throw Object.assign(new Error('没有内容'), { status: 400 })
|
|
67
|
+
if (src.length > MAX_CHARS) throw Object.assign(new Error(`太长了(${src.length} 字符):一次最多 ${MAX_CHARS} 字符,拆成几份导入`), { status: 400 })
|
|
68
|
+
if (/[一-鿿]/.test(src) && (src.match(/[一-鿿]/g) || []).length > src.length * 0.2) {
|
|
69
|
+
throw Object.assign(new Error('看起来大部分是中文:这里导入英文原文,中文由 AI 翻译'), { status: 400 })
|
|
70
|
+
}
|
|
71
|
+
const chapters = []
|
|
72
|
+
let cur = null
|
|
73
|
+
let para = 0
|
|
74
|
+
const newChapter = t => { cur = { title: t, sentences: [] }; chapters.push(cur) }
|
|
75
|
+
for (const block of src.split(/\n\s*\n/)) {
|
|
76
|
+
const lines = block.split('\n')
|
|
77
|
+
let buf = []
|
|
78
|
+
const flush = () => {
|
|
79
|
+
if (!buf.length) return
|
|
80
|
+
if (!cur) newChapter('')
|
|
81
|
+
para++
|
|
82
|
+
for (const s of splitSentences(buf.join('\n'))) cur.sentences.push({ text: s, para })
|
|
83
|
+
buf = []
|
|
84
|
+
}
|
|
85
|
+
for (const raw of lines) {
|
|
86
|
+
const line = raw.trim()
|
|
87
|
+
const h1 = /^#\s+(.+)$/.exec(line)
|
|
88
|
+
const h2 = /^#{2,6}\s+(.+)$/.exec(line)
|
|
89
|
+
if (h1) { flush(); newChapter(h1[1].trim()); continue }
|
|
90
|
+
if (h2) {
|
|
91
|
+
flush()
|
|
92
|
+
if (!cur) newChapter('')
|
|
93
|
+
para++
|
|
94
|
+
cur.sentences.push({ text: h2[1].trim().replace(/[*_`]/g, ''), heading: true, para })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
// Markdown 的列表符号、强调符号不读
|
|
98
|
+
buf.push(line.replace(/^[-*+]\s+|^\d+[.)]\s+/, '').replace(/[*_`]+/g, ''))
|
|
99
|
+
}
|
|
100
|
+
flush()
|
|
101
|
+
}
|
|
102
|
+
const docTitle = title.trim() || (chapters[0] && chapters[0].title) || firstWords(chapters)
|
|
103
|
+
// 去掉空章;没标题的章用文档标题;太长的章按段落拆开
|
|
104
|
+
const out = []
|
|
105
|
+
for (const [k, c] of chapters.filter(c => c.sentences.length).entries()) {
|
|
106
|
+
const t = c.title || (chapters.length > 1 ? `${docTitle} · 第 ${k + 1} 部分` : docTitle)
|
|
107
|
+
if (c.sentences.length <= MAX_SENTENCES) { out.push({ title: t, sentences: c.sentences }); continue }
|
|
108
|
+
let part = []
|
|
109
|
+
let n = 1
|
|
110
|
+
for (const [j, s] of c.sentences.entries()) {
|
|
111
|
+
part.push(s)
|
|
112
|
+
const paraEnds = !c.sentences[j + 1] || c.sentences[j + 1].para !== s.para
|
|
113
|
+
if (part.length >= MAX_SENTENCES * 0.8 && paraEnds) { out.push({ title: `${t}(${n++})`, sentences: part }); part = [] }
|
|
114
|
+
}
|
|
115
|
+
if (part.length) out.push({ title: n > 1 ? `${t}(${n})` : t, sentences: part })
|
|
116
|
+
}
|
|
117
|
+
if (!out.length) throw Object.assign(new Error('没找到英文句子'), { status: 400 })
|
|
118
|
+
return { title: docTitle, chapters: out }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function firstWords(chapters) {
|
|
122
|
+
const s = chapters[0] && chapters[0].sentences[0]
|
|
123
|
+
return s ? s.text.split(/\s+/).slice(0, 6).join(' ') : '英文文档'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 给别的 AI 的整理指令:用户复制过去,连同 PDF / 网页 / 电子书一起发给它 */
|
|
127
|
+
export const PREP_PROMPT = `请把我发给你的英文材料整理成纯文本,用来导入英语听力学习软件。要求:
|
|
128
|
+
1. 只保留正文英文,删掉页眉、页脚、页码、目录、脚注编号、图片说明、网址和联系方式。
|
|
129
|
+
2. 修好因为换行断开的单词(比如 "resonant-" 换行接 "tuning"),段落内部不要硬换行;每个段落一行,段落之间空一行。
|
|
130
|
+
3. 每一章单独起一行「# 章节标题」。原文没有章节的,按内容大约每 800–1500 个英文单词分一章,自己起个简短的英文标题。章里的小标题用「## 小标题」。
|
|
131
|
+
4. 不要翻译、不要改写、不要总结,原文一个字都不要改;也不要加任何说明。
|
|
132
|
+
5. 直接输出整理好的文本(Markdown 纯文本),我会保存成 .txt 或 .md 文件导入。`
|
package/lib/edge.js
CHANGED
|
@@ -29,6 +29,25 @@ export const EDGE_ZH_VOICES = [
|
|
|
29
29
|
]
|
|
30
30
|
export const edgeVoice = id => EDGE_ZH_VOICES.find(v => v.id === id) || EDGE_ZH_VOICES[0]
|
|
31
31
|
|
|
32
|
+
/** 英文(导入文档时朗读用;默认 Andrew) */
|
|
33
|
+
export const EDGE_EN_VOICES = [
|
|
34
|
+
{ id: 'en-US-AndrewNeural', label: 'Andrew(美式男声 · 温和)', gender: 'm' },
|
|
35
|
+
{ id: 'en-US-BrianNeural', label: 'Brian(美式男声 · 随和)', gender: 'm' },
|
|
36
|
+
{ id: 'en-US-ChristopherNeural', label: 'Christopher(美式男声 · 沉稳)', gender: 'm' },
|
|
37
|
+
{ id: 'en-US-GuyNeural', label: 'Guy(美式男声)', gender: 'm' },
|
|
38
|
+
{ id: 'en-US-EricNeural', label: 'Eric(美式男声)', gender: 'm' },
|
|
39
|
+
{ id: 'en-GB-RyanNeural', label: 'Ryan(英式男声)', gender: 'm' },
|
|
40
|
+
{ id: 'en-GB-ThomasNeural', label: 'Thomas(英式男声)', gender: 'm' },
|
|
41
|
+
{ id: 'en-US-AvaNeural', label: 'Ava(美式女声 · 亲切)', gender: 'f' },
|
|
42
|
+
{ id: 'en-US-EmmaNeural', label: 'Emma(美式女声 · 清晰)', gender: 'f' },
|
|
43
|
+
{ id: 'en-US-JennyNeural', label: 'Jenny(美式女声)', gender: 'f' },
|
|
44
|
+
{ id: 'en-US-AriaNeural', label: 'Aria(美式女声 · 播音)', gender: 'f' },
|
|
45
|
+
{ id: 'en-US-MichelleNeural', label: 'Michelle(美式女声)', gender: 'f' },
|
|
46
|
+
{ id: 'en-GB-SoniaNeural', label: 'Sonia(英式女声)', gender: 'f' },
|
|
47
|
+
{ id: 'en-GB-LibbyNeural', label: 'Libby(英式女声)', gender: 'f' },
|
|
48
|
+
]
|
|
49
|
+
export const edgeEnVoice = id => EDGE_EN_VOICES.find(v => v.id === id) || EDGE_EN_VOICES[0]
|
|
50
|
+
|
|
32
51
|
export const edgeDir = () => join(dataRoot(), 'edge-tts')
|
|
33
52
|
const win = process.platform === 'win32'
|
|
34
53
|
const venvBin = name => join(edgeDir(), win ? 'Scripts' : 'bin', win ? `${name}.exe` : name)
|
package/lib/importer.js
CHANGED
|
@@ -10,7 +10,9 @@ import { compactAudio, mergeSegments, transcribe } from './asr.js'
|
|
|
10
10
|
import { ensureModels } from './models.js'
|
|
11
11
|
import { audioPath, getLesson, makeId, saveLesson, titleFromFile } from './store.js'
|
|
12
12
|
import { summarizeLesson, translateSentences } from './translate.js'
|
|
13
|
-
import { buildTrack, TRACKS } from './tracks.js'
|
|
13
|
+
import { buildDocAudio, buildTrack, TRACKS } from './tracks.js'
|
|
14
|
+
import { parseDoc } from './doc.js'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
14
16
|
import { getVpsConfig, sendLesson } from './vps.js'
|
|
15
17
|
|
|
16
18
|
export const AUDIO_EXT = ['.mp3', '.m4a', '.aac', '.wav', '.flac', '.ogg', '.opus', '.wma', '.m4b', '.mp4', '.webm']
|
|
@@ -62,7 +64,7 @@ export class Importer {
|
|
|
62
64
|
/** 给界面看的任务列表 */
|
|
63
65
|
list() {
|
|
64
66
|
return this.jobs.map(j => ({
|
|
65
|
-
id: j.id, lessonId: j.lessonId, title: j.title, kind: j.kind, state: j.state, stage: j.stage,
|
|
67
|
+
id: j.id, lessonId: j.lessonId, title: j.title, kind: j.kind, track: j.track, state: j.state, stage: j.stage,
|
|
66
68
|
done: j.done, total: j.total, message: j.message, error: j.error, startedAt: j.startedAt, finishedAt: j.finishedAt,
|
|
67
69
|
}))
|
|
68
70
|
}
|
|
@@ -106,12 +108,42 @@ export class Importer {
|
|
|
106
108
|
return out
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 导入英文文档:一章一课;每课排一个任务:朗读 → 翻译讲解 → 概述
|
|
113
|
+
* @param {string} text 整理好的纯文本(# 章节标题 分章)
|
|
114
|
+
* @param {{title?:string, engine?:'edge'|'kokoro', voice?:string, speed?:number}} o
|
|
115
|
+
*/
|
|
116
|
+
async importDoc(text, o = {}) {
|
|
117
|
+
const doc = parseDoc(text, { title: o.title || '' })
|
|
118
|
+
const hash = createHash('sha1').update(text).digest('hex').slice(0, 6)
|
|
119
|
+
const slug = t => String(t).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'doc'
|
|
120
|
+
const out = []
|
|
121
|
+
for (const [k, c] of doc.chapters.entries()) {
|
|
122
|
+
const id = `${slug(c.title)}-${hash}${doc.chapters.length > 1 ? `-${k + 1}` : ''}`.replace(/^-+/, '')
|
|
123
|
+
const existing = await getLesson(id)
|
|
124
|
+
if (existing && existing.status === 'ready') { out.push({ lessonId: id, existed: true, title: existing.title }); continue }
|
|
125
|
+
const lesson = existing || {
|
|
126
|
+
id, version: 1, kind: 'doc',
|
|
127
|
+
title: c.title, group: doc.chapters.length > 1 ? doc.title : (o.group || '文档'), order: k,
|
|
128
|
+
source: { type: 'doc', name: doc.title, chars: text.length },
|
|
129
|
+
status: 'queued', createdAt: Date.now(),
|
|
130
|
+
sentences: c.sentences.map((s, i) => ({ i, start: 0, end: 0, text: s.text, en: s.text, zh: '', words: [], sound: '', skip: false, para: s.para, ...(s.heading ? { heading: true } : {}) })),
|
|
131
|
+
}
|
|
132
|
+
if (!existing) await saveLesson(lesson)
|
|
133
|
+
this.enqueue({ kind: 'doc', lessonId: id, title: lesson.title, engine: o.engine, voice: o.voice, speed: o.speed })
|
|
134
|
+
out.push({ lessonId: id, existed: false, title: lesson.title, sentences: lesson.sentences.length })
|
|
135
|
+
}
|
|
136
|
+
return { title: doc.title, items: out }
|
|
137
|
+
}
|
|
138
|
+
|
|
109
139
|
/** 补全翻译(缺翻译的句子、缺概述) */
|
|
110
140
|
async resume(lessonId) {
|
|
111
141
|
const l = await getLesson(lessonId)
|
|
112
142
|
if (!l) throw Object.assign(new Error('没有这节课'), { status: 404 })
|
|
113
143
|
if (this.jobs.some(j => j.lessonId === lessonId && (j.state === 'queued' || j.state === 'running'))) return
|
|
114
|
-
if (
|
|
144
|
+
if (l.kind === 'doc' && !existsSync(audioPath(lessonId))) {
|
|
145
|
+
this.enqueue({ kind: 'doc', lessonId, title: l.title, engine: l.engine, voice: l.voice, speed: l.speed })
|
|
146
|
+
} else if (!l.sentences || !l.sentences.length) {
|
|
115
147
|
const file = l.source && l.source.file
|
|
116
148
|
if (!file || !existsSync(file)) throw Object.assign(new Error('原音频文件不在了,删掉这节课重新导入'), { status: 400 })
|
|
117
149
|
this.enqueue({ kind: 'import', lessonId, title: l.title, file })
|
|
@@ -207,6 +239,24 @@ export class Importer {
|
|
|
207
239
|
async run(job) {
|
|
208
240
|
const signal = job.abort.signal
|
|
209
241
|
const opt = this.opts()
|
|
242
|
+
if (job.kind === 'doc') {
|
|
243
|
+
if (!existsSync(audioPath(job.lessonId))) {
|
|
244
|
+
const l = await getLesson(job.lessonId)
|
|
245
|
+
l.status = 'transcribing'; l.error = ''
|
|
246
|
+
await saveLesson(l)
|
|
247
|
+
const r = await buildDocAudio(l, {
|
|
248
|
+
ffmpeg: opt.ffmpeg, engine: job.engine, voice: job.voice, speed: job.speed, signal,
|
|
249
|
+
onStage: (stage, done, total, unit) => this.stage(job, stage, done, total, unit),
|
|
250
|
+
})
|
|
251
|
+
const fresh = await getLesson(job.lessonId)
|
|
252
|
+
for (const s of fresh.sentences) { const t = r.items[s.i]; if (t) { s.start = t[0]; s.end = t[1] } }
|
|
253
|
+
Object.assign(fresh, { duration: r.duration, engine: r.engine, voice: r.voice, voiceLabel: r.voiceLabel, speed: r.speed, status: 'translating' })
|
|
254
|
+
await saveLesson(fresh)
|
|
255
|
+
}
|
|
256
|
+
await this.translate(job, await getLesson(job.lessonId), signal)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
210
260
|
if (job.kind === 'send') {
|
|
211
261
|
const cfg = await getVpsConfig()
|
|
212
262
|
for (const [k, id] of job.ids.entries()) {
|
|
@@ -298,7 +348,7 @@ export class Importer {
|
|
|
298
348
|
const total = l.sentences.filter(s => !s.zh && !s.skip).length
|
|
299
349
|
this.stage(job, '翻译讲解', 0, total, '句')
|
|
300
350
|
const res = await translateSentences(llm, l.sentences, {
|
|
301
|
-
hint, signal, concurrency: this.opts().concurrency || 3,
|
|
351
|
+
hint, signal, concurrency: this.opts().concurrency || 3, doc: l.kind === 'doc',
|
|
302
352
|
onProgress: (d, t) => this.stage(job, '翻译讲解', d, t, '句'),
|
|
303
353
|
onBatch: () => (signal.aborted ? null : saveLesson(l)),
|
|
304
354
|
})
|
|
@@ -321,4 +371,3 @@ export class Importer {
|
|
|
321
371
|
await saveLesson(l)
|
|
322
372
|
}
|
|
323
373
|
}
|
|
324
|
-
|
package/lib/pcm.js
CHANGED
|
@@ -50,12 +50,15 @@ export function trimSilence(samples, rate = RATE, keep = 0.05) {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* 一边收句子一边编码:write(i, samples) 按顺序调用,finish() 返回时间表
|
|
53
|
-
* @param {object} o { ffmpeg, out, gap=0.7, bitrate='64k', signal }
|
|
53
|
+
* @param {object} o { ffmpeg, out, flac?(同时出一份 FLAC 的路径), gap=0.7, bitrate='64k', signal }
|
|
54
54
|
*/
|
|
55
55
|
export function createEncoder(o) {
|
|
56
56
|
const gap = o.gap ?? 0.7
|
|
57
|
-
const
|
|
58
|
-
'-codec:a', 'libmp3lame', '-b:a', o.bitrate || '64k', o.out]
|
|
57
|
+
const args = ['-hide_banner', '-nostdin', '-y', '-f', 'f32le', '-ar', String(RATE), '-ac', '1', '-i', 'pipe:0',
|
|
58
|
+
'-codec:a', 'libmp3lame', '-b:a', o.bitrate || '64k', o.out]
|
|
59
|
+
// 同时出一份无损 FLAC(44.1 kHz / 16 位,和 CD 一样,播放器都认):从同一份原始音频编码,不是拿 MP3 转的
|
|
60
|
+
if (o.flac) args.push('-ar', '44100', '-sample_fmt', 's16', '-codec:a', 'flac', o.flac)
|
|
61
|
+
const ff = spawn(o.ffmpeg, args, { stdio: ['pipe', 'ignore', 'pipe'] })
|
|
59
62
|
let stderr = ''
|
|
60
63
|
ff.stderr.on('data', d => { stderr = (stderr + d).slice(-3000) })
|
|
61
64
|
let broken = null
|
package/lib/server.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// GET <prefix>/voices 能用的朗读声音(系统语音、Kokoro)
|
|
9
9
|
// GET <prefix>/jobs 导入任务
|
|
10
10
|
// POST <prefix>/import {path, title?} 导入本机的文件或文件夹
|
|
11
|
+
// POST <prefix>/import-doc {text, title?, engine?, voice?, speed?} 导入整理好的英文文档(# 章节 分课),AI 朗读
|
|
12
|
+
// GET <prefix>/doc-help 导入文档的格式要求、给别的 AI 的整理指令
|
|
11
13
|
// POST <prefix>/upload?name= 上传音频(请求体就是文件)
|
|
12
14
|
// POST <prefix>/grade {id, i, g} 给一句评分(0 没懂 / 1 半懂 / 2 懂了)
|
|
13
15
|
// POST <prefix>/resume | retranslate | reset | delete {id}
|
|
@@ -21,6 +23,7 @@
|
|
|
21
23
|
// POST <prefix>/lookup {id, i, word} 点词查意思
|
|
22
24
|
// POST <prefix>/drill {id, track, repeat, pause, scope, from} 生成「磨耳朵」音频
|
|
23
25
|
// GET <prefix>/vps | POST /vps/detect {host} | /vps/config {...} | /vps/send {ids} 发送到 VPS
|
|
26
|
+
// GET <prefix>/update[?force=1] · POST /update · POST /uninstall 插件自己的版本、更新、卸载(只在 DSH 里)
|
|
24
27
|
// GET <prefix>/export?id=&format=&align= 导出文字:字幕 SRT、歌词 LRC、录音稿、表格
|
|
25
28
|
// POST <prefix>/voice-sample {engine, voice} 试听一个中文音色 · POST /edge/install 装 / 更新 Edge 语音组件
|
|
26
29
|
//
|
|
@@ -33,12 +36,13 @@ import { pipeline } from 'node:stream/promises'
|
|
|
33
36
|
import { findModels } from './models.js'
|
|
34
37
|
import { findFfmpeg, uploadsDir } from './paths.js'
|
|
35
38
|
import { AUDIO_EXT } from './importer.js'
|
|
36
|
-
import { listTracks, trackAudio, TRACKS, zhSample } from './tracks.js'
|
|
39
|
+
import { listTracks, trackAudio, trackFlac, TRACKS, zhSample } from './tracks.js'
|
|
37
40
|
import { buildDrill, DRILL_RE } from './drill.js'
|
|
38
41
|
import { explainSentences, lookupWord } from './lookup.js'
|
|
39
42
|
import { today } from './today.js'
|
|
40
43
|
import { exportText, FORMATS } from './export.js'
|
|
41
|
-
import { EDGE_ZH_VOICES, edgeStatus, installEdge } from './edge.js'
|
|
44
|
+
import { EDGE_EN_VOICES, EDGE_ZH_VOICES, edgeStatus, installEdge } from './edge.js'
|
|
45
|
+
import { MAX_CHARS, PREP_PROMPT } from './doc.js'
|
|
42
46
|
import { detect, getVpsConfig, panelUrl, saveVpsConfig, sshHosts } from './vps.js'
|
|
43
47
|
import { KOKORO_VOICES, kokoroReady, kokoroZhReady, SAY_HINT, sayVoices, ZH_VOICES } from './voices.js'
|
|
44
48
|
import { checkId, deleteLesson, editSentence, getLesson, getProgress, gradeSentence, listLessons, mergeWithNext, resetProgress, setProgressFields, setStar, splitSentence, summary } from './store.js'
|
|
@@ -75,14 +79,14 @@ function safePost(req) {
|
|
|
75
79
|
try { return new URL(origin).host === req.headers.host } catch { return false }
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
async function serveAudio(req, res, file, filename) {
|
|
82
|
+
async function serveAudio(req, res, file, filename, type = 'audio/mpeg') {
|
|
79
83
|
let st
|
|
80
84
|
try { st = await stat(file) } catch { return send(res, 404, { error: '这条声音还没有生成' }) }
|
|
81
85
|
const size = st.size
|
|
82
86
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''))
|
|
83
87
|
// 重新生成后文件会变:用修改时间做 ETag,别让浏览器拿旧的缓存配新的时间表
|
|
84
|
-
const head = { 'content-type':
|
|
85
|
-
if (filename) head['content-disposition'] = `attachment; filename="audio.
|
|
88
|
+
const head = { 'content-type': type, 'accept-ranges': 'bytes', 'cache-control': 'private, no-cache', etag: `"${st.size}-${Math.round(st.mtimeMs)}"` }
|
|
89
|
+
if (filename) head['content-disposition'] = `attachment; filename="audio${filename.slice(filename.lastIndexOf('.'))}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
|
86
90
|
if (!m || (!m[1] && !m[2])) {
|
|
87
91
|
res.writeHead(200, { ...head, 'content-length': size })
|
|
88
92
|
if (req.method === 'HEAD') return res.end()
|
|
@@ -142,13 +146,17 @@ export function createHandler(o) {
|
|
|
142
146
|
const id = checkId(q.get('id'))
|
|
143
147
|
const track = q.get('track') || 'full'
|
|
144
148
|
if (!TRACKS[track] && !DRILL_RE.test(track)) return send(res, 400, { error: '没有这种声音' })
|
|
149
|
+
// fmt=flac:无损版(只有中文配音生成时同时出了 FLAC)
|
|
150
|
+
const flac = q.get('fmt') === 'flac'
|
|
151
|
+
if (flac && track !== 'zh') return send(res, 400, { error: '只有中文配音有 FLAC' })
|
|
145
152
|
let filename = ''
|
|
146
153
|
if (q.get('download')) {
|
|
147
154
|
const l = await getLesson(id)
|
|
148
155
|
const meta = TRACKS[track] ? (await listTracks(id))[track] : null
|
|
149
156
|
const label = TRACKS[track] ? TRACKS[track].label + (meta && meta.voiceLabel ? `(${meta.voiceLabel})` : '') : '磨耳朵'
|
|
150
|
-
filename = `${(l && (l.titleZh || l.title)) || id} - ${label}
|
|
157
|
+
filename = `${(l && (l.titleZh || l.title)) || id} - ${label}.${flac ? 'flac' : 'mp3'}`.replace(/[\\/:*?"<>|]/g, '_')
|
|
151
158
|
}
|
|
159
|
+
if (flac) return serveAudio(req, res, trackFlac(id, track), filename, 'audio/flac')
|
|
152
160
|
return serveAudio(req, res, trackAudio(id, track), filename)
|
|
153
161
|
}
|
|
154
162
|
if (path === '/voices') {
|
|
@@ -156,7 +164,7 @@ export function createHandler(o) {
|
|
|
156
164
|
say: await sayVoices(), sayHint: SAY_HINT,
|
|
157
165
|
kokoro: { ready: await kokoroReady(), voices: KOKORO_VOICES },
|
|
158
166
|
zh: { ready: await kokoroZhReady(), voices: ZH_VOICES.map(({ id, label, gender }) => ({ id, label, gender })) },
|
|
159
|
-
edge: { ...(await edgeStatus()), voices: EDGE_ZH_VOICES },
|
|
167
|
+
edge: { ...(await edgeStatus()), voices: EDGE_ZH_VOICES, en: EDGE_EN_VOICES },
|
|
160
168
|
})
|
|
161
169
|
}
|
|
162
170
|
if (path === '/export') {
|
|
@@ -175,6 +183,11 @@ export function createHandler(o) {
|
|
|
175
183
|
return res.end(out.text)
|
|
176
184
|
}
|
|
177
185
|
if (path === '/formats') return send(res, 200, { formats: FORMATS })
|
|
186
|
+
if (path === '/doc-help') return send(res, 200, { prompt: PREP_PROMPT, maxChars: MAX_CHARS })
|
|
187
|
+
if (path === '/update') {
|
|
188
|
+
if (!o.manage) return send(res, 200, { managed: false })
|
|
189
|
+
return send(res, 200, { managed: true, ...(await o.manage.info(q.get('force') === '1')) })
|
|
190
|
+
}
|
|
178
191
|
if (path === '/jobs') return send(res, 200, { jobs: o.importer.list() })
|
|
179
192
|
if (path === '/today') return send(res, 200, await today({ tz: Number(q.get('tz')) || 0 }))
|
|
180
193
|
if (path === '/vps') {
|
|
@@ -204,7 +217,19 @@ export function createHandler(o) {
|
|
|
204
217
|
return send(res, 200, { ok: true, items })
|
|
205
218
|
}
|
|
206
219
|
|
|
207
|
-
|
|
220
|
+
// 导入文档的请求体是整篇文字,放宽到 2 MB;其余都是小参数
|
|
221
|
+
const body = await readJson(req, path === '/import-doc' ? 2 * 1024 * 1024 : 64 * 1024)
|
|
222
|
+
if (path === '/import-doc') {
|
|
223
|
+
if (typeof body.text !== 'string' || !body.text.trim()) return send(res, 400, { error: '没有文字:粘贴进来,或选一个 .txt / .md 文件' })
|
|
224
|
+
const speed = Number(body.speed)
|
|
225
|
+
const r = await o.importer.importDoc(body.text, {
|
|
226
|
+
title: typeof body.title === 'string' ? body.title.slice(0, 120) : '',
|
|
227
|
+
engine: body.engine === 'kokoro' ? 'kokoro' : 'edge',
|
|
228
|
+
voice: typeof body.voice === 'string' ? body.voice.slice(0, 60) : undefined,
|
|
229
|
+
speed: Number.isFinite(speed) && speed >= 0.6 && speed <= 1.4 ? speed : 1,
|
|
230
|
+
})
|
|
231
|
+
return send(res, 200, { ok: true, ...r })
|
|
232
|
+
}
|
|
208
233
|
if (path === '/import') {
|
|
209
234
|
if (typeof body.path !== 'string' || !body.path.trim()) return send(res, 400, { error: '请填音频文件或文件夹的路径' })
|
|
210
235
|
const items = await o.importer.importPath(body.path, { title: typeof body.title === 'string' ? body.title.trim() : '' })
|
|
@@ -265,6 +290,8 @@ export function createHandler(o) {
|
|
|
265
290
|
const file = await zhSample(String(body.voice || ''), { ffmpeg: opts().ffmpeg, engine: body.engine === 'edge' ? 'edge' : 'kokoro' })
|
|
266
291
|
return serveAudio(req, res, file)
|
|
267
292
|
}
|
|
293
|
+
if (path === '/update' && o.manage) return send(res, 200, await o.manage.update())
|
|
294
|
+
if (path === '/uninstall' && o.manage) return send(res, 200, await o.manage.uninstall())
|
|
268
295
|
if (path === '/edge/install') return send(res, 200, { ok: true, ...(await installEdge()) })
|
|
269
296
|
if (path === '/vps/detect') return send(res, 200, { ok: true, ...(await detect(String(body.host || ''))) })
|
|
270
297
|
if (path === '/vps/config') {
|
package/lib/store.js
CHANGED
|
@@ -215,6 +215,8 @@ export function summary(l, p) {
|
|
|
215
215
|
const translated = (l.sentences || []).filter(s => s.zh || s.skip).length
|
|
216
216
|
return {
|
|
217
217
|
id: l.id,
|
|
218
|
+
kind: l.kind || 'audio',
|
|
219
|
+
voiceLabel: l.voiceLabel || '',
|
|
218
220
|
title: l.title,
|
|
219
221
|
titleZh: l.titleZh || '',
|
|
220
222
|
group: l.group || '',
|
package/lib/tracks.js
CHANGED
|
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
17
17
|
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
|
-
import { edgeBin, edgeVoice } from './edge.js'
|
|
20
|
+
import { edgeBin, edgeEnVoice, edgeVoice } from './edge.js'
|
|
21
21
|
|
|
22
22
|
const WORKER = fileURLToPath(new URL('./tts-worker.js', import.meta.url))
|
|
23
23
|
|
|
@@ -33,6 +33,8 @@ export const TRACK_NAMES = Object.keys(TRACKS)
|
|
|
33
33
|
|
|
34
34
|
const tracksDir = id => join(lessonDir(id), 'tracks')
|
|
35
35
|
export const trackAudio = (id, name) => (name === 'full' ? join(lessonDir(id), 'audio.mp3') : join(tracksDir(id), `${name}.mp3`))
|
|
36
|
+
/** 无损版(目前只有中文配音有) */
|
|
37
|
+
export const trackFlac = (id, name) => join(tracksDir(id), `${name}.flac`)
|
|
36
38
|
|
|
37
39
|
/** 已生成的声音轨(不含 full) */
|
|
38
40
|
export async function listTracks(id) {
|
|
@@ -206,11 +208,51 @@ async function buildZh(lesson, o) {
|
|
|
206
208
|
? await ensureKokoroZh({ signal: o.signal, onProgress: (g, t) => o.onStage && o.onStage('下载中文语音模型', Math.round(g / 1048576), Math.round(t / 1048576), 'MB') })
|
|
207
209
|
: undefined
|
|
208
210
|
const out = join(tracksDir(lesson.id), 'zh.part.mp3')
|
|
209
|
-
const
|
|
211
|
+
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' }
|
|
210
213
|
o.onStage && o.onStage(`中文对译(${voice.label})`, 0, sentences.length, '句')
|
|
211
|
-
|
|
214
|
+
let res
|
|
215
|
+
try { res = await runWorker(args, o, `中文对译(${voice.label})`) } catch (e) { await rm(flac, { force: true }); throw e }
|
|
212
216
|
await rename(out, trackAudio(lesson.id, 'zh'))
|
|
213
|
-
|
|
217
|
+
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 }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 文档课:把整课英文念成这一课的主音频(audio.mp3,相当于音频课的「原声」),并得出每句的时间
|
|
223
|
+
* engine:'edge'(默认,微软在线语音)或 'kokoro'(离线)
|
|
224
|
+
* @returns {Promise<{items, duration, engine, voice, voiceLabel, speed}>}
|
|
225
|
+
*/
|
|
226
|
+
export async function buildDocAudio(lesson, o = {}) {
|
|
227
|
+
const ffmpeg = findFfmpeg(o.ffmpeg)
|
|
228
|
+
if (!ffmpeg) throw new Error(FFMPEG_HINT)
|
|
229
|
+
const engine = o.engine === 'kokoro' ? 'kokoro' : 'edge'
|
|
230
|
+
let voice
|
|
231
|
+
let voiceLabel
|
|
232
|
+
let kokoroDir
|
|
233
|
+
if (engine === 'edge') {
|
|
234
|
+
if (!edgeBin()) throw Object.assign(new Error('还没装 Edge 语音组件:在导入文档的地方点「安装 Edge 语音组件」,或者改用 Kokoro'), { status: 400 })
|
|
235
|
+
const v = edgeEnVoice(o.voice)
|
|
236
|
+
voice = v.id
|
|
237
|
+
voiceLabel = `Edge ${v.label.replace(/(.*)/, '')}`
|
|
238
|
+
} else {
|
|
239
|
+
kokoroDir = await ensureKokoro({ signal: o.signal, onProgress: (g, t) => o.onStage && o.onStage('下载 Kokoro 语音模型', Math.round(g / 1048576), Math.round(t / 1048576), 'MB') })
|
|
240
|
+
voice = o.voice && !/^en-/.test(o.voice) ? o.voice : 'am_michael'
|
|
241
|
+
voiceLabel = `Kokoro ${voice}`
|
|
242
|
+
}
|
|
243
|
+
// 停顿:句与句之间 0.6 秒,换段 1.2 秒,小标题后 1 秒
|
|
244
|
+
const list = lesson.sentences
|
|
245
|
+
const sentences = list.map((s, k) => {
|
|
246
|
+
const prev = list[k - 1]
|
|
247
|
+
const pause = !prev ? 0 : prev.heading ? 1 : prev.para !== s.para ? 1.2 : 0.6
|
|
248
|
+
return { i: s.i, text: s.en || s.text, pause }
|
|
249
|
+
})
|
|
250
|
+
await mkdir(lessonDir(lesson.id), { recursive: true })
|
|
251
|
+
const out = join(lessonDir(lesson.id), 'audio.part.mp3')
|
|
252
|
+
const args = { engine: engine === 'edge' ? 'edge-en' : 'kokoro', mode: 'reading', voice, speed: o.speed || 1, sentences, kokoroDir, ffmpeg, out, threads: o.threads || 2, bitrate: '64k' }
|
|
253
|
+
const res = await runWorker(args, o, `朗读(${voiceLabel})`)
|
|
254
|
+
await rename(out, trackAudio(lesson.id, 'full'))
|
|
255
|
+
return { items: res.items, duration: res.duration, engine, voice, voiceLabel, speed: args.speed }
|
|
214
256
|
}
|
|
215
257
|
|
|
216
258
|
/** 试听一个中文音色:同一句话,生成一次缓存起来 */
|