vomebook commited on
Commit
281e432
·
1 Parent(s): ad14842

feat: read complete words with low-latency first phrases

Browse files

Use bounded browser word and natural-clause boundaries, protected numbers and original UTF-16 plans rather than fixed-character cuts. First POST waits for published audio; synthesis is fixed at 1.2 independent of playback rate. Hide voice name and replace collapse chevron with a horizontal icon while preserving collapse/stop and preview independence.

docs/TTS_PILOT.md CHANGED
@@ -7,15 +7,19 @@
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
  顺延;跳读/调整音高不重新计时。实际锁屏/后台由浏览器决定,仍需真机验证。
 
7
  从当前阅读位置开始、回到播放位置。双击正文会选中所在句子并从那里继续播放。
8
  - 原文保留在搜索页内存,不修改正文或搜索高亮;后端返回原始 UTF-16 句段范围。
9
  高亮是句段级,不把播放时间比例冒充逐字定位。“回到播放位置”可重新打开原预览。
10
+ - 去掉整篇两万字限制:首批目标 48 字,后续目标 480 字,每个 API 请求仍最多 800
11
  个 Unicode 字符、相邻上下文各最多 32 字。日期和普通英文词不在常规批次边界拆开。
12
  只保留当前/下一批任务及一个后续 MP3 预载,不整本上传或生成整本音频。
13
+ - 词边界由浏览器中文分词器在有限窗口内提供,优先在自然标点停顿,避免把复合词、
14
+ 专有名词和数字在 60 字处硬拆。首个音频片段目标约 16 字,之后用较完整的分句。
15
+ 源范围经 API/worker 验证。首个创建请求最多异步等 8 秒,首段可用立即返回,减少
16
+ 反复穿过 HF 入口轮询;这不是固定的首字速度保证。
17
  - Search 请求显式使用 `unsupported_policy="pause"`:不能发音的字符/符号按短停顿
18
  跨过,前后可发音正文继续读,源字符和定位不变。符号段可生成短静音;这不是生僻字
19
  发音识别能力。网络、数值歧义、模型故障不冒充“无法识别字符”,保留位置后重试。
20
+ - 合成语速固定 1.2 倍;面板语速是浏览器即时倍速(默认 1.0),保持音高。音高是后端 -3…+3 半音处理,修改时从当前句段
21
  重新生成。暂停时调整音高/选择相邻句不提交任务,直到点击继续。
22
+ - 不显示音色名称;横线按钮收起保留播放,× 停止并释放任务。关闭/更换预览、搜索、过滤或主题都不替换声音
23
  快照。搜索站不用安装模型,也不访问独立试听页的私密本机书签。
24
  - 手机设置栏同样不覆盖整屏,保留预览原有键盘隔离。定时采用经过的时间,暂停不
25
  顺延;跳读/调整音高不重新计时。实际锁屏/后台由浏览器决定,仍需真机验证。
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?v=compact-2">
14
  </head>
15
  <body class="dark">
16
 
@@ -148,7 +148,7 @@
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>
@@ -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=compact-2"></script>
162
- <script src="/static/app.js?v=compact-2"></script>
163
  </body>
164
  </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=phrases-3">
14
  </head>
15
  <body class="dark">
16
 
 
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><button type="button" id="speech-collapse" class="icon-btn-sm" aria-label="收起设置,继续播放" title="收起,继续播放"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M5 12h14"/></svg></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>
 
158
  </section>
159
 
160
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
161
+ <script src="/static/tts-client.js?v=phrases-3"></script>
162
+ <script src="/static/app.js?v=phrases-3"></script>
163
  </body>
164
  </html>
