vomebook commited on
Commit
a507fa7
·
1 Parent(s): 7bcd1d8

fix: align spoken sentences and reuse prepared audio on seeks

Browse files

Do 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 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=inline-4"></script>
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 dangling = new Set(["的", "地", "得", "和", "与", "及", "或", "把", "被", "在", "对", "向", "为", "将", "不", "一", "很", "更", "最", "也", "还", "都", "就", "再", "又", "才", "没", "没有", "关于", "以及", "因为", "为了", "由于", "如果", "但是", "一个", "这个", "那个", "今后", "目前", "当时", "现在", "已经", "正在", "需要", "应该", "必须", "可以", "能够", "不能", "有关", "一些"]);
 
 
 
 
 
 
 
 
 
 
 
 
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 natural = cuts.filter(value => value.end > start + (short ? 5 : 20) && value.end <= ceiling && /[,,。!?!?;;::\n][”’"'」』))\t \r]*$/u.test(text.slice(start, value.end)));
 
 
35
  let end;
36
- if (natural.length) end = short ? natural[0].end : natural[natural.length - 1].end;
 
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 && closers.test(text[end])) end += 1;
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
- const body = text.slice(start, stop);
110
- return { start, end: stop, text: body, first, segment_ends: phraseEnds(body, first),
 
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
- saved = { snapshot: value.snapshot, position: value.position };
 
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, inline_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.ticking || checkSleep() || !value.wantsPlay) return;
453
- clearTimeout(value.timer); value.ticking = true;
 
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); advance(value);
 
 
461
  }
462
  } finally {
463
- value.ticking = false;
464
- if (live(value) && value.wantsPlay) value.timer = setTimeout(() => pump(value), value.current ? 600 : 250);
 
 
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: 0, current: null, pitch: Number(pitch.value), wantsPlay: playing,
474
- jobs: new Map(), controllers: new Set(), cancelled: false, blocked: false };
475
- value.jobs.set(batch.start, newJob(batch)); owner = value; saved = null;
 
 
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.sentence(owner.snapshot.text, Math.max(0, sentence.start - 1)).start;
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.wait_for_timeout(300)
200
- self.assertTrue(posts[before]["text"].startswith("𠮷※😀"), posts[before]["text"])
201
  before = len(posts)
202
  page.locator("#speech-previous").click()
 
 
 
 
 
 
 
 
 
 
 
203
  page.wait_for_timeout(300)
204
- self.assertTrue(posts[before]["text"].startswith("第0000段"), posts[before]["text"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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");