dsh-tingdong 0.3.1 → 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 +14 -4
- package/bin/dsh-tingdong.js +28 -0
- package/lib/doc.js +132 -0
- package/lib/edge.js +19 -0
- package/lib/importer.js +53 -4
- package/lib/server.js +19 -3
- package/lib/store.js +2 -0
- package/lib/tracks.js +38 -1
- package/lib/translate.js +8 -6
- package/lib/tts-worker.js +25 -0
- package/package.json +2 -2
- package/plugin/client.js +102 -7
- package/plugin/tools.js +29 -0
- package/web/core.js +3 -1
- package/web/listen.js +3 -3
- package/web/panel.css +25 -0
- package/web/panel.js +163 -78
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
|
翻成口语化中文、挑出生词(带音标)、写一句「听音提示」(哪里连读、弱读、吞音),再写全篇概述和关键术语表。
|
|
@@ -46,9 +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` 四个工具。
|
|
50
59
|
|
|
51
|
-
**更新 /
|
|
60
|
+
**更新 / 卸载**:在 DSH 的「设置 → 听懂」里。打开时自动查 npm 上有没有新版本;有就出现「更新到 vX」,点一下由 DSH 的插件管理器装好(和插件市场同一套机制),重启 DSH 生效。「卸载」会先确认,只移除插件,课程和学习进度留在 `~/.dsh/dsh-tingdong`。学习面板首页标题旁显示版本号,有新版本时会提醒。
|
|
52
61
|
|
|
53
62
|
## 安装
|
|
54
63
|
|
|
@@ -92,6 +101,7 @@ dsh plugin --profile desktop add /path/to/dsh-go
|
|
|
92
101
|
```bash
|
|
93
102
|
node bin/dsh-tingdong.js import "/Volumes/kuozhan/门罗/CD4" # 整个文件夹,每个音频一课
|
|
94
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 # 导入整理好的英文文档
|
|
95
105
|
node bin/dsh-tingdong.js list
|
|
96
106
|
node bin/dsh-tingdong.js voices all --track kokoro --voice am_michael # 批量生成声音轨
|
|
97
107
|
```
|
|
@@ -120,7 +130,7 @@ npm test
|
|
|
120
130
|
- `lib/translate.js` 校对 + 翻译 + 生词 + 听音提示;缺句自动缩小批次重试,失败的留空,之后可「补全翻译」
|
|
121
131
|
- `lib/tracks.js` 声音轨:原声按句截取拼接;朗读交给 `lib/tts-worker.js` 子进程(Kokoro / macOS say),`lib/pcm.js` 统一拼接编码并出时间表
|
|
122
132
|
- `lib/srs.js` 间隔复习;`lib/store.js` 原子写入的 JSON 存储;`lib/today.js` 今日学习(按用户时区算日期)
|
|
123
|
-
- `lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用
|
|
133
|
+
- `lib/doc.js` 文档:切句、分章、整理要求;`lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用
|
|
124
134
|
- `lib/drill.js` 磨耳朵音频;`lib/lookup.js` 点词查意思、重新讲解;`lib/vps.js` 经 SSH 发送到 VPS
|
|
125
135
|
- `plugin/` DSH 宿主端(路由、AI 工具)和界面入口(右侧栏、设置);路由先过 DSH 的 `connection.requestRejection()` 登录校验
|
|
126
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']
|
|
@@ -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/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}
|
|
@@ -39,7 +41,8 @@ import { buildDrill, DRILL_RE } from './drill.js'
|
|
|
39
41
|
import { explainSentences, lookupWord } from './lookup.js'
|
|
40
42
|
import { today } from './today.js'
|
|
41
43
|
import { exportText, FORMATS } from './export.js'
|
|
42
|
-
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'
|
|
43
46
|
import { detect, getVpsConfig, panelUrl, saveVpsConfig, sshHosts } from './vps.js'
|
|
44
47
|
import { KOKORO_VOICES, kokoroReady, kokoroZhReady, SAY_HINT, sayVoices, ZH_VOICES } from './voices.js'
|
|
45
48
|
import { checkId, deleteLesson, editSentence, getLesson, getProgress, gradeSentence, listLessons, mergeWithNext, resetProgress, setProgressFields, setStar, splitSentence, summary } from './store.js'
|
|
@@ -161,7 +164,7 @@ export function createHandler(o) {
|
|
|
161
164
|
say: await sayVoices(), sayHint: SAY_HINT,
|
|
162
165
|
kokoro: { ready: await kokoroReady(), voices: KOKORO_VOICES },
|
|
163
166
|
zh: { ready: await kokoroZhReady(), voices: ZH_VOICES.map(({ id, label, gender }) => ({ id, label, gender })) },
|
|
164
|
-
edge: { ...(await edgeStatus()), voices: EDGE_ZH_VOICES },
|
|
167
|
+
edge: { ...(await edgeStatus()), voices: EDGE_ZH_VOICES, en: EDGE_EN_VOICES },
|
|
165
168
|
})
|
|
166
169
|
}
|
|
167
170
|
if (path === '/export') {
|
|
@@ -180,6 +183,7 @@ export function createHandler(o) {
|
|
|
180
183
|
return res.end(out.text)
|
|
181
184
|
}
|
|
182
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 })
|
|
183
187
|
if (path === '/update') {
|
|
184
188
|
if (!o.manage) return send(res, 200, { managed: false })
|
|
185
189
|
return send(res, 200, { managed: true, ...(await o.manage.info(q.get('force') === '1')) })
|
|
@@ -213,7 +217,19 @@ export function createHandler(o) {
|
|
|
213
217
|
return send(res, 200, { ok: true, items })
|
|
214
218
|
}
|
|
215
219
|
|
|
216
|
-
|
|
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
|
+
}
|
|
217
233
|
if (path === '/import') {
|
|
218
234
|
if (typeof body.path !== 'string' || !body.path.trim()) return send(res, 400, { error: '请填音频文件或文件夹的路径' })
|
|
219
235
|
const items = await o.importer.importPath(body.path, { title: typeof body.title === 'string' ? body.title.trim() : '' })
|
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
|
|
|
@@ -218,6 +218,43 @@ async function buildZh(lesson, o) {
|
|
|
218
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
219
|
}
|
|
220
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 }
|
|
256
|
+
}
|
|
257
|
+
|
|
221
258
|
/** 试听一个中文音色:同一句话,生成一次缓存起来 */
|
|
222
259
|
const ZH_SAMPLE = '现在进入你的准备流程。首先,用你的能量转换盒,接着做共振调音。'
|
|
223
260
|
export async function zhSample(voiceId, o = {}) {
|
package/lib/translate.js
CHANGED
|
@@ -48,9 +48,10 @@ export function cleanItem(raw) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function batchPrompt(context, items, hint) {
|
|
51
|
+
function batchPrompt(context, items, hint, doc) {
|
|
52
52
|
const lines = []
|
|
53
53
|
if (hint) lines.push(`材料:${hint}`)
|
|
54
|
+
if (doc) lines.push('注意:这是书面文档(会用 AI 朗读给学生听),英文是准确的原文,不是语音识别:en 照抄原句,不要改;skip 一律 false。')
|
|
54
55
|
if (context.length) {
|
|
55
56
|
lines.push('前文(仅供参考,不用输出):')
|
|
56
57
|
for (const c of context) lines.push(`- ${c}`)
|
|
@@ -61,8 +62,8 @@ function batchPrompt(context, items, hint) {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** 跑一批;返回 Map<i, item>(可能缺一些) */
|
|
64
|
-
async function runBatch(llm, items, context, hint, signal) {
|
|
65
|
-
const text = await llm.complete({ system: SYSTEM, user: batchPrompt(context, items, hint), maxTokens: 16000, signal })
|
|
65
|
+
async function runBatch(llm, items, context, hint, signal, doc) {
|
|
66
|
+
const text = await llm.complete({ system: SYSTEM, user: batchPrompt(context, items, hint, doc), maxTokens: 16000, signal })
|
|
66
67
|
const json = extractJson(text)
|
|
67
68
|
const got = new Map()
|
|
68
69
|
for (const raw of Array.isArray(json.items) ? json.items : []) {
|
|
@@ -97,12 +98,13 @@ export async function translateSentences(llm, sentences, opt = {}) {
|
|
|
97
98
|
const batches = []
|
|
98
99
|
for (let k = 0; k < todo.length; k += size) batches.push(todo.slice(k, k + size))
|
|
99
100
|
|
|
101
|
+
// 文档课:英文是准确的书面原文,不让模型改;也没有「非讲话」
|
|
100
102
|
const apply = (s, item) => {
|
|
101
|
-
s.en = item.en || s.text
|
|
103
|
+
s.en = opt.doc ? s.text : (item.en || s.text)
|
|
102
104
|
s.zh = item.zh
|
|
103
105
|
s.words = item.words
|
|
104
106
|
s.sound = item.sound
|
|
105
|
-
s.skip = item.skip
|
|
107
|
+
s.skip = opt.doc ? false : item.skip
|
|
106
108
|
}
|
|
107
109
|
const contextFor = first => {
|
|
108
110
|
const idx = sentences.indexOf(first)
|
|
@@ -118,7 +120,7 @@ export async function translateSentences(llm, sentences, opt = {}) {
|
|
|
118
120
|
for (const part of parts) {
|
|
119
121
|
if (opt.signal && opt.signal.aborted) throw new Error('已取消')
|
|
120
122
|
try {
|
|
121
|
-
const got = await runBatch(llm, part, contextFor(part[0]), opt.hint, opt.signal)
|
|
123
|
+
const got = await runBatch(llm, part, contextFor(part[0]), opt.hint, opt.signal, opt.doc)
|
|
122
124
|
for (const s of part) {
|
|
123
125
|
const item = got.get(s.i)
|
|
124
126
|
if (item && (item.zh || item.skip)) { apply(s, item); done++ } else still.push(s)
|
package/lib/tts-worker.js
CHANGED
|
@@ -152,10 +152,35 @@ async function timeline(opt, speak) {
|
|
|
152
152
|
emit({ type: 'done', ...res, squeezed })
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* 文档朗读:一句接一句,停顿由每句的 pause(这句之前停几秒)决定——
|
|
157
|
+
* 句与句之间短、段落之间长,小标题后面也停一下,听起来像人在念书
|
|
158
|
+
*/
|
|
159
|
+
async function reading(opt, speak) {
|
|
160
|
+
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, gap: 0, bitrate: opt.bitrate })
|
|
161
|
+
const list = (opt.sentences || []).map(s => ({ ...s, text: tidy(s.text) })).filter(s => s.text)
|
|
162
|
+
const AHEAD = opt.engine === 'edge-en' ? 4 : 1
|
|
163
|
+
const pending = []
|
|
164
|
+
let next = 0
|
|
165
|
+
const fill = () => { while (next < list.length && pending.length < AHEAD) { const p = speak(list[next++].text); p.catch(() => {}); pending.push(p) } }
|
|
166
|
+
fill()
|
|
167
|
+
for (const [k, s] of list.entries()) {
|
|
168
|
+
const pcm = await pending.shift()
|
|
169
|
+
fill()
|
|
170
|
+
if (k) await enc.pad(s.pause ?? 0.6)
|
|
171
|
+
await enc.write(s.i, pcm)
|
|
172
|
+
emit({ type: 'progress', done: k + 1, total: list.length })
|
|
173
|
+
}
|
|
174
|
+
const res = await enc.finish()
|
|
175
|
+
emit({ type: 'done', ...res })
|
|
176
|
+
}
|
|
177
|
+
|
|
155
178
|
async function main() {
|
|
156
179
|
const opt = JSON.parse(process.argv[2] || '{}')
|
|
157
180
|
if (opt.engine === 'kokoro-zh') return timeline(opt, kokoroZhEngine(opt))
|
|
158
181
|
if (opt.engine === 'edge-zh') return timeline(opt, edgeEngine(opt))
|
|
182
|
+
if (opt.engine === 'edge-en') return reading(opt, edgeEngine(opt))
|
|
183
|
+
if (opt.mode === 'reading') return reading(opt, kokoroEngine(opt))
|
|
159
184
|
const speak = opt.engine === 'kokoro' ? kokoroEngine(opt) : sayEngine(opt)
|
|
160
185
|
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, gap: opt.gap ?? 0.7 })
|
|
161
186
|
const list = (opt.sentences || []).map(s => ({ i: s.i, text: tidy(s.text) })).filter(s => s.text)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tingdong",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"dsh-plugins",
|
package/plugin/client.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// 手写单文件 bundle,没有构建链:由 dsh web 客户端的 ModuleLoader 注入(写法照 dsh-pick)。挂两处:
|
|
5
5
|
// - 对话页右侧栏的一个页签:整个学习面板(iframe 指向宿主路由 /api-dsh-tingdong/panel)
|
|
6
|
-
// - 「设置 →
|
|
6
|
+
// - 「设置 → 听懂」:版本、更新、卸载,状态(ffmpeg、识别模型、翻译模型)和用法
|
|
7
7
|
// 面板本身是独立调好的页面(dsh-tingdong serve 也能直接打开),这里原样复用。
|
|
8
8
|
// 硬约束:界面崩了不能影响 dsh,注册一律包在 try/catch 里。
|
|
9
9
|
|
|
@@ -25,18 +25,115 @@ window.__ModuleLoader__.load({
|
|
|
25
25
|
border: 'var(--dsw-alias-border-l3, rgba(127,127,127,.25))',
|
|
26
26
|
good: '#1f9d55',
|
|
27
27
|
warn: '#c98a12',
|
|
28
|
+
accent: 'var(--dsw-alias-link, #2f6bff)',
|
|
29
|
+
danger: '#c94040',
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function Btn({ onClick, children }) {
|
|
32
|
+
function Btn({ onClick, disabled, primary, danger, title, children }) {
|
|
31
33
|
return h('button', {
|
|
32
|
-
type: 'button', onClick,
|
|
34
|
+
type: 'button', onClick, disabled, title,
|
|
33
35
|
style: {
|
|
34
36
|
appearance: 'none', fontFamily: 'inherit', fontSize: 12, lineHeight: '18px', padding: '2px 10px',
|
|
35
|
-
borderRadius: 6, cursor: '
|
|
37
|
+
borderRadius: 6, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1, whiteSpace: 'nowrap',
|
|
38
|
+
border: '1px solid ' + (danger ? C.danger : primary ? C.accent : C.border),
|
|
39
|
+
background: primary ? C.accent : 'transparent',
|
|
40
|
+
color: primary ? '#fff' : danger ? C.danger : C.text,
|
|
36
41
|
},
|
|
37
42
|
}, children)
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
async function api(path, method = 'GET') {
|
|
46
|
+
const r = await fetch('/api-dsh-tingdong/' + path, {
|
|
47
|
+
method, credentials: 'same-origin',
|
|
48
|
+
headers: method === 'POST' ? { 'content-type': 'application/json', 'x-tingdong': '1' } : undefined,
|
|
49
|
+
body: method === 'POST' ? '{}' : undefined,
|
|
50
|
+
})
|
|
51
|
+
return r.json()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 给用户自己在终端执行的命令:等宽显示,旁边一个「复制」 */
|
|
55
|
+
function CommandLine({ command }) {
|
|
56
|
+
const [copied, setCopied] = useState(false)
|
|
57
|
+
const copy = async () => {
|
|
58
|
+
try { await navigator.clipboard.writeText(command) } catch {
|
|
59
|
+
const ta = document.createElement('textarea')
|
|
60
|
+
ta.value = command
|
|
61
|
+
ta.style.position = 'fixed'
|
|
62
|
+
ta.style.opacity = '0'
|
|
63
|
+
document.body.appendChild(ta)
|
|
64
|
+
ta.select()
|
|
65
|
+
try { document.execCommand('copy') } catch { /* 实在不行用户还能手动选 */ }
|
|
66
|
+
ta.remove()
|
|
67
|
+
}
|
|
68
|
+
setCopied(true)
|
|
69
|
+
setTimeout(() => setCopied(false), 2000)
|
|
70
|
+
}
|
|
71
|
+
return h('div', { style: { display: 'flex', alignItems: 'center', gap: 6, marginTop: 6, flexWrap: 'wrap' } },
|
|
72
|
+
h('code', { style: { flex: '1 1 auto', minWidth: 0, padding: '4px 8px', borderRadius: 6, fontSize: 12, color: C.text, border: '1px solid ' + C.border, userSelect: 'all', overflowWrap: 'anywhere' } }, command),
|
|
73
|
+
h(Btn, { onClick: copy }, copied ? '已复制' : '复制'))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 标题行:听懂 · 版本号 · 更新 ……(最右)卸载
|
|
78
|
+
* 打开设置页时自动去 npm 查新版本;更新、卸载交给 DSH 的插件管理器(宿主路由 /update、/uninstall)
|
|
79
|
+
*/
|
|
80
|
+
function HeaderBar() {
|
|
81
|
+
const [info, setInfo] = useState(null)
|
|
82
|
+
const [phase, setPhase] = useState('checking') // checking | idle | updating | updated | removing | removed
|
|
83
|
+
const [confirm, setConfirm] = useState(false)
|
|
84
|
+
const [msg, setMsg] = useState(null) // { text, command?, ok? }
|
|
85
|
+
const check = (force) => {
|
|
86
|
+
setPhase('checking')
|
|
87
|
+
api('update' + (force ? '?force=1' : '')).then(j => { setInfo(j); setPhase('idle') }).catch(() => setPhase('idle'))
|
|
88
|
+
}
|
|
89
|
+
useEffect(() => { check(false) }, [])
|
|
90
|
+
const update = async () => {
|
|
91
|
+
setPhase('updating'); setMsg(null)
|
|
92
|
+
try {
|
|
93
|
+
const j = await api('update', 'POST')
|
|
94
|
+
setMsg({ text: j.message, command: j.command, ok: j.ok })
|
|
95
|
+
if (j.ok) { setPhase('updated'); return }
|
|
96
|
+
} catch (e) { setMsg({ text: '更新请求失败:' + e.message }) }
|
|
97
|
+
setPhase('idle')
|
|
98
|
+
}
|
|
99
|
+
const uninstall = async () => {
|
|
100
|
+
setConfirm(false); setPhase('removing'); setMsg(null)
|
|
101
|
+
try {
|
|
102
|
+
const j = await api('uninstall', 'POST')
|
|
103
|
+
setMsg({ text: j.message, command: j.command, ok: j.ok })
|
|
104
|
+
setPhase(j.ok ? 'removed' : 'idle')
|
|
105
|
+
} catch {
|
|
106
|
+
// 卸下插件时它自己的接口也随之消失,请求多半就是这么断的
|
|
107
|
+
setPhase('removed')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let upd
|
|
111
|
+
if (phase === 'updating') upd = { label: '正在更新…', disabled: true }
|
|
112
|
+
else if (phase === 'updated') upd = { label: '已更新,重启 DSH 生效', disabled: true }
|
|
113
|
+
else if (info && info.restartPending) upd = { label: `v${info.restartPending} 已装好,重启 DSH 生效`, disabled: true }
|
|
114
|
+
else if (info && info.available) upd = { label: `更新到 v${info.latest}`, primary: true, onClick: update }
|
|
115
|
+
else if (phase === 'checking') upd = { label: '检查更新…', disabled: true }
|
|
116
|
+
else upd = { label: info && info.latest ? '已是最新' : '检查更新', onClick: () => check(true), title: '点一下重新检查' }
|
|
117
|
+
const busy = phase === 'updating' || phase === 'removing' || phase === 'removed'
|
|
118
|
+
const managed = !info || info.managed !== false
|
|
119
|
+
return h('div', { style: { marginBottom: 8 } },
|
|
120
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
121
|
+
h('span', { style: { fontSize: 15, fontWeight: 650 } }, '听懂'),
|
|
122
|
+
info && info.running ? h('span', { style: { fontSize: 12, color: C.sub, fontVariantNumeric: 'tabular-nums' } }, 'v' + info.running) : null,
|
|
123
|
+
managed ? h(Btn, { onClick: upd.onClick, disabled: upd.disabled || busy, primary: upd.primary, title: upd.title }, upd.label) : null,
|
|
124
|
+
h('span', { style: { flex: 1 } }),
|
|
125
|
+
!managed ? null
|
|
126
|
+
: phase === 'removed' ? h('span', { style: { fontSize: 12, color: C.sub } }, '已卸载,重启 DSH 后彻底移除')
|
|
127
|
+
: phase === 'removing' ? h('span', { style: { fontSize: 12, color: C.sub } }, '正在卸载…')
|
|
128
|
+
: confirm ? h('span', { style: { display: 'inline-flex', gap: 6, alignItems: 'center' } },
|
|
129
|
+
h('span', { style: { fontSize: 12, color: C.sub } }, '确定卸载?课程和进度会保留'),
|
|
130
|
+
h(Btn, { danger: true, onClick: uninstall }, '确认卸载'),
|
|
131
|
+
h(Btn, { onClick: () => setConfirm(false) }, '取消'))
|
|
132
|
+
: h(Btn, { danger: true, disabled: busy, onClick: () => setConfirm(true) }, '卸载')),
|
|
133
|
+
msg ? h('div', { style: { fontSize: 12, color: msg.ok ? C.sub : C.danger, marginTop: 6, lineHeight: 1.6 } },
|
|
134
|
+
msg.text, msg.command ? h(CommandLine, { command: msg.command }) : null) : null)
|
|
135
|
+
}
|
|
136
|
+
|
|
40
137
|
/** 右侧栏:占满栏高的 iframe */
|
|
41
138
|
function Panel() {
|
|
42
139
|
return h('div', { style: { height: '100%', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' } },
|
|
@@ -68,9 +165,7 @@ window.__ModuleLoader__.load({
|
|
|
68
165
|
catch { setMsg('先回到一个对话,再从右侧栏打开') }
|
|
69
166
|
}
|
|
70
167
|
return h('div', { style: { color: C.text } },
|
|
71
|
-
h(
|
|
72
|
-
h('span', { style: { fontSize: 15, fontWeight: 650 } }, '听懂'),
|
|
73
|
-
st && st.version ? h('span', { style: { fontSize: 12, color: C.sub } }, 'v' + st.version) : null),
|
|
168
|
+
h(HeaderBar),
|
|
74
169
|
h('div', { style: { fontSize: 12.5, color: C.sub, lineHeight: 1.7, marginTop: 6 } },
|
|
75
170
|
'把英文有声读物(MP3)变成听力课:本机识别每一句英文,AI 翻译讲解,再用盲听、精听、间隔复习练到一听就懂。'),
|
|
76
171
|
h('div', { style: { marginTop: 14 } },
|
package/plugin/tools.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { isMastered } from '../lib/srs.js'
|
|
9
9
|
import { checkId, getLesson, getProgress, listLessons } from '../lib/store.js'
|
|
10
|
+
import { PREP_PROMPT } from '../lib/doc.js'
|
|
11
|
+
import { EDGE_EN_VOICES } from '../lib/edge.js'
|
|
10
12
|
|
|
11
13
|
const ANY_OBJECT = { type: 'object' }
|
|
12
14
|
|
|
@@ -62,6 +64,33 @@ export async function buildToolDefinitions({ importer }) {
|
|
|
62
64
|
},
|
|
63
65
|
},
|
|
64
66
|
|
|
67
|
+
{
|
|
68
|
+
name: 'tingdong_import_text',
|
|
69
|
+
description: '把一篇英文文档导入「听懂」做成听力课:AI 朗读成音频,再逐句翻译讲解。用户给了 PDF、网页、电子书或一段英文,想用来练听力时用。' +
|
|
70
|
+
'调用前先把材料整理好:' + PREP_PROMPT.split('\n').slice(1, 4).join(' '),
|
|
71
|
+
parameters: params({
|
|
72
|
+
text: { type: 'string', required: true, description: '整理好的英文纯文本:每章一行「# 章节标题」开头(每章做成一课),段落之间空一行,只留正文、原文不改不译' },
|
|
73
|
+
title: { type: 'string', description: '文档标题(可省略)' },
|
|
74
|
+
voice: { type: 'string', enum: EDGE_EN_VOICES.map(v => v.id), description: '朗读的声音(微软 Edge),默认 en-US-AndrewNeural(美式男声)' },
|
|
75
|
+
}),
|
|
76
|
+
output: {
|
|
77
|
+
schema: ANY_OBJECT,
|
|
78
|
+
render: (_a, v) => [{
|
|
79
|
+
type: 'text',
|
|
80
|
+
text: `《${v.title}》分成 ${v.items.length} 课:${v.items.map(it => `${it.title}${it.existed ? '(已导入过)' : `(${it.sentences} 句)`}`).join('、')}。` +
|
|
81
|
+
`正在后台朗读和翻译,几分钟后就能学。${OPEN_HINT}`,
|
|
82
|
+
}],
|
|
83
|
+
},
|
|
84
|
+
async execute(args) {
|
|
85
|
+
const r = await importer.importDoc(String(args.text || ''), {
|
|
86
|
+
title: typeof args.title === 'string' ? args.title : '',
|
|
87
|
+
engine: 'edge',
|
|
88
|
+
voice: typeof args.voice === 'string' ? args.voice : undefined,
|
|
89
|
+
})
|
|
90
|
+
return plain(r)
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
|
|
65
94
|
{
|
|
66
95
|
name: 'tingdong_lessons',
|
|
67
96
|
description: '列出「听懂」里的听力课和学习进度(掌握多少句、待复习多少句),以及正在处理的导入任务。',
|
package/web/core.js
CHANGED
|
@@ -184,7 +184,9 @@ export function useTrack(t) {
|
|
|
184
184
|
loadAudio()
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
export const
|
|
187
|
+
export const isDoc = (c = S.cur) => Boolean(c && c.lesson.kind === 'doc')
|
|
188
|
+
/** 声音的名字;文档课的「原声」其实是导入时的朗读 */
|
|
189
|
+
export const trackLabel = t => (t === 'full' && isDoc() ? `朗读(${S.cur.lesson.voiceLabel || 'AI'})` : (S.cur.trackDefs[t] && S.cur.trackDefs[t].label) || t)
|
|
188
190
|
|
|
189
191
|
/** 学到第几句:存服务器(防抖),换设备接着学 */
|
|
190
192
|
let posTimer
|
package/web/listen.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// web/listen.js — 连续听、磨耳朵、词汇
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
$, api, audio, clearMediaControls, curTrack, esc, fmt, getPos, keys, mediaControls, player, playSentence, S, store, tm,
|
|
4
|
+
$, api, audio, clearMediaControls, curTrack, esc, fmt, getPos, isDoc, keys, mediaControls, player, playSentence, S, store, tm,
|
|
5
5
|
toast, trackLabel, usable,
|
|
6
6
|
} from './core.js'
|
|
7
7
|
|
|
@@ -17,10 +17,10 @@ export function paneText(pane, l) {
|
|
|
17
17
|
<button class="btn primary" id="playAll">▶ 连续播放</button>
|
|
18
18
|
<button class="btn ${opt.en ? 'on' : ''}" id="tEn">英文</button>
|
|
19
19
|
<button class="btn ${opt.zh ? 'on' : ''}" id="tZh">中文</button>
|
|
20
|
-
${full ? `<button class="btn ${opt.skip ? 'on' : ''}" id="tSkip" title="跳过句子之间较长的空白和音乐">跳过空白</button>` : ''}
|
|
20
|
+
${full && !isDoc() ? `<button class="btn ${opt.skip ? 'on' : ''}" id="tSkip" title="跳过句子之间较长的空白和音乐">跳过空白</button>` : ''}
|
|
21
21
|
<button class="btn speed" id="speed">${player.rate}×</button>
|
|
22
22
|
<a class="btn ghost" href="audio?id=${encodeURIComponent(l.id)}&track=${curTrack()}&download=1" download title="存成 MP3,放到手机或播放器上听">下载 MP3</a>
|
|
23
|
-
</div>${full ? '<div class="faint" style="margin-top:4px">现在是完整原声(含音乐和空白)。上面「声音」选「原声·去空白」或朗读,就能不停顿地连续听。</div>' : ''}</div>
|
|
23
|
+
</div>${full && !isDoc() ? '<div class="faint" style="margin-top:4px">现在是完整原声(含音乐和空白)。上面「声音」选「原声·去空白」或朗读,就能不停顿地连续听。</div>' : ''}</div>
|
|
24
24
|
<div class="card" id="sents" style="padding:6px">${list.map(s => {
|
|
25
25
|
const t = tm(s)
|
|
26
26
|
return `<div class="sent ${opt.en ? '' : 'hide-e'} ${opt.zh ? '' : 'hide-z'}" data-i="${s.i}">
|
package/web/panel.css
CHANGED
|
@@ -308,3 +308,28 @@ textarea:focus { border-color: var(--accent); }
|
|
|
308
308
|
.btn.small { padding: 2px 9px; font-size: 12px; line-height: 18px; }
|
|
309
309
|
code.cmd { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; user-select: all; overflow-wrap: anywhere; }
|
|
310
310
|
@media (max-width: 640px) { .btn.small { padding: 4px 10px; font-size: 13px; } .top { flex-wrap: wrap; } }
|
|
311
|
+
|
|
312
|
+
/* —— 首页:新建课程 —— */
|
|
313
|
+
.section-title { font-size: 13px; font-weight: 600; color: var(--sub); margin: 18px 2px 8px; }
|
|
314
|
+
.new-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
315
|
+
.new-card {
|
|
316
|
+
appearance: none; cursor: pointer; text-align: left; font: inherit; color: var(--text);
|
|
317
|
+
background: var(--card); border: 1.5px solid var(--line); border-radius: var(--radius);
|
|
318
|
+
padding: 14px; display: flex; flex-direction: column; gap: 4px;
|
|
319
|
+
}
|
|
320
|
+
.new-card:hover { border-color: var(--accent); }
|
|
321
|
+
.new-card.on { border-color: var(--accent); background: var(--accent-soft); }
|
|
322
|
+
.new-card b { font-size: 15px; }
|
|
323
|
+
.new-card .faint { line-height: 1.5; }
|
|
324
|
+
.new-ico { font-size: 22px; }
|
|
325
|
+
#newPanel .card { margin-top: 10px; }
|
|
326
|
+
.doc-new .step { display: flex; gap: 10px; margin: 12px 0; }
|
|
327
|
+
.doc-new .num {
|
|
328
|
+
flex: none; width: 22px; height: 22px; border-radius: 50%; background: var(--accent); color: #fff;
|
|
329
|
+
font-size: 12px; font-weight: 700; display: grid; place-items: center; margin-top: 1px;
|
|
330
|
+
}
|
|
331
|
+
.doc-new textarea { margin-top: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
|
|
332
|
+
.prompt { white-space: pre-wrap; font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); border-radius: 8px; padding: 8px 10px; margin: 6px 0 0; color: var(--sub); }
|
|
333
|
+
.kind { font-size: 11px; font-weight: 600; padding: 0 6px; border-radius: 4px; margin-right: 6px; vertical-align: 1px; background: var(--accent-soft); color: var(--accent); }
|
|
334
|
+
.kind.doc { background: rgba(201, 138, 18, .14); color: var(--mid); }
|
|
335
|
+
@media (max-width: 640px) { .new-grid { grid-template-columns: 1fr; } }
|
package/web/panel.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { stats } from './srs.js'
|
|
8
8
|
import {
|
|
9
|
-
$, api, app, clearMediaControls, curTrack, esc, fmt, fmtDur, getPos, keys, loadAudio, loadLesson, loop, nav, player,
|
|
9
|
+
$, api, app, clearMediaControls, curTrack, esc, fmt, fmtDur, getPos, isDoc, keys, loadAudio, loadLesson, loop, nav, player,
|
|
10
10
|
playSentence, S, TRACK_ORDER, trackLabel, store, tm, toast, usable, useTrack, when,
|
|
11
11
|
} from './core.js'
|
|
12
12
|
import { paneDrill, paneText, paneWords } from './listen.js'
|
|
@@ -137,36 +137,35 @@ async function render(soft = false) {
|
|
|
137
137
|
function renderLibrary() {
|
|
138
138
|
const st = S.status || {}
|
|
139
139
|
const canImport = !S.status || st.ffmpeg
|
|
140
|
-
const warn = []
|
|
141
|
-
if (S.status && !st.models && canImport) warn.push('第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别。')
|
|
142
|
-
if (S.status && !st.llm && canImport) warn.push('没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。')
|
|
143
|
-
|
|
144
140
|
const groups = new Map()
|
|
145
141
|
for (const l of S.lessons) {
|
|
146
142
|
const g = l.group || ''
|
|
147
143
|
if (!groups.has(g)) groups.set(g, [])
|
|
148
144
|
groups.get(g).push(l)
|
|
149
145
|
}
|
|
146
|
+
const type = store.get('newType', '')
|
|
150
147
|
|
|
151
148
|
app.innerHTML = `
|
|
152
149
|
<div id="todayCard">${todayCard(S.today)}</div>
|
|
153
|
-
${
|
|
154
|
-
|
|
150
|
+
${canImport ? `
|
|
151
|
+
<div class="section-title">新建课程</div>
|
|
152
|
+
<div class="new-grid">
|
|
153
|
+
<button class="new-card ${type === 'audio' ? 'on' : ''}" data-new="audio">
|
|
154
|
+
<span class="new-ico">🎧</span><b>导入音频</b>
|
|
155
|
+
<span class="faint">有声书、讲座、引导音频……MP3 / M4A / WAV / FLAC。本机识别出每一句英文,AI 翻译讲解。</span>
|
|
156
|
+
</button>
|
|
157
|
+
<button class="new-card ${type === 'doc' ? 'on' : ''}" data-new="doc">
|
|
158
|
+
<span class="new-ico">📄</span><b>导入文档</b>
|
|
159
|
+
<span class="faint">文章、手册、书……整理好的英文文字(粘贴或 TXT / Markdown)。AI 朗读成音频,再翻译讲解。</span>
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
<div id="newPanel"></div>
|
|
163
|
+
<div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
|
|
164
|
+
<div class="section-title">我的课程</div>
|
|
165
|
+
${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或导入英文文档。</div>`}
|
|
155
166
|
${[...groups].map(([g, ls]) => `
|
|
156
167
|
${g ? `<div class="group-title">${esc(g)}</div>` : (groups.size > 1 ? '<div class="group-title">其他</div>' : '')}
|
|
157
168
|
${ls.map(lessonCard).join('')}`).join('')}
|
|
158
|
-
${canImport ? `<div class="card">
|
|
159
|
-
<h3>导入英文音频</h3>
|
|
160
|
-
<div class="drop" id="drop">
|
|
161
|
-
<div class="row">
|
|
162
|
-
<input type="text" class="grow" id="path" placeholder="粘贴 MP3 文件或文件夹的路径" style="flex:1 1 200px">
|
|
163
|
-
<button class="btn primary" id="importBtn">导入</button>
|
|
164
|
-
</div>
|
|
165
|
-
<div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
|
|
166
|
-
<input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
|
|
167
|
-
</div>
|
|
168
|
-
<div id="jobs"></div>
|
|
169
|
-
</div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
|
|
170
169
|
<div id="vpsCard"></div>
|
|
171
170
|
`
|
|
172
171
|
renderJobs()
|
|
@@ -174,7 +173,41 @@ function renderLibrary() {
|
|
|
174
173
|
renderVps()
|
|
175
174
|
app.querySelectorAll('.lesson').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
|
|
176
175
|
if (!canImport) return
|
|
176
|
+
app.querySelectorAll('[data-new]').forEach(b => {
|
|
177
|
+
b.onclick = () => {
|
|
178
|
+
const t = store.get('newType', '') === b.dataset.new ? '' : b.dataset.new // 再点一次收起
|
|
179
|
+
store.set('newType', t)
|
|
180
|
+
app.querySelectorAll('[data-new]').forEach(x => x.classList.toggle('on', x.dataset.new === t))
|
|
181
|
+
renderNewPanel(t)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
renderNewPanel(type)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 新建课程:展开所选的那种 */
|
|
188
|
+
function renderNewPanel(type) {
|
|
189
|
+
const box = $('#newPanel')
|
|
190
|
+
if (!box) return
|
|
191
|
+
if (type === 'audio') return newAudioPanel(box)
|
|
192
|
+
if (type === 'doc') return newDocPanel(box)
|
|
193
|
+
box.innerHTML = ''
|
|
194
|
+
}
|
|
177
195
|
|
|
196
|
+
function newAudioPanel(box) {
|
|
197
|
+
const st = S.status || {}
|
|
198
|
+
box.innerHTML = `<div class="card">
|
|
199
|
+
<h3>导入英文音频</h3>
|
|
200
|
+
${!st.models ? '<div class="faint">第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别,音频不上传。</div>' : ''}
|
|
201
|
+
${!st.llm ? '<div class="small" style="color:var(--mid)">没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。</div>' : ''}
|
|
202
|
+
<div class="drop" id="drop" style="margin-top:8px">
|
|
203
|
+
<div class="row">
|
|
204
|
+
<input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径" style="flex:1 1 200px">
|
|
205
|
+
<button class="btn primary" id="importBtn">导入</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
|
|
208
|
+
<input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
|
|
209
|
+
</div>
|
|
210
|
+
</div>`
|
|
178
211
|
const input = $('#path')
|
|
179
212
|
input.value = store.get('lastPath', '')
|
|
180
213
|
const doImport = async () => {
|
|
@@ -200,6 +233,104 @@ function renderLibrary() {
|
|
|
200
233
|
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); uploadFiles([...e.dataTransfer.files]) }
|
|
201
234
|
}
|
|
202
235
|
|
|
236
|
+
let docHelp = null
|
|
237
|
+
async function newDocPanel(box) {
|
|
238
|
+
box.innerHTML = '<div class="card"><div class="faint">…</div></div>'
|
|
239
|
+
try {
|
|
240
|
+
if (!docHelp) docHelp = await api('doc-help')
|
|
241
|
+
if (!S.voices) S.voices = await api('voices')
|
|
242
|
+
} catch (e) { box.innerHTML = `<div class="card small" style="color:var(--bad)">${esc(e.message)}</div>`; return }
|
|
243
|
+
const V = S.voices
|
|
244
|
+
const edgeReady = V.edge && V.edge.ready
|
|
245
|
+
const engine = store.get('docEngine', 'edge')
|
|
246
|
+
const list = engine === 'edge' ? ((V.edge && V.edge.en) || []) : ((V.kokoro && V.kokoro.voices) || []).map(v => ({ ...v, gender: v.id[1] === 'm' ? 'm' : 'f' }))
|
|
247
|
+
const voice = store.get(engine === 'edge' ? 'docVoiceEdge' : 'docVoiceKokoro', engine === 'edge' ? 'en-US-AndrewNeural' : 'am_michael')
|
|
248
|
+
const speed = store.get('docSpeed', 1)
|
|
249
|
+
const group = (g, name) => `<optgroup label="${name}">${list.filter(x => x.gender === g).map(x => `<option value="${esc(x.id)}" ${x.id === voice ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`
|
|
250
|
+
box.innerHTML = `<div class="card doc-new">
|
|
251
|
+
<h3>导入英文文档</h3>
|
|
252
|
+
<div class="step"><span class="num">1</span><div class="grow">
|
|
253
|
+
<b>先让 AI 把材料整理好</b>
|
|
254
|
+
<div class="faint">PDF、电子书、网页都行:把下面这段话连同材料一起发给 ChatGPT、DeepSeek、Kimi 等,它会整理成能直接导入的英文文字(只留正文、修好断行、按章节分好)。扫描版 PDF 也可以,让它先识别文字。</div>
|
|
255
|
+
${S.status && S.status.host === 'dsh' ? '<div class="faint" style="margin-top:4px">💡 更省事:在 DSH 的对话里把 PDF 发给 AI,说「按听懂的格式整理后导入听懂」,它会整理好直接导入,不用复制粘贴。</div>' : ''}
|
|
256
|
+
<details style="margin-top:6px"><summary class="link">查看整理要求</summary><pre class="prompt">${esc(docHelp.prompt)}</pre></details>
|
|
257
|
+
<button class="btn" id="copyPrompt" style="margin-top:6px">复制整理要求</button>
|
|
258
|
+
</div></div>
|
|
259
|
+
<div class="step"><span class="num">2</span><div class="grow">
|
|
260
|
+
<b>把整理好的文字粘贴进来</b>
|
|
261
|
+
<textarea id="docText" rows="8" placeholder="# 第一章标题 First paragraph of the chapter... Second paragraph... # 第二章标题 ..."></textarea>
|
|
262
|
+
<div class="row" style="margin-top:4px">
|
|
263
|
+
<span class="faint grow" id="docCount">每个「# 标题」开始一课;没有标题就整篇一课</span>
|
|
264
|
+
<a href="#" class="link" id="docPick">或选择 .txt / .md 文件</a>
|
|
265
|
+
<input type="file" id="docFile" accept=".txt,.md,.markdown,text/plain,text/markdown" hidden>
|
|
266
|
+
</div>
|
|
267
|
+
<input type="text" id="docTitle" placeholder="文档标题(可不填,用第一章的标题)" style="margin-top:6px">
|
|
268
|
+
</div></div>
|
|
269
|
+
<div class="step"><span class="num">3</span><div class="grow">
|
|
270
|
+
<b>选朗读的声音</b>
|
|
271
|
+
<div class="chips" style="margin-top:4px">
|
|
272
|
+
<button class="chip ${engine === 'edge' ? 'on' : ''}" data-docengine="edge">微软 Edge(最自然,要联网)</button>
|
|
273
|
+
<button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-docengine="kokoro">Kokoro(离线)</button>
|
|
274
|
+
</div>
|
|
275
|
+
${engine === 'edge' && !edgeReady ? `<div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。文字会发到微软的服务器朗读。</div>
|
|
276
|
+
<button class="btn" id="docEdgeInstall" style="margin-top:6px">安装 Edge 语音组件</button>` : `
|
|
277
|
+
<div class="row" style="margin-top:4px"><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select></div>
|
|
278
|
+
<div class="chips" style="margin-top:6px">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常语速' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
|
|
279
|
+
</div></div>
|
|
280
|
+
<button class="btn primary big" id="docGo" ${engine === 'edge' && !edgeReady ? 'disabled' : ''}>开始导入</button>
|
|
281
|
+
</div>`
|
|
282
|
+
const ta = $('#docText')
|
|
283
|
+
ta.value = store.get('docDraft', '')
|
|
284
|
+
const count = () => {
|
|
285
|
+
const t = ta.value
|
|
286
|
+
const chapters = (t.match(/^#\s+\S/gm) || []).length
|
|
287
|
+
const words = (t.match(/[A-Za-z']+/g) || []).length
|
|
288
|
+
$('#docCount').textContent = t.trim() ? `${words.toLocaleString()} 个英文单词 · ${chapters ? `${chapters} 章(每章一课)` : '没有「# 标题」:整篇一课'}` : '每个「# 标题」开始一课;没有标题就整篇一课'
|
|
289
|
+
}
|
|
290
|
+
ta.oninput = () => { count(); try { store.set('docDraft', ta.value.slice(0, 200000)) } catch { /* 太大就不存草稿 */ } }
|
|
291
|
+
count()
|
|
292
|
+
$('#copyPrompt').onclick = () => copy(docHelp.prompt)
|
|
293
|
+
$('#docPick').onclick = e => { e.preventDefault(); $('#docFile').click() }
|
|
294
|
+
$('#docFile').onchange = async e => {
|
|
295
|
+
const f = e.target.files[0]
|
|
296
|
+
if (!f) return
|
|
297
|
+
ta.value = await f.text()
|
|
298
|
+
if (!$('#docTitle').value) $('#docTitle').value = f.name.replace(/\.(txt|md|markdown)$/i, '')
|
|
299
|
+
ta.oninput()
|
|
300
|
+
}
|
|
301
|
+
box.querySelectorAll('[data-docengine]').forEach(b => { b.onclick = () => { store.set('docEngine', b.dataset.docengine); newDocPanel(box) } })
|
|
302
|
+
box.querySelectorAll('[data-docspeed]').forEach(b => {
|
|
303
|
+
b.onclick = () => { store.set('docSpeed', Number(b.dataset.docspeed)); box.querySelectorAll('[data-docspeed]').forEach(x => x.classList.toggle('on', x === b)) }
|
|
304
|
+
})
|
|
305
|
+
const vs = $('#docVoice')
|
|
306
|
+
if (vs) vs.onchange = () => store.set(engine === 'edge' ? 'docVoiceEdge' : 'docVoiceKokoro', vs.value)
|
|
307
|
+
const inst = $('#docEdgeInstall')
|
|
308
|
+
if (inst) inst.onclick = async () => {
|
|
309
|
+
inst.disabled = true
|
|
310
|
+
inst.textContent = '正在安装…(第一次要一两分钟)'
|
|
311
|
+
try {
|
|
312
|
+
const r = await api('edge/install', {})
|
|
313
|
+
toast(r.ready ? `Edge 语音组件已装好(${r.version})` : '安装没成功')
|
|
314
|
+
S.voices = null
|
|
315
|
+
newDocPanel(box)
|
|
316
|
+
} catch (e) { toast(e.message); inst.disabled = false; inst.textContent = '安装 Edge 语音组件' }
|
|
317
|
+
}
|
|
318
|
+
$('#docGo').onclick = async () => {
|
|
319
|
+
const btn = $('#docGo')
|
|
320
|
+
btn.disabled = true
|
|
321
|
+
try {
|
|
322
|
+
const r = await api('import-doc', { text: ta.value, title: $('#docTitle').value.trim(), engine, voice: vs ? vs.value : undefined, speed: store.get('docSpeed', 1) })
|
|
323
|
+
const fresh = r.items.filter(x => !x.existed)
|
|
324
|
+
toast(fresh.length ? `《${r.title}》分成 ${r.items.length} 课,正在朗读和翻译` : '这份文档导入过了')
|
|
325
|
+
store.set('docDraft', '')
|
|
326
|
+
ta.value = ''
|
|
327
|
+
count()
|
|
328
|
+
pollJobsNow()
|
|
329
|
+
} catch (e) { toast(e.message) }
|
|
330
|
+
btn.disabled = false
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
203
334
|
// —— 今日学习卡片 ——
|
|
204
335
|
async function loadToday() {
|
|
205
336
|
try {
|
|
@@ -243,7 +374,7 @@ function lessonCard(l) {
|
|
|
243
374
|
error: '<span class="badge err">出错</span>',
|
|
244
375
|
}[l.status] || (s.due ? `<span class="badge">待复习 ${s.due}</span>` : (s.total && s.mastered === s.total ? '<span class="badge ok">已掌握</span>' : ''))
|
|
245
376
|
return `<div class="card lesson" data-id="${esc(l.id)}">
|
|
246
|
-
<div class="row"><div class="grow"><div class="t">${esc(l.titleZh || l.title)}</div>${l.titleZh ? `<div class="en">${esc(l.title)}</div>` : ''}</div>${badge}</div>
|
|
377
|
+
<div class="row"><div class="grow"><div class="t"><span class="kind ${l.kind === 'doc' ? 'doc' : ''}">${l.kind === 'doc' ? '文档' : '音频'}</span>${esc(l.titleZh || l.title)}</div>${l.titleZh ? `<div class="en">${esc(l.title)}</div>` : ''}</div>${badge}</div>
|
|
247
378
|
<div class="faint" style="margin:6px 0">${fmtDur(l.duration)} · ${l.sentences} 句${l.translated < l.sentences ? ` · 已翻译 ${l.translated}` : ''} · 学过 ${s.seen} · 掌握 ${s.mastered}/${s.total}${s.nextDue && !s.due ? ` · 下次复习 ${when(s.nextDue)}` : ''}</div>
|
|
248
379
|
<div class="bar two"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
|
|
249
380
|
${l.error ? `<div class="small" style="color:var(--bad);margin-top:6px">${esc(l.error)}</div>` : ''}
|
|
@@ -440,7 +571,7 @@ function paneOverview(pane, l, st) {
|
|
|
440
571
|
pane.innerHTML = `
|
|
441
572
|
<div class="card">
|
|
442
573
|
${l.titleZh ? `<div style="font-weight:600">${esc(l.titleZh)}</div><div class="faint">${esc(l.title)}</div>` : `<div style="font-weight:600">${esc(l.title)}</div>`}
|
|
443
|
-
<div class="faint" style="margin-top:2px">${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
|
|
574
|
+
<div class="faint" style="margin-top:2px">${l.kind === 'doc' ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
|
|
444
575
|
${l.summaryZh ? `<p class="summary">${esc(l.summaryZh)}</p>` : ''}
|
|
445
576
|
${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
|
|
446
577
|
${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}
|
|
@@ -504,6 +635,8 @@ function voicesCard(l) {
|
|
|
504
635
|
const opts = (list, sel) => list.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.label)}</option>`).join('')
|
|
505
636
|
const speeds = sel => [0.8, 0.9, 1].map(x => `<option value="${x}" ${x === sel ? 'selected' : ''}>${x === 1 ? '正常语速' : x + ' 倍语速'}</option>`).join('')
|
|
506
637
|
const row = name => {
|
|
638
|
+
// 文档课没有真实录音:「去空白」「压背景」没意义
|
|
639
|
+
if (isDoc() && (name === 'clean' || name === 'cleannr')) return ''
|
|
507
640
|
const meta = name === 'full' ? { duration: l.duration } : S.cur.tracks[name]
|
|
508
641
|
if (!meta && !canMake) return ''
|
|
509
642
|
const job = busy(name)
|
|
@@ -518,7 +651,7 @@ function voicesCard(l) {
|
|
|
518
651
|
return `<div class="track ${name === t ? 'on' : ''}">
|
|
519
652
|
<div class="row"><b class="grow">${esc(trackLabel(name))}</b>
|
|
520
653
|
${meta ? `<span class="faint">${fmt(meta.duration)}${meta.voice ? ' · ' + esc(meta.voice) : ''}</span>` : '<span class="faint">未生成</span>'}</div>
|
|
521
|
-
<div class="faint">${TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
|
|
654
|
+
<div class="faint">${name === 'full' && isDoc() ? '导入文档时 AI 朗读生成的音频' : TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
|
|
522
655
|
${stale ? '<div class="small" style="color:var(--mid)">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
|
|
523
656
|
${job ? `<div class="small muted" style="margin-top:4px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : `
|
|
524
657
|
<div class="row" style="margin-top:6px">
|
|
@@ -699,68 +832,20 @@ function bindZh(root, l) {
|
|
|
699
832
|
}
|
|
700
833
|
}
|
|
701
834
|
|
|
702
|
-
// ————————————————————
|
|
703
|
-
//
|
|
835
|
+
// ———————————————————— 版本号(首页标题旁) ————————————————————
|
|
836
|
+
// 更新、卸载在 DSH 的「设置 → 听懂」里;这里只显示版本,有新版本时提醒一句。
|
|
704
837
|
let manageInfo = null
|
|
705
|
-
|
|
706
|
-
async function renderManage(force = false) {
|
|
838
|
+
async function renderManage() {
|
|
707
839
|
const box = $('#manage')
|
|
708
|
-
if (!manageInfo
|
|
709
|
-
try { manageInfo = await api(
|
|
840
|
+
if (!manageInfo) {
|
|
841
|
+
try { manageInfo = await api('update') } catch { manageInfo = null }
|
|
710
842
|
}
|
|
711
843
|
const m = manageInfo
|
|
712
844
|
if (!m || !m.managed) { box.hidden = true; return }
|
|
713
845
|
box.hidden = route().view !== 'library'
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
else if (m.available) upd = `<button class="btn small primary" id="mUpdate">更新到 v${esc(m.latest)}</button>`
|
|
718
|
-
else upd = `<button class="btn small ghost" id="mCheck" title="点一下重新检查">${m.latest ? '已是最新' : '检查更新'}</button>`
|
|
719
|
-
box.innerHTML = managePhase === 'removed' ? '<span class="faint">已卸载,重启 DSH 后移除</span>'
|
|
720
|
-
: `<span class="faint ver">v${esc(m.running)}</span>${upd}<button class="btn small ghost danger" id="mRemove" ${managePhase ? 'disabled' : ''}>卸载</button>`
|
|
721
|
-
const on = (sel, fn) => { const el = box.querySelector(sel); if (el) el.onclick = fn }
|
|
722
|
-
on('#mCheck', async () => { box.querySelector('#mCheck').textContent = '检查中…'; await renderManage(true); if (!manageInfo.available) toast(manageInfo.latest ? `已是最新版 v${manageInfo.running}` : '查不到 npm 上的版本,检查网络') })
|
|
723
|
-
on('#mRestart', () => manageNote({ message: m.restartHint }))
|
|
724
|
-
on('#mUpdate', async () => {
|
|
725
|
-
managePhase = 'updating'
|
|
726
|
-
renderManage()
|
|
727
|
-
try {
|
|
728
|
-
const r = await api('update', {})
|
|
729
|
-
managePhase = r.ok ? 'updated' : ''
|
|
730
|
-
if (r.ok) manageInfo.latest = r.version
|
|
731
|
-
manageNote(r)
|
|
732
|
-
} catch (e) { managePhase = ''; toast(e.message) }
|
|
733
|
-
renderManage()
|
|
734
|
-
})
|
|
735
|
-
on('#mRemove', async () => {
|
|
736
|
-
if (!confirm('从 DSH 卸载「听懂」插件?\n课程和学习进度会留在电脑上(~/.dsh/dsh-tingdong),重新装上就回来。')) return
|
|
737
|
-
managePhase = 'removing'
|
|
738
|
-
renderManage()
|
|
739
|
-
try {
|
|
740
|
-
const r = await api('uninstall', {})
|
|
741
|
-
managePhase = r.ok ? 'removed' : ''
|
|
742
|
-
manageNote(r)
|
|
743
|
-
} catch {
|
|
744
|
-
// 卸下插件时它自己的接口也随之消失,请求多半就是这么断的
|
|
745
|
-
managePhase = 'removed'
|
|
746
|
-
}
|
|
747
|
-
renderManage()
|
|
748
|
-
})
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/** 更新 / 卸载的结果:一段说明,失败时附上可以自己在终端执行的命令 */
|
|
752
|
-
function manageNote(r) {
|
|
753
|
-
const sheet = document.createElement('div')
|
|
754
|
-
sheet.className = 'sheet-wrap'
|
|
755
|
-
sheet.innerHTML = `<div class="sheet">
|
|
756
|
-
<div class="row"><b class="grow">${r.ok ? '完成' : r.ok === false ? '没有完成' : '提示'}</b><button class="icon-btn" data-close aria-label="关闭">×</button></div>
|
|
757
|
-
<div style="margin:8px 0;line-height:1.7">${esc(r.message || '')}</div>
|
|
758
|
-
${r.command ? `<div class="row"><code class="cmd grow">${esc(r.command)}</code><button class="btn" id="copyCmd">复制</button></div>` : ''}
|
|
759
|
-
</div>`
|
|
760
|
-
document.body.appendChild(sheet)
|
|
761
|
-
sheet.addEventListener('click', e => { if (e.target === sheet || e.target.closest('[data-close]')) sheet.remove() })
|
|
762
|
-
const c = sheet.querySelector('#copyCmd')
|
|
763
|
-
if (c) c.onclick = () => copy(r.command)
|
|
846
|
+
box.innerHTML = `<span class="faint ver">v${esc(m.running)}</span>${m.restartPending
|
|
847
|
+
? `<span class="badge warn">v${esc(m.restartPending)} 已装好,重启 DSH 生效</span>`
|
|
848
|
+
: m.available ? `<span class="badge" title="在 DSH 的「设置 → 听懂」里点更新">有新版本 v${esc(m.latest)} · 设置 → 听懂 里更新</span>` : ''}`
|
|
764
849
|
}
|
|
765
850
|
|
|
766
851
|
// ———————————————————— 启动 ————————————————————
|