dsh-tingdong 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,13 +23,14 @@ dsh plugin --profile desktop add github:AIcivilization/dsh-tingdong # 源码
23
23
 
24
24
  ## 它做什么
25
25
 
26
- 首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**导入文档**(文章、手册、书)。
26
+ 首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**文档转音频**(文章、手册、书)。
27
27
 
28
- **导入文档**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
28
+ **文档转音频**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
29
29
 
30
30
  1. 插件先取出文字,给你一张**确认卡片**:读到了多少词,预计分几课、朗读多长、处理要多久、大约用多少模型 token,并说明「会用 AI 整理文字」。
31
31
  2. 你点确定后,在后台:**AI 整理**(去掉页眉页码目录、修好断行、分好章节,原文一个字都不改、不翻译;长文分段、几段同时整理,逐段核对单词数,模型删多了就重来或改用原文)→ **存档**(原文件、取出的原始文字、整理后的文字都留在 `~/.dsh/dsh-tingdong/docs/`,课程概览里能下载)→ **分课**(每个大标题一课,太长的按段落拆开)→ **朗读**(默认微软 Edge,14 种美式 / 英式男女声,可选离线 Kokoro)→ **逐句翻译讲解**。
32
32
  3. 做好一课就能先学一课,之后和音频课一样:精听、复习、磨耳朵、连续听、导出。
33
+ 4. **下载音频**:文档课的概览页可以下载这一课的 MP3;分成好几课的文档还能「整篇合成一个 MP3」(章间停 1.5 秒),首页文档那一组旁边也有「下载整篇音频」。
33
34
 
34
35
  **大小和耗时**:文件最大 100 MB;文字最多约 6 万个英文单词(40 万字符,相当于 7 小时左右的朗读),再长请拆开导入。真正花时间和 token 的是 AI 整理和翻译:1 万词大约整理 4 万字符、朗读 1 小时多,处理约十几分钟(看模型速度)。扫描版 PDF(每页是图片)读不出文字,要先用 OCR 或 AI 转成文字。
35
36
  在 DSH 对话里也可以把 PDF 发给 AI,说「整理后导入听懂」(AI 工具 `tingdong_import_text`)。