static/style.css CHANGED
@@ -52,7 +52,7 @@
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; }
 
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-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; }
static/tts-client.js CHANGED
@@ -4,6 +4,61 @@ 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;
@@ -26,7 +81,7 @@ globalThis.CCRDSpeechPlan = (() => {
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);
@@ -47,12 +102,16 @@ globalThis.CCRDSpeechPlan = (() => {
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 = (() => {
@@ -267,10 +326,10 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
267
  });
268
  if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
269
  }
270
- async function request(path, options, value) {
271
  const controller = new AbortController(); controller.createsJob = options?.method === "POST";
272
  value.controllers.add(controller);
273
- const timeout = setTimeout(() => controller.abort(), 15000);
274
  try {
275
  const response = await fetch(ORIGIN + path, { ...options, signal: controller.signal, cache: "no-store", credentials: "omit" });
276
  const data = await response.json();
@@ -298,6 +357,7 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
298
  if (part.index !== index || part.start !== end || !Number.isInteger(part.end) || part.end <= end || part.end > job.batch.text.length ||
299
  (part.pitch_semitones ?? 0) !== value.pitch || part.audio_url !== `/tts/audio/${job.id}/parts/${index}.mp3`) throw new Error("语音分段未连续保留原文");
300
  end = part.end;
 
301
  if (job.parts[index] && (job.parts[index].start !== part.start || job.parts[index].end !== part.end)) throw new Error("已发布语音的原文范围发生变化");
302
  }
303
  if (data.state === "ready" && (end !== job.batch.text.length || parts.length !== data.progress?.total_parts)) throw new Error("语音未完整覆盖当前正文");
@@ -308,9 +368,10 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
308
  try {
309
  if (Date.now() > job.deadline) throw new Error("语音排队超时,点击继续重试");
310
  const data = job.id ? await request(`/tts/jobs/${job.id}`, {}, value) : await request("/tts/jobs", { method: "POST", headers: { "Content-Type": "application/json" },
311
- body: JSON.stringify({ text: job.batch.text, voice: "zf_001", speed: 1, pitch_semitones: value.pitch,
312
- unsupported_policy: "pause", progressive: true, context_before: job.batch.context_before,
313
- context_after: job.batch.context_after, request_id: job.nonce }) }, value);
 
314
  if (!live(value) || job.cancelled || value.jobs.get(job.batch.start) !== job) { release(data.id); return; }
315
  accept(job, data, value); job.failures = 0; job.retryAt = Date.now() + (job.parts.length ? 650 : 250);
316
  } catch (error) {
@@ -390,7 +451,7 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
390
  if (deadline) { sleepDeadline = deadline; if (checkSleep()) return; }
391
  $("#speech-title").textContent = snapshot.title || "朗读";
392
  show(); notice(playing ? "正在准备首句…" : "已选择句子,点击继续播放"); controls();
393
- if ("mediaSession" in navigator && "MediaMetadata" in globalThis) navigator.mediaSession.metadata = new MediaMetadata({ title: snapshot.title, artist: "女声01" });
394
  pump(value); // First POST directly; no iframe, voices or health round-trips.
395
  }
396
  function fromPreview(offset = visibleOffset()) {
@@ -410,7 +471,8 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
410
  if (!owner) {
411
  if (saved?.snapshot.id === preview?.id) start(saved.snapshot, saved.position);
412
  else fromPreview();
413
- } else { show(); if (!owner.wantsPlay && owner.snapshot.id === preview?.id) resume(); }
 
414
  }
415
  function navigate(direction) {
416
  if (!owner) return;
 
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]+)*" +
12
+ "|(?<![A-Za-z0-9])(?:版本|[Vv]ersion|[Vv])[ \\t]*[::]?[ \\t]*[vV]?[0-9]+(?:\\.[0-9]+)+" +
13
+ "|(?<![A-Za-z0-9])[0-9]{4}[-/][0-9]{1,2}[-/][0-9]{1,2}(?![0-9])" +
14
+ "|(?<![A-Za-z0-9])[0-9]{4}年[0-9]{1,2}月[0-9]{1,2}[日号]" +
15
+ "|(?<![A-Za-z0-9])[0-9]{4}年|(?<![0-9])[0-9]+(?:\\.[0-9]+){2,}" +
16
+ "|(?<![A-Za-z0-9])[+-]?[0-9]+/[0-9]+(?![0-9/])" +
17
+ "|[+-]?[0-9]+(?:,[0-9]{3})*(?:\\.[0-9]+)?(?:[%%]|℃)?", "gu");
18
+ return Array.from(text.matchAll(pattern), match => ({ start: match.index, end: match.index + match[0].length }));
19
+ }
20
+ function wordEnds(text) {
21
+ const atoms = numberSpans(text);
22
+ const values = words ? Array.from(words.segment(text), value => ({ end: value.index + value.segment.length, word: value.segment.trim() })) :
23
+ Array.from(text.matchAll(/[^,,。!?!?;;::\n]+[,,。!?!?;;::\n]?/gu), value => ({ end: value.index + value[0].length, word: "" }));
24
+ return values.filter(value => !atoms.some(atom => atom.start < value.end && value.end < atom.end));
25
+ }
26
+ function phraseEnds(text, first = false) {
27
+ const cuts = wordEnds(text), chars = Array.from(text), offsets = [0], ends = [];
28
+ for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length);
29
+ let start = 0;
30
+ while (start < text.length) {
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;
45
+ }
46
+ return ends;
47
+ }
48
+ function wordStart(text, offset) {
49
+ offset = safe(text, offset);
50
+ if (!offset || offset === text.length) return offset;
51
+ const begin = safe(text, Math.max(0, offset - 96));
52
+ const nearby = text.slice(begin, offset + 128), relative = offset - begin;
53
+ const atom = numberSpans(nearby).find(value => value.start < relative && relative < value.end);
54
+ if (atom) return begin + atom.start;
55
+ if (words) {
56
+ for (const value of words.segment(nearby)) {
57
+ if (value.isWordLike && value.index < relative && relative < value.index + value.segment.length) return begin + value.index;
58
+ }
59
+ }
60
+ return offset;
61
+ }
62
  function safe(text, offset) {
63
  offset = Math.max(0, Math.min(text.length, Math.floor(offset)));
64
  return offset && /[\uDC00-\uDFFF]/u.test(text[offset] || "") && /[\uD800-\uDBFF]/u.test(text[offset - 1]) ? offset - 1 : offset;
 
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;
86
  const chars = Array.from(text.slice(start, start + 1800)), offsets = [start];
87
  for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length);
 
