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 CHANGED
@@ -15,7 +15,7 @@ DeepSeek Harness 本地音乐/小说播放插件。
15
15
  - 实时 7 段频谱可视化(解码音频包络驱动)
16
16
  - 播放时申请屏幕唤醒锁,防止听歌时熄屏/休眠(支持 Wake Lock 的浏览器,如 Chrome/Edge)
17
17
  - 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
18
- - AI 讲书:本地 `.txt` 小说经 MiMo TTS 合成朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转、章节切歌,可选 4 种中文 AI 声音(默认白桦)
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
- // 与音量/播放列表按钮同款圆形样式(dsh-music-mode-trigger)。
1519
- isBook ? React.createElement('button', {
1520
- className: 'dsh-music-mode-trigger' + (s.tocOpen ? ' active' : ''),
1521
- title: '\u7ae0\u8282\u76ee\u5f55',
1522
- onClick: openToc,
1523
- }, React.createElement('svg', {
1524
- viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', 'aria-hidden': true,
1525
- }, React.createElement('path', { d: 'M4 6h16v2H4V6zm0 5h16v2H4v-2zm0 5h10v2H4v-2z' }))) : null,
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: fixed; right: 24px; bottom: 148px; 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: 1000; }\n' +
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('^' + envName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*[:=]\\s*(.+?)\\s*$').exec(ln)
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 TTS 模型(缺少 api key)')
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 decode it;
710
- // otherwise report a precise diagnosis instead of feeding it invalid bytes.
711
- if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
712
- throw new Error('TTS 返回的不是有效的 WAV 音频(前4字节=' + (buf.length >= 4 ? buf.toString('ascii', 0, 4) : '<空>') + ')——请改用支持 wav 的模型或检查 API 返回')
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) => { ttsAudioInflight.delete(cacheKey); throw 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.2",
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",