@@ -0,0 +1,74 @@
1
+ // lib/docaudio.js — 文档转音频:把一份文档分出来的几课朗读,按顺序接成一个 MP3 下载
2
+ //
3
+ // 各课的朗读都是同一个编码器出的(24 kHz 单声道 64 kbps MP3),参数一样,直接拼接、不重新编码:
4
+ // 几个小时的书也是一眨眼。章与章之间插一段 1.5 秒静音。拼好的缓存在 docs/<docId>/audio.mp3,
5
+ // 哪一课的朗读比它新就重拼。
6
+
7
+ import { spawn } from 'node:child_process'
8
+ import { existsSync } from 'node:fs'
9
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
10
+ import { join } from 'node:path'
11
+ import { dataRoot, findFfmpeg, FFMPEG_HINT } from './paths.js'
12
+ import { audioPath, getLesson, listLessons } from './store.js'
13
+
14
+ const GAP = 1.5
15
+
16
+ function run(ffmpeg, args) {
17
+ return new Promise((resolve, reject) => {
18
+ const p = spawn(ffmpeg, ['-hide_banner', '-nostdin', '-y', ...args], { stdio: ['ignore', 'ignore', 'pipe'] })
19
+ let err = ''
20
+ p.stderr.on('data', d => { err = (err + d).slice(-2000) })
21
+ p.on('error', reject)
22
+ p.on('close', code => (code === 0 ? resolve() : reject(new Error(`拼接音频失败:${err.trim().split('\n').slice(-2).join(' ')}`))))
23
+ })
24
+ }
25
+
26
+ /** 这份文档的几课(按原文顺序),以及它们的朗读文件 */
27
+ export async function docLessons(docId) {
28
+ const metas = (await listLessons()).filter(l => l.docId === docId).sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
29
+ return metas
30
+ }
31
+
32
+ /**
33
+ * 整篇文档的音频
34
+ * @returns {Promise<{file:string, title:string, voiceLabel:string, lessons:number}>}
35
+ */
36
+ export async function wholeDocAudio(docId, o = {}) {
37
+ if (!/^[a-f0-9]{12}$/.test(String(docId))) throw Object.assign(new Error('文档编号不对'), { status: 400 })
38
+ const ffmpeg = findFfmpeg(o.ffmpeg)
39
+ if (!ffmpeg) throw Object.assign(new Error(FFMPEG_HINT), { status: 500 })
40
+ const lessons = await docLessons(docId)
41
+ if (!lessons.length) throw Object.assign(new Error('没有这份文档的课'), { status: 404 })
42
+ const missing = lessons.filter(l => !existsSync(audioPath(l.id)))
43
+ if (missing.length) throw Object.assign(new Error(`还有 ${missing.length} 课没朗读完,等做好了再下载`), { status: 409 })
44
+ const first = await getLesson(lessons[0].id)
45
+ const dir = join(dataRoot(), 'docs', docId)
46
+ await mkdir(dir, { recursive: true })
47
+ let title = first.group || first.title
48
+ try { title = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8')).title || title } catch { /* 没有存档信息就用组名 */ }
49
+ const out = join(dir, 'audio.mp3')
50
+ const info = { file: out, title, voiceLabel: first.voiceLabel || '', lessons: lessons.length }
51
+ if (lessons.length === 1) return { ...info, file: audioPath(lessons[0].id) }
52
+
53
+ // 缓存还新就直接用
54
+ if (existsSync(out)) {
55
+ const built = (await stat(out)).mtimeMs
56
+ const newest = Math.max(...await Promise.all(lessons.map(async l => (await stat(audioPath(l.id))).mtimeMs)))
57
+ if (built >= newest) return info
58
+ }
59
+ const gap = join(dir, 'gap.mp3')
60
+ if (!existsSync(gap)) await run(ffmpeg, ['-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`, '-t', String(GAP), '-c:a', 'libmp3lame', '-b:a', '64k', gap])
61
+ const q = p => `file '${p.replace(/'/g, "'\\''")}'`
62
+ const list = lessons.flatMap((l, k) => (k ? [q(gap), q(audioPath(l.id))] : [q(audioPath(l.id))])).join('\n')
63
+ const listFile = join(dir, 'concat.txt')
64
+ await writeFile(listFile, list + '\n')
65
+ const part = join(dir, 'audio.part.mp3')
66
+ try {
67
+ await run(ffmpeg, ['-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', '-metadata', `title=${title}`, '-metadata', 'artist=听懂 dsh-tingdong', part])
68
+ await rename(part, out)
69
+ } finally {
70
+ await rm(part, { force: true })
71
+ await rm(listFile, { force: true })
72
+ }
73
+ return info
74
+ }
package/lib/importer.js CHANGED
@@ -168,6 +168,16 @@ export class Importer {
168
168
  async importDoc(text, o = {}) {
169
169
  const doc = parseDoc(text, { title: o.title || '' })
170
170
  const hash = createHash('sha1').update(text).digest('hex').slice(0, 6)
171
+ // 没经过「选文件 → 确认」的(对话里的 AI 工具、命令行粘贴)也建一份存档:整篇下载音频要靠它把几课归到一起
172
+ if (!o.docId) {
173
+ o = { ...o, docId: createHash('sha1').update(text).digest('hex').slice(0, 12) }
174
+ const dir = join(dataRoot(), 'docs', o.docId)
175
+ await mkdir(dir, { recursive: true })
176
+ await writeFile(join(dir, 'clean.md'), text)
177
+ if (!existsSync(join(dir, 'meta.json'))) {
178
+ await writeFile(join(dir, 'meta.json'), JSON.stringify({ docId: o.docId, name: doc.title, title: o.title || doc.title, type: '文字', ext: '.md', needsCleanup: false, confirmedAt: Date.now() }, null, 1))
179
+ }
180
+ }
171
181
  const slug = t => String(t).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'doc'
172
182
  const out = []
173
183
  for (const [k, c] of doc.chapters.entries()) {
package/lib/server.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // POST <prefix>/doc/text {text, title?} 粘贴的文字,同上
15
15
  // POST <prefix>/doc/confirm {docId, title?, engine?, voice?, speed?} 确定:AI 整理 → 分课朗读翻译
16
16
  // POST <prefix>/doc/cancel {docId} · GET /doc/file?id=&which=clean|raw|original 存档
17
+ // GET <prefix>/doc/audio?id= 文档转音频:整篇文档(几课按顺序接起来)下载成一个 MP3
17
18
  // POST <prefix>/upload?name= 上传音频(请求体就是文件)
18
19
  // POST <prefix>/grade {id, i, g} 给一句评分(0 没懂 / 1 半懂 / 2 懂了)
19
20
  // POST <prefix>/resume | retranslate | reset | delete {id}
@@ -48,6 +49,7 @@ import { exportText, FORMATS } from './export.js'
48
49
  import { EDGE_EN_VOICES, EDGE_ZH_VOICES, edgeStatus, installEdge } from './edge.js'
49
50
  import { MAX_CHARS, PREP_PROMPT } from './doc.js'
50
51
  import { DOC_EXT } from './extract.js'
52
+ import { wholeDocAudio } from './docaudio.js'
51
53
  import { detect, getVpsConfig, panelUrl, saveVpsConfig, sshHosts } from './vps.js'
52
54
  import { KOKORO_VOICES, kokoroReady, kokoroZhReady, SAY_HINT, sayVoices, ZH_VOICES } from './voices.js'
53
55
  import { checkId, deleteLesson, editSentence, getLesson, getProgress, gradeSentence, listLessons, mergeWithNext, resetProgress, setProgressFields, setStar, splitSentence, summary } from './store.js'
@@ -160,7 +162,9 @@ export function createHandler(o) {
160
162
  if (q.get('download')) {
161
163
  const l = await getLesson(id)
162
164
  const meta = TRACKS[track] ? (await listTracks(id))[track] : null
163
- const label = TRACKS[track] ? TRACKS[track].label + (meta && meta.voiceLabel ? `(${meta.voiceLabel})` : '') : '磨耳朵'
165
+ // 文档课的「原声」其实是朗读
166
+ const label = track === 'full' && l && l.kind === 'doc' ? `朗读(${l.voiceLabel || 'AI'})`
167
+ : TRACKS[track] ? TRACKS[track].label + (meta && meta.voiceLabel ? `(${meta.voiceLabel})` : '') : '磨耳朵'
164
168
  filename = `${(l && (l.titleZh || l.title)) || id} - ${label}.${flac ? 'flac' : 'mp3'}`.replace(/[\\/:*?"<>|]/g, '_')
165
169
  }
166
170
  if (flac) return serveAudio(req, res, trackFlac(id, track), filename, 'audio/flac')
@@ -191,6 +195,11 @@ export function createHandler(o) {
191
195
  }
192
196
  if (path === '/formats') return send(res, 200, { formats: FORMATS })
193
197
  if (path === '/doc-help') return send(res, 200, { prompt: PREP_PROMPT, maxChars: MAX_CHARS, maxUpload: MAX_DOC_UPLOAD, ext: DOC_EXT })
198
+ if (path === '/doc/audio') {
199
+ const a = await wholeDocAudio(String(q.get('id') || ''), { ffmpeg: opts().ffmpeg })
200
+ const name = `${a.title}${a.voiceLabel ? `(朗读:${a.voiceLabel})` : ''}.mp3`.replace(/[\\/:*?"<>|]/g, '_')
201
+ return serveAudio(req, res, a.file, name)
202
+ }
194
203
  if (path === '/doc/file') {
195
204
  const id = q.get('id')
196
205
  if (!DOC_ID.test(String(id))) return send(res, 400, { error: '文档编号不对' })
package/lib/store.js CHANGED
@@ -216,6 +216,7 @@ export function summary(l, p) {
216
216
  return {
217
217
  id: l.id,
218
218
  kind: l.kind || 'audio',
219
+ docId: (l.source && l.source.docId) || '',
219
220
  voiceLabel: l.voiceLabel || '',
220
221
  title: l.title,
221
222
  titleZh: l.titleZh || '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tingdong",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
package/web/panel.js CHANGED
@@ -155,16 +155,16 @@ function renderLibrary() {
155
155
  <span class="faint">有声书、讲座、引导音频……MP3 / M4A / WAV / FLAC。本机识别出每一句英文,AI 翻译讲解。</span>
156
156
  </button>
157
157
  <button class="new-card ${type === 'doc' ? 'on' : ''}" data-new="doc">
158
- <span class="new-ico">📄</span><b>导入文档</b>
158
+ <span class="new-ico">📄</span><b>文档转音频</b>
159
159
  <span class="faint">文章、手册、书……整理好的英文文字(粘贴或 TXT / Markdown)。AI 朗读成音频,再翻译讲解。</span>
160
160
  </button>
161
161
  </div>
162
162
  <div id="newPanel"></div>
163
163
  <div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
164
164
  <div class="section-title">我的课程</div>
165
- ${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或导入英文文档。</div>`}
165
+ ${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或把英文文档转成音频。</div>`}
166
166
  ${[...groups].map(([g, ls]) => `
167
- ${g ? `<div class="group-title">${esc(g)}</div>` : (groups.size > 1 ? '<div class="group-title">其他</div>' : '')}
167
+ ${g ? `<div class="group-title row"><span class="grow">${esc(g)}</span>${docDownload(ls)}</div>` : (groups.size > 1 ? '<div class="group-title">其他</div>' : '')}
168
168
  ${ls.map(lessonCard).join('')}`).join('')}
169
169
  <div id="vpsCard"></div>
170
170
  `
@@ -238,7 +238,7 @@ let pendingDoc = null // 已经读出文字、等确认的文档
238
238
  function newDocPanel(box) {
239
239
  if (pendingDoc) return docConfirm(box, pendingDoc)
240
240
  box.innerHTML = `<div class="card">
241
- <h3>导入英文文档</h3>
241
+ <h3>文档转音频</h3>
242
242
  <div class="drop" id="docDrop">
243
243
  <div class="new-ico">📄</div>
244
244
  <div><b>把文档拖到这里</b>,或 <a href="#" class="link" id="docPick">选择文件</a></div>
@@ -372,6 +372,15 @@ async function docConfirm(box, doc) {
372
372
  }
373
373
  }
374
374
 
375
+ /** 文档那一组:整篇音频下载(几课合成一个 MP3);还有课没做好就先不给 */
376
+ function docDownload(ls) {
377
+ const ids = [...new Set(ls.map(l => l.docId).filter(Boolean))]
378
+ if (ls.some(l => l.kind !== 'doc') || ids.length !== 1) return ''
379
+ const waiting = ls.filter(l => !l.hasAudio).length
380
+ if (waiting) return `<span class="faint">还有 ${waiting} 课在转音频</span>`
381
+ return `<a class="link" href="doc/audio?id=${ids[0]}" download title="整篇文档的朗读,合成一个 MP3">⬇ 下载整篇音频</a>`
382
+ }
383
+
375
384
  // —— 今日学习卡片 ——
376
385
  async function loadToday() {
377
386
  try {
@@ -618,6 +627,7 @@ function paneOverview(pane, l, st) {
618
627
  ${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
619
628
  ${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}
620
629
  </div>
630
+ ${l.kind === 'doc' && S.cur.lesson.sentences.length ? docAudioCard(l) : ''}
621
631
  <div class="card">
622
632
  <div class="stat-grid">
623
633
  <div><b>${st.fresh}</b><span>没学过</span></div>
@@ -659,6 +669,22 @@ function paneOverview(pane, l, st) {
659
669
  bindManage(pane, l)
660
670
  }
661
671
 
672
+ /** 文档课:下载朗读音频(这一课 / 整篇) */
673
+ function docAudioCard(l) {
674
+ const same = S.lessons.filter(x => x.docId && l.source && x.docId === l.source.docId)
675
+ const waiting = same.filter(x => !x.hasAudio).length
676
+ return `<div class="card">
677
+ <h3>下载音频</h3>
678
+ <div class="faint">把文档转成的音频下载成 MP3,放到手机或播放器上听(朗读:${esc(l.voiceLabel || 'AI')})。</div>
679
+ <div class="row" style="margin-top:8px">
680
+ <a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=full&download=1" download>这一课 MP3(${fmt(l.duration)})</a>
681
+ ${same.length > 1 ? (waiting
682
+ ? `<span class="faint">整篇 ${same.length} 课,还有 ${waiting} 课在转音频,做好后可以合成一个下载</span>`
683
+ : `<a class="btn" href="doc/audio?id=${l.source.docId}" download>整篇 ${same.length} 课合成一个 MP3</a>`) : ''}
684
+ </div>
685
+ </div>`
686
+ }
687
+
662
688
  // —— 概览里的「声音」卡片:每种声音一行:状态、选用、试听、生成、下载 ——
663
689
  const TRACK_NOTES = {
664
690
  full: '原始音频,含背景音乐和长空白',
@@ -693,7 +719,7 @@ function voicesCard(l) {
693
719
  return `<div class="track ${name === t ? 'on' : ''}">
694
720
  <div class="row"><b class="grow">${esc(trackLabel(name))}</b>
695
721
  ${meta ? `<span class="faint">${fmt(meta.duration)}${meta.voice ? ' · ' + esc(meta.voice) : ''}</span>` : '<span class="faint">未生成</span>'}</div>
696
- <div class="faint">${name === 'full' && isDoc() ? '导入文档时 AI 朗读生成的音频' : TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
722
+ <div class="faint">${name === 'full' && isDoc() ? '文档转音频时 AI 朗读生成的音频' : TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
697
723
  ${stale ? '<div class="small" style="color:var(--mid)">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
698
724
  ${job ? `<div class="small muted" style="margin-top:4px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : `
699
725
  <div class="row" style="margin-top:6px">