102
  }
103
  }
104
  }
105
+ let stop = offsets[end];
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 = (() => {
 
326
  });
327
  if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
328
  }
329
+ async function request(path, options, value, timeoutMs = 15000) {
330
  const controller = new AbortController(); controller.createsJob = options?.method === "POST";
331
  value.controllers.add(controller);
332
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
333
  try {
334
  const response = await fetch(ORIGIN + path, { ...options, signal: controller.signal, cache: "no-store", credentials: "omit" });
335
  const data = await response.json();
 
357
  if (part.index !== index || part.start !== end || !Number.isInteger(part.end) || part.end <= end || part.end > job.batch.text.length ||
358
  (part.pitch_semitones ?? 0) !== value.pitch || part.audio_url !== `/tts/audio/${job.id}/parts/${index}.mp3`) throw new Error("语音分段未连续保留原文");
359
  end = part.end;
360
+ if (job.batch.segment_ends[index] !== end) throw new Error("语音没有按完整词句边界生成");
361
  if (job.parts[index] && (job.parts[index].start !== part.start || job.parts[index].end !== part.end)) throw new Error("已发布语音的原文范围发生变化");
362
  }
363
  if (data.state === "ready" && (end !== job.batch.text.length || parts.length !== data.progress?.total_parts)) throw new Error("语音未完整覆盖当前正文");
 
368
  try {
369
  if (Date.now() > job.deadline) throw new Error("语音排队超时,点击继续重试");
370
  const data = job.id ? await request(`/tts/jobs/${job.id}`, {}, value) : await request("/tts/jobs", { method: "POST", headers: { "Content-Type": "application/json" },
371
+ body: JSON.stringify({ text: job.batch.text, voice: "zf_001", speed: 1.2, pitch_semitones: value.pitch,
372
+ unsupported_policy: "pause", progressive: true, segment_ends: job.batch.segment_ends, wait_first: job.batch.first,
373
+ context_before: job.batch.context_before,
374
+ context_after: job.batch.context_after, request_id: job.nonce }) }, value, job.batch.first ? 22000 : 15000);
375
  if (!live(value) || job.cancelled || value.jobs.get(job.batch.start) !== job) { release(data.id); return; }
376
  accept(job, data, value); job.failures = 0; job.retryAt = Date.now() + (job.parts.length ? 650 : 250);
377
  } catch (error) {
 
451
  if (deadline) { sleepDeadline = deadline; if (checkSleep()) return; }
452
  $("#speech-title").textContent = snapshot.title || "朗读";
453
  show(); notice(playing ? "正在准备首句…" : "已选择句子,点击继续播放"); controls();
454
+ if ("mediaSession" in navigator && "MediaMetadata" in globalThis) navigator.mediaSession.metadata = new MediaMetadata({ title: snapshot.title });
455
  pump(value); // First POST directly; no iframe, voices or health round-trips.
456
  }
457
  function fromPreview(offset = visibleOffset()) {
 
471
  if (!owner) {
472
  if (saved?.snapshot.id === preview?.id) start(saved.snapshot, saved.position);
473
  else fromPreview();
474
+ } else if (!panel.hidden) hide();
475
+ else { show(); if (!owner.wantsPlay && owner.snapshot.id === preview?.id) resume(); }
476
  }
477
  function navigate(direction) {
478
  if (!owner) return;
tests/test_tts_browser.py CHANGED
@@ -69,7 +69,7 @@ class SpeechReaderBrowserTests(unittest.TestCase):
69
  id = f"{len(posts):032d}"
70
  length = len(body["text"].encode("utf-16-le")) // 2
71
  middle = len(body["text"][:20].encode("utf-16-le")) // 2
72
- spans = [(0, middle), (middle, length)] if middle < length else [(0, length)]
73
  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)]
