dsh-tingdong 0.5.0 → 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 +34 -4
- 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 +1 -1
- package/plugin/manage.js +4 -1
- package/web/panel.js +31 -5
package/README.md
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
# 听懂 · dsh-tingdong
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-tingdong)
|
|
4
|
+
[](https://www.npmjs.com/package/dsh-tingdong)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://dsh-plugin.org/plugins/aicivilization/dsh-tingdong)
|
|
7
|
+
|
|
8
|
+
把英文有声读物(MP3)或英文文档变成听力课,练到**听到英文就知道是什么意思**。
|
|
4
9
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)插件,也能脱离 DSH 单独用。
|
|
5
10
|
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
dsh plugin --profile desktop add dsh-tingdong # 装进 DSH(把 desktop 换成你的 profile 名,先完全退出 DSH)
|
|
15
|
+
npm i -g dsh-tingdong # 命令行版:不开 DSH 也能用
|
|
16
|
+
dsh plugin --profile desktop add github:AIcivilization/dsh-tingdong # 源码安装
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **能力分类**:工具与能力(英语听力学习)
|
|
20
|
+
- **运行要求**:Node.js ≥ 20;DSH web / desktop profile;解码 MP3 需要 [ffmpeg](https://ffmpeg.org)(Mac:`brew install ffmpeg`)
|
|
21
|
+
- **许可证**:[MIT](LICENSE)
|
|
22
|
+
- **数据边界**:课程和学习进度都在本机 `~/.dsh/dsh-tingdong/`;识别用 DSH 自带语音输入模型(音频不上传);讲解和朗读用你在 DSH 里选的大模型;Edge 语音组件需联网(文字会发到微软服务器,详见下方导出说明)
|
|
23
|
+
|
|
6
24
|
## 它做什么
|
|
7
25
|
|
|
8
|
-
|
|
26
|
+
首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**文档转音频**(文章、手册、书)。
|
|
9
27
|
|
|
10
|
-
|
|
28
|
+
**文档转音频**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
|
|
11
29
|
|
|
12
30
|
1. 插件先取出文字,给你一张**确认卡片**:读到了多少词,预计分几课、朗读多长、处理要多久、大约用多少模型 token,并说明「会用 AI 整理文字」。
|
|
13
31
|
2. 你点确定后,在后台:**AI 整理**(去掉页眉页码目录、修好断行、分好章节,原文一个字都不改、不翻译;长文分段、几段同时整理,逐段核对单词数,模型删多了就重来或改用原文)→ **存档**(原文件、取出的原始文字、整理后的文字都留在 `~/.dsh/dsh-tingdong/docs/`,课程概览里能下载)→ **分课**(每个大标题一课,太长的按段落拆开)→ **朗读**(默认微软 Edge,14 种美式 / 英式男女声,可选离线 Kokoro)→ **逐句翻译讲解**。
|
|
14
32
|
3. 做好一课就能先学一课,之后和音频课一样:精听、复习、磨耳朵、连续听、导出。
|
|
33
|
+
4. **下载音频**:文档课的概览页可以下载这一课的 MP3;分成好几课的文档还能「整篇合成一个 MP3」(章间停 1.5 秒),首页文档那一组旁边也有「下载整篇音频」。
|
|
15
34
|
|
|
16
35
|
**大小和耗时**:文件最大 100 MB;文字最多约 6 万个英文单词(40 万字符,相当于 7 小时左右的朗读),再长请拆开导入。真正花时间和 token 的是 AI 整理和翻译:1 万词大约整理 4 万字符、朗读 1 小时多,处理约十几分钟(看模型速度)。扫描版 PDF(每页是图片)读不出文字,要先用 OCR 或 AI 转成文字。
|
|
17
36
|
在 DSH 对话里也可以把 PDF 发给 AI,说「整理后导入听懂」(AI 工具 `tingdong_import_text`)。
|
|
@@ -70,7 +89,7 @@
|
|
|
70
89
|
装进 DSH(把 `desktop` 换成你的 profile 名;先完全退出 DSH):
|
|
71
90
|
|
|
72
91
|
```bash
|
|
73
|
-
dsh plugin --profile desktop add
|
|
92
|
+
dsh plugin --profile desktop add dsh-tingdong
|
|
74
93
|
```
|
|
75
94
|
|
|
76
95
|
重新打开 DSH,在对话页打开右侧栏,选「听懂 · 英文听力」。设置里的「听懂」分区会显示 ffmpeg、识别模型、翻译模型是否就绪。
|
|
@@ -138,3 +157,14 @@ npm test
|
|
|
138
157
|
- `lib/drill.js` 磨耳朵音频;`lib/lookup.js` 点词查意思、重新讲解;`lib/vps.js` 经 SSH 发送到 VPS
|
|
139
158
|
- `plugin/` DSH 宿主端(路由、AI 工具)和界面入口(右侧栏、设置);路由先过 DSH 的 `connection.requestRejection()` 登录校验
|
|
140
159
|
- `web/` 学习面板(原生 JS 模块,无构建):`panel.js` 路由和首页 · `study.js` 精听、复习、今日学习 · `listen.js` 连续听、磨耳朵、词汇 · `edit.js` 改句子、查词 · `core.js` 播放器和公共状态
|
|
160
|
+
|
|
161
|
+
## 许可
|
|
162
|
+
|
|
163
|
+
[MIT](LICENSE) · Copyright © 2026 AIcivilization
|
|
164
|
+
|
|
165
|
+
## 收录渠道
|
|
166
|
+
|
|
167
|
+
- [DSH Plugin Hub(dsh-plugin.org 社区插件市场)](https://dsh-plugin.org/plugins/aicivilization/dsh-tingdong)
|
|
168
|
+
- [awesome-dsh-plugin 精选列表](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
169
|
+
- dsh-plugin-shop 插件商店(每日自动收录)
|
|
170
|
+
- 52DSH / tuofangzhe 插件目录(证据分级收录)
|
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
package/plugin/manage.js
CHANGED
|
@@ -54,7 +54,8 @@ const ALREADY = '这个版本的 DSH 不能在运行中升级已装的插件。
|
|
|
54
54
|
export function createManager(o) {
|
|
55
55
|
let running = ''
|
|
56
56
|
const readPkg = async () => { try { return JSON.parse(await readFile(join(o.pkgRoot, 'package.json'), 'utf8')) } catch { return {} } }
|
|
57
|
-
|
|
57
|
+
// 初始包信息读取:info()/update() 开先 await 它,避免 running 还没读到就比版本
|
|
58
|
+
const pkgReady = readPkg().then(p => { running = p.version || '' })
|
|
58
59
|
|
|
59
60
|
let cache = null
|
|
60
61
|
async function latest(force) {
|
|
@@ -79,6 +80,7 @@ export function createManager(o) {
|
|
|
79
80
|
return {
|
|
80
81
|
/** 版本信息:正在运行的、磁盘上装好的、npm 上最新的 */
|
|
81
82
|
async info(force) {
|
|
83
|
+
await pkgReady
|
|
82
84
|
const [pkg, newest] = await Promise.all([readPkg(), latest(force)])
|
|
83
85
|
const installed = pkg.version || ''
|
|
84
86
|
const current = compareVersions(installed, running) > 0 ? installed : running
|
|
@@ -96,6 +98,7 @@ export function createManager(o) {
|
|
|
96
98
|
}
|
|
97
99
|
},
|
|
98
100
|
async update() {
|
|
101
|
+
await pkgReady
|
|
99
102
|
const newest = await latest(true)
|
|
100
103
|
const cmd = command(`add ${PACKAGE}@${newest || 'latest'}`)
|
|
101
104
|
if (!newest) return { ok: false, message: '查不到 npm 上的最新版本,检查网络后再试', command: cmd }
|
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
|
|
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"
|
|
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
|
|
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() ? '
|
|
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">
|