dsh-tingdong 0.3.1 → 0.5.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/lib/importer.js CHANGED
@@ -4,13 +4,18 @@
4
4
  // 模型不可用时也能出课(只有英文),之后再「补全翻译」。
5
5
 
6
6
  import { existsSync } from 'node:fs'
7
- import { readdir, rename, rm, stat } from 'node:fs/promises'
7
+ import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
8
8
  import { basename, dirname, extname, join, resolve } from 'node:path'
9
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 { MAX_CHARS, parseDoc } from './doc.js'
15
+ import { cleanupText, estimate, plainTidy } from './docprep.js'
16
+ import { extractText } from './extract.js'
17
+ import { dataRoot } from './paths.js'
18
+ import { createHash } from 'node:crypto'
14
19
  import { getVpsConfig, sendLesson } from './vps.js'
15
20
 
16
21
  export const AUDIO_EXT = ['.mp3', '.m4a', '.aac', '.wav', '.flac', '.ogg', '.opus', '.wma', '.m4b', '.mp4', '.webm']
@@ -106,12 +111,91 @@ export class Importer {
106
111
  return out
107
112
  }
108
113
 
114
+ /**
115
+ * 导入文档第一步:取出文字、存档、预估,等用户确认(不调用 AI)
116
+ * @param {string} file 上传或本机的文件;text 模式时为 null
117
+ * @param {{name:string, text?:string, removeSource?:boolean}} o
118
+ */
119
+ async prepareDoc(file, o) {
120
+ const ext = (/\.[a-z0-9]+$/i.exec(o.name || '') || ['.txt'])[0].toLowerCase()
121
+ const r = file ? await extractText(file, o.name) : { type: '粘贴的文字', text: String(o.text || '') }
122
+ if (r.scanned) throw Object.assign(new Error('这是扫描版 PDF(每页都是图片),读不出文字。先用手机或电脑的文字识别(OCR)、或让 AI 把它转成文字(.txt),再导入。'), { status: 400 })
123
+ const text = r.text.trim()
124
+ if ((text.match(/[A-Za-z]+/g) || []).length < 20) throw Object.assign(new Error('文档里几乎没有英文'), { status: 400 })
125
+ if (text.length > MAX_CHARS) {
126
+ throw Object.assign(new Error(`太长了:约 ${Math.round(text.length / 6 / 1000)} 千词。一次最多约 6 万词(${MAX_CHARS / 1000} 千字符,相当于 7 小时左右的朗读),拆成几份导入。`), { status: 400 })
127
+ }
128
+ // 已经是「# 章节」格式的 Markdown 不用 AI 再整理
129
+ const formatted = (ext === '.md' || ext === '.markdown' || !file) && /^#\s+\S/m.test(text)
130
+ const llm = this.deps.llm()
131
+ const docId = createHash('sha1').update(text).digest('hex').slice(0, 12)
132
+ const dir = join(dataRoot(), 'docs', docId)
133
+ await mkdir(dir, { recursive: true })
134
+ if (file) await copyFile(file, join(dir, `original${ext}`))
135
+ if (o.removeSource && file) await rm(file, { force: true })
136
+ await writeFile(join(dir, 'raw.txt'), text)
137
+ // 粘贴的文字没有文件名:标题留空,整理后用第一章的标题
138
+ const title = file ? (o.name || '').replace(/\.[a-z0-9]+$/i, '').replace(/[_]+/g, ' ').trim() : (o.title || '')
139
+ const meta = {
140
+ docId, name: o.name || '粘贴的文字', title, type: r.type, ext, pages: r.pages || null,
141
+ needsCleanup: !formatted, model: llm && llm.label ? llm.label() : '', hasModel: Boolean(llm),
142
+ estimate: estimate(text, { needsCleanup: !formatted }), createdAt: Date.now(),
143
+ preview: text.slice(0, 600),
144
+ }
145
+ await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 1))
146
+ return meta
147
+ }
148
+
149
+ /** 导入文档第二步:用户确定后排队——AI 整理 → 存档 → 分课(每课再朗读、翻译) */
150
+ async confirmDoc(docId, o = {}) {
151
+ if (!/^[a-f0-9]{12}$/.test(String(docId))) throw Object.assign(new Error('文档编号不对'), { status: 400 })
152
+ const dir = join(dataRoot(), 'docs', docId)
153
+ let meta
154
+ try { meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8')) } catch { throw Object.assign(new Error('找不到这份文档,重新选一次文件'), { status: 404 }) }
155
+ meta.title = (o.title || meta.title || '').trim()
156
+ meta.voice = { engine: o.engine, voice: o.voice, speed: o.speed }
157
+ meta.confirmedAt = Date.now()
158
+ await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 1))
159
+ this.enqueue({ kind: 'docprep', docId, title: `整理文档:${meta.title || meta.name}` })
160
+ return meta
161
+ }
162
+
163
+ /**
164
+ * 导入英文文档:一章一课;每课排一个任务:朗读 → 翻译讲解 → 概述
165
+ * @param {string} text 整理好的纯文本(# 章节标题 分章)
166
+ * @param {{title?:string, engine?:'edge'|'kokoro', voice?:string, speed?:number}} o
167
+ */
168
+ async importDoc(text, o = {}) {
169
+ const doc = parseDoc(text, { title: o.title || '' })
170
+ const hash = createHash('sha1').update(text).digest('hex').slice(0, 6)
171
+ const slug = t => String(t).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'doc'
172
+ const out = []
173
+ for (const [k, c] of doc.chapters.entries()) {
174
+ const id = `${slug(c.title)}-${hash}${doc.chapters.length > 1 ? `-${k + 1}` : ''}`.replace(/^-+/, '')
175
+ const existing = await getLesson(id)
176
+ if (existing && existing.status === 'ready') { out.push({ lessonId: id, existed: true, title: existing.title }); continue }
177
+ const lesson = existing || {
178
+ id, version: 1, kind: 'doc',
179
+ title: c.title, group: doc.title || o.group || '文档', order: k,
180
+ source: { type: 'doc', name: o.docName || doc.title, chars: text.length, ...(o.docId ? { docId: o.docId } : {}) },
181
+ status: 'queued', createdAt: Date.now(),
182
+ 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 } : {}) })),
183
+ }
184
+ if (!existing) await saveLesson(lesson)
185
+ this.enqueue({ kind: 'doc', lessonId: id, title: lesson.title, engine: o.engine, voice: o.voice, speed: o.speed })
186
+ out.push({ lessonId: id, existed: false, title: lesson.title, sentences: lesson.sentences.length })
187
+ }
188
+ return { title: doc.title, items: out }
189
+ }
190
+
109
191
  /** 补全翻译(缺翻译的句子、缺概述) */