74
  if bad[0]: parts[0]["audio_url"] = "https://untrusted.test/leak"
75
  jobs[id] = {"id": id, "state": "ready", "source_segments": [{"start": begin, "end": end} for begin, end in spans],
@@ -107,10 +107,13 @@ class SpeechReaderBrowserTests(unittest.TestCase):
107
  button.click()
108
  page.wait_for_function("document.querySelector('#speech-audio')?.currentTime>.1", polling=100)
109
  self.assertTrue(posts)
110
- self.assertLessEqual(len(posts[0]["text"]), 80)
 
 
 
111
  self.assertEqual(posts[0]["voice"], "zf_001")
112
  self.assertEqual(posts[0]["unsupported_policy"], "pause")
113
- self.assertEqual(posts[0]["speed"], 1)
114
  self.assertFalse(any(path in {"/tts/", "/tts/health", "/tts/voices"} for path in paths))
115
  self.assertEqual(body.text_content(), text)
116
  self.assertLessEqual(len(posts), 2)
@@ -134,7 +137,7 @@ class SpeechReaderBrowserTests(unittest.TestCase):
134
  page.wait_for_timeout(350)
135
  self.assertEqual(len(posts), count, "Paused setting changes must not start synthesis")
136
  page.locator("#speech-play").click()
137
- page.locator("#speech-collapse").click()
138
  page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
139
  self.assertFalse(page.locator("#speech-panel").is_visible())
140
  # Search/preview/theme remain independent from audio.
@@ -150,7 +153,7 @@ class SpeechReaderBrowserTests(unittest.TestCase):
150
  page.locator("#speech-locate").click()
151
  page.wait_for_function("STATE.previewDocId==='CCRD:0'&&!previewController")
152
  self.assertEqual(page.locator(".preview-body").text_content(), text)
153
- page.locator("[data-speech-toggle]").click()
154
  self.assertTrue(page.locator("#speech-panel").evaluate("n=>document.querySelector('#preview-panel').contains(n)"))
155
  if mobile:
156
  page.locator("#speech-locate").focus()
@@ -166,10 +169,10 @@ class SpeechReaderBrowserTests(unittest.TestCase):
166
  source_index = len(text[:text.index(chunk)].encode("utf-16-le")) // 2
167
  self.assertGreater(source_index, 24000)
168
  self.assertLess(source_index, 25200)
169
- self.assertLessEqual(len(chunk), 80)
170
  # Double-click selects and starts the source sentence,
171
  # not search highlights/HTML or a guessed time ratio.
172
- page.locator("#speech-collapse").click()
173
  body.evaluate("n=>n.scrollTop=0")
174
  box = body.evaluate("n=>{const b=__sourceRange(n,12).getBoundingClientRect();return {x:b.x+b.width/2,y:b.y+b.height/2};}")
175
  before = len(posts)
 
69
  id = f"{len(posts):032d}"
70
  length = len(body["text"].encode("utf-16-le")) // 2
71
  middle = len(body["text"][:20].encode("utf-16-le")) // 2
72
+ spans = list(zip([0] + body["segment_ends"][:-1], body["segment_ends"]))
73
  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)]
74
  if bad[0]: parts[0]["audio_url"] = "https://untrusted.test/leak"
