dsh-tingdong 0.5.1 → 0.6.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
@@ -23,13 +23,17 @@ 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
+ - **英文音频 → 带时间的字幕 → 中文配音**:识别出每一句英文和它的起止时间,导出字幕(SRT / LRC / 录音稿);再按同样的时间生成另一种语言(中文)的音频(MP3 / FLAC)。
29
+ - **文档 / 文字 → 朗读音频 → 下载到本地**:PDF、Word、EPUB、TXT 或粘贴的文字,AI 朗读成 MP3,单课或整篇下载;同样能再导出带时间的字幕和中文配音。
30
+
31
+ **文档转音频**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
29
32
 
30
33
  1. 插件先取出文字,给你一张**确认卡片**:读到了多少词,预计分几课、朗读多长、处理要多久、大约用多少模型 token,并说明「会用 AI 整理文字」。
31
34
  2. 你点确定后,在后台:**AI 整理**(去掉页眉页码目录、修好断行、分好章节,原文一个字都不改、不翻译;长文分段、几段同时整理,逐段核对单词数,模型删多了就重来或改用原文)→ **存档**(原文件、取出的原始文字、整理后的文字都留在 `~/.dsh/dsh-tingdong/docs/`,课程概览里能下载)→ **分课**(每个大标题一课,太长的按段落拆开)→ **朗读**(默认微软 Edge,14 种美式 / 英式男女声,可选离线 Kokoro)→ **逐句翻译讲解**。
32
35
  3. 做好一课就能先学一课,之后和音频课一样:精听、复习、磨耳朵、连续听、导出。
36
+ 4. **下载音频**:文档课的概览页可以下载这一课的 MP3;分成好几课的文档还能「整篇合成一个 MP3」(章间停 1.5 秒),首页文档那一组旁边也有「下载整篇音频」。
33
37
 
34
38
  **大小和耗时**:文件最大 100 MB;文字最多约 6 万个英文单词(40 万字符,相当于 7 小时左右的朗读),再长请拆开导入。真正花时间和 token 的是 AI 整理和翻译:1 万词大约整理 4 万字符、朗读 1 小时多,处理约十几分钟(看模型速度)。扫描版 PDF(每页是图片)读不出文字,要先用 OCR 或 AI 转成文字。
35
39
  在 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,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tingdong",
3
- "version": "0.5.1",
4
- "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课。本地识别 / AI 朗读每一句英文、AI 翻译讲解,再用盲听、精听、间隔复习把它练到「一听就懂」。DeepSeek Harness (dsh) 插件。",
3
+ "version": "0.6.0",
4
+ "description": "听懂:把英文有声读物(MP3)或英文文档变成听力课,练到「一听就懂」。英文音频 → 带时间的字幕 → 时间对齐的中文配音;文档 / 文字 → AI 朗读音频 → 下载到本地。DeepSeek Harness (dsh) 插件。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
7
7
  "dsh-plugins",