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 CHANGED
@@ -5,7 +5,16 @@
5
5
 
6
6
  ## 它做什么
7
7
 
8
- 1. **导入**:选一个 MP3(或整个文件夹),本机识别出每一句英文和它的起止时间。
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 导入听懂」「我这课哪些句子总听不懂?给我讲讲」——插件给 AI 注册了 `tingdong_import`、`tingdong_lessons`、`tingdong_sentences` 三个工具。
58
+ 在 DSH 对话里也可以直接说:「把 /Volumes/…/CD4 导入听懂」「把这份 PDF 整理后导入听懂」「我这课哪些句子总听不懂?给我讲讲」——插件给 AI 注册了 `tingdong_import`、`tingdong_import_text`、`tingdong_lessons`、`tingdong_sentences` 四个工具。
50
59
 
51
- **更新 / 卸载**:首页标题旁显示版本号,打开时自动查 npm 上有没有新版本;有就出现「更新到 vX」,点一下由 DSH 的插件管理器装好(和插件市场同一套机制),重启 DSH 生效。「卸载」只移除插件,课程和学习进度留在 `~/.dsh/dsh-tingdong`。
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` 播放器和公共状态
@@ -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 (!l.sentences || !l.sentences.length) {
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
- const body = await readJson(req)
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.3.1",
4
- "description": "听懂:把英文有声读物 MP3 变成听力课。本地识别每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
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
- // - 「设置 → 听懂」:状态(ffmpeg、识别模型、翻译模型)和用法
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: 'pointer', border: '1px solid ' + C.border, background: 'transparent', color: C.text,
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('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
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 trackLabel = t => (S.cur.trackDefs[t] && S.cur.trackDefs[t].label) || t
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
- ${warn.length ? `<div class="card small">${warn.map(w => `<div>⚠ ${w}</div>`).join('')}</div>` : ''}
154
- ${S.lessons.length ? '' : `<div class="empty">还没有课。导入一个英文 MP3:本机识别出每一句英文,AI 翻译讲解,然后一句一句练到一听就懂。</div>`}
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="# 第一章标题&#10;&#10;First paragraph of the chapter...&#10;&#10;Second paragraph...&#10;&#10;# 第二章标题&#10;&#10;..."></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
- // 打开首页时自动去 npm 查新版本;有就亮出「更新到 vX」,点一下交给 DSH 的插件管理器装好。
835
+ // ———————————————————— 版本号(首页标题旁) ————————————————————
836
+ // 更新、卸载在 DSH 的「设置 → 听懂」里;这里只显示版本,有新版本时提醒一句。
704
837
  let manageInfo = null
705
- let managePhase = '' // updating | updated | removing | removed
706
- async function renderManage(force = false) {
838
+ async function renderManage() {
707
839
  const box = $('#manage')
708
- if (!manageInfo || force) {
709
- try { manageInfo = await api(`update${force ? '?force=1' : ''}`) } catch { manageInfo = null }
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
- let upd
715
- if (managePhase === 'updating') upd = '<button class="btn small" disabled>正在更新…</button>'
716
- else if (managePhase === 'updated' || m.restartPending) upd = `<button class="btn small" id="mRestart">v${esc(m.restartPending || m.latest)} 已装好,重启 DSH 生效</button>`
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
  // ———————————————————— 启动 ————————————————————