75
  jobs[id] = {"id": id, "state": "ready", "source_segments": [{"start": begin, "end": end} for begin, end in spans],
 
107
  button.click()
108
  page.wait_for_function("document.querySelector('#speech-audio')?.currentTime>.1", polling=100)
109
  self.assertTrue(posts)
110
+ self.assertLessEqual(len(posts[0]["text"]), 48)
111
+ self.assertTrue(posts[0]["wait_first"])
112
+ self.assertEqual(page.locator(".speech-voice").count(), 0)
113
+ self.assertEqual(page.locator("#speech-collapse svg").count(), 1)
114
  self.assertEqual(posts[0]["voice"], "zf_001")
115
  self.assertEqual(posts[0]["unsupported_policy"], "pause")
116
+ self.assertEqual(posts[0]["speed"], 1.2)
117
  self.assertFalse(any(path in {"/tts/", "/tts/health", "/tts/voices"} for path in paths))
118
  self.assertEqual(body.text_content(), text)
119
  self.assertLessEqual(len(posts), 2)
 
137
  page.wait_for_timeout(350)
138
  self.assertEqual(len(posts), count, "Paused setting changes must not start synthesis")
139
  page.locator("#speech-play").click()
140
+ page.locator("[data-speech-toggle]").click()
141
  page.wait_for_function("!document.querySelector('#speech-audio').paused", polling=100)
142
  self.assertFalse(page.locator("#speech-panel").is_visible())
143
  # Search/preview/theme remain independent from audio.
 
153
  page.locator("#speech-locate").click()
154
  page.wait_for_function("STATE.previewDocId==='CCRD:0'&&!previewController")
155
  self.assertEqual(page.locator(".preview-body").text_content(), text)
156
+ if page.locator("#speech-panel").is_hidden(): page.locator("[data-speech-toggle]").click()
157
  self.assertTrue(page.locator("#speech-panel").evaluate("n=>document.querySelector('#preview-panel').contains(n)"))
158
  if mobile:
159
  page.locator("#speech-locate").focus()
 
169
  source_index = len(text[:text.index(chunk)].encode("utf-16-le")) // 2
170
  self.assertGreater(source_index, 24000)
171
  self.assertLess(source_index, 25200)
172
+ self.assertLessEqual(len(chunk), 48)
173
  # Double-click selects and starts the source sentence,
174
  # not search highlights/HTML or a guessed time ratio.
175
+ page.locator("[data-speech-toggle]").click()
176
  body.evaluate("n=>n.scrollTop=0")
177
  box = body.evaluate("n=>{const b=__sourceRange(n,12).getBoundingClientRect();return {x:b.x+b.width/2,y:b.y+b.height/2};}")
178
  before = len(posts)
tests/test_tts_plan.cjs CHANGED
@@ -13,16 +13,31 @@ for (const text of ["甲𠮷乙。".repeat(8000), "正文。" + " \n".repeat(200
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");
 
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(part.segment_ends.length <= 64);
17
+ assert.equal(part.segment_ends.at(-1), part.text.length);
18
  assert(!/^[\uDC00-\uDFFF]/u.test(part.text));
19
  collected.push(part.text); offset = part.end; count++;
20
  }
21
  assert.equal(collected.join(""), text);
22
  }
23
+ const text = "甲".repeat(45) + "2026年10月18日版本1.2.3用于测试。";
24
+ assert.equal(plan.batch(text, 0, true).text, "甲".repeat(45));
25
+ assert.equal(plan.batch(text, 45, true).text, text.slice(45));
26
  const source = "第一句。第二句里有𠮷和版本1.2.3。第三句!";
27
  assert.deepEqual(plan.sentence(source, source.indexOf("𠮷")), { start: 4, end: source.indexOf("第三句") });
28
  assert.equal(plan.safe("甲𠮷乙", 2), 1);
29
+ assert.equal(plan.wordStart("我们需要进行分词处理。", "我们需要进行分词处理。".indexOf("处理") + 1), "我们需要进行分词处理。".indexOf("处理"));
30
+ assert.equal(plan.wordStart("前文日期2026-10-18后文", 10), 4);
31
+ assert(Array.from(plan.batch("文".repeat(50000), 23456, true).text).length <= 48);
32
+ for (const source of ["中共中央批转文化部党组关于今后文化艺术工作方针和任务问题的报告。", "我们正在研究中华人民共和国文化艺术工作,校长文章写得很好,我得亲自处理。".repeat(15), "日期2026-10-18版本1.2.3编号00123价格12,345.67元。".repeat(10)]) {
33
+ for(let offset=0;offset<source.length;) {
34
+ const part=plan.batch(source,offset,offset===0);
35
+ const cuts=new Set(Array.from(new Intl.Segmenter('zh',{granularity:'word'}).segment(part.text),v=>v.index+v.segment.length));
36
+ for(const end of part.segment_ends) {
37
+ assert(cuts.has(end), source.slice(offset,offset+50));
38
+ assert(!plan.numberSpans(part.text).some(atom=>atom.start<end&&end<atom.end));
39
+ }
40
+ offset=part.end;
41
+ }
42
+ }
43
  console.log("Rolling source plan: lossless >20k, non-BMP, numeric atoms, bounded batches/context PASS");