vomebook commited on
Commit
1143003
·
1 Parent(s): 45e0303

feat: compact native preview reader with rolling long-text playback

Browse files

One preview play icon, fixed female01, compact non-modal native controls and direct CORS audio. Start at visible source or double-clicked sentence; support locate, pitch/rate/sleep and pause-aware bounded prefetch. Remove document-wide 20k cap and iframe startup chain; first batch targets 80 codepoints, subsequent 480. Request opt-in pauses for unknown phones without changing originals.

docs/TTS_PILOT.md CHANGED
@@ -1,22 +1,27 @@
1
- # CCRD 朗读试点
2
 
3
- 在文章预览上方,可选“朗读选中文字”或“朗读当前预览”。首次点击后才载入
4
- Hitokoto 的独立 CPU 播放器;文字通过严格限定来源的浏览器消息传入,不放进 URL。
5
- **载入文字不等于提交合成**:在播放器确认正文、音色、合成语速和试验音高后,
6
- 主动点击“合成并试听”,才分批提交到 Hitokoto API。
7
 
8
- - 选文必须完全在当前预览正文里,含原文 Unicode 与有限相邻读音上下文。
9
- - 最多两万字;超长请自行选一段,不自动截断、不自动发送整本书。
10
- - 当前快照开始朗读后,更换搜索词、排序、过滤、打开另一篇或关闭预览不会替换它。
11
- - 播放器内支持暂停、句段跳读、倍速、睡眠定时、试验音高;高亮是句段级。
12
- - “收起”或 Escape 保留朗读,“停止并收起”停止。重新载入会要求明确确认替换。
13
- - 桌面保留搜索可操作;手机播放器独立弹层,收起后恢复预览。
14
- - 试点播放器不自动读取/保存独立试听页的本机正文书签,页面内可续读。
15
- - 模型、音色、缓存与任务限制来自 Hitokoto;API 不可用不影响搜索、预览和下载。
16
- - 旧 Service Worker 客户端可能需第二次刷新/重开,缓存名不变。
17
- - 实际手机后台、锁屏、自动播放限制、人工听感仍需真机确认,不承诺无缝。
 
 
 
 
 
 
 
 
18
 
19
- 生产搜索站:<https://vomebook-search.hf.space/>;语音服务:
20
- <https://vomebook-hitokoto.hf.space/tts/>。这次只接入 CCRD,其他三个搜索站未改。
21
-
22
- 开发/验收命令见 `TESTING.md` 的 CCRD / Hitokoto reader pilot tier。
 
1
+ # CCRD 简洁朗读
2
 
3
+ 预览只保留一个播放图标:电脑在全屏按钮左边,手机在明暗模式按钮下面。
4
+ 点击即从可视正文的最上方开始,右下角出现非模态设置栏,不再嵌入一言的试听页面。
 
 
5
 
6
+ - 固定女声01。只提供音高、即时语速、睡眠定时、上一句/下一句、暂停/继续、
7
+ 从当前阅读位置开始、回到播放位置。双击正文会选中所在句子并从那里继续播放。
8
+ - 原文保留在搜索页内存,不修改正文或搜索高亮;后端返回原始 UTF-16 句段范围。
9
+ 高亮是句段级,不把播放时间比例冒充逐字定位。“回到播放位置”可重新打开原预览。
10
+ - 去掉整篇两万字限制:首批目标 80 字,后续目标 480 字,每个 API 请求仍最多 800
11
+ 个 Unicode 字符、相邻上下文各最多 32 字。日期和普通英文词不在常规批次边界拆开。
12
+ 只保留当前/下一批任务及一个后续 MP3 预载,不整本上传或生成整本音频。
13
+ - Search 请求显式使用 `unsupported_policy="pause"`:不能发音的字符/符号按短停顿
14
+ 跨过,前后可发音正文继续读,源字符和定位不变。符号段可生成短静音;这不是生僻字
15
+ 发音识别能力。网络、数值歧义、模型故障不冒充“无法识别字符”,保留位置后重试。
16
+ - 语速是浏览器即时倍速,保持音高;音高是后端 -3…+3 半音处理,修改时从当前句段
17
+ 重新生成。暂停时调整音高/选择相邻句不提交任务,直到点击继续。
18
+ - 收起保留播放;× 停止并释放任务。关闭/更换预览、搜索、过滤或主题都不替换声音
19
+ 快照。搜索站不用安装模型,也不访问独立试听页的私密本机书签。
20
+ - 手机设置栏同样不覆盖整屏,保留预览原有键盘隔离。定时采用经过的时间,暂停不
21
+ 顺延;跳读/调整音高不重新计时。实际锁屏/后台由浏览器决定,仍需真机验证。
22
+ - 首批直接 POST,不串行等待 iframe、音色列表、健康检查。预览时只预连接语音域名,
23
+ 不运行固定词预热;精确性能数据需要分别测新音频、缓存和网络入口。
24
 
25
+ 服务:<https://vomebook-search.hf.space/>;后端:<https://vomebook-hitokoto.hf.space/tts/>。
26
+ 仍只接入 CCRD。Search SW 缓存名不变,前端静态 URL 带版本查询参数以避开旧组件混用。
27
+ 测试命令见 `TESTING.md`;不把静音浏览器/窄视口检查称为真人听感或手机锁屏验收。
 
static/app.js CHANGED
@@ -1119,6 +1119,10 @@ function previewFullscreenButtonHTML() {
1119
  return `<button type="button" class="icon-btn-sm preview-fullscreen-btn" data-preview-fullscreen aria-label="全屏预览" title="全屏预览" aria-pressed="false">${previewFullscreenIcon(false)}</button>`;
1120
  }
1121
 
 
 
 
 
1122
  function setPreviewFullscreen(fullscreen, sync = false) {
1123
  const expanded = Boolean(fullscreen && !STATE.isMobile);
1124
  STATE.previewFullscreen = expanded;
@@ -1229,12 +1233,12 @@ async function openPreview(docId, keyword, options = {}) {
1229
  ${textQuality || plainTextLink ? `<div class="preview-path">${escapeHTML(textQuality)}${plainTextLink ? " · " + plainTextLink : ""}</div>` : ""}
1230
  </div>
1231
  <div class="preview-tools">
 
1232
  ${previewFullscreenButtonHTML()}
1233
  <button type="button" class="icon-btn-sm" data-action="close-preview" aria-label="关闭预览">×</button>
1234
  <button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
1235
  </div>
1236
  </div>
1237
- <div class="preview-speech-tools"><button type="button" class="text-btn" data-speech="selection">朗读选中文字</button><button type="button" class="text-btn" data-speech="preview">朗读当前预览</button><button type="button" class="text-btn" data-speech="controls">播放器</button></div>
1238
  <pre class="preview-body"></pre>`;
1239
  const previewTitle = DOM.previewPanel.querySelector(".preview-title");
1240
  if (previewTitle) previewTitle.id = "preview-title";
@@ -2250,11 +2254,9 @@ function attachEvents() {
2250
  }
2251
  });
2252
  DOM.previewPanel.addEventListener("click", (event) => {
2253
- const speech = event.target.closest("[data-speech]");
2254
  if (speech) {
2255
- if (speech.dataset.speech === "controls") document.querySelector("#speech-launch")?.click();
2256
- else if (window.CCRDSpeech) window.CCRDSpeech.read(speech.dataset.speech);
2257
- else showToast("朗读组件尚未更新,请刷新页面后重试");
2258
  return;
2259
  }
2260
  const closeBtn = event.target.closest("[data-action='close-preview']");
@@ -2268,9 +2270,8 @@ function attachEvents() {
2268
  }
2269
  if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
2270
  });
2271
- if (!window.CCRDSpeech) $("#speech-launch")?.addEventListener("click", () => showToast("朗读组件加载失败,请刷新重试;搜索仍可使用"));
2272
  document.addEventListener("keydown", (event) => {
2273
- if (window.CCRDSpeech?.modal() || window.CCRDSpeech?.owns(event.target)) return;
2274
  if (STATE.previewDocId && !DOM.previewPanel.hidden && (previewIsModal() || DOM.previewPanel.contains(event.target))) {
2275
  if (event.key === "Escape") {
2276
  event.preventDefault();
 
1119
  return `<button type="button" class="icon-btn-sm preview-fullscreen-btn" data-preview-fullscreen aria-label="全屏预览" title="全屏预览" aria-pressed="false">${previewFullscreenIcon(false)}</button>`;
1120
  }
1121
 
1122
+ function previewSpeechButtonHTML() {
1123
+ return `<button type="button" class="icon-btn-sm preview-speech-btn" data-speech-toggle aria-label="朗读与设置" title="朗读与设置" aria-controls="speech-panel" aria-expanded="false"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></button>`;
1124
+ }
1125
+
1126
  function setPreviewFullscreen(fullscreen, sync = false) {
1127
  const expanded = Boolean(fullscreen && !STATE.isMobile);
1128
  STATE.previewFullscreen = expanded;
 
1233
  ${textQuality || plainTextLink ? `<div class="preview-path">${escapeHTML(textQuality)}${plainTextLink ? " · " + plainTextLink : ""}</div>` : ""}
1234
  </div>
1235
  <div class="preview-tools">
1236
+ ${previewSpeechButtonHTML()}
1237
  ${previewFullscreenButtonHTML()}
1238
  <button type="button" class="icon-btn-sm" data-action="close-preview" aria-label="关闭预览">×</button>
1239
  <button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
1240
  </div>
1241
  </div>
 
1242
  <pre class="preview-body"></pre>`;
1243
  const previewTitle = DOM.previewPanel.querySelector(".preview-title");
1244
  if (previewTitle) previewTitle.id = "preview-title";
 
2254
  }
2255
  });
2256
  DOM.previewPanel.addEventListener("click", (event) => {
2257
+ const speech = event.target.closest("[data-speech-toggle]");
2258
  if (speech) {
2259
+ if (!window.CCRDSpeech) showToast("朗读组件尚未更新,请刷新页面后重试");
 
 
2260
  return;
2261
  }
2262
  const closeBtn = event.target.closest("[data-action='close-preview']");
 
2270
  }
2271
  if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
2272
  });
 
