fix: align spoken sentences and reuse prepared audio on seeks
Browse filesDo not merge multiple sentences in one source part; keep connective prefixes with their clauses and preserve lexical tones. Seek within live current/next jobs instead of stop/rebuilding them, with epochs and in-flight guards. Retain only two played/ready descriptors for completed-cache-only reopening; misses prepare just the target, never an old batch. Preserve pause, sleep, source Unicode, inline/audio cleanup and all search behavior.
- docs/TTS_PILOT.md +8 -0
- static/index.html +1 -1
- static/tts-client.js +116 -26
- tests/test_tts_browser.py +48 -6
- tests/test_tts_plan.cjs +24 -0
docs/TTS_PILOT.md
CHANGED
|
@@ -5,6 +5,12 @@
|
|
| 5 |
|
| 6 |
- 固定女声01。只提供音高、即时语速、睡眠定时、上一句/下一句、暂停/继续、
|
| 7 |
从当前阅读位置开始、回到播放位置。双击正文会选中所在句子并从那里继续播放。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
- 原文保留在搜索页内存,不修改正文或搜索高亮;后端返回原始 UTF-16 句段范围。
|
| 9 |
高亮是句段级,不把播放时间比例冒充逐字定位。“回到播放位置”可重新打开原预览。
|
| 10 |
- 去掉整篇两万字限制:首批目标 48 字,后续目标 480 字,每个 API 请求仍最多 800
|
|
@@ -12,6 +18,8 @@
|
|
| 12 |
只保留当前/下一批任务及一个后续 MP3 预载,不整本上传或生成整本音频。
|
| 13 |
- 词边界由浏览器中文分词器在有限窗口内提供,优先在自然标点停顿,避免把复合词、
|
| 14 |
专有名词和数字在 60 字处硬拆。首个音频片段目标约 16 字,之后用较完整的分句。
|
|
|
|
|
|
|
| 15 |
源范围经 API/worker 验证。首个创建请求最多异步等 8 秒,首段可用立即返回,减少
|
| 16 |
反复穿过 HF 入口轮询;这不是固定的首字速度保证。
|
| 17 |
- 首批额外请求 `inline_first=true`:把一个已完成、最多 128 KiB 的首段 MP3 随创建
|
|
|
|
| 5 |
|
| 6 |
- 固定女声01。只提供音高、即时语速、睡眠定时、上一句/下一句、暂停/继续、
|
| 7 |
从当前阅读位置开始、回到播放位置。双击正文会选中所在句子并从那里继续播放。
|
| 8 |
+
- 音频片段不会再把多句正文合成一个长块;按钮按当前句子的原文位置前后移动。
|
| 9 |
+
当前/下一批内已生成的句子直接切换,不取消重建。双击同一句可从头重播,
|
| 10 |
+
不重新合成。尚未生成的位置仍需准备,不能保证所有跳读都瞬间出声。
|
| 11 |
+
- 只保留最近两批“已听且完成”的短小分段元数据(不保留额外声音/任务凭据)。
|
| 12 |
+
跳回时先用原样请求尝试后端完成缓存,`cache_only=true` 命中才取,不会因未命中
|
| 13 |
+
而排队生成整批旧文;未命中后只从所选位置重新准备。仍最多两个活跃批次任务。
|
| 14 |
- 原文保留在搜索页内存,不修改正文或搜索高亮;后端返回原始 UTF-16 句段范围。
|
| 15 |
高亮是句段级,不把播放时间比例冒充逐字定位。“回到播放位置”可重新打开原预览。
|
| 16 |
- 去掉整篇两万字限制:首批目标 48 字,后续目标 480 字,每个 API 请求仍最多 800
|
|
|
|
| 18 |
只保留当前/下一批任务及一个后续 MP3 预载,不整本上传或生成整本音频。
|
| 19 |
- 词边界由浏览器中文分词器在有限窗口内提供,优先在自然标点停顿,避免把复合词、
|
| 20 |
专有名词和数字在 60 字处硬拆。首个音频片段目标约 16 字,之后用较完整的分句。
|
| 21 |
+
“但是、同时、然而”等引导语与后续分句放在一起,避免拆成独立很短的音频;
|
| 22 |
+
保留词典正常声调,不统一改轻声、加重音或对字词任意调音量。声线听感仍需试听。
|
| 23 |
源范围经 API/worker 验证。首个创建请求最多异步等 8 秒,首段可用立即返回,减少
|
| 24 |
反复穿过 HF 入口轮询;这不是固定的首字速度保证。
|
| 25 |
- 首批额外请求 `inline_first=true`:把一个已完成、最多 128 KiB 的首段 MP3 随创建
|
static/index.html
CHANGED
|
@@ -158,7 +158,7 @@
|
|
| 158 |
</section>
|
| 159 |
|
| 160 |
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
|
| 161 |
-
<script src="/static/tts-client.js?v=
|
| 162 |
<script src="/static/app.js?v=phrases-3"></script>
|
| 163 |
</body>
|
| 164 |
</html>
|
|
|
|
| 158 |
</section>
|
| 159 |
|
| 160 |
<script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
|
| 161 |
+
<script src="/static/tts-client.js?v=navigation-5"></script>
|
| 162 |
<script src="/static/app.js?v=phrases-3"></script>
|
| 163 |
</body>
|
| 164 |
</html>
|
static/tts-client.js
CHANGED
|
@@ -2,10 +2,21 @@
|
|
| 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 |
const words = typeof Intl.Segmenter === "function" ? new Intl.Segmenter("zh", { granularity: "word" }) : null;
|
| 8 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
function numberSpans(text) {
|
| 10 |
const label = "(?:订单编号|訂單編號|订单号|訂單號|编号|編號|手机号|手機號|电话|電話|学号|學號|邮编|郵編|ISBN(?:-13|-10)?|IP)";
|
| 11 |
const pattern = new RegExp(label + "[ \\t]*[::]?[ \\t]*[A-Za-z0-9]+(?:[._/\\-][A-Za-z0-9]+)*" +
|
|
@@ -31,14 +42,23 @@ globalThis.CCRDSpeechPlan = (() => {
|
|
| 31 |
const position = offsets.indexOf(start), short = first && !ends.length;
|
| 32 |
const target = offsets[Math.min(chars.length, position + (short ? 16 : 90))];
|
| 33 |
const ceiling = offsets[Math.min(chars.length, position + (short ? 18 : 120))];
|
| 34 |
-
const
|
|
|
|
|
|
|
| 35 |
let end;
|
| 36 |
-
if (
|
|
|
|
| 37 |
else {
|
| 38 |
const choices = cuts.filter(value => value.end > start && value.end <= target && !dangling.has(value.word));
|
| 39 |
const any = cuts.filter(value => value.end > start && value.end <= target);
|
| 40 |
end = choices.length ? choices[choices.length - 1].end : any.length ? any[any.length - 1].end : cuts.find(value => value.end > start)?.end || text.length;
|
| 41 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
if (end <= start) throw new Error("无法确定完整词语边界");
|
| 43 |
if (end < text.length && !/[\p{L}\p{N}]/u.test(text.slice(end))) end = text.length;
|
| 44 |
ends.push(end); start = end;
|
|
@@ -77,9 +97,20 @@ globalThis.CCRDSpeechPlan = (() => {
|
|
| 77 |
const finish = boundary(text, end++);
|
| 78 |
if (finish) break;
|
| 79 |
}
|
| 80 |
-
while (end < text.length &&
|
| 81 |
return { start: safe(text, start), end: safe(text, end) };
|
| 82 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
function batch(text, offset, first = false) {
|
| 84 |
const start = first ? wordStart(text, offset) : safe(text, offset), limit = first ? 48 : 480;
|
| 85 |
if (start >= text.length) return null;
|
|
@@ -106,12 +137,13 @@ globalThis.CCRDSpeechPlan = (() => {
|
|
| 106 |
const candidates = wordEnds(text.slice(start, offsets[Math.min(chars.length, 800)]))
|
| 107 |
.filter(value => value.end <= stop - start && value.end > 0 && !dangling.has(value.word));
|
| 108 |
if (candidates.length && stop < text.length) stop = start + candidates[candidates.length - 1].end;
|
| 109 |
-
|
| 110 |
-
|
|
|
|
| 111 |
context_before: Array.from(text.slice(Math.max(0, start - 64), start)).slice(-32).join(""),
|
| 112 |
context_after: Array.from(text.slice(stop, stop + 64)).slice(0, 32).join("") };
|
| 113 |
}
|
| 114 |
-
return { safe, sentence, batch, phraseEnds, wordEnds, wordStart, numberSpans };
|
| 115 |
})();
|
| 116 |
|
| 117 |
if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
@@ -139,7 +171,12 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 139 |
function clearInline(job) {
|
| 140 |
if (job?.inlineUrl) { URL.revokeObjectURL(job.inlineUrl); job.inlineUrl = null; job.inlineConsumed = true; }
|
| 141 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
function discard(value, job) {
|
|
|
|
| 143 |
job.cancelled = true;
|
| 144 |
value.jobs.delete(job.batch.start);
|
| 145 |
clearInline(job);
|
|
@@ -179,8 +216,8 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 179 |
play.textContent = owner?.wantsPlay ? "暂停" : owner?.blocked ? "重试" : "继续";
|
| 180 |
play.setAttribute("aria-label", play.textContent + "朗读");
|
| 181 |
play.disabled = !owner && !saved;
|
| 182 |
-
previous.disabled = !owner || owner.position <= 0;
|
| 183 |
-
next.disabled = !owner || owner.position >= owner.snapshot.text.length;
|
| 184 |
visible.disabled = !preview;
|
| 185 |
locate.disabled = !owner && !saved;
|
| 186 |
$("#speech-pitch-value").textContent = (Number(pitch.value) > 0 ? "+" : "") + pitch.value;
|
|
@@ -278,7 +315,8 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 278 |
function stop(message = "已停止,可继续朗读", keepalive = false) {
|
| 279 |
const value = owner;
|
| 280 |
if (value) {
|
| 281 |
-
|
|
|
|
| 282 |
value.cancelled = true; clearTimeout(value.timer);
|
| 283 |
for (const controller of value.controllers) if (!controller.createsJob) controller.abort();
|
| 284 |
for (const job of value.jobs.values()) { clearInline(job); release(job.id, keepalive); }
|
|
@@ -390,18 +428,27 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 390 |
acceptInline(job, data);
|
| 391 |
}
|
| 392 |
async function update(value, job) {
|
| 393 |
-
if (!live(value) || job.cancelled || !value.wantsPlay || job.state === "ready" || job.blocked || Date.now() < job.retryAt || checkSleep()) return;
|
|
|
|
| 394 |
try {
|
| 395 |
if (Date.now() > job.deadline) throw new Error("语音排队超时,点击继续重试");
|
| 396 |
const data = job.id ? await request(`/tts/jobs/${job.id}`, {}, value) : await request("/tts/jobs", { method: "POST", headers: { "Content-Type": "application/json" },
|
| 397 |
body: JSON.stringify({ text: job.batch.text, voice: "zf_001", speed: 1.2, pitch_semitones: value.pitch,
|
| 398 |
-
unsupported_policy: "pause", progressive: true, segment_ends: job.batch.segment_ends, wait_first: job.batch.first,
|
|
|
|
|
|
|
| 399 |
context_before: job.batch.context_before,
|
| 400 |
context_after: job.batch.context_after, request_id: job.nonce }) }, value, job.batch.first ? 22000 : 15000);
|
| 401 |
if (!live(value) || job.cancelled || value.jobs.get(job.batch.start) !== job) { release(data.id); return; }
|
| 402 |
accept(job, data, value); job.failures = 0; job.retryAt = Date.now() + (job.parts.length ? 650 : 250);
|
| 403 |
} catch (error) {
|
| 404 |
if (!live(value) || job.cancelled) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
job.failures += 1;
|
| 406 |
const transient = [404, 410, 429, 503].includes(error.status) || error.name === "AbortError" || error instanceof TypeError;
|
| 407 |
job.retryAt = Date.now() + 1000 * (error.retryAfter || Math.min(20, 2 ** job.failures));
|
|
@@ -416,7 +463,7 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 416 |
notice(error.message + (job.blocked ? " · 点击继续重试" : " · 稍后重试,进度保留"));
|
| 417 |
}
|
| 418 |
controls();
|
| 419 |
-
}
|
| 420 |
}
|
| 421 |
function advance(value) {
|
| 422 |
if (!live(value) || !value.wantsPlay || value.blocked || checkSleep()) return;
|
|
@@ -426,6 +473,7 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 426 |
if (!value.current) {
|
| 427 |
const part = job.parts[value.part];
|
| 428 |
if (part) {
|
|
|
|
| 429 |
value.current = { job, part }; value.position = job.batch.start + part.start;
|
| 430 |
switching = true; audio.src = part.index === 0 && job.inlineUrl ? job.inlineUrl : ORIGIN + part.audio_url; audio.load(); applyRate();
|
| 431 |
notice("朗读中"); mark(); controls();
|
|
@@ -442,6 +490,9 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 442 |
// One following API batch and one following immutable MP3 only. No work on
|
| 443 |
// pause, no document-wide plan/DOM/audio cache and no whole-book POST.
|
| 444 |
if (!finishClip && value.current && job.state === "ready" && job.batch.end < value.snapshot.text.length && !value.jobs.has(job.batch.end)) {
|
|
|
|
|
|
|
|
|
|
| 445 |
const batch = plan.batch(value.snapshot.text, job.batch.end);
|
| 446 |
value.jobs.set(batch.start, newJob(batch));
|
| 447 |
}
|
|
@@ -449,30 +500,42 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 449 |
if (following && preloader.getAttribute("src") !== ORIGIN + following.audio_url) preloader.src = ORIGIN + following.audio_url;
|
| 450 |
}
|
| 451 |
async function pump(value) {
|
| 452 |
-
if (!live(value) || value.
|
| 453 |
-
|
|
|
|
| 454 |
try {
|
| 455 |
const current = value.jobs.get(value.batch.start);
|
| 456 |
if (current) await update(value, current);
|
|
|
|
| 457 |
advance(value); // Never wait for speculative work before playing a part.
|
| 458 |
if (!live(value) || !value.wantsPlay) return;
|
| 459 |
for (const job of Array.from(value.jobs.values())) if (job.batch.start !== current?.batch.start) {
|
| 460 |
-
await update(value, job);
|
|
|
|
|
|
|
| 461 |
}
|
| 462 |
} finally {
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
| 465 |
}
|
| 466 |
}
|
| 467 |
-
function start(snapshot, offset, playing = true) {
|
| 468 |
createAudio();
|
| 469 |
const mode = timer.value, deadline = sleepDeadline;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
stop("正在准备首句…");
|
| 471 |
-
const batch = plan.batch(snapshot.text, offset, true);
|
| 472 |
if (!batch) { notice("已到正文末尾"); return; }
|
| 473 |
-
const value = { snapshot, batch, position: batch.start, part:
|
| 474 |
-
|
| 475 |
-
|
|
|
|
|
|
|
| 476 |
timer.value = mode; armSleep();
|
| 477 |
if (deadline) { sleepDeadline = deadline; if (checkSleep()) return; }
|
| 478 |
$("#speech-title").textContent = snapshot.title || "朗读";
|
|
@@ -483,6 +546,7 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 483 |
function fromPreview(offset = visibleOffset()) {
|
| 484 |
if (!preview || !preview.text.trim()) { if (typeof showToast === "function") showToast("请等待预览正文加载完成"); return; }
|
| 485 |
if (owner && owner.snapshot.id !== preview.id && !confirm("切换到当前预览会停止原朗读,继续吗?")) return;
|
|
|
|
| 486 |
start({ id: preview.id, text: preview.text, title: preview.title }, offset);
|
| 487 |
}
|
| 488 |
function resume() {
|
|
@@ -503,8 +567,34 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 503 |
function navigate(direction) {
|
| 504 |
if (!owner) return;
|
| 505 |
const sentence = plan.sentence(owner.snapshot.text, owner.position);
|
| 506 |
-
const target = direction > 0 ? sentence.end : plan.
|
| 507 |
-
if (target < owner.snapshot.text.length) start(owner.snapshot, target, owner.wantsPlay);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
}
|
| 509 |
function previewLoaded(value) {
|
| 510 |
clearMark();
|
|
|
|
| 2 |
// Pure, bounded rolling source planner. All offsets refer to original UTF-16.
|
| 3 |
globalThis.CCRDSpeechPlan = (() => {
|
| 4 |
const ending = /[。!?!?;;\n]/u;
|
|
|
|
| 5 |
const ascii = /[A-Za-z0-9]/u;
|
| 6 |
const words = typeof Intl.Segmenter === "function" ? new Intl.Segmenter("zh", { granularity: "word" }) : null;
|
| 7 |
+
const connectors = "但是|可是|不过|然而|同时|而且|并且|因此|所以|此外|另外|于是|但是同时|与此同时|但是也|但是又|同时也|而是|或者|以及";
|
| 8 |
+
const connectorWords = connectors.split("|").sort((a, b) => b.length - a.length);
|
| 9 |
+
function connectorOnly(text) {
|
| 10 |
+
let index = 0, found = false;
|
| 11 |
+
while (index < text.length) {
|
| 12 |
+
if (/[,,、::\s]/u.test(text[index])) { index += 1; continue; }
|
| 13 |
+
const word = connectorWords.find(value => text.startsWith(value, index));
|
| 14 |
+
if (!word) return false;
|
| 15 |
+
found = true; index += word.length;
|
| 16 |
+
}
|
| 17 |
+
return found;
|
| 18 |
+
}
|
| 19 |
+
const dangling = new Set(["的", "地", "得", "和", "与", "及", "或", "把", "被", "在", "对", "向", "为", "将", "不", "一", "很", "更", "最", "也", "还", "都", "就", "再", "又", "才", "没", "没有", "关于", "以及", "因为", "为了", "由于", "如果", ...connectors.split("|"), "一个", "这个", "那个", "今后", "目前", "当时", "现在", "已经", "正在", "需要", "应该", "必须", "可以", "能够", "不能", "有关", "一些"]);
|
| 20 |
function numberSpans(text) {
|
| 21 |
const label = "(?:订单编号|訂單編號|订单号|訂單號|编号|編號|手机号|手機號|电话|電話|学号|學號|邮编|郵編|ISBN(?:-13|-10)?|IP)";
|
| 22 |
const pattern = new RegExp(label + "[ \\t]*[::]?[ \\t]*[A-Za-z0-9]+(?:[._/\\-][A-Za-z0-9]+)*" +
|
|
|
|
| 42 |
const position = offsets.indexOf(start), short = first && !ends.length;
|
| 43 |
const target = offsets[Math.min(chars.length, position + (short ? 16 : 90))];
|
| 44 |
const ceiling = offsets[Math.min(chars.length, position + (short ? 18 : 120))];
|
| 45 |
+
const hard = cuts.find(value => value.end > start && value.end <= ceiling && /[。!?!?;;\n.][”’"'」』))\t \r]*$/u.test(text.slice(start, value.end)));
|
| 46 |
+
const natural = cuts.filter(value => value.end > start + (short ? 5 : 20) && value.end <= ceiling &&
|
| 47 |
+
/[,,::][”’"'」』))\t \r]*$/u.test(text.slice(start, value.end)) && !connectorOnly(text.slice(start, value.end).trim()));
|
| 48 |
let end;
|
| 49 |
+
if (hard) end = hard.end; // Never merge different spoken sentences in one part.
|
| 50 |
+
else if (natural.length) end = short ? natural[0].end : natural[natural.length - 1].end;
|
| 51 |
else {
|
| 52 |
const choices = cuts.filter(value => value.end > start && value.end <= target && !dangling.has(value.word));
|
| 53 |
const any = cuts.filter(value => value.end > start && value.end <= target);
|
| 54 |
end = choices.length ? choices[choices.length - 1].end : any.length ? any[any.length - 1].end : cuts.find(value => value.end > start)?.end || text.length;
|
| 55 |
}
|
| 56 |
+
// Keep leading connectives with their predicate, not in a tiny independent
|
| 57 |
+
// model clip. No source replacement, phoneme gain or global tone changes.
|
| 58 |
+
if (connectorOnly(text.slice(start, end).trim()) && end < text.length) {
|
| 59 |
+
end = cuts.find(value => value.end > end && !dangling.has(value.word) && !connectorOnly(text.slice(start, value.end).trim()))?.end || text.length;
|
| 60 |
+
}
|
| 61 |
+
while (end < text.length && /[”’"'」』))\s]/u.test(text[end])) end += 1;
|
| 62 |
if (end <= start) throw new Error("无法确定完整词语边界");
|
| 63 |
if (end < text.length && !/[\p{L}\p{N}]/u.test(text.slice(end))) end = text.length;
|
| 64 |
ends.push(end); start = end;
|
|
|
|
| 97 |
const finish = boundary(text, end++);
|
| 98 |
if (finish) break;
|
| 99 |
}
|
| 100 |
+
while (end < text.length && /[”’"'」』))\s]/u.test(text[end])) end += 1;
|
| 101 |
return { start: safe(text, start), end: safe(text, end) };
|
| 102 |
}
|
| 103 |
+
function partIndex(batch, offset) {
|
| 104 |
+
const relative = offset - batch.start;
|
| 105 |
+
if (relative === 0) return 0;
|
| 106 |
+
const previous = batch.segment_ends.indexOf(relative);
|
| 107 |
+
return previous >= 0 && previous + 1 < batch.segment_ends.length ? previous + 1 : -1;
|
| 108 |
+
}
|
| 109 |
+
function previousSentence(text, offset) {
|
| 110 |
+
let cursor = sentence(text, offset).start - 1;
|
| 111 |
+
while (cursor > 0 && /[”’"'」』))\s]/u.test(text[cursor])) cursor -= 1;
|
| 112 |
+
return sentence(text, Math.max(0, cursor)).start;
|
| 113 |
+
}
|
| 114 |
function batch(text, offset, first = false) {
|
| 115 |
const start = first ? wordStart(text, offset) : safe(text, offset), limit = first ? 48 : 480;
|
| 116 |
if (start >= text.length) return null;
|
|
|
|
| 137 |
const candidates = wordEnds(text.slice(start, offsets[Math.min(chars.length, 800)]))
|
| 138 |
.filter(value => value.end <= stop - start && value.end > 0 && !dangling.has(value.word));
|
| 139 |
if (candidates.length && stop < text.length) stop = start + candidates[candidates.length - 1].end;
|
| 140 |
+
let body = text.slice(start, stop), ends = phraseEnds(body, first);
|
| 141 |
+
if (ends.length > 64) { stop = start + ends[63]; body = text.slice(start, stop); ends = ends.slice(0, 64); }
|
| 142 |
+
return { start, end: stop, text: body, first, segment_ends: ends,
|
| 143 |
context_before: Array.from(text.slice(Math.max(0, start - 64), start)).slice(-32).join(""),
|
| 144 |
context_after: Array.from(text.slice(stop, stop + 64)).slice(0, 32).join("") };
|
| 145 |
}
|
| 146 |
+
return { safe, sentence, batch, phraseEnds, wordEnds, wordStart, numberSpans, partIndex, previousSentence };
|
| 147 |
})();
|
| 148 |
|
| 149 |
if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
|
|
| 171 |
function clearInline(job) {
|
| 172 |
if (job?.inlineUrl) { URL.revokeObjectURL(job.inlineUrl); job.inlineUrl = null; job.inlineConsumed = true; }
|
| 173 |
}
|
| 174 |
+
function remember(value, job) {
|
| 175 |
+
if (job.state !== "ready" || !job.used) return;
|
| 176 |
+
value.history = [job.batch, ...value.history.filter(batch => batch.start !== job.batch.start)].slice(0, 2);
|
| 177 |
+
}
|
| 178 |
function discard(value, job) {
|
| 179 |
+
remember(value, job);
|
| 180 |
job.cancelled = true;
|
| 181 |
value.jobs.delete(job.batch.start);
|
| 182 |
clearInline(job);
|
|
|
|
| 216 |
play.textContent = owner?.wantsPlay ? "暂停" : owner?.blocked ? "重试" : "继续";
|
| 217 |
play.setAttribute("aria-label", play.textContent + "朗读");
|
| 218 |
play.disabled = !owner && !saved;
|
| 219 |
+
previous.disabled = !owner || plan.sentence(owner.snapshot.text, owner.position).start <= 0;
|
| 220 |
+
next.disabled = !owner || plan.sentence(owner.snapshot.text, owner.position).end >= owner.snapshot.text.length;
|
| 221 |
visible.disabled = !preview;
|
| 222 |
locate.disabled = !owner && !saved;
|
| 223 |
$("#speech-pitch-value").textContent = (Number(pitch.value) > 0 ? "+" : "") + pitch.value;
|
|
|
|
| 315 |
function stop(message = "已停止,可继续朗读", keepalive = false) {
|
| 316 |
const value = owner;
|
| 317 |
if (value) {
|
| 318 |
+
for (const job of value.jobs.values()) remember(value, job);
|
| 319 |
+
saved = { snapshot: value.snapshot, position: value.position, history: value.history, pitch: value.pitch };
|
| 320 |
value.cancelled = true; clearTimeout(value.timer);
|
| 321 |
for (const controller of value.controllers) if (!controller.createsJob) controller.abort();
|
| 322 |
for (const job of value.jobs.values()) { clearInline(job); release(job.id, keepalive); }
|
|
|
|
| 428 |
acceptInline(job, data);
|
| 429 |
}
|
| 430 |
async function update(value, job) {
|
| 431 |
+
if (!live(value) || job.cancelled || job.pending || !value.wantsPlay || job.state === "ready" || job.blocked || Date.now() < job.retryAt || checkSleep()) return;
|
| 432 |
+
job.pending = true;
|
| 433 |
try {
|
| 434 |
if (Date.now() > job.deadline) throw new Error("语音排队超时,点击继续重试");
|
| 435 |
const data = job.id ? await request(`/tts/jobs/${job.id}`, {}, value) : await request("/tts/jobs", { method: "POST", headers: { "Content-Type": "application/json" },
|
| 436 |
body: JSON.stringify({ text: job.batch.text, voice: "zf_001", speed: 1.2, pitch_semitones: value.pitch,
|
| 437 |
+
unsupported_policy: "pause", progressive: true, segment_ends: job.batch.segment_ends, wait_first: job.batch.first,
|
| 438 |
+
inline_first: job.batch.first && value.part === 0,
|
| 439 |
+
cache_only: job.restore === true,
|
| 440 |
context_before: job.batch.context_before,
|
| 441 |
context_after: job.batch.context_after, request_id: job.nonce }) }, value, job.batch.first ? 22000 : 15000);
|
| 442 |
if (!live(value) || job.cancelled || value.jobs.get(job.batch.start) !== job) { release(data.id); return; }
|
| 443 |
accept(job, data, value); job.failures = 0; job.retryAt = Date.now() + (job.parts.length ? 650 : 250);
|
| 444 |
} catch (error) {
|
| 445 |
if (!live(value) || job.cancelled) return;
|
| 446 |
+
if (job.restore && error.status === 410) {
|
| 447 |
+
// Metadata history is not an audio cache promise. A miss must NOT queue
|
| 448 |
+
// a large old batch; create just the requested new foreground sentence.
|
| 449 |
+
start(value.snapshot, value.position, value.wantsPlay, true);
|
| 450 |
+
return;
|
| 451 |
+
}
|
| 452 |
job.failures += 1;
|
| 453 |
const transient = [404, 410, 429, 503].includes(error.status) || error.name === "AbortError" || error instanceof TypeError;
|
| 454 |
job.retryAt = Date.now() + 1000 * (error.retryAfter || Math.min(20, 2 ** job.failures));
|
|
|
|
| 463 |
notice(error.message + (job.blocked ? " · 点击继续重试" : " · 稍后重试,进度保留"));
|
| 464 |
}
|
| 465 |
controls();
|
| 466 |
+
} finally { job.pending = false; }
|
| 467 |
}
|
| 468 |
function advance(value) {
|
| 469 |
if (!live(value) || !value.wantsPlay || value.blocked || checkSleep()) return;
|
|
|
|
| 473 |
if (!value.current) {
|
| 474 |
const part = job.parts[value.part];
|
| 475 |
if (part) {
|
| 476 |
+
job.used = true;
|
| 477 |
value.current = { job, part }; value.position = job.batch.start + part.start;
|
| 478 |
switching = true; audio.src = part.index === 0 && job.inlineUrl ? job.inlineUrl : ORIGIN + part.audio_url; audio.load(); applyRate();
|
| 479 |
notice("朗读中"); mark(); controls();
|
|
|
|
| 490 |
// One following API batch and one following immutable MP3 only. No work on
|
| 491 |
// pause, no document-wide plan/DOM/audio cache and no whole-book POST.
|
| 492 |
if (!finishClip && value.current && job.state === "ready" && job.batch.end < value.snapshot.text.length && !value.jobs.has(job.batch.end)) {
|
| 493 |
+
// A completed preceding batch can remain usable briefly after a seek, but
|
| 494 |
+
// never keep a third subscription or add extra speculative inference.
|
| 495 |
+
for (const other of Array.from(value.jobs.values())) if (other !== job) discard(value, other);
|
| 496 |
const batch = plan.batch(value.snapshot.text, job.batch.end);
|
| 497 |
value.jobs.set(batch.start, newJob(batch));
|
| 498 |
}
|
|
|
|
| 500 |
if (following && preloader.getAttribute("src") !== ORIGIN + following.audio_url) preloader.src = ORIGIN + following.audio_url;
|
| 501 |
}
|
| 502 |
async function pump(value) {
|
| 503 |
+
if (!live(value) || value.pumpEpoch === value.seekEpoch || checkSleep() || !value.wantsPlay) return;
|
| 504 |
+
const epoch = value.seekEpoch;
|
| 505 |
+
clearTimeout(value.timer); value.pumpEpoch = epoch;
|
| 506 |
try {
|
| 507 |
const current = value.jobs.get(value.batch.start);
|
| 508 |
if (current) await update(value, current);
|
| 509 |
+
if (!live(value) || epoch !== value.seekEpoch) return;
|
| 510 |
advance(value); // Never wait for speculative work before playing a part.
|
| 511 |
if (!live(value) || !value.wantsPlay) return;
|
| 512 |
for (const job of Array.from(value.jobs.values())) if (job.batch.start !== current?.batch.start) {
|
| 513 |
+
await update(value, job);
|
| 514 |
+
if (!live(value) || epoch !== value.seekEpoch) return;
|
| 515 |
+
advance(value);
|
| 516 |
}
|
| 517 |
} finally {
|
| 518 |
+
if (epoch === value.seekEpoch) {
|
| 519 |
+
value.pumpEpoch = null;
|
| 520 |
+
if (live(value) && value.wantsPlay) value.timer = setTimeout(() => pump(value), value.current ? 600 : 250);
|
| 521 |
+
}
|
| 522 |
}
|
| 523 |
}
|
| 524 |
+
function start(snapshot, offset, playing = true, skipHistory = false) {
|
| 525 |
createAudio();
|
| 526 |
const mode = timer.value, deadline = sleepDeadline;
|
| 527 |
+
if (owner) for (const job of owner.jobs.values()) remember(owner, job);
|
| 528 |
+
const recent = owner || saved;
|
| 529 |
+
const history = recent?.snapshot.id === snapshot.id && recent.snapshot.text === snapshot.text && recent.pitch === Number(pitch.value) ? recent.history.slice() : [];
|
| 530 |
+
const restored = !skipHistory && history.find(batch => plan.partIndex(batch, offset) >= 0);
|
| 531 |
stop("正在准备首句…");
|
| 532 |
+
const batch = restored || plan.batch(snapshot.text, offset, true);
|
| 533 |
if (!batch) { notice("已到正文末尾"); return; }
|
| 534 |
+
const value = { snapshot, batch, position: restored ? offset : batch.start, part: restored ? plan.partIndex(batch, offset) : 0,
|
| 535 |
+
current: null, pitch: Number(pitch.value), wantsPlay: playing, history,
|
| 536 |
+
jobs: new Map(), controllers: new Set(), cancelled: false, blocked: false, seekEpoch: 0, pumpEpoch: null };
|
| 537 |
+
const job = newJob(batch); job.restore = !!restored;
|
| 538 |
+
value.jobs.set(batch.start, job); owner = value; saved = null;
|
| 539 |
timer.value = mode; armSleep();
|
| 540 |
if (deadline) { sleepDeadline = deadline; if (checkSleep()) return; }
|
| 541 |
$("#speech-title").textContent = snapshot.title || "朗读";
|
|
|
|
| 546 |
function fromPreview(offset = visibleOffset()) {
|
| 547 |
if (!preview || !preview.text.trim()) { if (typeof showToast === "function") showToast("请等待预览正文加载完成"); return; }
|
| 548 |
if (owner && owner.snapshot.id !== preview.id && !confirm("切换到当前预览会停止原朗读,继续吗?")) return;
|
| 549 |
+
if (owner?.snapshot.id === preview.id && owner.snapshot.text === preview.text && seek(owner, offset, true)) return;
|
| 550 |
start({ id: preview.id, text: preview.text, title: preview.title }, offset);
|
| 551 |
}
|
| 552 |
function resume() {
|
|
|
|
| 567 |
function navigate(direction) {
|
| 568 |
if (!owner) return;
|
| 569 |
const sentence = plan.sentence(owner.snapshot.text, owner.position);
|
| 570 |
+
const target = direction > 0 ? sentence.end : plan.previousSentence(owner.snapshot.text, owner.position);
|
| 571 |
+
if (target < owner.snapshot.text.length && !seek(owner, target, owner.wantsPlay)) start(owner.snapshot, target, owner.wantsPlay);
|
| 572 |
+
}
|
| 573 |
+
function seek(value, offset, playing) {
|
| 574 |
+
if (!live(value) || value.settingsChanged || value.pitch !== Number(pitch.value)) return false;
|
| 575 |
+
const job = Array.from(value.jobs.values()).find(item => !item.cancelled && !item.blocked && plan.partIndex(item.batch, offset) >= 0);
|
| 576 |
+
if (!job) return false;
|
| 577 |
+
const index = plan.partIndex(job.batch, offset), current = value.current;
|
| 578 |
+
value.seekEpoch += 1; clearTimeout(value.timer); clearPreload();
|
| 579 |
+
value.position = offset; value.batch = job.batch; value.part = index; value.wantsPlay = playing;
|
| 580 |
+
value.blocked = false;
|
| 581 |
+
// Do not let synthesis of a now irrelevant batch hold up the target job.
|
| 582 |
+
for (const other of Array.from(value.jobs.values())) {
|
| 583 |
+
if (other !== job && other.batch.start !== job.batch.end && other.state !== "ready") discard(value, other);
|
| 584 |
+
else if (!playing && other !== job && other.state !== "ready") discard(value, other);
|
| 585 |
+
}
|
| 586 |
+
if (current?.part.index === 0 && (current.job !== job || current.part.index !== index)) clearInline(current.job);
|
| 587 |
+
if (current?.job === job && current.part.index === index && audio.readyState >= 1 && !audio.error) {
|
| 588 |
+
audio.currentTime = 0;
|
| 589 |
+
if (playing) playAudio(value); else audio.pause();
|
| 590 |
+
} else {
|
| 591 |
+
value.current = null; switching = true; audio.pause(); audio.removeAttribute("src"); audio.load();
|
| 592 |
+
advance(value);
|
| 593 |
+
}
|
| 594 |
+
show(); mark(); controls();
|
| 595 |
+
notice(playing ? (value.current ? "朗读中" : "正在准备所选句…") : "已选择句子,点击继续播放");
|
| 596 |
+
pump(value);
|
| 597 |
+
return true;
|
| 598 |
}
|
| 599 |
function previewLoaded(value) {
|
| 600 |
clearMark();
|
tests/test_tts_browser.py
CHANGED
|
@@ -34,10 +34,11 @@ class SpeechReaderBrowserTests(unittest.TestCase):
|
|
| 34 |
context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900}, reduced_motion="reduce")
|
| 35 |
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});")
|
| 36 |
context.add_init_script("window.__sourceRange=(body,offset)=>{const w=document.createTreeWalker(body,NodeFilter.SHOW_TEXT);let n;while(n=w.nextNode()){if(offset<n.length){const r=document.createRange();r.setStart(n,offset);r.setEnd(n,offset+1);return r;}offset-=n.length;}};")
|
|
|
|
| 37 |
context.add_init_script("window.__audioBlobs=new Set();window.__blobCount=0;const make=URL.createObjectURL.bind(URL),drop=URL.revokeObjectURL.bind(URL);URL.createObjectURL=blob=>{const url=make(blob);__audioBlobs.add(url);__blobCount++;return url;};URL.revokeObjectURL=url=>{__audioBlobs.delete(url);drop(url);};")
|
| 38 |
if mobile:
|
| 39 |
context.add_init_script("Object.defineProperty(CSS,'highlights',{value:null,configurable:true});")
|
| 40 |
-
posts, deleted, paths, jobs, errors, held = [], [], [], {}, [], []
|
| 41 |
hold_next, offline, missing, bad = [False], [False], [False], [False]
|
| 42 |
inline_mode = ["valid"]
|
| 43 |
headers = {"Access-Control-Allow-Origin": SEARCH, "Access-Control-Allow-Headers": "Content-Type,Range", "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS"}
|
|
@@ -69,13 +70,19 @@ class SpeechReaderBrowserTests(unittest.TestCase):
|
|
| 69 |
if path == "/tts/jobs" and request.method == "POST":
|
| 70 |
body = request.post_data_json
|
| 71 |
posts.append(body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
id = f"{len(posts):032d}"
|
| 73 |
length = len(body["text"].encode("utf-16-le")) // 2
|
| 74 |
middle = len(body["text"][:20].encode("utf-16-le")) // 2
|
| 75 |
spans = list(zip([0] + body["segment_ends"][:-1], body["segment_ends"]))
|
| 76 |
parts = [{"index": index, "start": begin, "end": end, "audio_url": f"/tts/audio/{id}/parts/{index}.mp3", "pitch_semitones": body["pitch_semitones"], "bytes": len(mp3)} for index, (begin, end) in enumerate(spans)]
|
| 77 |
if bad[0]: parts[0]["audio_url"] = "https://untrusted.test/leak"
|
| 78 |
-
jobs[id] = {"id": id, "state": "ready", "source_segments": [{"start": begin, "end": end} for begin, end in spans],
|
| 79 |
"progress": {"total_parts": len(parts), "parts": parts}, "result": {"pitch_semitones": body["pitch_semitones"]}}
|
| 80 |
if body.get("inline_first") and inline_mode[0] != "omit":
|
| 81 |
jobs[id]["first_audio"] = {"index": 0, "encoding": "base64", "content_type": "audio/mpeg", "bytes": len(mp3),
|
|
@@ -194,14 +201,48 @@ class SpeechReaderBrowserTests(unittest.TestCase):
|
|
| 194 |
self.assertGreater(len(posts), before)
|
| 195 |
self.assertTrue(posts[before]["text"].startswith("第0000段"))
|
| 196 |
page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
|
|
|
|
| 197 |
before = len(posts)
|
| 198 |
page.locator("#speech-next").click()
|
| 199 |
-
page.
|
| 200 |
-
self.
|
| 201 |
before = len(posts)
|
| 202 |
page.locator("#speech-previous").click()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
page.wait_for_timeout(300)
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
# Pitch changes regenerate only from the current
|
| 206 |
# source clip, while speed remains instantaneous.
|
| 207 |
before = len(posts)
|
|
@@ -260,8 +301,9 @@ class SpeechReaderBrowserTests(unittest.TestCase):
|
|
| 260 |
self.assertEqual(page.evaluate("__audioBlobs.size"), 0)
|
| 261 |
# Optional delivery can be absent or malformed: use
|
| 262 |
# only the canonical GET, without skipping source.
|
| 263 |
-
for mode in ("omit", "invalid", "decode-error", "paused-error"):
|
| 264 |
inline_mode[0] = mode
|
|
|
|
| 265 |
first_path = f"/tts/audio/{len(posts) + 1:032d}/parts/0.mp3"
|
| 266 |
page.locator("[data-speech-toggle]").click()
|
| 267 |
page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
|
|
|
|
| 34 |
context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900}, reduced_motion="reduce")
|
| 35 |
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});")
|
| 36 |
context.add_init_script("window.__sourceRange=(body,offset)=>{const w=document.createTreeWalker(body,NodeFilter.SHOW_TEXT);let n;while(n=w.nextNode()){if(offset<n.length){const r=document.createRange();r.setStart(n,offset);r.setEnd(n,offset+1);return r;}offset-=n.length;}};")
|
| 37 |
+
context.add_init_script("window.__spokenText=()=>document.querySelector('.speech-current')?.textContent||Array.from(CSS.highlights?.get('ccrd-speech')||[])[0]?.toString()||'';")
|
| 38 |
context.add_init_script("window.__audioBlobs=new Set();window.__blobCount=0;const make=URL.createObjectURL.bind(URL),drop=URL.revokeObjectURL.bind(URL);URL.createObjectURL=blob=>{const url=make(blob);__audioBlobs.add(url);__blobCount++;return url;};URL.revokeObjectURL=url=>{__audioBlobs.delete(url);drop(url);};")
|
| 39 |
if mobile:
|
| 40 |
context.add_init_script("Object.defineProperty(CSS,'highlights',{value:null,configurable:true});")
|
| 41 |
+
posts, deleted, paths, jobs, errors, held, cached = [], [], [], {}, [], [], set()
|
| 42 |
hold_next, offline, missing, bad = [False], [False], [False], [False]
|
| 43 |
inline_mode = ["valid"]
|
| 44 |
headers = {"Access-Control-Allow-Origin": SEARCH, "Access-Control-Allow-Headers": "Content-Type,Range", "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS"}
|
|
|
|
| 70 |
if path == "/tts/jobs" and request.method == "POST":
|
| 71 |
body = request.post_data_json
|
| 72 |
posts.append(body)
|
| 73 |
+
key = json.dumps({name: body.get(name) for name in ("text", "voice", "speed", "pitch_semitones", "segment_ends", "context_before", "context_after", "unsupported_policy")}, sort_keys=True)
|
| 74 |
+
hit = key in cached
|
| 75 |
+
if body.get("cache_only") and not hit:
|
| 76 |
+
reply(route, {"error": {"code": "audio_expired", "message": "已完成的句段缓存已过期"}}, 410)
|
| 77 |
+
return
|
| 78 |
+
cached.add(key)
|
| 79 |
id = f"{len(posts):032d}"
|
| 80 |
length = len(body["text"].encode("utf-16-le")) // 2
|
| 81 |
middle = len(body["text"][:20].encode("utf-16-le")) // 2
|
| 82 |
spans = list(zip([0] + body["segment_ends"][:-1], body["segment_ends"]))
|
| 83 |
parts = [{"index": index, "start": begin, "end": end, "audio_url": f"/tts/audio/{id}/parts/{index}.mp3", "pitch_semitones": body["pitch_semitones"], "bytes": len(mp3)} for index, (begin, end) in enumerate(spans)]
|
| 84 |
if bad[0]: parts[0]["audio_url"] = "https://untrusted.test/leak"
|
| 85 |
+
jobs[id] = {"id": id, "state": "ready", "cache_hit": hit, "source_segments": [{"start": begin, "end": end} for begin, end in spans],
|
| 86 |
"progress": {"total_parts": len(parts), "parts": parts}, "result": {"pitch_semitones": body["pitch_semitones"]}}
|
| 87 |
if body.get("inline_first") and inline_mode[0] != "omit":
|
| 88 |
jobs[id]["first_audio"] = {"index": 0, "encoding": "base64", "content_type": "audio/mpeg", "bytes": len(mp3),
|
|
|
|
| 201 |
self.assertGreater(len(posts), before)
|
| 202 |
self.assertTrue(posts[before]["text"].startswith("第0000段"))
|
| 203 |
page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
|
| 204 |
+
page.wait_for_timeout(300)
|
| 205 |
before = len(posts)
|
| 206 |
page.locator("#speech-next").click()
|
| 207 |
+
page.wait_for_function("__spokenText().startsWith('𠮷※��')&&!document.querySelector('#speech-audio').paused")
|
| 208 |
+
self.assertEqual(len(posts), before, "Next must reuse the already-generated job rather than resubmit")
|
| 209 |
before = len(posts)
|
| 210 |
page.locator("#speech-previous").click()
|
| 211 |
+
page.wait_for_function("__spokenText().startsWith('第0000段')&&!document.querySelector('#speech-audio').paused")
|
| 212 |
+
self.assertEqual(len(posts), before, "Previous must reuse the already-generated job rather than resubmit")
|
| 213 |
+
page.mouse.dblclick(box["x"], box["y"])
|
| 214 |
+
page.wait_for_function("__spokenText().startsWith('第0000段')&&!document.querySelector('#speech-audio').paused")
|
| 215 |
+
self.assertEqual(len(posts), before, "Double-click within buffered source must not regenerate")
|
| 216 |
+
# Crossing the two-job window must restore the exact
|
| 217 |
+
# completed batch as cache-only, not new inference.
|
| 218 |
+
page.locator("#speech-next").click()
|
| 219 |
+
page.wait_for_function("__spokenText().startsWith('𠮷※😀')")
|
| 220 |
+
page.locator("#speech-next").click()
|
| 221 |
+
page.wait_for_function("__spokenText().startsWith('第0001段')&&!document.querySelector('#speech-audio').paused")
|
| 222 |
page.wait_for_timeout(300)
|
| 223 |
+
before = len(posts)
|
| 224 |
+
page.locator("#speech-previous").click()
|
| 225 |
+
page.wait_for_function("__spokenText().startsWith('𠮷※😀')&&!document.querySelector('#speech-audio').paused")
|
| 226 |
+
restored = [row for row in posts[before:] if row.get("cache_only")]
|
| 227 |
+
self.assertEqual(len(restored), 1)
|
| 228 |
+
self.assertTrue(restored[0]["text"].startswith("第0000段"))
|
| 229 |
+
page.locator("#speech-previous").click()
|
| 230 |
+
page.wait_for_function("__spokenText().startsWith('第0000段')&&!document.querySelector('#speech-audio').paused")
|
| 231 |
+
# Eviction must cause only one cache lookup miss,
|
| 232 |
+
# then new synthesis starts at the target source.
|
| 233 |
+
cached.clear()
|
| 234 |
+
page.locator("#speech-next").click()
|
| 235 |
+
page.wait_for_function("__spokenText().startsWith('𠮷※😀')")
|
| 236 |
+
page.locator("#speech-next").click()
|
| 237 |
+
page.wait_for_function("__spokenText().startsWith('第0001段')&&!document.querySelector('#speech-audio').paused")
|
| 238 |
+
page.wait_for_timeout(200)
|
| 239 |
+
before = len(posts)
|
| 240 |
+
page.locator("#speech-previous").click()
|
| 241 |
+
page.wait_for_function("__spokenText().startsWith('𠮷※😀')&&!document.querySelector('#speech-audio').paused")
|
| 242 |
+
self.assertEqual(len([row for row in posts[before:] if row.get("cache_only")]), 1)
|
| 243 |
+
self.assertTrue(any(not row.get("cache_only") and row["text"].startswith("𠮷※😀") for row in posts[before:]))
|
| 244 |
+
page.locator("#speech-previous").click()
|
| 245 |
+
page.wait_for_function("__spokenText().startsWith('第0000段')&&!document.querySelector('#speech-audio').paused")
|
| 246 |
# Pitch changes regenerate only from the current
|
| 247 |
# source clip, while speed remains instantaneous.
|
| 248 |
before = len(posts)
|
|
|
|
| 301 |
self.assertEqual(page.evaluate("__audioBlobs.size"), 0)
|
| 302 |
# Optional delivery can be absent or malformed: use
|
| 303 |
# only the canonical GET, without skipping source.
|
| 304 |
+
for index, mode in enumerate(("omit", "invalid", "decode-error", "paused-error")):
|
| 305 |
inline_mode[0] = mode
|
| 306 |
+
page.evaluate("id=>openPreview(id,'',{scroll:false})", "CCRD:1" if index % 2 == 0 else "CCRD:0")
|
| 307 |
first_path = f"/tts/audio/{len(posts) + 1:032d}/parts/0.mp3"
|
| 308 |
page.locator("[data-speech-toggle]").click()
|
| 309 |
page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
|
tests/test_tts_plan.cjs
CHANGED
|
@@ -40,4 +40,28 @@ for (const source of ["中共中央批转文化部党组关于今后文化艺术
|
|
| 40 |
offset=part.end;
|
| 41 |
}
|
| 42 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
console.log("Rolling source plan: lossless >20k, non-BMP, numeric atoms, bounded batches/context PASS");
|
|
|
|
| 40 |
offset=part.end;
|
| 41 |
}
|
| 42 |
}
|
| 43 |
+
for (const source of ["第一句。第二句继续说。第三句接着说。".repeat(50), "第一句。\n\n“第二句继续说。” 第三句接着说。".repeat(20)]) {
|
| 44 |
+
let offset = 0;
|
| 45 |
+
while (offset < source.length) {
|
| 46 |
+
const batch = plan.batch(source, offset, offset === 0);
|
| 47 |
+
let start = batch.start;
|
| 48 |
+
for (const end of batch.segment_ends) {
|
| 49 |
+
assert(plan.sentence(source, start).end >= batch.start + end, 'a clip must not merge separate sentences');
|
| 50 |
+
assert.equal(plan.partIndex(batch, start), batch.segment_ends.indexOf(end));
|
| 51 |
+
start = batch.start + end;
|
| 52 |
+
}
|
| 53 |
+
offset = batch.end;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
const spaced = "第一句。\n\n第二句。 第三句。";
|
| 57 |
+
assert.equal(plan.previousSentence(spaced, spaced.indexOf('第三句')), spaced.indexOf('第二句'));
|
| 58 |
+
assert.equal(plan.previousSentence(spaced, spaced.indexOf('第二句')), 0);
|
| 59 |
+
for (const source of ['但是,我们需要继续检查。', '同时,也要把后文读完整。', '但是,同时,我们仍然应该继续检查。']) {
|
| 60 |
+
const batch=plan.batch(source,0,true);
|
| 61 |
+
let start=0;
|
| 62 |
+
for (const end of batch.segment_ends) {
|
| 63 |
+
assert(!/^(?:但是|同时|[,,、\s])+$/u.test(batch.text.slice(start,end)), 'connectives must not become isolated synthesis clips');
|
| 64 |
+
start=end;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
console.log("Rolling source plan: lossless >20k, non-BMP, numeric atoms, bounded batches/context PASS");
|