dsh-tingdong 0.5.2 → 0.7.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 +8 -4
- package/lib/ming.js +212 -0
- package/lib/server.js +7 -2
- package/lib/spoken.js +75 -0
- package/lib/tracks.js +28 -9
- package/lib/tts-worker.js +85 -1
- package/package.json +3 -2
- package/python/ming-requirements.txt +887 -0
- package/python/ming_worker.py +77 -0
- package/web/panel.css +263 -264
- package/web/panel.js +238 -156
package/web/panel.js
CHANGED
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
import { paneDrill, paneText, paneWords } from './listen.js'
|
|
13
13
|
import { paneReview, paneStudy, paneToday } from './study.js'
|
|
14
14
|
|
|
15
|
+
// ———————————————————— 图标(细线,跟着文字颜色) ————————————————————
|
|
16
|
+
|
|
17
|
+
const ICONS = {
|
|
18
|
+
audio: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
|
|
19
|
+
doc: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/>',
|
|
20
|
+
down: '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
|
|
21
|
+
phone: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
|
|
22
|
+
}
|
|
23
|
+
const ico = name => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
|
|
24
|
+
|
|
15
25
|
// ———————————————————— 路由 ————————————————————
|
|
16
26
|
|
|
17
27
|
function route() {
|
|
@@ -70,7 +80,7 @@ function renderJobs() {
|
|
|
70
80
|
return
|
|
71
81
|
}
|
|
72
82
|
const recent = S.jobs.filter(j => j.state === 'running' || j.state === 'queued' || (j.finishedAt && Date.now() - j.finishedAt < 60e3 && j.state !== 'done'))
|
|
73
|
-
box.innerHTML = recent.length ? `<div
|
|
83
|
+
box.innerHTML = recent.length ? `<div class="card jobs">${recent.map(jobLine).join('')}</div>` : ''
|
|
74
84
|
box.querySelectorAll('[data-cancel]').forEach(b => {
|
|
75
85
|
b.onclick = async () => { try { await api('cancel', { job: b.dataset.cancel }); pollJobsNow() } catch (e) { toast(e.message) } }
|
|
76
86
|
})
|
|
@@ -79,10 +89,10 @@ function renderJobs() {
|
|
|
79
89
|
function jobLine(j) {
|
|
80
90
|
const pct = j.total ? Math.round(j.done / j.total * 100) : (j.state === 'running' ? 5 : 0)
|
|
81
91
|
const state = { queued: '排队中', running: j.stage, error: '出错', cancelled: '已取消', done: '完成' }[j.state]
|
|
82
|
-
return `<div
|
|
83
|
-
<div class="row small"><span class="grow ellipsis">${esc(j.title)}</span>
|
|
84
|
-
<span class="muted">${esc(state)}${j.total ? ` ${j.done}/${j.total} ${esc(j.message || '')}` : ''}</span>
|
|
85
|
-
${j.state === 'running' || j.state === 'queued' ? `<button class="btn ghost small" data-cancel="${esc(j.id)}"
|
|
92
|
+
return `<div class="job">
|
|
93
|
+
<div class="row nowrap small"><span class="grow ellipsis">${esc(j.title)}</span>
|
|
94
|
+
<span class="muted ellipsis">${esc(state)}${j.total ? ` ${j.done}/${j.total} ${esc(j.message || '')}` : ''}</span>
|
|
95
|
+
${j.state === 'running' || j.state === 'queued' ? `<button class="btn ghost small" data-cancel="${esc(j.id)}">取消</button>` : ''}</div>
|
|
86
96
|
${j.state === 'running' ? `<div class="bar"><i style="width:${pct}%"></i></div>` : ''}
|
|
87
97
|
${j.error ? `<div class="small" style="color:var(--bad)">${esc(j.error)}</div>` : ''}
|
|
88
98
|
</div>`
|
|
@@ -145,33 +155,37 @@ function renderLibrary() {
|
|
|
145
155
|
}
|
|
146
156
|
const type = store.get('newType', '')
|
|
147
157
|
|
|
158
|
+
const flow = (kind, icon, steps, note, cta) => `
|
|
159
|
+
<button class="flow ${type === kind ? 'on' : ''}" data-new="${kind}">
|
|
160
|
+
${ico(icon)}
|
|
161
|
+
<span class="grow">
|
|
162
|
+
<span class="flow-line">${steps.map((s, k) => `${k ? '<span class="arr">→</span>' : ''}<span>${s}</span>`).join('')}</span>
|
|
163
|
+
<span class="faint">${note}</span>
|
|
164
|
+
</span>
|
|
165
|
+
<span class="cta">${cta} ›</span>
|
|
166
|
+
</button>`
|
|
167
|
+
|
|
148
168
|
app.innerHTML = `
|
|
149
169
|
<div id="todayCard">${todayCard(S.today)}</div>
|
|
150
170
|
${canImport ? `
|
|
151
|
-
<div class="section-title"
|
|
152
|
-
<div class="
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<span class="faint">有声书、讲座、引导音频……MP3 / M4A / WAV / FLAC。本机识别出每一句英文,AI 翻译讲解。</span>
|
|
156
|
-
</button>
|
|
157
|
-
<button class="new-card ${type === 'doc' ? 'on' : ''}" data-new="doc">
|
|
158
|
-
<span class="new-ico">📄</span><b>文档转音频</b>
|
|
159
|
-
<span class="faint">文章、手册、书……整理好的英文文字(粘贴或 TXT / Markdown)。AI 朗读成音频,再翻译讲解。</span>
|
|
160
|
-
</button>
|
|
171
|
+
<div class="section-title">新建 <span class="n">做好的同时也是一节听力课,成品都能下载到本地</span></div>
|
|
172
|
+
<div class="flows">
|
|
173
|
+
${flow('audio', 'audio', ['英文音频', '带时间的字幕', '中文配音'], '识别出每一句英文和它的时间,导出字幕;再按同样的时间生成中文音频', '导入音频')}
|
|
174
|
+
${flow('doc', 'doc', ['文档 / 文字', '朗读音频', '下载到本地'], 'PDF、Word、EPUB、TXT 或粘贴的文字,AI 朗读成 MP3,单课或整篇下载', '文档转音频')}
|
|
161
175
|
</div>
|
|
162
176
|
<div id="newPanel"></div>
|
|
163
|
-
<div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS
|
|
164
|
-
<div class="section-title"
|
|
177
|
+
<div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。</div><div id="jobs"></div>`}
|
|
178
|
+
<div class="section-title">我的课程 ${S.lessons.length ? `<span class="n">${S.lessons.length}</span>` : ''}</div>
|
|
165
179
|
${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或把英文文档转成音频。</div>`}
|
|
166
|
-
${[...groups].map(([g, ls]) =>
|
|
167
|
-
${g ? `<div class="
|
|
168
|
-
${ls.map(
|
|
180
|
+
${[...groups].map(([g, ls]) => `<div class="list">
|
|
181
|
+
${g || groups.size > 1 ? `<div class="list-head"><b class="ellipsis">${esc(g || '其他')}</b><span class="grow">${ls.length} 课</span>${docDownload(ls)}</div>` : ''}
|
|
182
|
+
${ls.map(lessonRow).join('')}</div>`).join('')}
|
|
169
183
|
<div id="vpsCard"></div>
|
|
170
184
|
`
|
|
171
185
|
renderJobs()
|
|
172
186
|
loadToday()
|
|
173
187
|
renderVps()
|
|
174
|
-
app.querySelectorAll('.
|
|
188
|
+
app.querySelectorAll('.lrow').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
|
|
175
189
|
if (!canImport) return
|
|
176
190
|
app.querySelectorAll('[data-new]').forEach(b => {
|
|
177
191
|
b.onclick = () => {
|
|
@@ -196,17 +210,17 @@ function renderNewPanel(type) {
|
|
|
196
210
|
function newAudioPanel(box) {
|
|
197
211
|
const st = S.status || {}
|
|
198
212
|
box.innerHTML = `<div class="card">
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
<div class="drop" id="drop" style="margin-top:8px">
|
|
203
|
-
<div class="row">
|
|
204
|
-
<input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径" style="flex:1 1 200px">
|
|
213
|
+
<div class="drop" id="drop">
|
|
214
|
+
<div class="row nowrap">
|
|
215
|
+
<input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径">
|
|
205
216
|
<button class="btn primary" id="importBtn">导入</button>
|
|
206
217
|
</div>
|
|
207
|
-
<div class="faint" style="margin-top:
|
|
218
|
+
<div class="faint" style="margin-top:6px">或把音频拖到这里 · <a href="#" class="link" id="pick">选择文件</a> · MP3 / M4A / WAV / FLAC,文件夹里每个音频各做成一课</div>
|
|
208
219
|
<input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
|
|
209
220
|
</div>
|
|
221
|
+
<div class="faint" style="margin-top:6px">做好后在课程的「导出」里下载:带时间的字幕(SRT / LRC)、时间对齐的中文配音(MP3 / FLAC)。</div>
|
|
222
|
+
${!st.models ? '<div class="faint">第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别,音频不上传。</div>' : ''}
|
|
223
|
+
${!st.llm ? '<div class="small" style="color:var(--mid)">没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。</div>' : ''}
|
|
210
224
|
</div>`
|
|
211
225
|
const input = $('#path')
|
|
212
226
|
input.value = store.get('lastPath', '')
|
|
@@ -238,21 +252,19 @@ let pendingDoc = null // 已经读出文字、等确认的文档
|
|
|
238
252
|
function newDocPanel(box) {
|
|
239
253
|
if (pendingDoc) return docConfirm(box, pendingDoc)
|
|
240
254
|
box.innerHTML = `<div class="card">
|
|
241
|
-
<h3>文档转音频</h3>
|
|
242
255
|
<div class="drop" id="docDrop">
|
|
243
|
-
<div class="new-ico">📄</div>
|
|
244
256
|
<div><b>把文档拖到这里</b>,或 <a href="#" class="link" id="docPick">选择文件</a></div>
|
|
245
|
-
<div class="faint"
|
|
257
|
+
<div class="faint">PDF · Word(.docx)· EPUB · 网页 · TXT · Markdown,最大 100 MB、约 6 万词</div>
|
|
246
258
|
<input type="file" id="docFile" accept=".pdf,.docx,.epub,.html,.htm,.txt,.md,.markdown" hidden>
|
|
247
259
|
</div>
|
|
248
|
-
<div class="faint" style="margin-top:
|
|
249
|
-
<details style="margin-top:
|
|
250
|
-
<textarea id="docText" rows="
|
|
251
|
-
<button class="btn" id="docPaste" style="margin-top:
|
|
260
|
+
<div class="faint" style="margin-top:6px">先让你确认:AI 把文字整理好(去页眉页码、修断行、分章节,原文不改),再朗读成音频。转好的音频在课程列表和课程页里下载到本地。</div>
|
|
261
|
+
<details style="margin-top:6px"><summary class="link">没有文件?直接粘贴英文</summary>
|
|
262
|
+
<textarea id="docText" rows="5" placeholder="把英文文章粘贴到这里" style="margin-top:6px"></textarea>
|
|
263
|
+
<button class="btn" id="docPaste" style="margin-top:4px">下一步</button>
|
|
252
264
|
</details>
|
|
253
265
|
<div id="docBusy"></div>
|
|
254
266
|
</div>`
|
|
255
|
-
const busy = msg => { $('#docBusy').innerHTML = msg ? `<div class="faint" style="margin-top:
|
|
267
|
+
const busy = msg => { $('#docBusy').innerHTML = msg ? `<div class="faint" style="margin-top:6px">${msg}</div>` : '' }
|
|
256
268
|
const got = doc => { pendingDoc = doc; docConfirm(box, doc) }
|
|
257
269
|
const upload = f => {
|
|
258
270
|
if (!f) return
|
|
@@ -303,16 +315,14 @@ async function docConfirm(box, doc) {
|
|
|
303
315
|
const e = doc.estimate
|
|
304
316
|
const blocked = engine === 'edge' && !edgeReady
|
|
305
317
|
box.innerHTML = `<div class="card doc-confirm">
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
<div class="faint">${esc(doc.type)}${doc.pages ? ` · ${doc.pages} 页` : ''} · 约 ${e.words.toLocaleString()} 个英文单词</div></div></div>
|
|
309
|
-
<details style="margin-top:6px"><summary class="link">看看读到的文字</summary><pre class="prompt">${esc(doc.preview)}${doc.preview.length >= 600 ? '…' : ''}</pre></details>
|
|
318
|
+
<div class="row nowrap">${ico('doc')}<div class="grow"><b>${esc(doc.name)}</b>
|
|
319
|
+
<div class="faint">${esc(doc.type)}${doc.pages ? ` · ${doc.pages} 页` : ''} · 约 ${e.words.toLocaleString()} 个英文单词 · <details style="display:inline"><summary class="link" style="display:inline">看看读到的文字</summary><pre class="prompt">${esc(doc.preview)}${doc.preview.length >= 600 ? '…' : ''}</pre></details></div></div></div>
|
|
310
320
|
<div class="ai-note">
|
|
311
321
|
${doc.needsCleanup
|
|
312
322
|
? (doc.hasModel
|
|
313
|
-
?
|
|
314
|
-
: '
|
|
315
|
-
: '
|
|
323
|
+
? `<b>会用 AI 整理文字</b>(${esc(doc.model)}):去掉页眉页码和目录、修好断行、分好章节。只整理格式,原文一个字都不改,不翻译。整理好的文字会存档,随时能下载。`
|
|
324
|
+
: '没有可用的大模型:只能简单接好断行,页眉页码可能留下。配好 DSH 的默认模型再导入效果更好。')
|
|
325
|
+
: '已经是整理好的格式(有「# 章节」),不用 AI 整理。'}
|
|
316
326
|
</div>
|
|
317
327
|
<div class="est">
|
|
318
328
|
<div><b>${e.lessons}</b><span>约几课</span></div>
|
|
@@ -332,8 +342,8 @@ async function docConfirm(box, doc) {
|
|
|
332
342
|
<label>声音</label><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select>
|
|
333
343
|
<label>语速</label><div class="chips">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
|
|
334
344
|
</div>
|
|
335
|
-
<div class="row" style="margin-top:
|
|
336
|
-
<button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}
|
|
345
|
+
<div class="row" style="margin-top:10px">
|
|
346
|
+
<button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}>确定,开始转音频</button>
|
|
337
347
|
<button class="btn ghost" id="docCancel">取消</button>
|
|
338
348
|
</div>
|
|
339
349
|
</div>`
|
|
@@ -378,7 +388,7 @@ function docDownload(ls) {
|
|
|
378
388
|
if (ls.some(l => l.kind !== 'doc') || ids.length !== 1) return ''
|
|
379
389
|
const waiting = ls.filter(l => !l.hasAudio).length
|
|
380
390
|
if (waiting) return `<span class="faint">还有 ${waiting} 课在转音频</span>`
|
|
381
|
-
return `<a class="
|
|
391
|
+
return `<a class="btn small" href="doc/audio?id=${ids[0]}" download title="整篇文档的朗读,合成一个 MP3 下载到本地">${ico('down')}整篇音频</a>`
|
|
382
392
|
}
|
|
383
393
|
|
|
384
394
|
// —— 今日学习卡片 ——
|
|
@@ -395,24 +405,23 @@ function todayCard(t) {
|
|
|
395
405
|
const n = t.queue.length
|
|
396
406
|
const max = Math.max(1, ...t.week.map(d => d.count))
|
|
397
407
|
const wd = ['日', '一', '二', '三', '四', '五', '六']
|
|
408
|
+
const sub = [
|
|
409
|
+
n ? `复习 ${t.dueCount} · 新句 ${t.newCount}` : '可以去「连续听」或「磨耳朵」再听一遍',
|
|
410
|
+
t.todayCount ? `今天已学 ${t.todayCount} 句 · 约 ${t.minutes} 分钟` : '',
|
|
411
|
+
t.streak ? `连续 ${t.streak} 天` : '',
|
|
412
|
+
].filter(Boolean).join(' · ')
|
|
398
413
|
return `<div class="card today">
|
|
399
|
-
<div class="
|
|
400
|
-
<div class="
|
|
401
|
-
|
|
402
|
-
<div class="faint">${n ? `复习 ${t.dueCount} 句 · 新句子 ${t.newCount} 句` : '可以去「连续听」或「磨耳朵」再听一遍'}</div>
|
|
403
|
-
</div>
|
|
404
|
-
<div class="streak" title="连续学习天数"><b>${t.streak}</b><span>连续天数</span></div>
|
|
405
|
-
</div>
|
|
406
|
-
<div class="week">${t.week.map(d => `<div class="day ${d.day === t.today ? 'now' : ''}"><i style="height:${Math.round(d.count / max * 100)}%" title="${d.count} 句"></i><span>${wd[new Date(d.day + 'T12:00:00').getDay()]}</span></div>`).join('')}</div>
|
|
407
|
-
<div class="row" style="margin-top:10px">
|
|
408
|
-
<button class="btn primary big grow" id="goToday" ${n ? '' : 'disabled'}>${n ? '开始今天的学习' : '已完成'}</button>
|
|
409
|
-
<span class="faint">今天 ${t.todayCount} 句 · 约 ${t.minutes} 分钟</span>
|
|
414
|
+
<div class="grow">
|
|
415
|
+
<div class="t">${n ? `今天要学 <b>${n}</b> 句` : (t.todayCount ? '今天的学完了' : '今天没有要学的')}</div>
|
|
416
|
+
<div class="faint ellipsis">${sub}</div>
|
|
410
417
|
</div>
|
|
418
|
+
<div class="spark" title="最近 7 天学了多少句">${t.week.map(d => `<i class="${d.count ? 'has' : ''} ${d.day === t.today ? 'now' : ''}" style="height:${Math.round(d.count / max * 100)}%" title="周${wd[new Date(d.day + 'T12:00:00').getDay()]} ${d.count} 句"></i>`).join('')}</div>
|
|
419
|
+
${n ? '<button class="btn primary big" id="goToday">开始 ›</button>' : ''}
|
|
411
420
|
</div>`
|
|
412
421
|
}
|
|
413
422
|
function bindToday() { const b = $('#goToday'); if (b) b.onclick = () => go('#/today') }
|
|
414
423
|
|
|
415
|
-
function
|
|
424
|
+
function lessonRow(l) {
|
|
416
425
|
const s = l.stats
|
|
417
426
|
const pct = s.total ? Math.round(s.mastered / s.total * 100) : 0
|
|
418
427
|
const seenPct = s.total ? Math.round(s.seen / s.total * 100) : 0
|
|
@@ -423,11 +432,17 @@ function lessonCard(l) {
|
|
|
423
432
|
paused: '<span class="badge warn">已暂停</span>',
|
|
424
433
|
error: '<span class="badge err">出错</span>',
|
|
425
434
|
}[l.status] || (s.due ? `<span class="badge">待复习 ${s.due}</span>` : (s.total && s.mastered === s.total ? '<span class="badge ok">已掌握</span>' : ''))
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
<
|
|
429
|
-
<div class="
|
|
430
|
-
|
|
435
|
+
const doc = l.kind === 'doc'
|
|
436
|
+
return `<div class="lrow ${doc ? 'doc' : ''}" data-id="${esc(l.id)}" title="${esc(l.title)}">
|
|
437
|
+
<span title="${doc ? '文档转的音频' : '导入的音频'}" style="display:flex">${ico(doc ? 'doc' : 'audio')}</span>
|
|
438
|
+
<div class="grow">
|
|
439
|
+
<div class="t ellipsis">${esc(l.titleZh || l.title)}</div>
|
|
440
|
+
<div class="m ellipsis">${fmtDur(l.duration)} · ${l.sentences} 句${l.translated < l.sentences ? ` · 已翻译 ${l.translated}` : ''} · 掌握 ${s.mastered}/${s.total}${s.nextDue && !s.due ? ` · ${when(s.nextDue)}复习` : ''}</div>
|
|
441
|
+
${l.error ? `<div class="err ellipsis">${esc(l.error)}</div>` : ''}
|
|
442
|
+
</div>
|
|
443
|
+
${badge}
|
|
444
|
+
<div class="bar two prog" title="学过 ${s.seen} · 掌握 ${s.mastered} / ${s.total}"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
|
|
445
|
+
<span class="chev">›</span>
|
|
431
446
|
</div>`
|
|
432
447
|
}
|
|
433
448
|
|
|
@@ -472,24 +487,23 @@ async function renderVps() {
|
|
|
472
487
|
if (!v.enabled || (!v.config && !v.hosts.length)) { box.innerHTML = ''; return }
|
|
473
488
|
const c = v.config
|
|
474
489
|
const editing = !c || box.dataset.edit === '1'
|
|
475
|
-
box.innerHTML = `<div class="card" id="vps">
|
|
476
|
-
<h3>手机学习(VPS)</h3>
|
|
490
|
+
box.innerHTML = `<div class="section-title">手机上学</div><div class="card" id="vps">
|
|
477
491
|
${!editing ? `
|
|
478
|
-
<div class="
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
<button class="btn
|
|
482
|
-
<button class="btn ghost" id="vpsEdit">改设置</button>
|
|
492
|
+
<div class="row nowrap">${ico('phone')}
|
|
493
|
+
<div class="grow"><div class="ellipsis"><b>${esc(c.domain || c.host)}</b> <span class="faint">SSH ${esc(c.host)} · 用户 ${esc(c.user)}</span></div></div>
|
|
494
|
+
<button class="btn primary small" id="sendAll" title="把全部课程发到 VPS;只发课程内容和声音,不会覆盖那边的学习进度">全部发送</button>
|
|
495
|
+
<button class="btn ghost small" id="vpsEdit">设置</button>
|
|
483
496
|
</div>
|
|
484
|
-
|
|
497
|
+
${v.url ? `<div class="row nowrap" style="margin-top:6px"><span class="faint">手机打开</span><a class="link ellipsis grow" href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.url)}</a><button class="btn ghost small" id="copyUrl">复制</button></div>` : ''}
|
|
498
|
+
<div class="faint" style="margin-top:4px">只发课程和声音,不覆盖 VPS 上的学习进度;每课的「管理」里也能单独发送。</div>` : `
|
|
485
499
|
<div class="faint">在电脑上做好课,发到 VPS 上的 DSH,手机打开就能学。这台电脑要能用 SSH 钥匙登录 VPS(dsh-vps-manager 添加机器时会自动配好)。</div>
|
|
486
|
-
<div class="row" style="margin-top:
|
|
500
|
+
<div class="row" style="margin-top:6px">
|
|
487
501
|
<span class="faint">SSH 主机</span>
|
|
488
502
|
<select id="vpsHost">${v.hosts.map(h => `<option ${c && c.host === h ? 'selected' : ''}>${esc(h)}</option>`).join('')}</select>
|
|
489
503
|
<button class="btn" id="vpsDetect">查找 VPS 上的 DSH</button>
|
|
504
|
+
${c ? '<button class="btn ghost" id="vpsCancel">取消</button>' : ''}
|
|
490
505
|
</div>
|
|
491
|
-
<div id="vpsFound">${vpsFound ? foundList(vpsFound) : ''}</div
|
|
492
|
-
${c ? '<button class="btn ghost" id="vpsCancel" style="margin-top:8px">取消</button>' : ''}`}
|
|
506
|
+
<div id="vpsFound">${vpsFound ? foundList(vpsFound) : ''}</div>`}
|
|
493
507
|
</div>`
|
|
494
508
|
const on = (sel, fn) => { const el = box.querySelector(sel); if (el) el.onclick = fn }
|
|
495
509
|
on('#vpsEdit', () => { box.dataset.edit = '1'; renderVps() })
|
|
@@ -512,11 +526,11 @@ async function renderVps() {
|
|
|
512
526
|
|
|
513
527
|
function foundList(f) {
|
|
514
528
|
if (!f.instances.length) return '<div class="small" style="color:var(--bad);margin-top:8px">这台 VPS 上没找到正在运行的 DSH 网页版。先用 dsh-vps 装好 DSH。</div>'
|
|
515
|
-
return `<div style="margin-top:
|
|
516
|
-
<div class="row inst">
|
|
529
|
+
return `<div style="margin-top:6px">${f.instances.map((x, k) => `
|
|
530
|
+
<div class="row nowrap inst">
|
|
517
531
|
<div class="grow"><b>${esc(x.domain || `端口 ${x.port}`)}</b> <span class="faint">用户 ${esc(x.user)} · ${esc(x.home)}</span>
|
|
518
|
-
<div class="faint">${x.hasPlugin ? '
|
|
519
|
-
<button class="btn primary" data-pick="${k}">用这个</button>
|
|
532
|
+
<div class="faint">${x.hasPlugin ? '已装「听懂」插件' : '还没装「听懂」插件'}</div></div>
|
|
533
|
+
<button class="btn primary small" data-pick="${k}">用这个</button>
|
|
520
534
|
</div>`).join('')}
|
|
521
535
|
${!f.root && f.instances.some(x => x.user !== f.sshUser) ? `<div class="faint">注意:SSH 用的是 ${esc(f.sshUser)},和 DSH 的用户不同,可能没有权限写进去。</div>` : ''}</div>`
|
|
522
536
|
}
|
|
@@ -586,12 +600,13 @@ function bindVoiceBar() {
|
|
|
586
600
|
function manageButtons(l) {
|
|
587
601
|
const missing = (l.sentences || []).some(s => !s.zh && !s.skip) || (l.sentences.length && !l.summaryZh) || l.status === 'error' || l.status === 'paused'
|
|
588
602
|
const canWork = !S.status || S.status.ffmpeg
|
|
589
|
-
return `<div class="row
|
|
590
|
-
|
|
591
|
-
${
|
|
592
|
-
${
|
|
593
|
-
<button class="btn ghost" data-act="
|
|
594
|
-
<button class="btn ghost
|
|
603
|
+
return `<div class="row manage-row">
|
|
604
|
+
<span class="faint">管理</span>
|
|
605
|
+
${S.vps && S.vps.config && l.status === 'ready' ? `<button class="btn small" data-act="send">发送到 VPS</button>` : ''}
|
|
606
|
+
${missing && canWork ? '<button class="btn small" data-act="resume">补全翻译</button>' : ''}
|
|
607
|
+
${l.sentences && l.sentences.length && canWork ? '<button class="btn ghost small" data-act="retranslate">重新翻译</button>' : ''}
|
|
608
|
+
<button class="btn ghost small" data-act="reset">重置进度</button>
|
|
609
|
+
<button class="btn ghost small danger" data-act="delete">删除这课</button>
|
|
595
610
|
</div>`
|
|
596
611
|
}
|
|
597
612
|
|
|
@@ -618,39 +633,44 @@ function bindManage(root, l) {
|
|
|
618
633
|
function paneOverview(pane, l, st) {
|
|
619
634
|
const pos = getPos()
|
|
620
635
|
const stars = (S.cur.progress.stars || []).length
|
|
636
|
+
const doc = l.kind === 'doc'
|
|
621
637
|
pane.innerHTML = `
|
|
622
|
-
<div class="card">
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
${l.
|
|
638
|
+
<div class="card lesson-head">
|
|
639
|
+
<div class="title">${esc(l.titleZh || l.title)}</div>
|
|
640
|
+
${l.titleZh ? `<div class="faint">${esc(l.title)}</div>` : ''}
|
|
641
|
+
<div class="faint">${doc ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
|
|
642
|
+
${l.source && l.source.docId ? `<div class="faint">原文档:${esc(l.source.name)} · <a class="link" href="doc/file?id=${l.source.docId}&which=original" download>原文件</a> · <a class="link" href="doc/file?id=${l.source.docId}&which=clean" download>整理后的文字</a></div>` : ''}
|
|
643
|
+
${l.summaryZh ? `<p class="summary clamp" id="summary" title="点一下展开">${esc(l.summaryZh)}</p>` : ''}
|
|
627
644
|
${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
|
|
628
645
|
${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
<div><b>${st.fresh}</b><span>没学过</span></div>
|
|
634
|
-
<div><b>${st.learning}</b><span>学习中</span></div>
|
|
635
|
-
<div><b style="color:var(--good)">${st.mastered}</b><span>已掌握</span></div>
|
|
636
|
-
<div><b style="color:var(--accent)">${st.due}</b><span>待复习</span></div>
|
|
646
|
+
<div class="statline">
|
|
647
|
+
<span><b>${st.fresh}</b>没学过</span><span><b>${st.learning}</b>学习中</span>
|
|
648
|
+
<span><b style="color:var(--good)">${st.mastered}</b>已掌握</span><span><b style="color:var(--accent)">${st.due}</b>待复习</span>
|
|
649
|
+
${stars ? `<span title="在「复习」或「磨耳朵」里选「收藏」专门练"><b style="color:var(--mid)">${stars}</b>难句</span>` : ''}
|
|
637
650
|
</div>
|
|
638
651
|
<div class="bar good"><i style="width:${st.total ? Math.round(st.mastered / st.total * 100) : 0}%"></i></div>
|
|
639
|
-
<div class="row" style="margin-top:
|
|
640
|
-
<button class="btn primary big" id="goStudy">${pos > 0 ?
|
|
652
|
+
<div class="row" style="margin-top:10px">
|
|
653
|
+
<button class="btn primary big" id="goStudy">${pos > 0 ? `继续精听 · 第 ${pos + 1} 句` : '开始精听'}</button>
|
|
641
654
|
<button class="btn big" id="goReview" ${st.due || st.fresh ? '' : 'disabled'}>复习${st.due ? ` ${st.due} 句` : ''}</button>
|
|
642
655
|
<button class="btn big" id="goDrill">磨耳朵</button>
|
|
656
|
+
${!st.due && st.nextDue ? `<span class="faint">下一批复习 ${when(st.nextDue)}</span>` : ''}
|
|
643
657
|
</div>
|
|
644
|
-
${!st.due && st.nextDue ? `<div class="faint" style="margin-top:6px">下一批复习:${when(st.nextDue)}</div>` : ''}
|
|
645
|
-
${stars ? `<div class="faint" style="margin-top:6px">难句本:收藏了 ${stars} 句,在「复习」或「磨耳朵」里选「收藏」专门练。</div>` : ''}
|
|
646
658
|
</div>
|
|
647
|
-
${
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
<button class="
|
|
659
|
+
${doc ? `<div class="card"><div class="row"><b>下载转好的音频</b><span class="faint grow">存到本地,放手机或播放器上听</span></div>
|
|
660
|
+
<div class="row" style="margin-top:6px">${docAudioButtons(l)}</div></div>` : ''}
|
|
661
|
+
<div class="flows" style="margin-bottom:8px">
|
|
662
|
+
<button class="flow" id="goExport">
|
|
663
|
+
${ico('down')}
|
|
664
|
+
<span class="grow">
|
|
665
|
+
<span class="flow-line"><span>导出到本地</span></span>
|
|
666
|
+
<span class="faint">${doc ? '朗读音频(MP3)' : '英文音频(去空白 / 朗读版)'} · 带时间的字幕(SRT / LRC)· 时间对齐的中文配音</span>
|
|
667
|
+
</span>
|
|
668
|
+
<span class="cta">去导出 ›</span>
|
|
669
|
+
</button>
|
|
651
670
|
</div>
|
|
652
|
-
|
|
653
|
-
|
|
671
|
+
${voicesCard(l)}
|
|
672
|
+
<details class="card">
|
|
673
|
+
<summary class="row nowrap"><b class="grow">怎么练到「一听就懂」</b><span class="faint">展开</span></summary>
|
|
654
674
|
<ol class="steps">
|
|
655
675
|
<li><b>精听</b>:每句先<b>只听不看</b>,猜意思 → 看英文,找出没听出来的词(点词看意思)→ 看中文和生词 → 再听一遍。</li>
|
|
656
676
|
<li>老实自评:<span class="g0">没听懂</span> / <span class="g1">半懂</span> / <span class="g2">听懂了</span>;拿不准的点 ☆ 收进难句本。</li>
|
|
@@ -659,9 +679,11 @@ function paneOverview(pane, l, st) {
|
|
|
659
679
|
<li><b>连续听</b>:选「原声·去空白」一句接一句不停地听;先用朗读的声音听清每个词,再回到原声;最后关掉字幕,能跟上就是真听懂了。</li>
|
|
660
680
|
</ol>
|
|
661
681
|
<div class="faint" style="margin-top:6px">电脑快捷键:空格 重听 · 回车 下一步 · 1/2/3 评分 · ←/→ 换句 · S 变速 · L 循环。手机上左右滑动换句。</div>
|
|
662
|
-
</
|
|
663
|
-
|
|
682
|
+
</details>
|
|
683
|
+
${manageButtons(l)}`
|
|
664
684
|
bindVoices(pane, l)
|
|
685
|
+
const sum = $('#summary')
|
|
686
|
+
if (sum) sum.onclick = () => sum.classList.toggle('clamp')
|
|
665
687
|
$('#goExport').onclick = () => go(`#/l/${l.id}/export`)
|
|
666
688
|
$('#goStudy').onclick = () => go(`#/l/${l.id}/study`)
|
|
667
689
|
$('#goReview').onclick = () => go(`#/l/${l.id}/review`)
|
|
@@ -670,19 +692,13 @@ function paneOverview(pane, l, st) {
|
|
|
670
692
|
}
|
|
671
693
|
|
|
672
694
|
/** 文档课:下载朗读音频(这一课 / 整篇) */
|
|
673
|
-
function
|
|
695
|
+
function docAudioButtons(l) {
|
|
674
696
|
const same = S.lessons.filter(x => x.docId && l.source && x.docId === l.source.docId)
|
|
675
697
|
const waiting = same.filter(x => !x.hasAudio).length
|
|
676
|
-
return `<
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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>`
|
|
698
|
+
return `<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=full&download=1" download>${ico('down')}这一课 MP3 · ${fmt(l.duration)}</a>
|
|
699
|
+
${same.length > 1 ? (waiting
|
|
700
|
+
? `<span class="faint">整篇 ${same.length} 课,还有 ${waiting} 课在转音频</span>`
|
|
701
|
+
: `<a class="btn" href="doc/audio?id=${l.source.docId}" download>${ico('down')}整篇 ${same.length} 课合成一个 MP3</a>`) : ''}`
|
|
686
702
|
}
|
|
687
703
|
|
|
688
704
|
// —— 概览里的「声音」卡片:每种声音一行:状态、选用、试听、生成、下载 ——
|
|
@@ -716,25 +732,25 @@ function voicesCard(l) {
|
|
|
716
732
|
? opts(((v.kokoro && v.kokoro.voices) || []), (meta && meta.voice) || 'am_michael')
|
|
717
733
|
: opts(sayList, (meta && meta.voice) || (sayList[0] && sayList[0].id))}</select>
|
|
718
734
|
<select data-speed="${name}">${speeds((meta && meta.speed) || 0.9)}</select>` : ''
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
<
|
|
723
|
-
${
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
${meta ? (name === t ? '<span class="badge ok">正在用</span>' : `<button class="btn" data-use="${name}">选用</button>`) : ''}
|
|
727
|
-
${meta ? `<button class="btn ghost" data-try="${name}"
|
|
728
|
-
${meta ? `<a class="btn ghost" href="audio?id=${encodeURIComponent(l.id)}&track=${name}&download=1" download
|
|
729
|
-
${
|
|
730
|
-
${
|
|
731
|
-
|
|
735
|
+
const note = (name === 'full' && isDoc() ? '文档转音频时 AI 朗读生成的音频' : TRACK_NOTES[name]) + (name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : '')
|
|
736
|
+
const make = name !== 'full' && canMake
|
|
737
|
+
return `<div class="track ${name === t ? 'on' : ''}" title="${esc(note)}">
|
|
738
|
+
<span class="name">${esc(trackLabel(name))}</span>
|
|
739
|
+
${meta ? `<span class="faint">${fmt(meta.duration)}</span>` : ''}
|
|
740
|
+
<span class="faint grow ellipsis">${meta && meta.voice ? esc(meta.voice) + ' · ' : ''}${esc(note)}</span>
|
|
741
|
+
${job ? `<span class="small muted">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</span>` : `
|
|
742
|
+
${meta ? (name === t ? '<span class="badge ok">正在用</span>' : `<button class="btn small" data-use="${name}">选用</button>`) : ''}
|
|
743
|
+
${meta ? `<button class="btn ghost small" data-try="${name}" title="试听">▶</button>` : ''}
|
|
744
|
+
${meta ? `<a class="btn ghost small" href="audio?id=${encodeURIComponent(l.id)}&track=${name}&download=1" download title="下载 MP3 到本地">${ico('down')}</a>` : ''}
|
|
745
|
+
${make && !tts ? `<button class="btn small ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重做' : '生成'}</button>` : ''}
|
|
746
|
+
${make && tts ? `<div class="opts">${voiceSel}<button class="btn small ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重新生成' : '生成'}</button></div>` : ''}`}
|
|
747
|
+
${stale ? '<div class="small" style="color:var(--mid);flex-basis:100%">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
|
|
732
748
|
</div>`
|
|
733
749
|
}
|
|
734
|
-
return `<div class="card" id="voices"
|
|
735
|
-
<div class="
|
|
750
|
+
return `<div class="card" id="voices">
|
|
751
|
+
<div class="row nowrap" style="margin-bottom:2px"><b>声音</b><span class="faint grow ellipsis">同样的句子换一种声音听,都能下载到本地</span></div>
|
|
736
752
|
${TRACK_ORDER.map(row).join('')}
|
|
737
|
-
${canMake && v.sayHint && (v.say || []).length && (v.say || []).every(x => x.quality < 2) ? `<div class="faint" style="margin-top:
|
|
753
|
+
${canMake && v.sayHint && (v.say || []).length && (v.say || []).every(x => x.quality < 2) ? `<div class="faint" style="margin-top:6px">${esc(v.sayHint)}</div>` : ''}
|
|
738
754
|
</div>`
|
|
739
755
|
}
|
|
740
756
|
|
|
@@ -778,59 +794,104 @@ function paneExport(pane, l) {
|
|
|
778
794
|
|
|
779
795
|
const TEXT_FORMATS = [['srt-bi', '双语字幕'], ['srt-en', '英文字幕'], ['srt-zh', '中文字幕'], ['lrc', '歌词 LRC'], ['script', '录音稿'], ['csv', '表格']]
|
|
780
796
|
|
|
797
|
+
/** 中文配音用哪种语音:kokoro / edge / ming(这台电脑用不了 Ming 就退回 Kokoro) */
|
|
798
|
+
const ZH_VOICE_KEY = { kokoro: 'zhVoice', edge: 'zhVoiceEdge', ming: 'zhVoiceMing' }
|
|
799
|
+
function zhEngineNow() {
|
|
800
|
+
const meta = S.cur.tracks.zh
|
|
801
|
+
const V = S.voices || {}
|
|
802
|
+
const e = store.get('zhEngine', (meta && meta.engine) || 'kokoro')
|
|
803
|
+
return e === 'ming' && !(V.ming && V.ming.supported) ? 'kokoro' : (ZH_VOICE_KEY[e] ? e : 'kokoro')
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** 本机 Ming 还没装好时:说明 + 安装按钮,或者安装进度 */
|
|
807
|
+
function mingSetup(m) {
|
|
808
|
+
if (m.installing) {
|
|
809
|
+
const x = m.installing
|
|
810
|
+
const pct = x.total ? Math.round(x.done / x.total * 100) : 0
|
|
811
|
+
return `<div class="small muted">${esc(x.step)}${x.total ? ` · ${Math.round(x.done / 1048576)} / ${Math.round(x.total / 1048576)} MB` : ''}…</div>
|
|
812
|
+
${x.total ? `<div class="bar" style="margin-top:4px"><i style="width:${pct}%"></i></div>` : ''}
|
|
813
|
+
<div class="faint" style="margin-top:4px">在后台装,可以先去做别的;装好后这里会变成选音色。</div>`
|
|
814
|
+
}
|
|
815
|
+
return `<div class="faint">Mac 本机 Ming:在 Apple 芯片上离线合成,声音自然,不联网、不花钱。要先装一次:约 450 MB 程序 + 1.6 GB 声音模型(和 dsh-her 的本机 Ming 是同一个,装过 dsh-her 的就不用再下)。生成时占约 2 GB 内存。</div>
|
|
816
|
+
${m.error ? `<div class="small" style="color:var(--bad);margin-top:4px">${esc(m.error)}</div>` : ''}
|
|
817
|
+
<button class="btn" id="mingInstall" style="margin-top:6px">安装本机 Ming(约 2 GB)</button>`
|
|
818
|
+
}
|
|
819
|
+
|
|
781
820
|
function zhCard(l) {
|
|
782
821
|
if (!l.sentences || !l.sentences.length) return ''
|
|
783
822
|
const meta = S.cur.tracks.zh
|
|
784
823
|
const canMake = !S.status || S.status.ffmpeg
|
|
785
824
|
const V = S.voices || {}
|
|
786
|
-
const engine =
|
|
787
|
-
const list = engine === 'edge' ? ((V.edge && V.edge.voices) || []) : ((V.zh && V.zh.voices) || [])
|
|
825
|
+
const engine = zhEngineNow()
|
|
826
|
+
const list = engine === 'edge' ? ((V.edge && V.edge.voices) || []) : engine === 'ming' ? ((V.ming && V.ming.voices) || []) : ((V.zh && V.zh.voices) || [])
|
|
788
827
|
const job = S.jobs.find(j => j.lessonId === l.id && j.kind === 'track' && j.track === 'zh' && (j.state === 'running' || j.state === 'queued'))
|
|
789
|
-
const selKey = engine
|
|
828
|
+
const selKey = ZH_VOICE_KEY[engine]
|
|
790
829
|
const sel = (meta && meta.engine === engine && meta.voice) || store.get(selKey, list[0] && list[0].id)
|
|
791
830
|
const speed = (meta && meta.speed) || store.get('zhSpeed', 1)
|
|
792
831
|
const clean = S.cur.tracks.clean
|
|
793
832
|
const align = store.get('zhAlign', (meta && meta.align) || 'full')
|
|
794
833
|
const group = (g, name) => `<optgroup label="${name}">${list.filter(x => x.gender === g).map(x => `<option value="${x.id}" ${x.id === sel ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`
|
|
795
834
|
const edgeMissing = engine === 'edge' && !(V.edge && V.edge.ready)
|
|
835
|
+
const mingMissing = engine === 'ming' && !(V.ming && V.ming.ready)
|
|
836
|
+
const missing = edgeMissing || mingMissing
|
|
796
837
|
const alignChips = attr => `<button class="chip ${align === 'full' ? 'on' : ''}" data-${attr}="full">原音频(${fmt(l.duration)})</button>
|
|
797
838
|
${clean ? `<button class="chip ${align === 'clean' ? 'on' : ''}" data-${attr}="clean">原声·去空白(${fmt(clean.duration)})</button>` : ''}`
|
|
798
|
-
|
|
799
|
-
|
|
839
|
+
const doc = l.kind === 'doc'
|
|
840
|
+
const id = encodeURIComponent(l.id)
|
|
841
|
+
// 三步,顺序就是流水线:音频课「字幕 → 中文配音 → 英文音频」,文档课「朗读音频 → 字幕 → 中文配音」
|
|
842
|
+
const audioCard = n => `<div class="card">
|
|
843
|
+
<div class="exp-h"><span class="num">${n}</span><b>${doc ? '朗读音频' : '英文音频'}</b><span class="faint">${doc ? '文档转好的音频,存到本地' : '换一种声音的版本,存到本地'}</span></div>
|
|
844
|
+
<div class="row">${doc ? docAudioButtons(l) : TRACK_ORDER.filter(x => x === 'full' || S.cur.tracks[x]).map(x => {
|
|
845
|
+
const m = x === 'full' ? { duration: l.duration } : S.cur.tracks[x]
|
|
846
|
+
return `<a class="btn" href="audio?id=${id}&track=${x}&download=1" download>${ico('down')}${esc(trackLabel(x))} · ${fmt(m.duration)}</a>`
|
|
847
|
+
}).join('')}</div>
|
|
848
|
+
${doc ? '' : '<div class="faint" style="margin-top:4px">「去空白」「朗读」这些版本在「概览 → 声音」里生成。</div>'}
|
|
849
|
+
</div>`
|
|
850
|
+
const textCard = n => `<div class="card">
|
|
851
|
+
<div class="exp-h"><span class="num">${n}</span><b>带时间的字幕</b><span class="faint">每句英文、中文对译和它的起止时间</span></div>
|
|
852
|
+
<div class="form" style="margin-top:4px">
|
|
853
|
+
<label>时间按</label><div class="chips">${alignChips('txalign')}</div>
|
|
854
|
+
<label>下载</label><div class="row wrap">${TEXT_FORMATS.map(([f, name]) => `<a class="btn" data-fmt="${f}" href="export?id=${id}&format=${f}&align=${align}" download>${name}</a>`).join('')}</div>
|
|
855
|
+
</div>
|
|
856
|
+
<div class="faint" style="margin-top:4px">想自己录中文:下载「录音稿」,每句标了开始时间和可用时长。</div>
|
|
857
|
+
</div>`
|
|
858
|
+
const steps = doc ? ['文档', '朗读音频', '带时间的字幕', '中文配音'] : ['英文音频', '带时间的字幕', '中文配音']
|
|
859
|
+
return `<div id="zhCard">
|
|
860
|
+
<div class="pipe">${steps.map((s, k) => `${k ? '<span class="arr">→</span>' : ''}<b>${s}</b>`).join('')}<span>每一步的成品都能下载到本地</span></div>
|
|
861
|
+
${doc ? audioCard(1) + textCard(2) : textCard(1)}
|
|
862
|
+
<div class="card">
|
|
863
|
+
<div class="exp-h"><span class="num">${doc ? 3 : 2}</span><b>中文配音</b><span class="faint">和英文同一时间的中文音频</span></div>
|
|
800
864
|
<div class="faint">只有中文、没有背景音,每句中文从对应英文开始的时刻开始说;比空档长的句子会读快一点,不会挤到下一句。</div>
|
|
801
|
-
${meta ? `<div class="row" style="margin-top:
|
|
865
|
+
${meta ? `<div class="row nowrap" style="margin-top:6px"><span class="badge ok">已生成</span><span class="faint grow ellipsis">${esc(meta.voiceLabel || meta.voice)} · ${meta.align === 'clean' ? '对齐原声·去空白' : '对齐原音频'} · ${fmt(meta.duration)}${meta.squeezed ? ` · ${meta.squeezed} 句读快了一点` : ''}</span></div>
|
|
802
866
|
<div class="row" style="margin-top:6px">
|
|
803
867
|
<button class="btn" id="zhPlay">▶ 试听</button>
|
|
804
|
-
<a class="btn primary" href="audio?id=${
|
|
805
|
-
${meta.flac ? `<a class="btn primary" href="audio?id=${
|
|
868
|
+
<a class="btn primary" href="audio?id=${id}&track=zh&download=1" download>${ico('down')}MP3</a>
|
|
869
|
+
${meta.flac ? `<a class="btn primary" href="audio?id=${id}&track=zh&fmt=flac&download=1" download>${ico('down')}FLAC(无损)</a>` : '<span class="faint">要 FLAC:重新生成一次</span>'}
|
|
806
870
|
</div>` : ''}
|
|
807
871
|
${job ? `<div class="small muted" style="margin-top:8px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : canMake ? `
|
|
808
872
|
<div class="form">
|
|
809
873
|
<label>语音</label><div class="chips">
|
|
810
|
-
<button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-zhengine="kokoro"
|
|
811
|
-
<button class="chip ${engine === 'edge' ? 'on' : ''}" data-zhengine="edge">微软 Edge
|
|
874
|
+
<button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-zhengine="kokoro" title="100 种声音,离线">Kokoro(离线)</button>
|
|
875
|
+
<button class="chip ${engine === 'edge' ? 'on' : ''}" data-zhengine="edge" title="14 种声音,要联网">微软 Edge(联网)</button>
|
|
876
|
+
${V.ming && V.ming.supported ? `<button class="chip ${engine === 'ming' ? 'on' : ''}" data-zhengine="ming" title="${V.ming.voices.length} 种声音,Apple 芯片上离线合成">Mac 本机 Ming</button>` : ''}
|
|
812
877
|
</div>
|
|
813
|
-
${edgeMissing ? `<label></label><div><div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。用的时候中文会发到微软的服务器;这不是微软公开的正式接口,以后可能失效。</div>
|
|
878
|
+
${mingMissing ? `<label></label><div>${mingSetup(V.ming)}</div>` : edgeMissing ? `<label></label><div><div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。用的时候中文会发到微软的服务器;这不是微软公开的正式接口,以后可能失效。</div>
|
|
814
879
|
<button class="btn" id="edgeInstall" style="margin-top:6px">安装 Edge 语音组件</button></div>` : `
|
|
815
880
|
<label>音色</label><div class="row"><select id="zhVoice">${group('f', '女声')}${group('m', '男声')}</select><button class="btn ghost" id="zhTry">试听这个音色</button></div>
|
|
816
881
|
<label>语速</label><div class="chips">${[0.9, 1, 1.1].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-zhspeed="${x}">${x === 1 ? '正常' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>
|
|
817
882
|
<label>时间对齐</label><div class="chips">${alignChips('zhalign')}</div>`}
|
|
818
883
|
</div>
|
|
819
|
-
${
|
|
884
|
+
${missing ? '' : `<div class="faint" style="margin:6px 0 8px">对齐原音频:总长和原音频一样,可以和原音频对着听;对齐去空白:和「原声·去空白」一句对一句。${engine === 'kokoro' && !(V.zh && V.zh.ready) ? '第一次会先下载约 147 MB 中文语音模型。' : ''}${engine === 'ming' ? ` 本机 Ming 每句约 2 秒,一课要几分钟。${V.ming.shared ? '用的是 dsh-her 装好的本机 Ming。' : V.ming.own ? '<a href="#" class="link" id="mingRemove">卸载本机 Ming</a>(空出约 2 GB)。' : ''}` : ''}${engine === 'edge' && V.edge && V.edge.version ? ` Edge 组件 ${esc(V.edge.version)},<a href="#" class="link" id="edgeUpdate">更新</a>。` : ''}</div>
|
|
820
885
|
<button class="btn ${meta ? 'ghost' : 'primary'}" id="zhMake">${meta ? '换音色重新生成' : '生成中文配音'}</button>`}` : ''}
|
|
821
|
-
<div class="sub-h" style="margin-top:16px">文字(字幕 / 歌词 / 录音稿)</div>
|
|
822
|
-
<div class="faint">英文原文和中文对译,带时间。想自己录中文配音,就下载「录音稿」:每句标了开始时间和可用时长。</div>
|
|
823
|
-
<div class="form">
|
|
824
|
-
<label>时间按</label><div class="chips">${alignChips('txalign')}</div>
|
|
825
|
-
<label>下载</label><div class="row wrap">${TEXT_FORMATS.map(([f, name]) => `<a class="btn ghost" data-fmt="${f}" href="export?id=${encodeURIComponent(l.id)}&format=${f}&align=${align}" download>${name}</a>`).join('')}</div>
|
|
826
886
|
</div>
|
|
887
|
+
${doc ? '' : audioCard(3)}
|
|
827
888
|
</div>`
|
|
828
889
|
}
|
|
829
890
|
|
|
830
891
|
function bindZh(root, l) {
|
|
831
892
|
const card = root.querySelector('#zhCard')
|
|
832
893
|
if (!card) return
|
|
833
|
-
const engine =
|
|
894
|
+
const engine = zhEngineNow
|
|
834
895
|
let speed = (S.cur.tracks.zh && S.cur.tracks.zh.speed) || store.get('zhSpeed', 1)
|
|
835
896
|
const rerender = () => { const box = document.createElement('div'); box.innerHTML = zhCard(l); card.replaceWith(box.firstElementChild); bindZh(root, l) }
|
|
836
897
|
card.querySelectorAll('[data-zhengine]').forEach(b => { b.onclick = () => { store.set('zhEngine', b.dataset.zhengine); stopZhAudio(); rerender() } })
|
|
@@ -842,7 +903,28 @@ function bindZh(root, l) {
|
|
|
842
903
|
b.onclick = () => { store.set('zhAlign', b.dataset.zhalign || b.dataset.txalign); rerender() }
|
|
843
904
|
})
|
|
844
905
|
const voiceSel = card.querySelector('#zhVoice')
|
|
845
|
-
if (voiceSel) voiceSel.onchange = () => store.set(engine()
|
|
906
|
+
if (voiceSel) voiceSel.onchange = () => store.set(ZH_VOICE_KEY[engine()], voiceSel.value)
|
|
907
|
+
// 本机 Ming:安装在后台跑,装的时候每 2 秒看一次进度
|
|
908
|
+
const mingInst = card.querySelector('#mingInstall')
|
|
909
|
+
if (mingInst) mingInst.onclick = async () => {
|
|
910
|
+
mingInst.disabled = true
|
|
911
|
+
try { await api('ming/install', {}); S.voices = await api('voices'); rerender() } catch (e) { toast(e.message); mingInst.disabled = false }
|
|
912
|
+
}
|
|
913
|
+
if (S.voices && S.voices.ming && S.voices.ming.installing) {
|
|
914
|
+
setTimeout(async () => {
|
|
915
|
+
if (!card.isConnected) return
|
|
916
|
+
try { S.voices = await api('voices') } catch { /* 下次再看 */ }
|
|
917
|
+
if (!card.isConnected) return
|
|
918
|
+
if (S.voices.ming && S.voices.ming.ready) toast('本机 Ming 装好了')
|
|
919
|
+
rerender()
|
|
920
|
+
}, 2000)
|
|
921
|
+
}
|
|
922
|
+
const mingRm = card.querySelector('#mingRemove')
|
|
923
|
+
if (mingRm) mingRm.onclick = async e => {
|
|
924
|
+
e.preventDefault()
|
|
925
|
+
if (!confirm('卸载本机 Ming?会删掉它的程序和声音模型(约 2 GB)。已经生成的中文配音不受影响。')) return
|
|
926
|
+
try { await api('ming/uninstall', {}); S.voices = await api('voices'); rerender() } catch (err) { toast(err.message) }
|
|
927
|
+
}
|
|
846
928
|
const install = async (btn) => {
|
|
847
929
|
btn.disabled = true
|
|
848
930
|
const old = btn.textContent
|
|
@@ -865,7 +947,7 @@ function bindZh(root, l) {
|
|
|
865
947
|
player.stop()
|
|
866
948
|
tryBtn.disabled = true
|
|
867
949
|
const first = engine() === 'kokoro' && !(S.voices && S.voices.zh && S.voices.zh.ready)
|
|
868
|
-
tryBtn.textContent = first ? '第一次要下载中文语音模型,稍等…' : '正在生成试听…'
|
|
950
|
+
tryBtn.textContent = first ? '第一次要下载中文语音模型,稍等…' : engine() === 'ming' ? '正在加载本机 Ming,约 10 秒…' : '正在生成试听…'
|
|
869
951
|
try {
|
|
870
952
|
const r = await fetch('voice-sample', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-tingdong': '1' }, body: JSON.stringify({ engine: engine(), voice: voiceSel.value }) })
|
|
871
953
|
if (!r.ok) throw new Error(((await r.json().catch(() => ({}))).error) || `试听失败(${r.status})`)
|