110
192
  async resume(lessonId) {
111
193
  const l = await getLesson(lessonId)
112
194
  if (!l) throw Object.assign(new Error('没有这节课'), { status: 404 })
113
195
  if (this.jobs.some(j => j.lessonId === lessonId && (j.state === 'queued' || j.state === 'running'))) return
114
- if (!l.sentences || !l.sentences.length) {
196
+ if (l.kind === 'doc' && !existsSync(audioPath(lessonId))) {
197
+ this.enqueue({ kind: 'doc', lessonId, title: l.title, engine: l.engine, voice: l.voice, speed: l.speed })
198
+ } else if (!l.sentences || !l.sentences.length) {
115
199
  const file = l.source && l.source.file
116
200
  if (!file || !existsSync(file)) throw Object.assign(new Error('原音频文件不在了,删掉这节课重新导入'), { status: 400 })
117
201
  this.enqueue({ kind: 'import', lessonId, title: l.title, file })
@@ -207,6 +291,50 @@ export class Importer {
207
291
  async run(job) {
208
292
  const signal = job.abort.signal
209
293
  const opt = this.opts()
294
+ if (job.kind === 'docprep') {
295
+ const dir = join(dataRoot(), 'docs', job.docId)
296
+ const meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8'))
297
+ const raw = await readFile(join(dir, 'raw.txt'), 'utf8')
298
+ let text = raw
299
+ let warnings = []
300
+ if (meta.needsCleanup) {
301
+ const llm = this.deps.llm()
302
+ if (llm) {
303
+ this.stage(job, 'AI 整理文档', 0, Math.max(1, meta.estimate.chunks), '段')
304
+ const r = await cleanupText(llm, raw, { title: meta.title, signal, concurrency: opt.concurrency || 3, onProgress: (d, t) => this.stage(job, 'AI 整理文档', d, t, '段') })
305
+ text = r.text
306
+ warnings = r.warnings
307
+ } else {
308
+ text = plainTidy(raw)
309
+ warnings = ['没有可用的大模型:只简单接了断行,页眉页码可能还在']
310
+ }
311
+ }
312
+ await writeFile(join(dir, 'clean.md'), text)
313
+ const r = await this.importDoc(text, { title: meta.title, ...meta.voice, docId: job.docId, docName: meta.name })
314
+ Object.assign(meta, { cleanedAt: Date.now(), warnings, lessons: r.items.map(x => x.lessonId) })
315
+ await writeFile(join(dir, 'meta.json'), JSON.stringify(meta, null, 1))
316
+ if (warnings.length) job.message = warnings[0]
317
+ return
318
+ }
319
+
320
+ if (job.kind === 'doc') {
321
+ if (!existsSync(audioPath(job.lessonId))) {
322
+ const l = await getLesson(job.lessonId)
323
+ l.status = 'transcribing'; l.error = ''
324
+ await saveLesson(l)
325
+ const r = await buildDocAudio(l, {
326
+ ffmpeg: opt.ffmpeg, engine: job.engine, voice: job.voice, speed: job.speed, signal,
327
+ onStage: (stage, done, total, unit) => this.stage(job, stage, done, total, unit),
328
+ })
329
+ const fresh = await getLesson(job.lessonId)
330
+ for (const s of fresh.sentences) { const t = r.items[s.i]; if (t) { s.start = t[0]; s.end = t[1] } }
331
+ Object.assign(fresh, { duration: r.duration, engine: r.engine, voice: r.voice, voiceLabel: r.voiceLabel, speed: r.speed, status: 'translating' })
332
+ await saveLesson(fresh)
333
+ }
334
+ await this.translate(job, await getLesson(job.lessonId), signal)
335
+ return
336
+ }
337
+
210
338
  if (job.kind === 'send') {
211
339
  const cfg = await getVpsConfig()
212
340
  for (const [k, id] of job.ids.entries()) {
@@ -298,7 +426,7 @@ export class Importer {
298
426
  const total = l.sentences.filter(s => !s.zh && !s.skip).length
299
427
  this.stage(job, '翻译讲解', 0, total, '句')
300
428
  const res = await translateSentences(llm, l.sentences, {
301
- hint, signal, concurrency: this.opts().concurrency || 3,
429
+ hint, signal, concurrency: this.opts().concurrency || 3, doc: l.kind === 'doc',
302
430
  onProgress: (d, t) => this.stage(job, '翻译讲解', d, t, '句'),
303
431
  onBatch: () => (signal.aborted ? null : saveLesson(l)),
304
432
  })
@@ -321,4 +449,3 @@ export class Importer {
321
449
  await saveLesson(l)
322
450
  }
323
451
  }
324
-
package/lib/server.js CHANGED
@@ -8,6 +8,12 @@
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 的整理指令
13
+ // POST <prefix>/doc/upload?name= 上传文档(PDF / Word / EPUB / 网页 / TXT / MD)→ 取出文字、存档、预估,等确认
14
+ // POST <prefix>/doc/text {text, title?} 粘贴的文字,同上
15
+ // POST <prefix>/doc/confirm {docId, title?, engine?, voice?, speed?} 确定:AI 整理 → 分课朗读翻译
16
+ // POST <prefix>/doc/cancel {docId} · GET /doc/file?id=&which=clean|raw|original 存档
11
17
  // POST <prefix>/upload?name= 上传音频(请求体就是文件)
12
18
  // POST <prefix>/grade {id, i, g} 给一句评分(0 没懂 / 1 半懂 / 2 懂了)
13
19
  // POST <prefix>/resume | retranslate | reset | delete {id}
@@ -32,14 +38,16 @@ import { mkdir, readFile, rm, stat } from 'node:fs/promises'
32
38
  import { extname, join } from 'node:path'
33
39
  import { pipeline } from 'node:stream/promises'
34
40
  import { findModels } from './models.js'
35
- import { findFfmpeg, uploadsDir } from './paths.js'
41
+ import { dataRoot, findFfmpeg, uploadsDir } from './paths.js'
36
42
  import { AUDIO_EXT } from './importer.js'
37
43
  import { listTracks, trackAudio, trackFlac, TRACKS, zhSample } from './tracks.js'
38
44
  import { buildDrill, DRILL_RE } from './drill.js'
39
45
  import { explainSentences, lookupWord } from './lookup.js'
40
46
  import { today } from './today.js'
41
47
  import { exportText, FORMATS } from './export.js'
42
- import { EDGE_ZH_VOICES, edgeStatus, installEdge } from './edge.js'
48
+ import { EDGE_EN_VOICES, EDGE_ZH_VOICES, edgeStatus, installEdge } from './edge.js'
49
+ import { MAX_CHARS, PREP_PROMPT } from './doc.js'
50
+ import { DOC_EXT } from './extract.js'
43
51
  import { detect, getVpsConfig, panelUrl, saveVpsConfig, sshHosts } from './vps.js'
44
52
  import { KOKORO_VOICES, kokoroReady, kokoroZhReady, SAY_HINT, sayVoices, ZH_VOICES } from './voices.js'
45
53
  import { checkId, deleteLesson, editSentence, getLesson, getProgress, gradeSentence, listLessons, mergeWithNext, resetProgress, setProgressFields, setStar, splitSentence, summary } from './store.js'
@@ -53,6 +61,8 @@ const STATIC = {
53
61
  for (const name of ['panel', 'core', 'study', 'listen', 'edit', 'dict']) STATIC[`/${name}.js`] = [`web/${name}.js`, 'text/javascript; charset=utf-8']
54
62
 
55
63
  const MAX_UPLOAD = 2 * 1024 * 1024 * 1024
64
+ const MAX_DOC_UPLOAD = 100 * 1024 * 1024
65
+ const DOC_ID = /^[a-f0-9]{12}$/
56
66
 
57
67
  function send(res, status, body) {
58
68
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
@@ -161,7 +171,7 @@ export function createHandler(o) {
161
171
  say: await sayVoices(), sayHint: SAY_HINT,
162
172
  kokoro: { ready: await kokoroReady(), voices: KOKORO_VOICES },
163
173
  zh: { ready: await kokoroZhReady(), voices: ZH_VOICES.map(({ id, label, gender }) => ({ id, label, gender })) },
164
- edge: { ...(await edgeStatus()), voices: EDGE_ZH_VOICES },
174
+ edge: { ...(await edgeStatus()), voices: EDGE_ZH_VOICES, en: EDGE_EN_VOICES },
165
175
  })
166
176
  }
167
177
  if (path === '/export') {
@@ -180,6 +190,26 @@ export function createHandler(o) {
180
190
  return res.end(out.text)
181
191
  }
182
192
  if (path === '/formats') return send(res, 200, { formats: FORMATS })
193
+ if (path === '/doc-help') return send(res, 200, { prompt: PREP_PROMPT, maxChars: MAX_CHARS, maxUpload: MAX_DOC_UPLOAD, ext: DOC_EXT })
194
+ if (path === '/doc/file') {
195
+ const id = q.get('id')
196
+ if (!DOC_ID.test(String(id))) return send(res, 400, { error: '文档编号不对' })
197
+ const dir = join(dataRoot(), 'docs', id)
198
+ let meta
199
+ try { meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8')) } catch { return send(res, 404, { error: '没有这份文档' }) }
200
+ const which = q.get('which') === 'original' ? 'original' : q.get('which') === 'raw' ? 'raw' : 'clean'
201
+ const file = which === 'original' ? join(dir, `original${meta.ext}`) : join(dir, which === 'raw' ? 'raw.txt' : 'clean.md')
202
+ const base = (meta.title || meta.name || 'document').replace(/\.[a-z0-9]+$/i, '')
203
+ const filename = which === 'original' ? meta.name : `${base}${which === 'raw' ? '(原始文字).txt' : '(整理后).md'}`
204
+ let buf
205
+ try { buf = await readFile(file) } catch { return send(res, 404, { error: which === 'clean' ? '还没整理好' : '没有这个文件' }) }
206
+ res.writeHead(200, {
207
+ 'content-type': which === 'original' ? 'application/octet-stream' : 'text/plain; charset=utf-8',
208
+ 'content-disposition': `attachment; filename="document${extname(filename)}"; filename*=UTF-8''${encodeURIComponent(filename.replace(/[\\/:*?"<>|]/g, '_'))}`,
209
+ 'cache-control': 'no-store',
210
+ })
211
+ return res.end(buf)
212
+ }
183
213
  if (path === '/update') {
184
214
  if (!o.manage) return send(res, 200, { managed: false })
185
215
  return send(res, 200, { managed: true, ...(await o.manage.info(q.get('force') === '1')) })
@@ -196,6 +226,19 @@ export function createHandler(o) {
196
226
 
197
227
  if (!safePost(req)) return send(res, 403, { error: '只接受本页面发出的请求' })
198
228
 
229
+ if (path === '/doc/upload') {
230
+ const name = String(q.get('name') || 'document.txt').replace(/[\\/]/g, '_').slice(0, 200)
231
+ const ext = extname(name).toLowerCase()
232
+ if (!DOC_EXT.includes(ext)) return send(res, 400, { error: '不支持这种文件:可以导入 PDF、Word(.docx)、EPUB、网页(.html)、TXT、Markdown' })
233
+ if (Number(req.headers['content-length'] || 0) > MAX_DOC_UPLOAD) return send(res, 413, { error: '文件太大(最多 100 MB)' })
234
+ await mkdir(uploadsDir(), { recursive: true })
235
+ const tmp = join(uploadsDir(), `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
236
+ try { await pipeline(req, createWriteStream(tmp)) } catch (e) { await rm(tmp, { force: true }); throw e }
237
+ try {
238
+ return send(res, 200, { ok: true, doc: await o.importer.prepareDoc(tmp, { name, removeSource: true }) })
239
+ } finally { await rm(tmp, { force: true }) }
240
+ }
241
+
199
242
  if (path === '/upload') {
200
243
  const name = String(q.get('name') || 'audio.mp3').replace(/[\\/]/g, '_').slice(0, 200)
201
244
  const ext = extname(name).toLowerCase()
@@ -213,7 +256,44 @@ export function createHandler(o) {
213
256
  return send(res, 200, { ok: true, items })
214
257
  }
215
258
 
216
- const body = await readJson(req)
259
+ // 导入文档的请求体是整篇文字,放宽到 2 MB;其余都是小参数
260
+ const body = await readJson(req, path === '/import-doc' ? 2 * 1024 * 1024 : 64 * 1024)
261
+ if (path === '/doc/text') {
262
+ if (typeof body.text !== 'string' || !body.text.trim()) return send(res, 400, { error: '没有文字' })
263
+ const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim().slice(0, 120) : ''
264
+ return send(res, 200, { ok: true, doc: await o.importer.prepareDoc(null, { name: title || '粘贴的文字', title, text: body.text }) })
265
+ }
266
+ if (path === '/doc/confirm') {
267
+ const speed = Number(body.speed)
268
+ const meta = await o.importer.confirmDoc(String(body.docId || ''), {
269
+ title: typeof body.title === 'string' ? body.title.slice(0, 120) : '',
270
+ engine: body.engine === 'kokoro' ? 'kokoro' : 'edge',
271
+ voice: typeof body.voice === 'string' ? body.voice.slice(0, 60) : undefined,
272
+ speed: Number.isFinite(speed) && speed >= 0.6 && speed <= 1.4 ? speed : 1,
273
+ })
274
+ return send(res, 200, { ok: true, doc: meta })
275
+ }
276
+ if (path === '/doc/cancel') {
277
+ const id = String(body.docId || '')
278
+ if (!DOC_ID.test(id)) return send(res, 400, { error: '文档编号不对' })
279
+ const dir = join(dataRoot(), 'docs', id)
280
+ try {
281
+ const meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8'))
282
+ if (!meta.confirmedAt) await rm(dir, { recursive: true, force: true }) // 确认过的是存档,不删
283
+ } catch { /* 本来就没有 */ }
284
+ return send(res, 200, { ok: true })
285
+ }
286
+ if (path === '/import-doc') {
287
+ if (typeof body.text !== 'string' || !body.text.trim()) return send(res, 400, { error: '没有文字:粘贴进来,或选一个 .txt / .md 文件' })
288
+ const speed = Number(body.speed)
289
+ const r = await o.importer.importDoc(body.text, {
290
+ title: typeof body.title === 'string' ? body.title.slice(0, 120) : '',
291
+ engine: body.engine === 'kokoro' ? 'kokoro' : 'edge',
292
+ voice: typeof body.voice === 'string' ? body.voice.slice(0, 60) : undefined,
293
+ speed: Number.isFinite(speed) && speed >= 0.6 && speed <= 1.4 ? speed : 1,
294
+ })
295
+ return send(res, 200, { ok: true, ...r })
296
+ }
217
297
  if (path === '/import') {
218
298
  if (typeof body.path !== 'string' || !body.path.trim()) return send(res, 400, { error: '请填音频文件或文件夹的路径' })
219
299
  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.5.0",
4
+ "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
7
7
  "dsh-plugins",
@@ -54,7 +54,8 @@
54
54
  "node": ">=20"
55
55
  },
56
56
  "dependencies": {
57
- "sherpa-onnx-node": "1.13.8"
57
+ "sherpa-onnx-node": "1.13.8",
58
+ "unpdf": "1.9.1"
58
59
  },
59
60
  "dsh": {
60
61
  "bundle": {
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 } },