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 +6 -2
- package/lib/docaudio.js +74 -0
- package/lib/importer.js +10 -0
- package/lib/server.js +10 -1
- package/lib/store.js +1 -0
- package/package.json +2 -2
- package/web/panel.css +263 -264
- package/web/panel.js +196 -135
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
|
-
|
|
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`)。
|
package/lib/docaudio.js
ADDED
|
@@ -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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tingdong",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "听懂:把英文有声读物(MP3
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "听懂:把英文有声读物(MP3)或英文文档变成听力课,练到「一听就懂」。英文音频 → 带时间的字幕 → 时间对齐的中文配音;文档 / 文字 → AI 朗读音频 → 下载到本地。DeepSeek Harness (dsh) 插件。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"dsh-plugins",
|