dsh-music-player 0.3.2 → 0.3.4
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 +5 -2
- package/lib/client.js +66 -16
- package/lib/index.js +75 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ DeepSeek Harness 本地音乐/小说播放插件。
|
|
|
15
15
|
- 实时 7 段频谱可视化(解码音频包络驱动)
|
|
16
16
|
- 播放时申请屏幕唤醒锁,防止听歌时熄屏/休眠(支持 Wake Lock 的浏览器,如 Chrome/Edge)
|
|
17
17
|
- 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
|
|
18
|
-
- AI 讲书:本地 `.txt` 小说经 MiMo TTS
|
|
18
|
+
- AI 讲书:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转(打开即定位到当前正在播放的章节)、章节切歌,可选 4 种中文 AI 声音(默认白桦)
|
|
19
19
|
- `music_play` 模型工具:agent 可按关键词播放本地音乐,也可按小说名启动 AI 讲书
|
|
20
20
|
- 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
|
|
21
21
|
- **自建歌单**:可新建多个歌单,从本地文件(支持多选、可跨目录)添加歌曲;播放条爱心按钮一键收藏到默认歌单「我最喜欢」;歌单作为播放来源时,顺序/乱序循环只在该歌单内进行
|
|
@@ -108,7 +108,7 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
|
|
|
108
108
|
1. 打开播放面板,切到「小说」标签,点「选择小说目录」选定包含 `.txt` 的目录(默认与音乐目录相同)。
|
|
109
109
|
2. 点击某一本小说开始朗读;也可让 agent 用 `music_play` 工具按小说名播放(如「播放《中国制造》」)。
|
|
110
110
|
3. 播放条上的讲书控制:
|
|
111
|
-
- **章节目录**(📖
|
|
111
|
+
- **章节目录**(📖 按钮):自动识别全书结构(书名/前言/章节/尾声),点击弹出**位于按钮正上方**的目录(自动定位到当前正在播放的章节),点击任意章节即从该章开头朗读
|
|
112
112
|
- **后退 / 前进**:讲书模式下跳上一章 / 下一章(音乐模式下仍是上一首 / 下一首)
|
|
113
113
|
- **AI 声音**:点音量按钮,在弹层选择声音——冰糖(女)、茉莉(女)、苏打(男)、白桦(男,默认)
|
|
114
114
|
4. 刷新页面后从上次位置续读(断点续播)。
|
|
@@ -152,6 +152,9 @@ AI 语音目前仅支持 xiaomi 提供方(限时免费)。请先在 DSH 模
|
|
|
152
152
|
**讲书播放时点后退/前进没反应?**
|
|
153
153
|
讲书模式下后退/前进是跳上一章/下一章;如果当前小说没有识别出章节结构(目录按钮提示"暂无章节结构"),则无法跳章,只能整本顺序播。
|
|
154
154
|
|
|
155
|
+
**听书偶尔"没声音但时间还在走"?**
|
|
156
|
+
这种一般是某一段的合成结果异常(返回了退化/静音音频),或瞬时合成失败。0.3.3 起 Host 端会严格校验合成音频(拒绝空数据/非 PCM 等退化 WAV)并自动重试一次瞬时失败,同时把每次合成结果记录在诊断日志里。若再遇到,可访问 `http://<DSH地址>/dsh-music/tts-logs` 查看最近 60 条合成记录(含失败原因、退化音频事件),据此定位具体是哪个块出的问题。
|
|
157
|
+
|
|
155
158
|
**想支持更多音频格式?**
|
|
156
159
|
格式支持由 Host 端 `AUDIO_TYPES` 表驱动,在 `lib/index.js` 里加扩展名与 MIME 即可(播放器本身用浏览器原生 `<audio>` 解码,最终能否播放还取决于浏览器对该编码的支持)。
|
|
157
160
|
|
package/lib/client.js
CHANGED
|
@@ -631,6 +631,7 @@ window.__ModuleLoader__.load({
|
|
|
631
631
|
const id = currentBookId();
|
|
632
632
|
if (id !== null) {
|
|
633
633
|
unlockAutoplay();
|
|
634
|
+
bookAutoRetried = false; // manual retry resets the auto-retry budget
|
|
634
635
|
set({ bookError: '', bookBuffering: true, bookBufferingSince: Date.now() });
|
|
635
636
|
playBookFrom(id, bookFromRef, false);
|
|
636
637
|
}
|
|
@@ -655,6 +656,9 @@ window.__ModuleLoader__.load({
|
|
|
655
656
|
let lastPosSaveAt = 0; // throttle for the periodic playback-state save
|
|
656
657
|
let restoredMusicPos = null; // restored music position to display until the audio truly reaches it
|
|
657
658
|
let bookRestorePos = -1; // restored book's in-chunk position, seeked on play
|
|
659
|
+
// 当前块是否已自动重试过一次(瞬时 LLM 合成失败时先静音重试一次,
|
|
660
|
+
// 只有重试仍失败才弹错误 + 手动重试)。成功播放后复位。
|
|
661
|
+
let bookAutoRetried = false;
|
|
658
662
|
const preAudio = new Audio();
|
|
659
663
|
preAudio.preload = 'auto';
|
|
660
664
|
const bookUrl = (id, from) => {
|
|
@@ -815,7 +819,7 @@ window.__ModuleLoader__.load({
|
|
|
815
819
|
}
|
|
816
820
|
}
|
|
817
821
|
// `from` lets the toc jump straight to a chapter's chunk index.
|
|
818
|
-
function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
|
|
822
|
+
function playBook(id, from = 0) { unlockAutoplay(); bookTotal = -1; bookBufferedFrom = -1; bookBaseTime = 0; bookRestorePos = -1; bookAutoRetried = false; playBookFrom(id, from, false); saveCurrentBookPlayback(); }
|
|
819
823
|
// Play a novel from its saved progress when available (e.g. switching to
|
|
820
824
|
// music and back), otherwise start fresh from the beginning. Explicit
|
|
821
825
|
// chapter jumps keep using playBook(id, fromChunk) and are unaffected.
|
|
@@ -841,6 +845,8 @@ window.__ModuleLoader__.load({
|
|
|
841
845
|
if (store.currentId === null || !String(store.currentId).startsWith('book:')) return false;
|
|
842
846
|
const id = String(store.currentId).slice('book:'.length);
|
|
843
847
|
if (bookFromRef + 1 < bookTotal) {
|
|
848
|
+
// 进入新块:每个块各拥有一次自动重试的机会。
|
|
849
|
+
bookAutoRetried = false;
|
|
844
850
|
const endedDur = Number.isFinite(audio.duration) ? audio.duration : (audio.currentTime || 0);
|
|
845
851
|
if (Number.isFinite(endedDur)) bookBaseTime += endedDur;
|
|
846
852
|
playBookFrom(id, bookFromRef + 1, true);
|
|
@@ -1006,6 +1012,7 @@ window.__ModuleLoader__.load({
|
|
|
1006
1012
|
audio.removeAttribute('src');
|
|
1007
1013
|
audio.load();
|
|
1008
1014
|
stopBookHelper();
|
|
1015
|
+
bookAutoRetried = false;
|
|
1009
1016
|
set({ currentId: null, currentName: null, playing: false, position: 0, duration: 0, pendingId: null, pendingName: null, vizState: 'ok', bookBuffering: false, bookError: '', bookBufferingSince: 0, bookBufferingSilent: false, tocOpen: false, currentSection: '', voiceSwitching: false });
|
|
1010
1017
|
clearPref(PREF_PLAYBACK);
|
|
1011
1018
|
if (stoppedBook !== null) clearBookPlayback(stoppedBook.name);
|
|
@@ -1105,6 +1112,9 @@ window.__ModuleLoader__.load({
|
|
|
1105
1112
|
// next block until the whole book is done, then stop (never step into
|
|
1106
1113
|
// the music list). Book ids are 'book:'-prefixed.
|
|
1107
1114
|
if (store.currentId !== null && String(store.currentId).startsWith('book:')) {
|
|
1115
|
+
// 切块进行中(bookBuffering 为真,新块还没开始播放)忽略旧块的
|
|
1116
|
+
// ended:防止陈旧事件重复触发导致跳块/时间空走。
|
|
1117
|
+
if (store.bookBuffering) return;
|
|
1108
1118
|
if (!maybeAdvanceBook()) stop();
|
|
1109
1119
|
return;
|
|
1110
1120
|
}
|
|
@@ -1121,6 +1131,15 @@ window.__ModuleLoader__.load({
|
|
|
1121
1131
|
// clear the "合成中…" spinner and surface a real message instead of
|
|
1122
1132
|
// leaving the bar stuck buffering forever.
|
|
1123
1133
|
if (store.currentId !== null && String(store.currentId).startsWith('book:')) {
|
|
1134
|
+
// 瞬时 LLM 合成失败:先自动静音重试一次当前块(听书不中断),
|
|
1135
|
+
// 重试仍失败才进入错误 + 手动重试。
|
|
1136
|
+
if (!bookAutoRetried) {
|
|
1137
|
+
bookAutoRetried = true;
|
|
1138
|
+
const retryId = String(store.currentId).slice('book:'.length);
|
|
1139
|
+
unlockAutoplay();
|
|
1140
|
+
playBookFrom(retryId, bookFromRef, true);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1124
1143
|
set({ bookBuffering: false, bookBufferingSince: 0, playing: false, bookError: '\u8bb2\u4e66\u97f3\u9891\u83b7\u53d6\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5' });
|
|
1125
1144
|
// Best-effort: fetch the URL to show the server's actual diagnostic
|
|
1126
1145
|
// (e.g. "TTS 请求失败 401 ..."), which the <audio> error object lacks.
|
|
@@ -1238,6 +1257,13 @@ window.__ModuleLoader__.load({
|
|
|
1238
1257
|
bookBuffering: false, bookBufferingSilent: false, bookError: '', bookBufferingSince: 0,
|
|
1239
1258
|
});
|
|
1240
1259
|
bookRestorePos = pos;
|
|
1260
|
+
// 恢复后立即加载章节结构并计算当前章节:让播放条章节徽标与章节目录
|
|
1261
|
+
// 立刻显示正在播放的章节(而不是等用户点 ▶、playBookFrom 才开始加载)。
|
|
1262
|
+
// ensureBookToc 内部会 set bookToc;这里再补上 currentSection。
|
|
1263
|
+
void ensureBookToc(book.id).then((meta) => {
|
|
1264
|
+
if (meta === null || meta.sections.length === 0) return;
|
|
1265
|
+
set({ currentSection: sectionForChunk(meta.sections, from) });
|
|
1266
|
+
});
|
|
1241
1267
|
// Refresh the chunk total in the background (the book file may have
|
|
1242
1268
|
// changed since the save); fall back to the saved total on failure.
|
|
1243
1269
|
const savedTotal = bookTotal;
|
|
@@ -1348,6 +1374,13 @@ window.__ModuleLoader__.load({
|
|
|
1348
1374
|
return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
|
|
1349
1375
|
React.createElement('path', { d: 'M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z' }));
|
|
1350
1376
|
}
|
|
1377
|
+
// 讲书(AI 听书)时名称前的话筒图标,贴合「朗读/播讲」功能,
|
|
1378
|
+
// 与音乐的音符图标区分。
|
|
1379
|
+
function MicIcon(props) {
|
|
1380
|
+
const cls = props.className || '';
|
|
1381
|
+
return React.createElement('svg', { className: cls, width: 12, height: 12, viewBox: '0 0 24 24', fill: 'currentColor', 'aria-hidden': true },
|
|
1382
|
+
React.createElement('path', { d: 'M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z' }));
|
|
1383
|
+
}
|
|
1351
1384
|
|
|
1352
1385
|
// ---- components ----
|
|
1353
1386
|
// Custom vertical volume slider. The native <input type=range> cannot be
|
|
@@ -1472,8 +1505,9 @@ window.__ModuleLoader__.load({
|
|
|
1472
1505
|
onClick: () => { const t = resolvePlayable(s.currentId); if (t !== null) loadEnvelope(t.id, t.url); },
|
|
1473
1506
|
}, '\u9891\u8c31\u4e0d\u53ef\u7528\uff0c\u70b9\u51fb\u91cd\u8bd5');
|
|
1474
1507
|
}
|
|
1475
|
-
const note = React.createElement(MusicNote, { className: 'dsh-music-note' });
|
|
1476
1508
|
const isBook = s.currentId !== null && String(s.currentId).startsWith('book:');
|
|
1509
|
+
// 名称前的图标:讲书用话筒图标,音乐用音符图标(空闲态无曲目 = 音乐)。
|
|
1510
|
+
const note = React.createElement(isBook ? MicIcon : MusicNote, { className: 'dsh-music-note' });
|
|
1477
1511
|
// While a novel chunk is being synthesized, show a live "合成中… Ns"
|
|
1478
1512
|
// counter (so the wait is transparent, not an endless spinner) and, on
|
|
1479
1513
|
// error, the real message plus a retry button. Music keeps its old UI.
|
|
@@ -1515,14 +1549,18 @@ window.__ModuleLoader__.load({
|
|
|
1515
1549
|
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: isBook ? '\u4e0b\u4e00\u7ae0' : '\u4e0b\u4e00\u9996', onClick: () => (isBook ? stepBook(1) : step(1)) }, '\u23ed') : null,
|
|
1516
1550
|
hasTrack ? React.createElement('button', { className: 'dsh-music-bar-btn', title: '\u505c\u6b62', onClick: stop }, '\u23f9') : null,
|
|
1517
1551
|
// 章节目录按钮:仅讲书(book)时出现,点击弹出章节列表并可跳章。
|
|
1518
|
-
//
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1552
|
+
// 与音量/播放模式按钮同款圆形样式(dsh-music-mode-trigger);弹层用
|
|
1553
|
+
// 与音量/播放模式同款的「相对容器 + 绝对定位」锚在按钮正上方。
|
|
1554
|
+
isBook ? React.createElement('div', { className: 'dsh-music-toc-trigger' },
|
|
1555
|
+
React.createElement('button', {
|
|
1556
|
+
className: 'dsh-music-mode-trigger' + (s.tocOpen ? ' active' : ''),
|
|
1557
|
+
title: '\u7ae0\u8282\u76ee\u5f55',
|
|
1558
|
+
onClick: openToc,
|
|
1559
|
+
}, React.createElement('svg', {
|
|
1560
|
+
viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true,
|
|
1561
|
+
}, React.createElement('path', { d: 'M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h10v2H4v-2z' }))),
|
|
1562
|
+
React.createElement(BookTocPanel, null),
|
|
1563
|
+
) : null,
|
|
1526
1564
|
// 音乐播放模式按钮:仅在音乐语境(非讲书)显示,与章节目录按钮互斥。
|
|
1527
1565
|
!isBook ? React.createElement(ModeDropdown, null) : null,
|
|
1528
1566
|
React.createElement('div', { className: 'dsh-music-bar-vol', ref: volRef },
|
|
@@ -1599,6 +1637,7 @@ window.__ModuleLoader__.load({
|
|
|
1599
1637
|
function BookTocPanel() {
|
|
1600
1638
|
const s = useStore();
|
|
1601
1639
|
const ref = useRef(null);
|
|
1640
|
+
const listRef = useRef(null);
|
|
1602
1641
|
useEffect(() => {
|
|
1603
1642
|
if (!s.tocOpen) return;
|
|
1604
1643
|
const onDown = (e) => {
|
|
@@ -1607,6 +1646,15 @@ window.__ModuleLoader__.load({
|
|
|
1607
1646
|
document.addEventListener('mousedown', onDown);
|
|
1608
1647
|
return () => document.removeEventListener('mousedown', onDown);
|
|
1609
1648
|
}, [s.tocOpen]);
|
|
1649
|
+
// 打开章节目录时定位到正在播放的章节:把高亮的当前章节滚动进可视区,
|
|
1650
|
+
// 而不是从列表顶部开始。依赖 bookToc(打开时可能先为空、随后异步到达)。
|
|
1651
|
+
useEffect(() => {
|
|
1652
|
+
if (!s.tocOpen) return;
|
|
1653
|
+
const list = listRef.current;
|
|
1654
|
+
if (list === null) return;
|
|
1655
|
+
const active = list.querySelector('.dsh-music-toc-item.active');
|
|
1656
|
+
if (active !== null && typeof active.scrollIntoView === 'function') active.scrollIntoView({ block: 'nearest' });
|
|
1657
|
+
}, [s.tocOpen, s.bookToc]);
|
|
1610
1658
|
if (!s.tocOpen) return null;
|
|
1611
1659
|
if (s.currentId === null || !String(s.currentId).startsWith('book:')) return null;
|
|
1612
1660
|
const id = currentBookId();
|
|
@@ -1629,11 +1677,14 @@ window.__ModuleLoader__.load({
|
|
|
1629
1677
|
const body = (s.bookToc || []).length > 0
|
|
1630
1678
|
? rows
|
|
1631
1679
|
: React.createElement('div', { className: 'dsh-music-empty' }, '\u6682\u65e0\u7ae0\u8282\u7ed3\u6784\uff08\u8be5\u4e66\u65e0\u6cd5\u8bc6\u522b\u5206\u8282\u3002\uff09');
|
|
1680
|
+
// 弹层用 CSS 定位在「章节目录」按钮正上方(与音量/播放模式弹窗同款:
|
|
1681
|
+
// 相对容器 + position:absolute + bottom:calc(100%+6px) 居中),见
|
|
1682
|
+
// .dsh-music-toc-trigger / .dsh-music-toc 样式。
|
|
1632
1683
|
return React.createElement('div', { className: 'dsh-music-toc', ref },
|
|
1633
1684
|
React.createElement('div', { className: 'dsh-music-toc-head' },
|
|
1634
1685
|
React.createElement('span', { className: 'dsh-music-toc-title' }, '\u7ae0\u8282\u76ee\u5f55'),
|
|
1635
1686
|
React.createElement('button', { className: 'dsh-music-icon-btn', title: '\u5173\u95ed', onClick: closeToc }, '\u2715')),
|
|
1636
|
-
React.createElement('div', { className: 'dsh-music-toc-list' }, body),
|
|
1687
|
+
React.createElement('div', { className: 'dsh-music-toc-list', ref: listRef }, body),
|
|
1637
1688
|
);
|
|
1638
1689
|
}
|
|
1639
1690
|
function PlayerPanel() {
|
|
@@ -2187,10 +2238,6 @@ window.__ModuleLoader__.load({
|
|
|
2187
2238
|
{ name: 'shell.overlay', id: 'music-player-panel', order: 20 },
|
|
2188
2239
|
() => React.createElement(PlayerPanel),
|
|
2189
2240
|
)), 'music-player: overlay panel');
|
|
2190
|
-
ctx.effect(() => slots.inject('shell.overlay', () => slots.register(
|
|
2191
|
-
{ name: 'shell.overlay', id: 'music-player-book-toc', order: 19 },
|
|
2192
|
-
() => React.createElement(BookTocPanel),
|
|
2193
|
-
)), 'music-player: book toc panel');
|
|
2194
2241
|
|
|
2195
2242
|
ctx.effect(() => () => clearInterval(intentTimer), 'music-player: intent poll stop');
|
|
2196
2243
|
}
|
|
@@ -2295,7 +2342,10 @@ window.__ModuleLoader__.load({
|
|
|
2295
2342
|
// 讲书时章节名是主信息:占满剩余弹性空间、尽量完整显示;书名让出空间(可截断)。
|
|
2296
2343
|
'.dsh-music-bar.book .dsh-music-bar-name { max-width: 24%; }\n' +
|
|
2297
2344
|
'.dsh-music-bar-section { margin-left: 8px; flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, #8a8f98); font-size: 11px; border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); border-radius: 6px; padding: 0 6px; line-height: 16px; }\n' +
|
|
2298
|
-
'.dsh-music-toc { position:
|
|
2345
|
+
'.dsh-music-toc-trigger { position: relative; flex: none; display: inline-flex; align-self: center; }\n' +
|
|
2346
|
+
// 章节目录弹层:与音量/播放模式弹窗同款定位——相对容器 + 绝对定位,
|
|
2347
|
+
// bottom:calc(100%+6px) 让其出现在按钮正上方,left:50% 居中。
|
|
2348
|
+
'.dsh-music-toc { position: absolute; left: 50%; transform: translateX(-50%); bottom: calc(100% + 6px); width: 380px; max-height: 60vh; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--dsw-alias-bg-overlay, #1e1f22); border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35)); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.35); color: var(--dsw-alias-label-primary, #e6e6e6); font-size: 13px; z-index: 60; }\n' +
|
|
2299
2349
|
'.dsh-music-toc-head { display: flex; align-items: center; gap: 6px; }\n' +
|
|
2300
2350
|
'.dsh-music-toc-title { font-weight: 600; margin-right: auto; }\n' +
|
|
2301
2351
|
'.dsh-music-toc-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }\n' +
|
package/lib/index.js
CHANGED
|
@@ -580,6 +580,9 @@ export function apply(ctx) {
|
|
|
580
580
|
// Reuse the api key the user already configured in DSH. DSH stores configured
|
|
581
581
|
// provider keys in ~/.dsh/.credentials.yaml keyed by the same name as
|
|
582
582
|
// settings.yaml's apiKeyEnv, so read it there (falling back to a real env var).
|
|
583
|
+
// Since DSH v1 the file nests keys under a refs: block (two-space indent,
|
|
584
|
+
// root "version: 1"); older builds used a flat layout with keys at column 0.
|
|
585
|
+
// Allow leading whitespace so both layouts resolve.
|
|
583
586
|
const readCredential = async (envName) => {
|
|
584
587
|
if (!envName) return null
|
|
585
588
|
if (process.env[envName]) return process.env[envName]
|
|
@@ -588,7 +591,7 @@ export function apply(ctx) {
|
|
|
588
591
|
if (file === null || !existsSync(file)) return null
|
|
589
592
|
const lines = readFileSync(file, 'utf8').split(/\r?\n/)
|
|
590
593
|
for (const ln of lines) {
|
|
591
|
-
const m = new RegExp('
|
|
594
|
+
const m = new RegExp('^\\s*' + envName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*[:=]\\s*(.+?)\\s*$').exec(ln)
|
|
592
595
|
if (m) return m[1]
|
|
593
596
|
}
|
|
594
597
|
return null
|
|
@@ -672,7 +675,7 @@ export function apply(ctx) {
|
|
|
672
675
|
// Synthesize a chunk of prose into a Buffer of wav audio (non-streaming, format=wav).
|
|
673
676
|
const synthesize = async (text, voice) => {
|
|
674
677
|
const { baseURL, key } = await resolveTts()
|
|
675
|
-
if (!key) throw new Error('未配置 xiaomi/MiMo
|
|
678
|
+
if (!key) throw new Error('未配置 xiaomi/MiMo 提供方(缺少 api key)')
|
|
676
679
|
const body = {
|
|
677
680
|
model: MIMO_DEFAULT_MODEL,
|
|
678
681
|
messages: [
|
|
@@ -706,16 +709,59 @@ export function apply(ctx) {
|
|
|
706
709
|
const data = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.audio && json.choices[0].message.audio.data
|
|
707
710
|
if (typeof data !== 'string') throw new Error('TTS 响应缺少音频数据')
|
|
708
711
|
const buf = Buffer.from(data, 'base64')
|
|
709
|
-
// Verify it is actually a WAVE file so the browser <audio> can
|
|
710
|
-
// otherwise report a precise diagnosis instead of feeding it
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
712
|
+
// Verify it is actually a playable WAVE file so the browser <audio> can
|
|
713
|
+
// decode it; otherwise report a precise diagnosis instead of feeding it
|
|
714
|
+
// invalid bytes. A shallow RIFF/WAVE check is NOT enough: a header-only or
|
|
715
|
+
// non-PCM wav passes it and the browser then "plays" silence while the
|
|
716
|
+
// reader's clock keeps advancing — the exact "没声音但时长还在走" symptom.
|
|
717
|
+
// Parse the fmt + data chunks and reject degenerate files outright.
|
|
718
|
+
validateWav(buf)
|
|
714
719
|
// Return the MiMo audio as-is: the 24kHz source played clearly in the smoke
|
|
715
720
|
// test, and our naive linear-interpolation resample to 48kHz degraded
|
|
716
721
|
// intelligibility. Revisit resampling only if it becomes the confirmed cause.
|
|
717
722
|
return buf
|
|
718
723
|
}
|
|
724
|
+
// Strict WAV sanity check: refuses to serve audio the browser would decode
|
|
725
|
+
// but hear as silence/broken output. Throws with a precise message so the
|
|
726
|
+
// client surfaces it (with retry) instead of playing a silent chunk.
|
|
727
|
+
const validateWav = (buf) => {
|
|
728
|
+
if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
|
|
729
|
+
throw new Error('TTS 返回的不是有效的 WAV 音频(前4字节=' + (buf.length >= 4 ? buf.toString('ascii', 0, 4) : '<空>') + ')——请改用支持 wav 的模型或检查 API 返回')
|
|
730
|
+
}
|
|
731
|
+
// Walk the RIFF chunks to locate fmt + data.
|
|
732
|
+
let fmtFormat = -1
|
|
733
|
+
let fmtRate = -1
|
|
734
|
+
let fmtCh = -1
|
|
735
|
+
let fmtBits = -1
|
|
736
|
+
let dataSize = -1
|
|
737
|
+
let dataStart = -1
|
|
738
|
+
let off = 12
|
|
739
|
+
while (off + 8 <= buf.length) {
|
|
740
|
+
const id = buf.toString('ascii', off, off + 4)
|
|
741
|
+
const sz = buf.readUInt32LE(off + 4)
|
|
742
|
+
if (id === 'fmt ' && sz >= 16) {
|
|
743
|
+
fmtFormat = buf.readUInt16LE(off + 8)
|
|
744
|
+
fmtCh = buf.readUInt16LE(off + 10)
|
|
745
|
+
fmtRate = buf.readUInt32LE(off + 12)
|
|
746
|
+
fmtBits = buf.readUInt16LE(off + 22)
|
|
747
|
+
} else if (id === 'data') {
|
|
748
|
+
dataSize = sz
|
|
749
|
+
dataStart = off + 8 // payload begins right after the 8-byte chunk header
|
|
750
|
+
}
|
|
751
|
+
off += 8 + sz + (sz % 2)
|
|
752
|
+
if (off > buf.length) break
|
|
753
|
+
}
|
|
754
|
+
const actualData = (dataSize >= 0 && dataStart >= 0) ? Math.min(dataSize, Math.max(0, buf.length - dataStart)) : 0
|
|
755
|
+
const problems = []
|
|
756
|
+
if (fmtFormat !== 1) problems.push('非 PCM 格式(fmt=' + fmtFormat + ')')
|
|
757
|
+
if (!(fmtRate >= 4000 && fmtRate <= 192000)) problems.push('采样率异常(' + fmtRate + 'Hz)')
|
|
758
|
+
if (!(fmtCh >= 1 && fmtCh <= 8)) problems.push('声道数异常(' + fmtCh + ')')
|
|
759
|
+
if (dataSize < 2 || actualData < 2) problems.push('音频数据为空(data=' + dataSize + ')')
|
|
760
|
+
if (problems.length > 0) {
|
|
761
|
+
logTts({ kind: 'degenerate', detail: problems.join(',') })
|
|
762
|
+
throw new Error('TTS 返回的 WAV 异常(' + problems.join(',') + ')——该段无法朗读,请重试或检查 API')
|
|
763
|
+
}
|
|
764
|
+
}
|
|
719
765
|
|
|
720
766
|
// In-memory synthesized-audio cache + in-flight dedup.
|
|
721
767
|
// The browser requests the same chunk URL more than once per listen (the
|
|
@@ -728,11 +774,21 @@ export function apply(ctx) {
|
|
|
728
774
|
const ttsAudioCache = new Map()
|
|
729
775
|
const ttsAudioInflight = new Map()
|
|
730
776
|
const MAX_TTS_CACHE_CHUNKS = 80
|
|
777
|
+
// Recent TTS synthesis diagnostics (successes with timing + every failure and
|
|
778
|
+
// every degenerate-wav rejection), served at /dsh-music/tts-logs so the user
|
|
779
|
+
// can confirm what a "没声音但时长还在走" episode was actually caused by.
|
|
780
|
+
const ttsLog = []
|
|
781
|
+
const MAX_TTS_LOG = 60
|
|
782
|
+
const logTts = (entry) => {
|
|
783
|
+
ttsLog.push({ ts: Date.now(), ...entry })
|
|
784
|
+
if (ttsLog.length > MAX_TTS_LOG) ttsLog.shift()
|
|
785
|
+
}
|
|
731
786
|
const synthesizeCached = async (cacheKey, text, voice) => {
|
|
732
787
|
const hit = ttsAudioCache.get(cacheKey)
|
|
733
788
|
if (hit !== undefined) return hit
|
|
734
789
|
const inFlight = ttsAudioInflight.get(cacheKey)
|
|
735
790
|
if (inFlight !== undefined) return inFlight
|
|
791
|
+
const t0 = Date.now()
|
|
736
792
|
const p = synthesize(text, voice)
|
|
737
793
|
.then((buf) => {
|
|
738
794
|
ttsAudioInflight.delete(cacheKey)
|
|
@@ -741,9 +797,14 @@ export function apply(ctx) {
|
|
|
741
797
|
const oldest = ttsAudioCache.keys().next().value
|
|
742
798
|
ttsAudioCache.delete(oldest)
|
|
743
799
|
}
|
|
800
|
+
logTts({ kind: 'ok', key: cacheKey, ms: Date.now() - t0, bytes: buf.length, head: text.slice(0, 12) })
|
|
744
801
|
return buf
|
|
745
802
|
})
|
|
746
|
-
.catch((err) => {
|
|
803
|
+
.catch((err) => {
|
|
804
|
+
ttsAudioInflight.delete(cacheKey)
|
|
805
|
+
logTts({ kind: 'error', key: cacheKey, ms: Date.now() - t0, error: String((err && err.message) || err).slice(0, 160), head: text.slice(0, 12) })
|
|
806
|
+
throw err
|
|
807
|
+
})
|
|
747
808
|
ttsAudioInflight.set(cacheKey, p)
|
|
748
809
|
return p
|
|
749
810
|
}
|
|
@@ -1069,6 +1130,12 @@ export function apply(ctx) {
|
|
|
1069
1130
|
writeJson(res, it || null)
|
|
1070
1131
|
return
|
|
1071
1132
|
}
|
|
1133
|
+
// TTS 合成诊断日志(最近 60 条,含每次失败/退化 WAV),便于排查
|
|
1134
|
+
// 「没声音但时长继续走」等异常。
|
|
1135
|
+
if (pathname === '/dsh-music/tts-logs' && req.method === 'GET') {
|
|
1136
|
+
writeJson(res, { logs: ttsLog.slice() })
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1072
1139
|
// AI 讲书:一本书的元信息(总块数)或某个块的 wav。
|
|
1073
1140
|
// GET /dsh-music/book/<id>/meta -> { id, name, total, size } (JSON)
|
|
1074
1141
|
// GET /dsh-music/book/<id>?from=n -> chunk n as wav audio
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-music-player",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|