2273
  document.addEventListener("keydown", (event) => {
2274
+ if (window.CCRDSpeech?.modal()) return;
2275
  if (STATE.previewDocId && !DOM.previewPanel.hidden && (previewIsModal() || DOM.previewPanel.contains(event.target))) {
2276
  if (event.key === "Escape") {
2277
  event.preventDefault();
static/index.html CHANGED
@@ -10,7 +10,7 @@
10
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
11
  <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
12
  <script>fetch("/api/ping", { cache: "no-store" }).catch(() => {});</script>
13
- <link rel="stylesheet" href="/static/style.css">
14
  </head>
15
  <body class="dark">
16
 
@@ -51,7 +51,6 @@
51
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
52
  </div>
53
  <div class="status-right">
54
- <button type="button" id="speech-launch" class="text-btn" aria-controls="speech-dialog" aria-expanded="false">朗读</button>
55
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
56
  <div class="sort-group">
57
  <span class="sort-label">排序:</span>
@@ -148,20 +147,18 @@
148
  </footer>
149
  <div id="overlay" class="overlay" style="display:none"></div>
150
  <div id="toast" class="toast" role="status" aria-live="polite" style="display:none"></div>
151
- <dialog id="speech-dialog" class="speech-dialog" aria-labelledby="speech-title">
152
- <div class="speech-heading">
153
- <strong id="speech-title">CPU 朗读(试点)</strong>
154
- <button type="button" id="speech-collapse" class="text-btn">收起(保留朗读)</button>
155
- <button type="button" id="speech-stop" class="text-btn">停止并收起</button>
156
- </div>
157
- <p id="speech-status" class="speech-note" role="status">打开后连接 Hitokoto 独立语音服务。</p>
158
- <button type="button" id="speech-retry" class="text-btn" hidden>重新连接(停止原朗读)</button>
159
- <div id="speech-frame-host"></div>
160
- <p class="speech-note">关闭预览或切换搜索不打断朗读。正文通过浏览器交给播放器,点击合成才提交语音 API。手机后台效果待真机确认。</p>
161
- </dialog>
162
 
163
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
164
- <script src="/static/tts-client.js"></script>
165
- <script src="/static/app.js"></script>
166
  </body>
167
  </html>
 
10
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
11
  <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
12
  <script>fetch("/api/ping", { cache: "no-store" }).catch(() => {});</script>
13
+ <link rel="stylesheet" href="/static/style.css?v=compact-2">
14
  </head>
15
  <body class="dark">
16
 
 
51
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
52
  </div>
53
  <div class="status-right">
 
54
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
55
  <div class="sort-group">
56
  <span class="sort-label">排序:</span>
 
147
  </footer>
148
  <div id="overlay" class="overlay" style="display:none"></div>
149
  <div id="toast" class="toast" role="status" aria-live="polite" style="display:none"></div>
150
+ <section id="speech-panel" class="speech-panel" aria-label="朗读设置" hidden>
151
+ <div class="speech-heading"><span id="speech-title">朗读</span><span class="speech-voice">女声01</span><button type="button" id="speech-collapse" class="icon-btn-sm" aria-label="收起朗读设置(继续播放)" title="收起(继续播放)">⌄</button><button type="button" id="speech-stop" class="icon-btn-sm" aria-label="停止朗读" title="停止朗读">×</button></div>
152
+ <p id="speech-status" class="speech-note" role="status">从当前阅读位置开始</p>
153
+ <label class="speech-setting" for="speech-pitch"><span>音高</span><input id="speech-pitch" type="range" min="-3" max="3" step="1" value="0"><output id="speech-pitch-value">0</output></label>
154
+ <label class="speech-setting" for="speech-speed"><span>语速</span><input id="speech-speed" type="range" min="0.5" max="2" step="0.05" value="1"><output id="speech-speed-value">1.0×</output></label>
155
+ <label class="speech-setting" for="speech-timer"><span>睡眠定时</span><select id="speech-timer"><option value="off">关闭</option><option value="clip">读完当前句</option><option value="5">5 分钟</option><option value="15">15 分钟</option><option value="30">30 分钟</option><option value="60">60 分钟</option></select></label>
156
+ <div class="speech-transport"><button type="button" id="speech-previous" class="text-btn" aria-label="上一句">上一句</button><button type="button" id="speech-play" class="speech-play">暂停</button><button type="button" id="speech-next" class="text-btn" aria-label="下一句">下一句</button></div>
157
+ <div class="speech-links"><button type="button" id="speech-visible" class="text-btn">从当前阅读位置开始</button><button type="button" id="speech-locate" class="text-btn">回到播放位置</button></div>
158
+ </section>
 
 
159
 
160
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
161
+ <script src="/static/tts-client.js?v=compact-2"></script>
162
+ <script src="/static/app.js?v=compact-2"></script>
163
  </body>
164
  </html>
static/style.css CHANGED
@@ -48,15 +48,27 @@
48
  color-scheme: dark;
49
  }
50
 
51
- .preview-speech-tools { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 12px; flex-shrink: 0; border-bottom: 1px solid var(--reader-divider); }
52
- .speech-dialog { position: fixed; inset: auto 12px 12px auto; margin: 0; width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); padding: 10px; background: var(--surface-container); color: var(--on-surface); border: 1px solid var(--outline); border-radius: 12px; box-shadow: var(--shadow-lg); z-index: 180; overflow: auto; user-select: text; }
53
- .speech-dialog:not([open]) { display: none; }
54
- .speech-dialog::backdrop { background: rgba(0, 0, 0, .45); }
55
- .speech-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
56
- .speech-heading strong { margin-right: auto; }
57
- .speech-note { font-size: 12px; color: var(--on-surface-variant); margin: 8px 0; overflow-wrap: anywhere; }
58
- #speech-frame-host iframe { display: block; width: 100%; height: min(62dvh, 660px); min-height: 220px; border: 0; background: var(--surface); }
59
- body.mobile .speech-dialog { inset: 12px; width: auto; margin: auto; max-width: 650px; }
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  body.light {
62
  --surface: #fdfbff;
 
48
  color-scheme: dark;
49
  }
50
 
51
+ .speech-panel { position: fixed; inset: auto 16px 16px auto; width: min(320px, calc(100vw - 24px)); padding: 12px; color: var(--on-surface); background: var(--surface-container); border: 1px solid var(--outline-variant); border-radius: 14px; box-shadow: var(--shadow-lg); z-index: 180; max-height: calc(100dvh - 24px); overflow: auto; }
52
+ .speech-panel[hidden] { display: none; }
53
+ .speech-heading { display: flex; align-items: center; gap: 4px; }
54
+ #speech-title { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
55
+ .speech-voice, .speech-note { color: var(--on-surface-variant); font-size: 11px; }
56
+ .speech-note { margin: 4px 0 10px; min-height: 16px; }
57
+ .speech-setting { display: flex; align-items: center; gap: 10px; margin: 8px 0; font-size: 12px; }
58
+ .speech-setting > span { min-width: 4em; }
59
+ .speech-setting input, .speech-setting select { flex: 1; min-width: 0; accent-color: var(--primary); }
60
+ .speech-setting output { min-width: 3em; text-align: right; font-variant-numeric: tabular-nums; }
61
+ .speech-setting select { padding: 4px 6px; color: var(--on-surface); background: var(--surface-variant); border: 1px solid var(--outline-variant); border-radius: 6px; }
62
+ .speech-transport, .speech-links { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 10px; }
63
+ .speech-play { color: var(--on-primary); background: var(--primary); border-radius: 20px; padding: 7px 24px; font-size: 12px; }
64
+ .speech-panel button:disabled { opacity: .4; cursor: default; }
65
+ .speech-links .text-btn { font-size: 11px; padding: 4px 2px; }
66
+ .preview-speech-btn svg { width: 18px; height: 18px; }
67
+ .preview-speech-btn.speech-active { color: var(--primary); }
68
+ body.mobile .preview-speech-btn { order: 4; }
69
+ body.mobile .speech-panel { right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); }
70
+ .preview-body::highlight(ccrd-speech) { background: var(--reader-mark); color: var(--reader-on-mark); text-decoration: underline; }
71
+ .preview-body .speech-current { text-decoration: underline; }
72
 
73
  body.light {
74
  --surface: #fdfbff;
static/tts-client.js CHANGED
@@ -1,191 +1,484 @@
1
  "use strict";
2
- // CCRD pilot: shared Hitokoto player in a persistent, isolated browsing context.
3
- // No model code, browser speechSynthesis, remote text URLs or automatic TTS POSTs.
4
- window.CCRDSpeech = (() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  const ORIGIN = "https://vomebook-hitokoto.hf.space";
6
- const PLAYER = ORIGIN + "/tts/?embed=ccrd-v1";
7
- const $ = (selector) => document.querySelector(selector);
8
- const dialog = $("#speech-dialog");
9
- const host = $("#speech-frame-host");
10
- const status = $("#speech-status");
11
- const launch = $("#speech-launch");
12
- const collapse = $("#speech-collapse");
13
- const stopButton = $("#speech-stop");
14
- const retry = $("#speech-retry");
15
- if (!dialog || !host || !launch) return null; // Older cached HTML stays usable.
16
- let frame = null, channel = "", connected = false, timeout, modalActive = false;
17
- let pending = null, currentPreview = null, selected = null, focusReturn = null;
18
 
19
- function notice(message) {
20
- status.textContent = message;
21
- if (typeof showToast === "function") showToast(message, 4000);
 
 
22
  }
23
- function token() {
24
- return Array.from(crypto.getRandomValues(new Uint8Array(24)), (value) => value.toString(16).padStart(2, "0")).join("");
 
 
 
25
  }
26
- function send(type, data = {}) {
27
- frame?.contentWindow?.postMessage({ type, channel, ...data }, ORIGIN);
28
  }
29
- function theme() { if (connected) send("ccrd-reader-theme", { dark: !document.body.classList.contains("light") }); }
30
- function modal() { return dialog.open && modalActive; }
31
- function owns(node) { return !!node && dialog.contains(node); }
32
- function outerControls() {
33
- return Array.from(dialog.querySelectorAll("button, a[href]")).filter(node => !node.disabled && !node.hidden && node.getClientRects().length);
34
  }
35
- function show() {
36
- if (!dialog.open) {
37
- focusReturn = document.activeElement instanceof HTMLElement ? document.activeElement : null;
38
- if (document.body.classList.contains("mobile")) dialog.showModal();
39
- else dialog.show();
40
- modalActive = document.body.classList.contains("mobile");
 
41
  }
42
- launch.setAttribute("aria-expanded", "true");
43
- collapse.focus({ preventScroll: true });
44
- if (!frame) createFrame();
45
- else if (connected) send("ccrd-reader-layout", { modal: modal() });
 
 
 
 
46
  }
47
  function hide() {
48
- if (dialog.open) dialog.close();
49
- modalActive = false;
50
- if (connected) send("ccrd-reader-layout", { modal: false });
51
- launch.setAttribute("aria-expanded", "false");
52
- const previous = focusReturn;
53
- if (previous?.isConnected && !previous.closest("[inert]")) previous.focus({ preventScroll: true });
54
- else if (!launch.closest("[inert]")) launch.focus({ preventScroll: true });
55
- }
56
- function createFrame() {
57
- clearTimeout(timeout);
58
- connected = false;
59
- channel = token();
60
- frame = document.createElement("iframe");
61
- frame.id = "speech-frame";
62
- frame.title = "Hitokoto CPU 朗读播放器";
63
- frame.allow = "autoplay";
64
- frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
65
- frame.referrerPolicy = "no-referrer";
66
- frame.addEventListener("load", () => {
67
- connected = false;
68
- send("ccrd-reader-init", { version: 1, modal: modal() });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  });
70
- frame.src = PLAYER; // Text/title/capabilities never go in the URL.
71
- host.replaceChildren(frame);
72
- status.textContent = "正在连接独立语音播放器,搜索和预览仍可使用…";
73
- retry.hidden = true;
74
- timeout = setTimeout(() => {
75
- if (!connected) {
76
- status.textContent = "播放器连接失败或仍在加载。可重连;不会影响搜索和原文预览。";
77
- retry.hidden = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
- }, 20000);
 
80
  }
81
- function flush() {
82
- if (!connected || !pending) return;
83
- const payload = pending;
84
- pending = null;
85
- send("ccrd-reader-import", payload);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  }
87
- function previewLoaded(value) {
88
- currentPreview = value;
89
- selected = null;
90
- }
91
- function previewClosed() { currentPreview = null; selected = null; }
92
- function selection() {
93
- const body = $("#preview-panel .preview-body");
94
- const value = window.getSelection();
95
- if (!currentPreview || !body || !value || value.rangeCount !== 1) { selected = null; return; }
96
- if (value.isCollapsed) {
97
- if (!document.activeElement?.matches("[data-speech='selection']")) selected = null;
98
- return;
99
- }
100
- const range = value.getRangeAt(0);
101
- if (!body.contains(range.startContainer) || !body.contains(range.endContainer)) { selected = null; return; }
102
- const prefix = range.cloneRange();
103
- prefix.selectNodeContents(body);
104
- prefix.setEnd(range.startContainer, range.startOffset);
105
- const start = prefix.toString().length;
106
- const text = range.toString();
107
- if (currentPreview.text.slice(start, start + text.length) === text) selected = { id: currentPreview.id, start, text };
108
- }
109
- function read(mode) {
110
- selection();
111
- if (!currentPreview) { notice("预览仍在加载,请等待正文出现后重试"); return; }
112
- let text = currentPreview.text, before = "", after = "";
113
- if (mode === "selection") {
114
- if (!selected || selected.id !== currentPreview.id || !selected.text.trim()) {
115
- notice("请先在当前预览正文内选中要朗读的文字"); return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  }
117
- text = selected.text;
118
- before = Array.from(currentPreview.text.slice(Math.max(0, selected.start - 128), selected.start)).slice(-32).join("");
119
- after = Array.from(currentPreview.text.slice(selected.start + text.length, selected.start + text.length + 128)).slice(0, 32).join("");
120
- }
121
- let count = 0;
122
- for (const _ of text) { if (++count > 20000) break; }
123
- if (count > 20000) { notice("单次朗读最多两万字,请选中较短的一部分;不会截掉正文"); return; }
124
- if (!text.trim()) { notice("当前预览没有可朗读正文"); return; }
125
- pending = { text, title: String(currentPreview.title || "当前预览").slice(0, 200),
126
- context_before: before, context_after: after };
127
- show();
128
- flush();
129
- }
130
- document.addEventListener("selectionchange", selection);
131
- window.addEventListener("message", (event) => {
132
- const data = event.data;
133
- if (event.origin !== ORIGIN || event.source !== frame?.contentWindow || !data || data.channel !== channel) return;
134
- if (data.type === "ccrd-reader-ready" && data.version === 1) {
135
- clearTimeout(timeout);
136
- connected = true;
137
- retry.hidden = true;
138
- theme();
139
- status.textContent = "播放器已连接。载入正文后,确认文字并点击合成才向语音 API 提交。";
140
- flush();
141
- } else if (data.type === "ccrd-reader-loaded") {
142
- status.textContent = `已载入 ${data.characters} 字。请在播放器确认文字、音色和音高后点击合成。`;
143
- } else if (data.type === "ccrd-reader-rejected") {
144
- status.textContent = "未替换正在朗读的文字;原朗读保留。";
145
- } else if (data.type === "ccrd-reader-pending") {
146
- status.textContent = "播放器正在朗读,请在播放器中选择替换文字或保留原朗读。";
147
- } else if (data.type === "ccrd-reader-stopped") {
148
- status.textContent = "朗读已停止,可收起或重新载入文字。";
149
- } else if (data.type === "ccrd-reader-collapse") {
150
- hide();
151
- } else if (data.type === "ccrd-reader-focus-out" && modal() && typeof data.reverse === "boolean") {
152
- const buttons = outerControls();
153
- (data.reverse ? buttons[buttons.length - 1] : buttons[0])?.focus();
154
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  });
156
- launch.addEventListener("click", show);
 
 
 
 
157
  collapse.addEventListener("click", hide);
158
- stopButton.addEventListener("click", () => {
159
- if (connected) send("ccrd-reader-stop");
160
- else { clearTimeout(timeout); frame?.remove(); frame = null; connected = false; pending = null; }
161
- hide();
162
- });
163
- retry.addEventListener("click", () => {
164
- if (!confirm("重新连接会停止此播放器中尚未完成的朗读,是否继续?")) return;
165
- frame?.remove();
166
- frame = null;
167
- createFrame();
168
  });
169
- dialog.addEventListener("cancel", (event) => { event.preventDefault(); hide(); });
170
- dialog.addEventListener("keydown", (event) => {
171
- if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); hide(); }
172
- if (event.key === "Tab" && modal() && connected) {
173
- const buttons = outerControls();
174
- if (event.shiftKey && event.target === buttons[0]) {
175
- event.preventDefault(); send("ccrd-reader-focus", { last: true });
176
- } else if (!event.shiftKey && event.target === buttons[buttons.length - 1]) {
177
- event.preventDefault(); send("ccrd-reader-focus", { last: false });
178
- }
179
  }
180
- });
181
- new MutationObserver(() => {
182
- theme();
183
- if (dialog.open) {
184
- const targetModal = document.body.classList.contains("mobile");
185
- if (modal() !== targetModal) { dialog.close(); targetModal ? dialog.showModal() : dialog.show(); modalActive = targetModal; }
186
- if (connected) send("ccrd-reader-layout", { modal: modal() });
187
- }
188
- }).observe(document.body, { attributes: true, attributeFilter: ["class"] });
189
- window.addEventListener("pagehide", () => { if (connected) send("ccrd-reader-stop"); });
190
- return { read, previewLoaded, previewClosed, modal, owns };
191
  })();
 
1
  "use strict";
2
+ // Pure, bounded rolling source planner. All offsets refer to original UTF-16.
3
+ globalThis.CCRDSpeechPlan = (() => {
4
+ const ending = /[。!?!?;;\n]/u;
5
+ const closers = /[”’"'」』))\t \r]/u;
6
+ const ascii = /[A-Za-z0-9]/u;
7
+ function safe(text, offset) {
8
+ offset = Math.max(0, Math.min(text.length, Math.floor(offset)));
9
+ return offset && /[\uDC00-\uDFFF]/u.test(text[offset] || "") && /[\uD800-\uDBFF]/u.test(text[offset - 1]) ? offset - 1 : offset;
10
+ }
11
+ function boundary(text, index) {
12
+ return ending.test(text[index]) || (text[index] === "." && !ascii.test(text[index + 1] || ""));
13
+ }
14
+ function sentence(text, offset) {
15
+ offset = safe(text, offset);
16
+ // Navigation examines nearby source, not a document-sized sentence/DOM list.
17
+ let start = offset;
18
+ while (start > 0 && offset - start < 4096 && !boundary(text, start - 1)) start -= 1;
19
+ while (start < offset && /[\s”’"'」』))]/u.test(text[start])) start += 1;
20
+ let end = Math.max(start, offset);
21
+ while (end < text.length && end - offset < 4096) {
22
+ const finish = boundary(text, end++);
23
+ if (finish) break;
24
+ }
25
+ while (end < text.length && closers.test(text[end])) end += 1;
26
+ return { start: safe(text, start), end: safe(text, end) };
27
+ }
28
+ function batch(text, offset, first = false) {
29
+ const start = safe(text, offset), limit = first ? 80 : 480;
30
+ if (start >= text.length) return null;
31
+ const chars = Array.from(text.slice(start, start + 1800)), offsets = [start];
32
+ for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length);
33
+ let end = Math.min(limit, chars.length);
34
+ if (offsets[end] < text.length) {
35
+ for (let index = end - 1; index >= Math.floor(limit / 2); index -= 1) {
36
+ if (boundary(text, offsets[index])) { end = index + 1; break; }
37
+ }
38
+ // Dates, versions, identifiers and words remain whole across API batches.
39
+ const atoms = /(?:[0-9]{4}年[0-9]{1,2}月[0-9]{1,2}[日号]|[0-9]{4}年|[A-Za-z0-9]+(?:[._/\-][A-Za-z0-9]+)*)/gu;
40
+ for (const match of text.slice(start, offsets[Math.min(chars.length, 800)]).matchAll(atoms)) {
41
+ const begin = start + match.index, stop = begin + match[0].length;
42
+ if (begin < offsets[end] && offsets[end] < stop) {
43
+ const target = begin > start ? begin : stop;
44
+ const found = offsets.indexOf(target);
45
+ if (found > 0 && found <= 800) end = found;
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ const stop = offsets[end];
51
+ return { start, end: stop, text: text.slice(start, stop),
52
+ context_before: Array.from(text.slice(Math.max(0, start - 64), start)).slice(-32).join(""),
53
+ context_after: Array.from(text.slice(stop, stop + 64)).slice(0, 32).join("") };
54
+ }
55
+ return { safe, sentence, batch };
56
+ })();
57
+
58
+ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
59
  const ORIGIN = "https://vomebook-hitokoto.hf.space";
60
+ const plan = CCRDSpeechPlan;
61
+ const $ = selector => document.querySelector(selector);
62
+ const panel = $("#speech-panel");
63
+ if (!panel) return null; // Mixed cached shells must still search normally.
64
+ const status = $("#speech-status"), play = $("#speech-play");
65
+ const previous = $("#speech-previous"), next = $("#speech-next");
66
+ const pitch = $("#speech-pitch"), speed = $("#speech-speed"), timer = $("#speech-timer");
67
+ const visible = $("#speech-visible"), locate = $("#speech-locate");
68
+ const collapse = $("#speech-collapse"), stopButton = $("#speech-stop");
69
+ let preview = null, owner = null, saved = null, audio = null, preloader = null, switching = false;
70
+ let sleepDeadline = 0, finishClip = false, sleepTimer;
71
+ let marked = null;
72
 
73
+ function notice(text) { status.textContent = text; }
74
+ function live(value) { return owner === value && !value.cancelled; }
75
+ function nonce() { return Array.from(crypto.getRandomValues(new Uint8Array(24)), value => value.toString(16).padStart(2, "0")).join(""); }
76
+ function release(id, keepalive = false) {
77
+ if (id) fetch(`${ORIGIN}/tts/jobs/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "omit", keepalive }).catch(() => {});
78
  }
79
+ function clearPreload() { if (preloader?.hasAttribute("src")) { preloader.removeAttribute("src"); preloader.load(); } }
80
+ function discard(value, job) {
81
+ job.cancelled = true;
82
+ value.jobs.delete(job.batch.start);
83
+ release(job.id);
84
  }
85
+ function clearSleep() {
86
+ clearTimeout(sleepTimer); sleepDeadline = 0; finishClip = false; timer.value = "off";
87
  }
88
+ function checkSleep() {
89
+ if (sleepDeadline && Date.now() >= sleepDeadline) { stop("睡眠定时已结束,可继续朗读"); return true; }
90
+ return false;
 
 
91
  }
92
+ function armSleep() {
93
+ clearTimeout(sleepTimer);
94
+ sleepDeadline = timer.value !== "off" && timer.value !== "clip" ? Date.now() + Number(timer.value) * 60000 : 0;
95
+ finishClip = timer.value === "clip";
96
+ if (finishClip && owner) {
97
+ clearPreload();
98
+ for (const job of Array.from(owner.jobs.values())) if (job.batch.start !== owner.batch.start && job.state !== "ready") discard(owner, job);
99
  }
100
+ function tickSleep() { if (!checkSleep() && sleepDeadline) sleepTimer = setTimeout(tickSleep, Math.min(1000, sleepDeadline - Date.now())); }
101
+ tickSleep();
102
+ }
103
+ function show() {
104
+ const parent = preview?.body?.isConnected ? preview.body.parentElement : document.body;
105
+ if (panel.parentElement !== parent) parent.append(panel);
106
+ panel.hidden = false;
107
+ $("[data-speech-toggle]")?.setAttribute("aria-expanded", "true");
108
  }
109
  function hide() {
110
+ panel.hidden = true;
111
+ const button = $("[data-speech-toggle]");
112
+ button?.setAttribute("aria-expanded", "false");
113
+ if (panel.contains(document.activeElement) && button?.isConnected) button.focus({ preventScroll: true });
114
+ }
115
+ function controls() {
116
+ play.textContent = owner?.wantsPlay ? "暂停" : owner?.blocked ? "重试" : "继续";
117
+ play.setAttribute("aria-label", play.textContent + "朗读");
118
+ play.disabled = !owner && !saved;
119
+ previous.disabled = !owner || owner.position <= 0;
120
+ next.disabled = !owner || owner.position >= owner.snapshot.text.length;
121
+ visible.disabled = !preview;
122
+ locate.disabled = !owner && !saved;
123
+ $("#speech-pitch-value").textContent = (Number(pitch.value) > 0 ? "+" : "") + pitch.value;
124
+ $("#speech-speed-value").textContent = Number(speed.value).toFixed(2).replace(/0$/, "") + "×";
125
+ const button = $("[data-speech-toggle]");
126
+ if (button) {
127
+ button.setAttribute("aria-expanded", String(!panel.hidden));
128
+ button.classList.toggle("speech-active", !!owner && owner.snapshot.id === preview?.id);
129
+ }
130
+ }
131
+ function sourceRange(start, end = start + 1) {
132
+ if (!preview || preview.body.textContent !== preview.text) return null;
133
+ const range = document.createRange(), walker = document.createTreeWalker(preview.body, NodeFilter.SHOW_TEXT);
134
+ let node, offset = 0, begun = false;
135
+ while ((node = walker.nextNode())) {
136
+ if (!begun && start < offset + node.length) { range.setStart(node, start - offset); begun = true; }
137
+ if (begun && end <= offset + node.length) { range.setEnd(node, end - offset); return range; }
138
+ offset += node.length;
139
+ }
140
+ return null;
141
+ }
142
+ function clearMark() {
143
+ if (globalThis.CSS?.highlights) CSS.highlights.delete("ccrd-speech");
144
+ for (const node of marked || []) node.replaceWith(...node.childNodes);
145
+ marked = null;
146
+ }
147
+ function mark() {
148
+ clearMark();
149
+ if (!owner?.current || preview?.id !== owner.snapshot.id) return;
150
+ const start = owner.current.job.batch.start + owner.current.part.start;
151
+ const end = owner.current.job.batch.start + owner.current.part.end;
152
+ const range = sourceRange(start, end);
153
+ if (!range) return;
154
+ if (globalThis.CSS?.highlights && globalThis.Highlight) CSS.highlights.set("ccrd-speech", new Highlight(range));
155
+ else {
156
+ const node = document.createElement("mark"); node.className = "speech-current";
157
+ node.append(range.extractContents()); range.insertNode(node); marked = [node];
158
+ }
159
+ }
160
+ function offsetAtPoint(x, y) {
161
+ const point = document.caretPositionFromPoint?.(x, y);
162
+ const range = !point && document.caretRangeFromPoint?.(x, y);
163
+ const node = point?.offsetNode || range?.startContainer, offset = point?.offset ?? range?.startOffset;
164
+ if (!node || !preview?.body.contains(node)) return null;
165
+ const prefix = document.createRange(); prefix.selectNodeContents(preview.body); prefix.setEnd(node, offset);
166
+ return plan.safe(preview.text, prefix.toString().length);
167
+ }
168
+ function visibleOffset() {
169
+ if (!preview) return 0;
170
+ const body = preview.body, box = body.getBoundingClientRect(), style = getComputedStyle(body);
171
+ const top = Math.max(box.top, 0, body.parentElement.querySelector(".preview-header")?.getBoundingClientRect().bottom || 0);
172
+ const bottom = Math.min(box.bottom, innerHeight);
173
+ if (bottom <= top) return 0;
174
+ const x = Math.max(1, box.left + parseFloat(style.paddingLeft) + 1);
175
+ // At the top line's leading edge, not at the pointer or the document start.
176
+ for (let y = top + 2; y < Math.min(bottom, top + parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + 8); y += 3) {
177
+ const offset = offsetAtPoint(x, y);
178
+ if (offset !== null) {
179
+ const range = sourceRange(offset, Math.min(preview.text.length, offset + (preview.text.codePointAt(offset) > 65535 ? 2 : 1)));
180
+ const rect = range?.getBoundingClientRect();
181
+ if (rect?.height && rect.bottom > top && rect.top < bottom) return offset;
182
+ }
183
+ }
184
+ return 0;
185
+ }
186
+ async function locateSource() {
187
+ const value = owner || saved;
188
+ if (!value) return;
189
+ const position = value.position, snapshot = value.snapshot;
190
+ if (preview?.id !== snapshot.id) {
191
+ if (typeof openPreview !== "function") return;
192
+ await openPreview(snapshot.id, "", { scroll: false });
193
+ }
194
+ if (preview?.id !== snapshot.id) return;
195
+ const range = sourceRange(Math.min(position, preview.text.length - 1));
196
+ const rect = range?.getBoundingClientRect();
197
+ if (rect) {
198
+ const box = preview.body.getBoundingClientRect();
199
+ preview.body.scrollTop += rect.top - box.top - Math.min(80, box.height / 3);
200
+ if (box.bottom < 0 || box.top > innerHeight) preview.body.scrollIntoView({ block: "nearest" });
201
+ mark();
202
+ }
203
+ }
204
+ function pause() {
205
+ if (!owner) return;
206
+ owner.wantsPlay = false;
207
+ clearPreload();
208
+ for (const job of Array.from(owner.jobs.values())) if (job.batch.start !== owner.batch.start && job.state !== "ready") discard(owner, job);
209
+ audio.pause(); notice("已暂停"); controls();
210
+ }
211
+ function stop(message = "已停止,可继续朗读", keepalive = false) {
212
+ const value = owner;
213
+ if (value) {
214
+ saved = { snapshot: value.snapshot, position: value.position };
215
+ value.cancelled = true; clearTimeout(value.timer);
216
+ for (const controller of value.controllers) if (!controller.createsJob) controller.abort();
217
+ for (const job of value.jobs.values()) release(job.id, keepalive);
218
+ }
219
+ owner = null;
220
+ clearSleep(); clearPreload(); switching = true;
221
+ if (audio) { audio.pause(); audio.removeAttribute("src"); audio.load(); }
222
+ if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "none";
223
+ notice(message); controls();
224
+ }
225
+ function createAudio() {
226
+ if (audio) return;
227
+ audio = new Audio(); audio.id = "speech-audio"; audio.preload = "auto"; audio.hidden = true;
228
+ audio.crossOrigin = "anonymous"; document.body.append(audio);
229
+ preloader = new Audio(); preloader.preload = "auto"; preloader.crossOrigin = "anonymous";
230
+ audio.addEventListener("loadedmetadata", () => {
231
+ if (!owner?.current || checkSleep()) return;
232
+ switching = false; applyRate();
233
+ if (owner.wantsPlay) playAudio(owner);
234
+ });
235
+ audio.addEventListener("ended", () => {
236
+ const value = owner;
237
+ if (!value?.current || checkSleep()) return;
238
+ value.position = value.current.job.batch.start + value.current.part.end;
239
+ value.part += 1; value.current = null;
240
+ if (finishClip) { stop("本句已读完,睡眠定时结束"); return; }
241
+ advance(value); pump(value);
242
  });
243
+ audio.addEventListener("pause", () => {
244
+ if (owner && !switching && !audio.ended && owner.wantsPlay) pause();
245
+ });
246
+ audio.addEventListener("error", () => {
247
+ if (!owner?.current || !audio.hasAttribute("src")) return;
248
+ owner.blocked = true; pause(); notice("音频连接中断,点击继续重试,不跳过正文"); controls();
249
+ });
250
+ audio.addEventListener("timeupdate", checkSleep);
251
+ }
252
+ function applyRate() {
253
+ if (!audio) return;
254
+ audio.defaultPlaybackRate = audio.playbackRate = Number(speed.value);
255
+ if ("preservesPitch" in audio) audio.preservesPitch = true;
256
+ }
257
+ function playAudio(value) {
258
+ if (!live(value) || !value.wantsPlay || checkSleep()) return;
259
+ const current = value.current;
260
+ audio.play().catch(() => {
261
+ if (!live(value) || current !== value.current) return;
262
+ pause(); notice("音频已就绪,请点击继续播放");
263
+ });
264
+ if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
265
+ }
266
+ async function request(path, options, value) {
267
+ const controller = new AbortController(); controller.createsJob = options?.method === "POST";
268
+ value.controllers.add(controller);
269
+ const timeout = setTimeout(() => controller.abort(), 15000);
270
+ try {
271
+ const response = await fetch(ORIGIN + path, { ...options, signal: controller.signal, cache: "no-store", credentials: "omit" });
272
+ const data = await response.json();
273
+ if (!response.ok) {
274
+ const error = new Error(data.error?.message || `语音请求失败(${response.status})`);
275
+ error.status = response.status; error.retryAfter = Math.max(2, Math.min(30, Number(response.headers.get("Retry-After")) || 5));
276
+ throw error;
277
  }
278
+ return data;
279
+ } finally { clearTimeout(timeout); value.controllers.delete(controller); }
280
  }
281
+ function newJob(batch) { return { batch, id: "", nonce: nonce(), parts: [], state: "new", retryAt: 0, failures: 0, deadline: Date.now() + 600000 }; }
282
+ function accept(job, data, value) {
283
+ if (!/^[A-Za-z0-9_-]{32}$/u.test(data.id) || (job.id && job.id !== data.id)) throw new Error("语音任务标识无效");
284
+ job.id = data.id;
285
+ if (data.state === "error") throw new Error(data.error?.message || "语音合成失败,正文未跳过");
286
+ if (["expired", "cancelled"].includes(data.state)) { const error = new Error("语音任务已过期"); error.status = 410; throw error; }
287
+ const parts = data.progress?.parts || [];
288
+ if (!Array.isArray(parts) || parts.length > 64 || (data.result?.pitch_semitones ?? value.pitch) !== value.pitch) throw new Error("语音分段信息无效");
289
+ if (data.progress && (!Number.isInteger(data.progress.total_parts) || data.progress.total_parts < 1 ||
290
+ data.progress.total_parts > 64 || parts.length > data.progress.total_parts)) throw new Error("语音分段总数无效");
291
+ let end = 0;
292
+ for (let index = 0; index < parts.length; index += 1) {
293
+ const part = parts[index];
294
+ if (part.index !== index || part.start !== end || !Number.isInteger(part.end) || part.end <= end || part.end > job.batch.text.length ||
295
+ (part.pitch_semitones ?? 0) !== value.pitch || part.audio_url !== `/tts/audio/${job.id}/parts/${index}.mp3`) throw new Error("语音分段未连续保留原文");
296
+ end = part.end;
297
+ if (job.parts[index] && (job.parts[index].start !== part.start || job.parts[index].end !== part.end)) throw new Error("已发布语音的原文范围发生变化");
298
+ }
299
+ if (data.state === "ready" && (end !== job.batch.text.length || parts.length !== data.progress?.total_parts)) throw new Error("语音未完整覆盖当前正文");
300
+ job.parts = parts; job.state = data.state;
301
  }
302
+ async function update(value, job) {
303
+ if (!live(value) || job.cancelled || !value.wantsPlay || job.state === "ready" || job.blocked || Date.now() < job.retryAt || checkSleep()) return;
304
+ try {
305
+ if (Date.now() > job.deadline) throw new Error("语音排队超时,点击继续重试");
306
+ const data = job.id ? await request(`/tts/jobs/${job.id}`, {}, value) : await request("/tts/jobs", { method: "POST", headers: { "Content-Type": "application/json" },
307
+ body: JSON.stringify({ text: job.batch.text, voice: "zf_001", speed: 1, pitch_semitones: value.pitch,
308
+ unsupported_policy: "pause", progressive: true, context_before: job.batch.context_before,
309
+ context_after: job.batch.context_after, request_id: job.nonce }) }, value);
310
+ if (!live(value) || job.cancelled || value.jobs.get(job.batch.start) !== job) { release(data.id); return; }
311
+ accept(job, data, value); job.failures = 0; job.retryAt = Date.now() + (job.parts.length ? 650 : 250);
312
+ } catch (error) {
313
+ if (!live(value) || job.cancelled) return;
314
+ job.failures += 1;
315
+ const transient = [404, 410, 429, 503].includes(error.status) || error.name === "AbortError" || error instanceof TypeError;
316
+ job.retryAt = Date.now() + 1000 * (error.retryAfter || Math.min(20, 2 ** job.failures));
317
+ job.message = error.message;
318
+ if ([404, 410].includes(error.status)) {
319
+ release(job.id); job.id = ""; job.nonce = nonce(); job.state = "new";
320
+ if (job === value.jobs.get(value.batch.start)) { job.blocked = true; }
321
+ }
322
+ job.blocked = job.blocked || !transient || job.failures >= 3;
323
+ if (job.batch.start === value.batch.start) {
324
+ if (job.blocked) { value.blocked = true; pause(); }
325
+ notice(error.message + (job.blocked ? " · 点击继续重试" : " · 稍后重试,进度保留"));
326
+ }
327
+ controls();
328
+ }
329
+ }
330
+ function advance(value) {
331
+ if (!live(value) || !value.wantsPlay || value.blocked || checkSleep()) return;
332
+ let job = value.jobs.get(value.batch.start);
333
+ if (!job) return;
334
+ if (job.blocked) { value.blocked = true; pause(); notice(job.message || "下一段暂未就绪,点击继续重试"); return; }
335
+ if (!value.current) {
336
+ const part = job.parts[value.part];
337
+ if (part) {
338
+ value.current = { job, part }; value.position = job.batch.start + part.start;
339
+ switching = true; audio.src = ORIGIN + part.audio_url; audio.load(); applyRate();
340
+ notice("朗读中"); mark(); controls();
341
+ } else if (job.state === "ready") {
342
+ value.position = job.batch.end;
343
+ discard(value, job);
344
+ if (value.position >= value.snapshot.text.length) { stop("已读完全文"); saved = null; controls(); return; }
345
+ value.batch = plan.batch(value.snapshot.text, value.position);
346
+ value.part = 0;
347
+ if (!value.jobs.has(value.batch.start)) value.jobs.set(value.batch.start, newJob(value.batch));
348
+ advance(value); return;
349
+ }
350
+ }
351
+ // One following API batch and one following immutable MP3 only. No work on
352
+ // pause, no document-wide plan/DOM/audio cache and no whole-book POST.
353
+ if (!finishClip && value.current && job.state === "ready" && job.batch.end < value.snapshot.text.length && !value.jobs.has(job.batch.end)) {
354
+ const batch = plan.batch(value.snapshot.text, job.batch.end);
355
+ value.jobs.set(batch.start, newJob(batch));
356
+ }
357
+ const following = !finishClip && (job.parts[value.part + 1] || value.jobs.get(job.batch.end)?.parts[0]);
358
+ if (following && preloader.getAttribute("src") !== ORIGIN + following.audio_url) preloader.src = ORIGIN + following.audio_url;
359
+ }
360
+ async function pump(value) {
361
+ if (!live(value) || value.ticking || checkSleep() || !value.wantsPlay) return;
362
+ clearTimeout(value.timer); value.ticking = true;
363
+ try {
364
+ const current = value.jobs.get(value.batch.start);
365
+ if (current) await update(value, current);
366
+ advance(value); // Never wait for speculative work before playing a part.
367
+ if (!live(value) || !value.wantsPlay) return;
368
+ for (const job of Array.from(value.jobs.values())) if (job.batch.start !== current?.batch.start) {
369
+ await update(value, job); advance(value);
370
  }
371
+ } finally {
372
+ value.ticking = false;
373
+ if (live(value) && value.wantsPlay) value.timer = setTimeout(() => pump(value), value.current ? 600 : 250);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  }
375
+ }
376
+ function start(snapshot, offset, playing = true) {
377
+ createAudio();
378
+ const mode = timer.value, deadline = sleepDeadline;
379
+ stop("正在准备首句…");
380
+ const batch = plan.batch(snapshot.text, offset, true);
381
+ if (!batch) { notice("已到正文末尾"); return; }
382
+ const value = { snapshot, batch, position: batch.start, part: 0, current: null, pitch: Number(pitch.value), wantsPlay: playing,
383
+ jobs: new Map(), controllers: new Set(), cancelled: false, blocked: false };
384
+ value.jobs.set(batch.start, newJob(batch)); owner = value; saved = null;
385
+ timer.value = mode; armSleep();
386
+ if (deadline) { sleepDeadline = deadline; if (checkSleep()) return; }
387
+ $("#speech-title").textContent = snapshot.title || "朗读";
388
+ show(); notice(playing ? "正在准备首句…" : "已选择句子,点击继续播放"); controls();
389
+ if ("mediaSession" in navigator && "MediaMetadata" in globalThis) navigator.mediaSession.metadata = new MediaMetadata({ title: snapshot.title, artist: "女声01" });
390
+ pump(value); // First POST directly; no iframe, voices or health round-trips.
391
+ }
392
+ function fromPreview(offset = visibleOffset()) {
393
+ if (!preview || !preview.text.trim()) { if (typeof showToast === "function") showToast("请等待预览正文加载完成"); return; }
394
+ if (owner && owner.snapshot.id !== preview.id && !confirm("切换到当前预览会停止原朗读,继续吗?")) return;
395
+ start({ id: preview.id, text: preview.text, title: preview.title }, offset);
396
+ }
397
+ function resume() {
398
+ if (checkSleep()) return;
399
+ if (!owner) { if (saved) start(saved.snapshot, saved.position); return; }
400
+ if (owner.blocked || owner.settingsChanged) { start(owner.snapshot, owner.position); return; }
401
+ owner.wantsPlay = true; notice("正在继续…"); controls();
402
+ if (owner.current) playAudio(owner);
403
+ advance(owner); pump(owner);
404
+ }
405
+ function launch() {
406
+ if (!owner || owner.snapshot.id !== preview?.id) {
407
+ if (!owner && saved?.snapshot.id === preview?.id) start(saved.snapshot, saved.position);
408
+ else fromPreview();
409
+ } else { show(); if (!owner.wantsPlay) resume(); }
410
+ }
411
+ function navigate(direction) {
412
+ if (!owner) return;
413
+ const sentence = plan.sentence(owner.snapshot.text, owner.position);
414
+ const target = direction > 0 ? sentence.end : plan.sentence(owner.snapshot.text, Math.max(0, sentence.start - 1)).start;
415
+ if (target < owner.snapshot.text.length) start(owner.snapshot, target, owner.wantsPlay);
416
+ }
417
+ function previewLoaded(value) {
418
+ clearMark();
419
+ preview = { ...value, body: $("#preview-panel .preview-body") };
420
+ if (!preview.body) { preview = null; return; }
421
+ // Older cached app.js may still produce the former three-button toolbar.
422
+ $(".preview-speech-tools")?.remove();
423
+ const tools = $("#preview-panel .preview-tools");
424
+ if (tools && !tools.querySelector("[data-speech-toggle]")) {
425
+ const button = document.createElement("button"); button.type = "button"; button.className = "icon-btn-sm preview-speech-btn";
426
+ button.dataset.speechToggle = ""; button.setAttribute("aria-label", "朗读与设置"); button.setAttribute("aria-controls", "speech-panel");
427
+ button.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
428
+ tools.prepend(button);
429
+ }
430
+ arrangeToggle();
431
+ if (!panel.hidden) show();
432
+ mark(); controls();
433
+ // Establish the TLS connection while reading the preview, not with an
434
+ // expensive fixed-query/model warmup or speculative synthesis.
435
+ if (!$("link[data-speech-connect]")) {
436
+ const link = document.createElement("link"); link.rel = "preconnect"; link.href = ORIGIN; link.crossOrigin = "anonymous";
437
+ link.dataset.speechConnect = ""; document.head.append(link);
438
+ }
439
+ }
440
+ function previewClosed() { clearMark(); preview = null; if (panel.parentElement !== document.body) document.body.append(panel); controls(); }
441
+ function arrangeToggle() {
442
+ const button = $("[data-speech-toggle]"), tools = button?.parentElement;
443
+ if (!tools) return;
444
+ if (document.body.classList.contains("mobile")) tools.append(button);
445
+ else tools.prepend(button);
446
+ }
447
+ new MutationObserver(arrangeToggle).observe(document.body, { attributes: true, attributeFilter: ["class"] });
448
+ document.addEventListener("click", event => { if (event.target.closest("[data-speech-toggle]")) launch(); });
449
+ document.addEventListener("dblclick", event => {
450
+ if (!preview?.body.contains(event.target)) return;
451
+ const offset = offsetAtPoint(event.clientX, event.clientY);
452
+ if (offset === null) return;
453
+ const sentence = plan.sentence(preview.text, offset);
454
+ const range = sourceRange(sentence.start, sentence.end);
455
+ if (range) { const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); }
456
+ fromPreview(sentence.start);
457
  });
458
+ play.addEventListener("click", () => { if (owner?.wantsPlay) pause(); else resume(); });
459
+ previous.addEventListener("click", () => navigate(-1));
460
+ next.addEventListener("click", () => navigate(1));
461
+ visible.addEventListener("click", () => fromPreview());
462
+ locate.addEventListener("click", locateSource);
463
  collapse.addEventListener("click", hide);
464
+ stopButton.addEventListener("click", () => { stop(); clearMark(); hide(); });
465
+ pitch.addEventListener("input", controls);
466
+ pitch.addEventListener("change", () => {
467
+ if (!owner) return;
468
+ if (owner.wantsPlay) start(owner.snapshot, owner.position);
469
+ else { owner.settingsChanged = true; notice("音高已调整,点击继续后从当前句重新生成"); }
 
 
 
 
470
  });
471
+ speed.addEventListener("input", () => { applyRate(); controls(); });
472
+ timer.addEventListener("change", armSleep);
473
+ panel.addEventListener("keydown", event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); hide(); } });
474
+ window.addEventListener("pagehide", () => stop("已停止", true));
475
+ document.addEventListener("visibilitychange", checkSleep);
476
+ window.addEventListener("online", () => { if (owner?.wantsPlay) pump(owner); });
477
+ if ("mediaSession" in navigator) {
478
+ for (const [action, handler] of Object.entries({ play: resume, pause, stop: () => stop(), previoustrack: () => navigate(-1), nexttrack: () => navigate(1) })) {
479
+ try { navigator.mediaSession.setActionHandler(action, handler); } catch (_) { /* Optional. */ }
 
480
  }
481
+ }
482
+ controls();
483
+ return { launch, previewLoaded, previewClosed, owns: node => !!node && panel.contains(node), modal: () => false };
 
 
 
 
 
 
 
 
484
  })();
tests/test_browser.py CHANGED
@@ -808,11 +808,10 @@ class BrowserInteractionTests(unittest.TestCase):
808
  count = page.evaluate("previewFocusableElements().length")
809
  # Reader entry points are intentionally keyboard-accessible after
810
  # the original header controls. Walk the complete visible cycle.
811
- for _ in range(count - 1):
812
  previous = page.evaluate("previewFocusableElements().indexOf(document.activeElement)")
813
  page.keyboard.press("Tab")
814
  self.assertTrue(page.evaluate("index => document.activeElement === previewFocusableElements()[(index + 1) % previewFocusableElements().length]", previous))
815
- self.assertTrue(page.evaluate("document.activeElement === previewFocusableElements()[0]"))
816
 
817
  button.click()
818
  self.assertEqual(button.get_attribute("aria-pressed"), "false")
 
808
  count = page.evaluate("previewFocusableElements().length")
809
  # Reader entry points are intentionally keyboard-accessible after
810
  # the original header controls. Walk the complete visible cycle.
811
+ for _ in range(count):
812
  previous = page.evaluate("previewFocusableElements().indexOf(document.activeElement)")
813
  page.keyboard.press("Tab")
814
  self.assertTrue(page.evaluate("index => document.activeElement === previewFocusableElements()[(index + 1) % previewFocusableElements().length]", previous))
 
815
 
816
  button.click()
817
  self.assertEqual(button.get_attribute("aria-pressed"), "false")
tests/test_search.py CHANGED
@@ -979,7 +979,8 @@ class FrontendContractTests(unittest.TestCase):
979
  "folder-toggle-all",
980
  "source-filter-cancel", "folder-filter-cancel",
981
  "filter-min-size", "filter-min-unit", "filter-max-size", "filter-max-unit", "random-book-btn",
982
- "speech-launch", "speech-collapse", "speech-stop", "speech-retry",
 
983
  }
984
  self.assertEqual(parser.ids, expected)
985
  selector_exceptions = {"header-logo", "search-input"}
@@ -1004,7 +1005,9 @@ class FrontendContractTests(unittest.TestCase):
1004
  "filter-min-size": "DOM.filterMinSize", "filter-min-unit": "DOM.filterMinUnit",
1005
  "filter-max-size": "DOM.filterMaxSize", "filter-max-unit": "DOM.filterMaxUnit",
1006
  "random-book-btn": "DOM.randomBookBtn",
1007
- "speech-launch": "launch", "speech-collapse": "collapse", "speech-stop": "stopButton", "speech-retry": "retry",
 
 
1008
  }
1009
  for control_id, object_name in event_objects.items():
1010
  self.assertIn(f'{object_name}.addEventListener(', source, control_id)
 
979
  "folder-toggle-all",
980
  "source-filter-cancel", "folder-filter-cancel",
981
  "filter-min-size", "filter-min-unit", "filter-max-size", "filter-max-unit", "random-book-btn",
982
+ "speech-collapse", "speech-stop", "speech-play", "speech-previous", "speech-next",
983
+ "speech-pitch", "speech-speed", "speech-timer", "speech-visible", "speech-locate",
984
  }
985
  self.assertEqual(parser.ids, expected)
986
  selector_exceptions = {"header-logo", "search-input"}
 
1005
  "filter-min-size": "DOM.filterMinSize", "filter-min-unit": "DOM.filterMinUnit",
1006
  "filter-max-size": "DOM.filterMaxSize", "filter-max-unit": "DOM.filterMaxUnit",
1007
  "random-book-btn": "DOM.randomBookBtn",
1008
+ "speech-collapse": "collapse", "speech-stop": "stopButton", "speech-play": "play",
1009
+ "speech-previous": "previous", "speech-next": "next", "speech-pitch": "pitch",
1010
+ "speech-speed": "speed", "speech-timer": "timer", "speech-visible": "visible", "speech-locate": "locate",
1011
  }
1012
  for control_id, object_name in event_objects.items():
1013
  self.assertIn(f'{object_name}.addEventListener(', source, control_id)
tests/test_tts_browser.py CHANGED
@@ -1,14 +1,9 @@
1
- """Explicit offline CCRD/Hitokoto cross-origin bridge and reader acceptance.
2
-
3
- Requires Playwright, HITOKOTO_SOURCE_DIR and an existing CCRD_TTS_SAMPLE MP3.
4
- Requests are routed fixtures; no production, corpus scan or model loading.
5
- """
6
  from __future__ import annotations
7
 
8
  import json
9
  import os
10
  from pathlib import Path
11
- import re
12
  import unittest
13
  from urllib.parse import unquote, urlparse
14
 
@@ -22,250 +17,236 @@ SEARCH = "https://vomebook-search.hf.space"
22
  SPEECH = "https://vomebook-hitokoto.hf.space"
23
 
24
 
25
- @unittest.skipUnless(sync_playwright and os.environ.get("HITOKOTO_SOURCE_DIR") and os.environ.get("CCRD_TTS_SAMPLE"),
26
- "explicit: set HITOKOTO_SOURCE_DIR and CCRD_TTS_SAMPLE, install Playwright/Chromium")
27
  class SpeechReaderBrowserTests(unittest.TestCase):
28
- def test_preview_selection_player_isolation_and_cross_origin_contract(self):
29
- speech_root = Path(os.environ["HITOKOTO_SOURCE_DIR"])
30
  mp3 = Path(os.environ["CCRD_TTS_SAMPLE"]).read_bytes()
31
- source_text = "前言。𠮷这是一篇长文,校长文章写得很好,我得亲自处理。\n版本1.2.3与日期2026-10-18。" * 4
32
  sources = [{"slug": "CCRD", "name": "CCRD", "count": 2}]
33
- items = [{"doc_id": f"CCRD:{index}", "File": f"朗读测试{index}", "Source": "CCRD", "SourceName": "CCRD",
34
- "Folder": ["测试"], "Size": 1234, "snippet": "长文与手机测试", "snippet_complete": True} for index in range(2)]
35
  with sync_playwright() as playwright:
36
  browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
37
  try:
38
  for mobile in (False, True):
39
  with self.subTest(mobile=mobile):
40
- context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900},
41
- reduced_motion="reduce")
42
- context.add_init_script("delete Navigator.prototype.serviceWorker")
43
- context.add_init_script("document.addEventListener('DOMContentLoaded',()=>{const audio=document.querySelector('#audio');if(audio)audio.muted=true;})")
44
- context.add_init_script("window.__bridge=[];addEventListener('message',event=>{if(event.data?.type?.startsWith('ccrd-reader-'))__bridge.push({origin:event.origin,data:event.data});})")
45
- jobs, tts_posts, connections, deleted = {}, [], [], []
46
- blocked = [False]
47
- missing_client, old_html = [False], [False]
48
- errors = []
49
 
50
- def reply(route, data, status=200, headers=None):
51
- route.fulfill(status=status, content_type="application/json", body=json.dumps(data), headers=headers or {})
52
 
53
  def handler(route):
54
  request = route.request
55
- parsed = urlparse(request.url)
56
- path = unquote(parsed.path)
57
  if request.url.startswith(SEARCH):
58
- if path == "/":
59
- html = (ROOT / "static/index.html").read_text()
60
- if old_html[0]:
61
- html = re.sub(r'<button[^>]+id="speech-launch"[^>]*>.*?</button>', '', html)
62
- html = re.sub(r'<dialog id="speech-dialog".*?</dialog>', '', html, flags=re.S)
63
- html = html.replace('<script src="/static/tts-client.js"></script>', '')
64
- route.fulfill(content_type="text/html", body=html)
65
  elif path.startswith("/static/"):
66
- if path == "/static/tts-client.js" and missing_client[0]:
67
- route.abort("connectionfailed"); return
68
- file = ROOT / path.lstrip("/")
69
- if file.is_file():
70
- route.fulfill(path=file)
71
- else:
72
- reply(route, {}, 404)
73
- elif path == "/api/ping":
74
- route.fulfill(status=204)
75
- elif path == "/api/sources":
76
- reply(route, sources)
77
- elif path.startswith("/data/sidebar/"):
78
- reply(route, {"sources": sources})
79
- elif path.startswith("/data/initial/") or path.startswith("/api/search"):
80
- reply(route, {"results": items, "total": 2, "page": 1, "page_size": 100, "q": "",
81
- "sort": "relevance", "exact": True, "fulltext": True, "search_paths": True, "source": None})
82
  elif path.startswith("/api/preview/"):
83
  id = path.rsplit("/", 1)[-1]
84
- reply(route, {"doc_id": id, "title": "朗读测试 <b>原文</b>", "source": "CCRD", "path": "测试/原文.txt",
85
- "text": source_text if id == "CCRD:0" else "长"*20001 if id == "CCRD:long" else "另一个预览,不应该打断原朗读。"})
86
- elif path == "/manifest.json":
87
- reply(route, {"name": "Test"})
88
- else:
89
- reply(route, {}, 404)
90
  elif request.url.startswith(SPEECH):
91
- connections.append(request.url)
92
- if blocked[0] and path == "/tts/":
93
- route.abort("connectionfailed"); return
94
- if path == "/tts/":
95
- route.fulfill(content_type="text/html", body=(speech_root / "static/tts.html").read_bytes(),
96
- headers={"Content-Security-Policy": "frame-ancestors " + SEARCH})
97
- elif path in {"/tts/app.js", "/tts/reader.js", "/tts/embed.js", "/tts/style.css"}:
98
- name = {"app.js": "tts.js", "style.css": "tts.css"}.get(path.rsplit("/", 1)[-1], path.rsplit("/", 1)[-1])
99
- route.fulfill(path=speech_root / "static" / name)
100
- elif path == "/tts/health":
101
- reply(route, {"ready": True, "engine": {"threads": 2}, "queued": 0})
102
- elif path == "/tts/voices":
103
- reply(route, {"voices": [{"id": id, "name": id} for id in ("zf_001", "zf_017", "zm_010", "zm_029")]})
104
- elif path == "/tts/jobs" and request.method == "POST":
105
  body = request.post_data_json
106
- tts_posts.append(body)
107
- id = f"{len(jobs)+1:032d}"
108
  length = len(body["text"].encode("utf-16-le")) // 2
109
- jobs[id] = {"id": id, "state": "ready", "cache_hit": False, "result": {
110
- "audio_url": f"/tts/audio/{id}.mp3", "audio_seconds": 10.6, "synthesis_seconds": .2,
111
- "pitch_semitones": body["pitch_semitones"]}, "source_segments": [{"start": 0, "end": length}],
112
- "progress": {"complete": True, "total_parts": 1, "parts": [{"index": 0, "start": 0, "end": length,
113
- "audio_url": f"/tts/audio/{id}/parts/0.mp3", "first_audio_seconds": .2,
114
- "audio_seconds": 10.6, "pitch_semitones": body["pitch_semitones"]}]}}
 
115
  reply(route, jobs[id], 202)
116
  elif path.startswith("/tts/jobs/"):
117
  id = path.rsplit("/", 1)[-1]
118
  if request.method == "DELETE": deleted.append(id)
119
- reply(route, jobs.get(id, {"state": "error"}))
120
- elif path.startswith("/tts/audio/"):
121
- route.fulfill(content_type="audio/mpeg", body=mp3)
122
- else:
123
- reply(route, {}, 404)
124
- else:
125
- route.fulfill(status=204)
126
 
127
  context.route("**/*", handler)
128
  page = context.new_page()
 
129
  page.on("pageerror", lambda error: errors.append(str(error)))
130
- page.set_default_timeout(30000)
131
  try:
132
- page.goto(SEARCH + "/", wait_until="domcontentloaded")
133
  page.locator(".result-item").first.wait_for()
134
- self.assertFalse(connections, "Opening search must not load the speech service")
135
- self.assertFalse(tts_posts)
136
  page.locator("[data-action=preview]").first.click()
137
- page.locator(".preview-body").wait_for()
138
- self.assertEqual(page.locator(".preview-body").text_content(), source_text)
139
- # No selection is an explicit error, not a fallback to full text.
140
- page.locator("[data-speech=selection]").click()
141
- self.assertFalse(connections)
142
- selected_text = "这是一篇长文,校长文章写得很好,我得亲自处理。"
143
- page.evaluate("""text=>{
144
- const body=document.querySelector('.preview-body');const start=body.textContent.indexOf(text);
145
- const walker=document.createTreeWalker(body,NodeFilter.SHOW_TEXT);let node;let offset=0;
146
- const range=document.createRange();let begun=false;
147
- while(node=walker.nextNode()){
148
- if(!begun&&start<offset+node.length){range.setStart(node,start-offset);begun=true;}
149
- if(begun&&start+text.length<=offset+node.length){range.setEnd(node,start+text.length-offset);break;}offset+=node.length;
150
- }
151
- getSelection().removeAllRanges();getSelection().addRange(range);
152
- }""", selected_text)
153
- page.locator("[data-speech=selection]").click()
154
- page.wait_for_function("document.querySelector('#speech-status').textContent.includes('已载入')")
155
- frame = page.frame_locator("#speech-frame")
156
- self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
157
- self.assertFalse(tts_posts, "Import alone must not submit speech")
158
- self.assertFalse(frame.locator("#storage-controls").is_visible())
159
- self.assertFalse(frame.locator("#remember").is_checked())
160
- self.assertTrue(page.evaluate("CCRDSpeech.modal()") if mobile else not page.evaluate("CCRDSpeech.modal()"))
161
- self.assertTrue(page.evaluate("document.documentElement.scrollWidth<=innerWidth+1"))
162
- self.assertEqual(frame.locator("main b").count(), 0)
163
  if mobile:
164
- frame.locator("#playback-rate").focus()
165
- page.keyboard.press("Tab")
166
- page.wait_for_function("document.activeElement.id==='speech-collapse'")
167
- # Same-origin spoof from the wrong Window/channel is ignored.
168
- page.evaluate("""()=>window.postMessage({type:'ccrd-reader-loaded',channel:'0'.repeat(48),characters:99999},location.origin)""")
169
- self.assertNotIn("99999", page.locator("#speech-status").text_content())
170
- bridge_channel = page.evaluate("__bridge.find(event=>event.data.type==='ccrd-reader-ready').data.channel")
171
- # The source origin alone is insufficient: a sibling
172
- # Window with that origin and nonce is not the parent.
173
- page.evaluate("""({channel,target})=>{
174
- const sibling=document.createElement('iframe');sibling.hidden=true;document.body.append(sibling);
175
- const data={type:'ccrd-reader-import',channel,text:'不应该载入',title:'伪造',context_before:'',context_after:''};
176
- sibling.contentWindow.eval(`parent.document.querySelector('#speech-frame').contentWindow.postMessage(${JSON.stringify(data)},${JSON.stringify(target)})`);
177
- setTimeout(()=>sibling.remove(),100);
178
- }""", {"channel": bridge_channel, "target": SPEECH})
179
- page.wait_for_timeout(200)
180
- self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
181
- frame.locator("#pitch").evaluate("n=>{n.value=-2;n.dispatchEvent(new Event('input',{bubbles:true}));}")
182
- frame.locator("#playback-rate").evaluate("n=>{n.value=.5;n.dispatchEvent(new Event('input',{bubbles:true}));}")
183
- frame.locator("#generate").click()
184
- page.wait_for_function("document.querySelector('#speech-frame')!==null")
185
- speech_page = next(f for f in page.frames if f.url.startswith(SPEECH))
186
- speech_page.wait_for_function("session && document.querySelector('#audio').currentTime>.1")
187
- self.assertEqual(tts_posts[0]["text"], selected_text)
188
- self.assertEqual(tts_posts[0]["pitch_semitones"], -2)
189
- self.assertTrue(tts_posts[0]["context_before"].endswith("前言。𠮷"))
190
- self.assertNotIn("原文.txt", json.dumps(tts_posts))
191
- # Hiding/reopening keeps the exact iframe and audio session alive.
192
- original_src = page.locator("#speech-frame").get_attribute("src")
 
 
 
 
 
 
193
  page.locator("#speech-collapse").click()
194
- self.assertFalse(page.locator("#speech-dialog").is_visible())
195
- speech_page.wait_for_function("document.querySelector('#audio').currentTime>.3", polling=100)
196
- self.assertFalse(deleted)
197
  if mobile:
198
  page.locator("[data-action=close-preview]").click()
199
  page.wait_for_function("!STATE.previewDocId")
200
  page.locator("#search-input").fill("手机")
201
- page.wait_for_function("STATE.query==='手机' && !STATE.isLoading")
202
- page.locator("#sort-select").select_option("name")
203
  page.locator("[data-action=preview]").last.click()
204
- page.wait_for_function("STATE.previewDocId==='CCRD:1'")
205
- self.assertTrue(speech_page.evaluate("session!==null"))
206
- self.assertFalse(deleted)
207
- page.locator("[data-speech=controls]").click()
208
- self.assertEqual(page.locator("#speech-frame").get_attribute("src"), original_src)
209
- self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  page.locator("#speech-collapse").click()
211
- page.locator("[data-speech=preview]").click()
212
- frame.locator("#embed-keep").wait_for(state="visible")
213
- self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
214
- self.assertEqual(len(tts_posts), 1)
215
- frame.locator("#embed-keep").click()
216
- self.assertTrue(speech_page.evaluate("session!==null"))
217
- page.locator("#speech-collapse").click()
218
- page.locator("[data-speech=preview]").click()
219
- frame.locator("#embed-replace").click()
220
- self.assertEqual(frame.locator("#speech-text").input_value(), "另一个预览,不应该打断原朗读。")
221
- self.assertEqual(len(tts_posts), 1, "Replacement requires a separate explicit synthesis click")
222
- frame.locator("#generate").click()
223
- speech_page.wait_for_function("session && document.querySelector('#audio').currentTime>.1")
224
- self.assertEqual(tts_posts[-1]["text"], "另一个预览,不应该打断原朗读。")
225
- frame.locator("#speech-text").press("Escape")
226
- page.wait_for_function("!document.querySelector('#speech-dialog').open")
227
- page.locator("[data-speech=controls]").click()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  page.locator("#speech-stop").click()
229
- speech_page.wait_for_function("session===null", polling=100)
230
- self.assertTrue(deleted)
231
- count = len(tts_posts)
232
- page.evaluate("openPreview('CCRD:long','')")
233
- page.wait_for_function("document.querySelector('.preview-body').textContent.length===20001")
234
- page.locator("[data-speech=preview]").click()
235
- self.assertEqual(len(tts_posts), count)
236
- self.assertIn("两万字", page.locator("#speech-status").text_content())
237
- # Offline player failure leaves real search/preview
238
- # code working; no auto retry loop or speech POST.
239
- blocked[0] = True
240
- page.goto(SEARCH + "/", wait_until="domcontentloaded")
241
- page.locator(".result-item").first.wait_for()
242
- page.wait_for_function("!STATE.isLoading")
243
- page.evaluate("const native=setTimeout;window.setTimeout=(fn,ms,...args)=>native(fn,ms===20000?100:ms,...args)")
244
- if not page.locator(".preview-body").count():
245
- page.locator("[data-action=preview]").first.click()
246
- page.locator("[data-speech=preview]").click()
247
- page.locator("#speech-retry").wait_for(state="visible")
248
- page.locator("#speech-collapse").click()
249
  if mobile:
250
  page.locator("[data-action=close-preview]").click()
251
  page.wait_for_function("!STATE.previewDocId")
252
- page.locator("#search-input").fill("朗读故障后仍能搜索")
253
- page.wait_for_function("STATE.query==='朗读故障后仍能搜索' && !STATE.isLoading")
 
 
 
 
 
 
 
 
254
  self.assertEqual(page.locator(".result-item").count(), 2)
255
- self.assertEqual(len(tts_posts), count)
256
- # New HTML with a missing bridge, and an old cached
257
- # shell with new app.js, must both retain search.
258
- for mode in ('missing-client', 'old-html'):
259
- missing_client[0] = mode == 'missing-client'
260
- old_html[0] = mode == 'old-html'
261
- page.goto(SEARCH + "/", wait_until="domcontentloaded")
262
- page.locator(".result-item").first.wait_for()
263
- page.locator("#search-input").fill("缓存兼容")
264
- page.wait_for_function("STATE.query==='缓存兼容' && !STATE.isLoading")
265
- self.assertEqual(page.locator(".result-item").count(), 2)
266
- self.assertEqual(len(tts_posts), count)
267
  self.assertFalse(errors, errors)
268
  finally:
 
269
  context.close()
270
  finally:
271
  browser.close()
 
1
+ """Explicit offline native-player browser tier; no corpus/model/production calls."""
 
 
 
 
2
  from __future__ import annotations
3
 
4
  import json
5
  import os
6
  from pathlib import Path
 
7
  import unittest
8
  from urllib.parse import unquote, urlparse
9
 
 
17
  SPEECH = "https://vomebook-hitokoto.hf.space"
18
 
19
 
20
+ @unittest.skipUnless(sync_playwright and os.environ.get("CCRD_TTS_SAMPLE"), "explicit: install Playwright and set CCRD_TTS_SAMPLE to an existing MP3")
 
21
  class SpeechReaderBrowserTests(unittest.TestCase):
22
+ def test_native_player_long_source_navigation_latency_pause_and_independence(self):
 
23
  mp3 = Path(os.environ["CCRD_TTS_SAMPLE"]).read_bytes()
24
+ text = "".join(f"第{index:04d}段,这是一篇长文,校长文章写得很好,我得亲自处理。𠮷※😀仍继续读。\n" for index in range(1000))
25
  sources = [{"slug": "CCRD", "name": "CCRD", "count": 2}]
26
+ items = [{"doc_id": f"CCRD:{index}", "File": f"朗读测试{index}", "Source": "CCRD", "SourceName": "CCRD", "Folder": ["测试"],
27
+ "Size": 1234, "snippet": "长文与手机测试", "snippet_complete": True} for index in range(2)]
28
  with sync_playwright() as playwright:
29
  browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
30
  try:
31
  for mobile in (False, True):
32
  with self.subTest(mobile=mobile):
33
+ context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900}, reduced_motion="reduce")
34
+ context.add_init_script("delete Navigator.prototype.serviceWorker;new MutationObserver(()=>{const a=document.querySelector('#speech-audio');if(a)a.muted=true;}).observe(document,{childList:true,subtree:true});")
35
+ posts, deleted, paths, jobs, errors, held = [], [], [], {}, [], []
36
+ hold_next, offline, missing, bad = [False], [False], [False], [False]
37
+ headers = {"Access-Control-Allow-Origin": SEARCH, "Access-Control-Allow-Headers": "Content-Type,Range", "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS"}
 
 
 
 
38
 
39
+ def reply(route, data, code=200):
40
+ route.fulfill(status=code, content_type="application/json", body=json.dumps(data), headers=headers)
41
 
42
  def handler(route):
43
  request = route.request
44
+ path = unquote(urlparse(request.url).path)
 
45
  if request.url.startswith(SEARCH):
46
+ if path == "/": route.fulfill(path=ROOT / "static/index.html")
 
 
 
 
 
 
47
  elif path.startswith("/static/"):
48
+ if missing[0] and path == "/static/tts-client.js": route.abort("connectionfailed")
49
+ else: route.fulfill(path=ROOT / path.lstrip("/"))
50
+ elif path == "/api/ping": route.fulfill(status=204)
51
+ elif path == "/api/sources": reply(route, sources)
52
+ elif path.startswith("/data/sidebar/"): reply(route, {"sources": sources})
 
 
 
 
 
 
 
 
 
 
 
53
  elif path.startswith("/api/preview/"):
54
  id = path.rsplit("/", 1)[-1]
55
+ reply(route, {"title": "原文 <b>不作 HTML</b>", "source": "CCRD", "path": "测试.txt", "text": text if id == "CCRD:0" else "另一个预览,不应该打断原朗读。"})
56
+ elif path.startswith("/api/search") or path.startswith("/data/initial/"):
57
+ reply(route, {"results": items, "total": 2, "page": 1, "page_size": 100, "q": "", "exact": True, "fulltext": True, "search_paths": True, "sort": "relevance", "source": None})
58
+ else: reply(route, {}, 404)
 
 
59
  elif request.url.startswith(SPEECH):
60
+ paths.append(path)
61
+ if offline[0]: route.abort("connectionfailed"); return
62
+ if request.method == "OPTIONS": route.fulfill(status=200, headers=headers); return
63
+ if path == "/tts/jobs" and request.method == "POST":
 
 
 
 
 
 
 
 
 
 
64
  body = request.post_data_json
65
+ posts.append(body)
66
+ id = f"{len(posts):032d}"
67
  length = len(body["text"].encode("utf-16-le")) // 2
68
+ middle = len(body["text"][:20].encode("utf-16-le")) // 2
69
+ spans = [(0, middle), (middle, length)] if middle < length else [(0, length)]
70
+ parts = [{"index": index, "start": begin, "end": end, "audio_url": f"/tts/audio/{id}/parts/{index}.mp3", "pitch_semitones": body["pitch_semitones"]} for index, (begin, end) in enumerate(spans)]
71
+ if bad[0]: parts[0]["audio_url"] = "https://untrusted.test/leak"
72
+ jobs[id] = {"id": id, "state": "ready", "source_segments": [{"start": begin, "end": end} for begin, end in spans],
73
+ "progress": {"total_parts": len(parts), "parts": parts}, "result": {"pitch_semitones": body["pitch_semitones"]}}
74
+ if hold_next[0]: held.append((route, id)); return
75
  reply(route, jobs[id], 202)
76
  elif path.startswith("/tts/jobs/"):
77
  id = path.rsplit("/", 1)[-1]
78
  if request.method == "DELETE": deleted.append(id)
79
+ reply(route, jobs[id])
80
+ elif path.startswith("/tts/audio/"): route.fulfill(content_type="audio/mpeg", body=mp3, headers=headers)
81
+ else: reply(route, {}, 404)
82
+ else: route.fulfill(status=204)
 
 
 
83
 
84
  context.route("**/*", handler)
85
  page = context.new_page()
86
+ page.set_default_timeout(20000)
87
  page.on("pageerror", lambda error: errors.append(str(error)))
 
88
  try:
89
+ page.goto(SEARCH, wait_until="domcontentloaded")
90
  page.locator(".result-item").first.wait_for()
91
+ self.assertEqual(page.locator("#status-bar [id^=speech]").count(), 0)
 
92
  page.locator("[data-action=preview]").first.click()
93
+ body = page.locator(".preview-body"); body.wait_for()
94
+ self.assertFalse(posts)
95
+ self.assertEqual(page.locator(".preview-speech-tools").count(), 0)
96
+ self.assertEqual(page.locator("iframe").count(), 0)
97
+ button = page.locator("[data-speech-toggle]")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  if mobile:
99
+ self.assertGreaterEqual(button.bounding_box()["y"], page.locator(".preview-theme-btn").bounding_box()["y"] + 25)
100
+ else:
101
+ self.assertLess(button.bounding_box()["x"], page.locator("[data-preview-fullscreen]").bounding_box()["x"])
102
+ # A single user play gesture starts a bounded first
103
+ # POST directly, not health/voices/iframe requests.
104
+ button.click()
105
+ page.wait_for_function("document.querySelector('#speech-audio')?.currentTime>.1", polling=100)
106
+ self.assertTrue(posts)
107
+ self.assertLessEqual(len(posts[0]["text"]), 80)
108
+ self.assertEqual(posts[0]["voice"], "zf_001")
109
+ self.assertEqual(posts[0]["unsupported_policy"], "pause")
110
+ self.assertEqual(posts[0]["speed"], 1)
111
+ self.assertFalse(any(path in {"/tts/", "/tts/health", "/tts/voices"} for path in paths))
112
+ self.assertEqual(body.text_content(), text)
113
+ self.assertLessEqual(len(posts), 2)
114
+ self.assertTrue(page.locator("#speech-panel").is_visible())
115
+ self.assertTrue(page.evaluate("document.documentElement.scrollWidth<=innerWidth+1"))
116
+ # Current + following only; concatenation is exact,
117
+ # and source past 20k is not pre-submitted/scanned.
118
+ page.wait_for_function("document.querySelector('#speech-audio').currentTime>.3", polling=100)
119
+ page.wait_for_timeout(800)
120
+ self.assertLessEqual(len(posts), 2)
121
+ self.assertEqual("".join(row["text"] for row in posts), text[:sum(len(row["text"]) for row in posts)])
122
+ count = len(posts)
123
+ page.locator("#speech-speed").evaluate("n=>{n.value=1.5;n.dispatchEvent(new Event('input',{bubbles:true}));}")
124
+ self.assertEqual(page.locator("#speech-audio").evaluate("n=>n.playbackRate"), 1.5)
125
+ self.assertEqual(len(posts), count)
126
+ page.locator("#speech-play").click()
127
+ page.wait_for_timeout(750)
128
+ self.assertEqual(page.locator("#speech-audio").evaluate("n=>n.paused"), True)
129
+ self.assertEqual(len(posts), count)
130
+ page.locator("#speech-pitch").evaluate("n=>{n.value=1;n.dispatchEvent(new Event('change',{bubbles:true}));}")
131
+ page.wait_for_timeout(350)
132
+ self.assertEqual(len(posts), count, "Paused setting changes must not start synthesis")
133
+ page.locator("#speech-play").click()
134
  page.locator("#speech-collapse").click()
135
+ page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
136
+ self.assertFalse(page.locator("#speech-panel").is_visible())
137
+ # Search/preview/theme remain independent from audio.
138
  if mobile:
139
  page.locator("[data-action=close-preview]").click()
140
  page.wait_for_function("!STATE.previewDocId")
141
  page.locator("#search-input").fill("手机")
142
+ page.wait_for_function("STATE.query==='手机'&&!STATE.isLoading")
 
143
  page.locator("[data-action=preview]").last.click()
144
+ page.wait_for_function("document.querySelector('.preview-body')?.textContent.startsWith('另一个')")
145
+ self.assertFalse(page.locator("#speech-audio").evaluate("n=>n.paused"))
146
+ page.evaluate("document.querySelector('#speech-panel').hidden=false")
147
+ page.locator("#speech-locate").click()
148
+ page.wait_for_function("STATE.previewDocId==='CCRD:0'")
149
+ self.assertEqual(page.locator(".preview-body").text_content(), text)
150
+ page.locator("[data-speech-toggle]").click()
151
+ # Exact viewport-top restart, well past 20k characters.
152
+ body.evaluate("n=>{const r=document.createRange();r.setStart(n.firstChild,25000);r.setEnd(n.firstChild,25001);n.scrollTop+=r.getBoundingClientRect().top-n.getBoundingClientRect().top-5;}")
153
+ before = len(posts)
154
+ page.locator("#speech-visible").click()
155
+ page.wait_for_timeout(500)
156
+ self.assertGreater(len(posts), before)
157
+ chunk = posts[before]["text"]
158
+ source_index = len(text[:text.index(chunk)].encode("utf-16-le")) // 2
159
+ self.assertGreater(source_index, 24000)
160
+ self.assertLess(source_index, 25200)
161
+ self.assertLessEqual(len(chunk), 80)
162
+ # Double-click selects and starts the source sentence,
163
+ # not search highlights/HTML or a guessed time ratio.
164
  page.locator("#speech-collapse").click()
165
+ body.evaluate("n=>n.scrollTop=0")
166
+ box = body.evaluate("n=>{const r=document.createRange();r.setStart(n.firstChild,12);r.setEnd(n.firstChild,13);const b=r.getBoundingClientRect();return {x:b.x+b.width/2,y:b.y+b.height/2};}")
167
+ before = len(posts)
168
+ page.mouse.dblclick(box["x"], box["y"])
169
+ page.wait_for_timeout(400)
170
+ self.assertGreater(len(posts), before)
171
+ self.assertTrue(posts[before]["text"].startswith("第0000段"))
172
+ page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
173
+ before = len(posts)
174
+ page.locator("#speech-next").click()
175
+ page.wait_for_timeout(300)
176
+ self.assertTrue(posts[before]["text"].startswith("𠮷※😀"), posts[before]["text"])
177
+ before = len(posts)
178
+ page.locator("#speech-previous").click()
179
+ page.wait_for_timeout(300)
180
+ self.assertTrue(posts[before]["text"].startswith("第0000段"), posts[before]["text"])
181
+ # Pitch changes regenerate only from the current
182
+ # source clip, while speed remains instantaneous.
183
+ before = len(posts)
184
+ page.locator("#speech-pitch").evaluate("n=>{n.value=-2;n.dispatchEvent(new Event('input',{bubbles:true}));n.dispatchEvent(new Event('change',{bubbles:true}));}")
185
+ page.wait_for_timeout(300)
186
+ self.assertGreater(len(posts), before)
187
+ self.assertEqual(posts[before]["pitch_semitones"], -2)
188
+ page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
189
+ # A held accepted speculative POST must be deleted
190
+ # when its late capability arrives after pausing.
191
+ page.locator("#speech-stop").click()
192
+ hold_next[0] = True
193
+ page.locator("[data-speech-toggle]").click()
194
+ page.wait_for_timeout(200)
195
+ self.assertTrue(held)
196
+ route, id = held.pop(0); reply(route, jobs[id], 202)
197
+ page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
198
+ page.wait_for_timeout(500)
199
+ page.locator("#speech-play").click()
200
+ hold_next[0] = False
201
+ for route, id in held:
202
+ reply(route, jobs[id], 202)
203
+ late_ids = [id for _, id in held]; held.clear()
204
+ page.wait_for_timeout(400)
205
+ self.assertTrue(all(id in deleted for id in late_ids))
206
+ count = len(posts)
207
+ page.wait_for_timeout(600)
208
+ self.assertEqual(len(posts), count)
209
+ page.locator("#speech-play").click()
210
+ page.locator("#speech-timer").select_option("clip")
211
+ page.evaluate("document.querySelector('#speech-audio').dispatchEvent(new Event('ended'))")
212
+ page.wait_for_timeout(200)
213
+ self.assertFalse(page.locator("#speech-audio").get_attribute("src"))
214
+ self.assertIn("睡眠", page.locator("#speech-status").text_content())
215
+ page.locator("#speech-play").click()
216
+ page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
217
+ page.locator("#speech-timer").select_option("5")
218
+ page.evaluate("const oldNow=Date.now;Date.now=()=>oldNow()+301000;document.dispatchEvent(new Event('visibilitychange'))")
219
+ self.assertFalse(page.locator("#speech-audio").get_attribute("src"))
220
+ page.locator("#speech-stop").click()
221
+ # Transport/invalid metadata never skip source or
222
+ # load arbitrary URLs, and never break search.
223
+ bad[0] = True
224
+ page.locator("[data-speech-toggle]").click()
225
+ page.wait_for_timeout(300)
226
+ self.assertFalse(page.locator("#speech-audio").get_attribute("src"))
227
+ self.assertIn("重试", page.locator("#speech-status").text_content())
228
+ page.locator("#speech-stop").click()
229
+ bad[0] = False; offline[0] = True
230
+ page.locator("[data-speech-toggle]").click()
231
+ page.wait_for_timeout(300)
232
  page.locator("#speech-stop").click()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  if mobile:
234
  page.locator("[data-action=close-preview]").click()
235
  page.wait_for_function("!STATE.previewDocId")
236
+ page.locator("#search-input").fill("语音故障不影响搜索")
237
+ page.wait_for_function("STATE.query==='语音故障不影响搜索'&&!STATE.isLoading")
238
+ self.assertEqual(page.locator(".result-item").count(), 2)
239
+ # Mixed-cached/missing optional reader JS must never
240
+ # break the main search controller.
241
+ missing[0] = True
242
+ page.goto(SEARCH, wait_until="domcontentloaded")
243
+ page.locator(".result-item").first.wait_for()
244
+ page.locator("#search-input").fill("旧缓存仍可搜索")
245
+ page.wait_for_function("STATE.query==='旧缓存仍可搜索'&&!STATE.isLoading")
246
  self.assertEqual(page.locator(".result-item").count(), 2)
 
 
 
 
 
 
 
 
 
 
 
 
247
  self.assertFalse(errors, errors)
248
  finally:
249
+ for route, id in held: reply(route, jobs[id], 202)
250
  context.close()
251
  finally:
252
  browser.close()
tests/test_tts_plan.cjs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+ const assert = require("node:assert/strict");
3
+ require("../static/tts-client.js");
4
+ const plan = globalThis.CCRDSpeechPlan;
5
+ for (const text of ["甲𠮷乙。".repeat(8000), "正文。" + " \n".repeat(2000) + "末尾。", "甲".repeat(77) + "2026年10月18日版本1.2.3与编号1234567890正文。".repeat(20), "LongEnglishWord".repeat(100) + "中文。", "😀★※。".repeat(1000)]) {
6
+ let offset = 0, count = 0;
7
+ const collected = [];
8
+ while (offset < text.length) {
9
+ const part = plan.batch(text, offset, count === 0);
10
+ assert.equal(part.start, offset);
11
+ assert(part.end > offset);
12
+ assert(Array.from(part.text).length <= 800);
13
+ assert(Array.from(part.context_before).length <= 32);
14
+ assert(Array.from(part.context_after).length <= 32);
15
+ assert.equal(part.text, text.slice(part.start, part.end));
16
+ assert(!/^[\uDC00-\uDFFF]/u.test(part.text));
17
+ collected.push(part.text); offset = part.end; count++;
18
+ }
19
+ assert.equal(collected.join(""), text);
20
+ }
21
+ const text = "甲".repeat(77) + "2026年10月18日版本1.2.3用于测试。";
22
+ assert.equal(plan.batch(text, 0, true).text, "甲".repeat(77));
23
+ assert.equal(plan.batch(text, 77, true).text, text.slice(77));
24
+ const source = "第一句。第二句里有𠮷和版本1.2.3。第三句!";
25
+ assert.deepEqual(plan.sentence(source, source.indexOf("𠮷")), { start: 4, end: source.indexOf("第三句") });
26
+ assert.equal(plan.safe("甲𠮷乙", 2), 1);
27
+ assert.equal(Array.from(plan.batch("文".repeat(50000), 23456, true).text).length, 80);
28
+ console.log("Rolling source plan: lossless >20k, non-BMP, numeric atoms, bounded batches/context PASS");