feat: read complete words with low-latency first phrases
Browse filesUse 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 +7 -3
- static/index.html +4 -4
- static/style.css +1 -1
- static/tts-client.js +73 -11
- tests/test_tts_browser.py +10 -7
- tests/test_tts_plan.cjs +19 -4
docs/TTS_PILOT.md
CHANGED
|
@@ -7,15 +7,19 @@
|
|
| 7 |
从当前阅读位置开始、回到播放位置。双击正文会选中所在句子并从那里继续播放。
|
| 8 |
- 原文保留在搜索页内存,不修改正文或搜索高亮;后端返回原始 UTF-16 句段范围。
|
| 9 |
高亮是句段级,不把播放时间比例冒充逐字定位。“回到播放位置”可重新打开原预览。
|
| 10 |
-
- 去掉整篇两万字限制:首批目标
|
| 11 |
个 Unicode 字符、相邻上下文各最多 32 字。日期和普通英文词不在常规批次边界拆开。
|
| 12 |
只保留当前/下一批任务及一个后续 MP3 预载,不整本上传或生成整本音频。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
- Search 请求显式使用 `unsupported_policy="pause"`:不能发音的字符/符号按短停顿
|
| 14 |
跨过,前后可发音正文继续读,源字符和定位不变。符号段可生成短静音;这不是生僻字
|
| 15 |
发音识别能力。网络、数值歧义、模型故障不冒充“无法识别字符”,保留位置后重试。
|
| 16 |
-
- 语速是浏览器即时倍速,保持音高
|
| 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=
|
| 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><
|
| 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=
|
| 162 |
-
<script src="/static/app.js?v=
|
| 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-
|
| 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 ?
|
| 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 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(),
|
| 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,
|
| 313 |
-
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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 =
|
| 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"]),
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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),
|
| 170 |
# Double-click selects and starts the source sentence,
|
| 171 |
# not search highlights/HTML or a guessed time ratio.
|
| 172 |
-
page.locator("
|
| 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(
|
| 22 |
-
assert.equal(plan.batch(text, 0, true).text, "甲".repeat(
|
| 23 |
-
assert.equal(plan.batch(text,
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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");
|