feat: compact native preview reader with rolling long-text playback
Browse filesOne 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 +24 -19
- static/app.js +8 -7
- static/index.html +12 -15
- static/style.css +21 -9
- static/tts-client.js +463 -170
- tests/test_browser.py +1 -2
- tests/test_search.py +5 -2
- tests/test_tts_browser.py +186 -205
- tests/test_tts_plan.cjs +28 -0
docs/TTS_PILOT.md
CHANGED
|
@@ -1,22 +1,27 @@
|
|
| 1 |
-
# CCRD 朗读
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
**载入文字不等于提交合成**:在播放器确认正文、音色、合成语速和试验音高后,
|
| 6 |
-
主动点击“合成并试听”,才分批提交到 Hitokoto API。
|
| 7 |
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 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 (
|
| 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()
|
| 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 |
-
<
|
| 152 |
-
<div class="speech-heading">
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
</
|
| 157 |
-
<
|
| 158 |
-
<button type="button" id="speech-
|
| 159 |
-
|
| 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 |
-
.
|
| 52 |
-
.speech-
|
| 53 |
-
.speech-
|
| 54 |
-
|
| 55 |
-
.speech-
|
| 56 |
-
.speech-
|
| 57 |
-
.speech-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
const ORIGIN = "https://vomebook-hitokoto.hf.space";
|
| 6 |
-
const
|
| 7 |
-
const $ =
|
| 8 |
-
const
|
| 9 |
-
|
| 10 |
-
const status = $("#speech-status");
|
| 11 |
-
const
|
| 12 |
-
const
|
| 13 |
-
const
|
| 14 |
-
const
|
| 15 |
-
|
| 16 |
-
let
|
| 17 |
-
let
|
| 18 |
|
| 19 |
-
function notice(
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
-
function
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
-
function
|
| 27 |
-
|
| 28 |
}
|
| 29 |
-
function
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
function outerControls() {
|
| 33 |
-
return Array.from(dialog.querySelectorAll("button, a[href]")).filter(node => !node.disabled && !node.hidden && node.getClientRects().length);
|
| 34 |
}
|
| 35 |
-
function
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
| 41 |
}
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
function hide() {
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
});
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
-
|
|
|
|
| 80 |
}
|
| 81 |
-
function
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
-
function
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
return;
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
collapse.addEventListener("click", hide);
|
| 158 |
-
stopButton.addEventListener("click", () => {
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
if (!confirm("重新连接会停止此播放器中尚未完成的朗读,是否继续?")) return;
|
| 165 |
-
frame?.remove();
|
| 166 |
-
frame = null;
|
| 167 |
-
createFrame();
|
| 168 |
});
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
}
|
| 179 |
}
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
|
| 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
|
| 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-
|
|
|
|
| 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-
|
|
|
|
|
|
|
| 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
|
| 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("
|
| 26 |
-
"explicit: set HITOKOTO_SOURCE_DIR and CCRD_TTS_SAMPLE, install Playwright/Chromium")
|
| 27 |
class SpeechReaderBrowserTests(unittest.TestCase):
|
| 28 |
-
def
|
| 29 |
-
speech_root = Path(os.environ["HITOKOTO_SOURCE_DIR"])
|
| 30 |
mp3 = Path(os.environ["CCRD_TTS_SAMPLE"]).read_bytes()
|
| 31 |
-
|
| 32 |
sources = [{"slug": "CCRD", "name": "CCRD", "count": 2}]
|
| 33 |
-
items = [{"doc_id": f"CCRD:{index}", "File": f"朗读测试{index}", "Source": "CCRD", "SourceName": "CCRD",
|
| 34 |
-
"
|
| 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 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
jobs, tts_posts, connections, deleted = {}, [], [], []
|
| 46 |
-
blocked = [False]
|
| 47 |
-
missing_client, old_html = [False], [False]
|
| 48 |
-
errors = []
|
| 49 |
|
| 50 |
-
def reply(route, data,
|
| 51 |
-
route.fulfill(status=
|
| 52 |
|
| 53 |
def handler(route):
|
| 54 |
request = route.request
|
| 55 |
-
|
| 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"
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 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, {"
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
else:
|
| 89 |
-
reply(route, {}, 404)
|
| 90 |
elif request.url.startswith(SPEECH):
|
| 91 |
-
|
| 92 |
-
if
|
| 93 |
-
|
| 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 |
-
|
| 107 |
-
id = f"{len(
|
| 108 |
length = len(body["text"].encode("utf-16-le")) // 2
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
| 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
|
| 120 |
-
elif path.startswith("/tts/audio/"):
|
| 121 |
-
|
| 122 |
-
|
| 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
|
| 133 |
page.locator(".result-item").first.wait_for()
|
| 134 |
-
self.
|
| 135 |
-
self.assertFalse(tts_posts)
|
| 136 |
page.locator("[data-action=preview]").first.click()
|
| 137 |
-
page.locator(".preview-body").wait_for()
|
| 138 |
-
self.
|
| 139 |
-
|
| 140 |
-
page.locator("
|
| 141 |
-
|
| 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 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
#
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
page.
|
| 180 |
-
self.
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
page.
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
self.
|
| 190 |
-
self.
|
| 191 |
-
#
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
page.locator("#speech-collapse").click()
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 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==='手机'
|
| 202 |
-
page.locator("#sort-select").select_option("name")
|
| 203 |
page.locator("[data-action=preview]").last.click()
|
| 204 |
-
page.wait_for_function("
|
| 205 |
-
self.
|
| 206 |
-
|
| 207 |
-
page.locator("
|
| 208 |
-
|
| 209 |
-
self.assertEqual(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
page.locator("#speech-collapse").click()
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
self.
|
| 217 |
-
|
| 218 |
-
page.
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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==='
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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");
|