fix(tts): hand off at sentence ends too; ramp clips by 1.45x of the previous clip
Browse files- static/tts-client.js +14 -6
static/tts-client.js
CHANGED
|
@@ -34,7 +34,11 @@ globalThis.CCRDSpeechPlan = (() => {
|
|
| 34 |
Array.from(text.matchAll(/[^,,。!?!?;;::\n]+[,,。!?!?;;::\n]?/gu), value => ({ end: value.index + value[0].length, word: "" }));
|
| 35 |
return values.filter(value => !atoms.some(atom => atom.start < value.end && value.end < atom.end));
|
| 36 |
}
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
function phraseEnds(text, first = false) {
|
| 39 |
const cuts = wordEnds(text), chars = Array.from(text), offsets = [0], ends = [];
|
| 40 |
for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length);
|
|
@@ -52,13 +56,16 @@ globalThis.CCRDSpeechPlan = (() => {
|
|
| 52 |
natural: cuts.filter(value => value.end > start + (short ? 5 : Math.min(20, goal / 2)) && value.end <= ceiling &&
|
| 53 |
/[,,::、—…][”’"'」』))\t \r]*$/u.test(text.slice(start, value.end)) && !connectorOnly(text.slice(start, value.end).trim())),
|
| 54 |
// A run of book titles (《甲》《乙》) has no comma; between titles is a weak mark.
|
| 55 |
-
weak: cuts.filter(value => value.end > start + (short ? 5 :
|
| 56 |
text[value.end - 1] === "》" && text[value.end] === "《") };
|
| 57 |
};
|
| 58 |
// A clip ending without punctuation is read as an utterance end and its
|
| 59 |
// last words fade (measured -17 to -21 dB). A ramp clip therefore never
|
| 60 |
// cuts mid-phrase: without a mark in its range it widens to the next size.
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
let found = marks(...sizes[0]);
|
| 63 |
for (const size of sizes.slice(1)) {
|
| 64 |
if (found.hard || found.natural.length) break;
|
|
@@ -175,7 +182,7 @@ globalThis.CCRDSpeechPlan = (() => {
|
|
| 175 |
}
|
| 176 |
function batch(text, offset, first = false) {
|
| 177 |
// The first batch holds the whole short-to-long clip ramp (see RAMP).
|
| 178 |
-
const start = first ? clauseStart(text, offset) : safe(text, offset), limit = first ?
|
| 179 |
if (start >= text.length) return null;
|
| 180 |
const chars = Array.from(text.slice(start, start + 1800)), offsets = [start];
|
| 181 |
for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length);
|
|
@@ -577,8 +584,9 @@ if (typeof document !== "undefined") window.CCRDSpeech = (() => {
|
|
| 577 |
const next = nextClip(value);
|
| 578 |
if (!next) return;
|
| 579 |
// Speech ends `pause_after_seconds` before the clip does; keep its intended pause.
|
| 580 |
-
|
| 581 |
-
|
|
|
|
| 582 |
spare.src = next.url; spare.load();
|
| 583 |
const token = handoff = { ...next, current: value.current, timer: 0 };
|
| 584 |
const wait = () => {
|
|
|
|
| 34 |
Array.from(text.matchAll(/[^,,。!?!?;;::\n]+[,,。!?!?;;::\n]?/gu), value => ({ end: value.index + value[0].length, word: "" }));
|
| 35 |
return values.filter(value => !atoms.some(atom => atom.start < value.end && value.end < atom.end));
|
| 36 |
}
|
| 37 |
+
// First-batch clip sizes (goal, ceiling characters), like ElevenLabs'
|
| 38 |
+
// chunk_length_schedule. Production CPU synthesis runs at ~0.65-0.72 s per
|
| 39 |
+
// second of audio (2026-10-20 cold CW:0), so a clip may be at most ~1.4x the
|
| 40 |
+
// previous one or it is not ready when the previous clip's speech ends.
|
| 41 |
+
const RAMP = [[16, 30], [24, 40], [34, 56], [50, 78], [72, 110]];
|
| 42 |
function phraseEnds(text, first = false) {
|
| 43 |
const cuts = wordEnds(text), chars = Array.from(text), offsets = [0], ends = [];
|
| 44 |
for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length);
|
|
|
|
| 56 |
natural: cuts.filter(value => value.end > start + (short ? 5 : Math.min(20, goal / 2)) && value.end <= ceiling &&
|
| 57 |
/[,,::、—…][”’"'」』))\t \r]*$/u.test(text.slice(start, value.end)) && !connectorOnly(text.slice(start, value.end).trim())),
|
| 58 |
// A run of book titles (《甲》《乙》) has no comma; between titles is a weak mark.
|
| 59 |
+
weak: cuts.filter(value => value.end > start + (short ? 5 : 8) && value.end <= ceiling &&
|
| 60 |
text[value.end - 1] === "》" && text[value.end] === "《") };
|
| 61 |
};
|
| 62 |
// A clip ending without punctuation is read as an utterance end and its
|
| 63 |
// last words fade (measured -17 to -21 dB). A ramp clip therefore never
|
| 64 |
// cuts mid-phrase: without a mark in its range it widens to the next size.
|
| 65 |
+
// The preferred range follows the previous clip's actual length (<= 1.45x).
|
| 66 |
+
const previous = ends.length ? ends[ends.length - 1] - (ends[ends.length - 2] || 0) : 0;
|
| 67 |
+
const fit = ([goal, limit]) => previous ? [goal, Math.min(limit, Math.max(goal + 4, Math.round(previous * 1.45)))] : [goal, limit];
|
| 68 |
+
const sizes = first && ends.length < RAMP.length ? [fit(RAMP[ends.length]), ...RAMP.slice(ends.length + 1), [90, 120]] : [[90, 120]];
|
| 69 |
let found = marks(...sizes[0]);
|
| 70 |
for (const size of sizes.slice(1)) {
|
| 71 |
if (found.hard || found.natural.length) break;
|
|
|
|
| 182 |
}
|
| 183 |
function batch(text, offset, first = false) {
|
| 184 |
// The first batch holds the whole short-to-long clip ramp (see RAMP).
|
| 185 |
+
const start = first ? clauseStart(text, offset) : safe(text, offset), limit = first ? 260 : 480;
|
| 186 |
if (start >= text.length) return null;
|
| 187 |
const chars = Array.from(text.slice(start, start + 1800)), offsets = [start];
|
| 188 |
for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length);
|
|
|
|
| 584 |
const next = nextClip(value);
|
| 585 |
if (!next) return;
|
| 586 |
// Speech ends `pause_after_seconds` before the clip does; keep its intended pause.
|
| 587 |
+
// Even when that pause is the whole tail (sentence, paragraph) the preloaded
|
| 588 |
+
// start still saves the src swap/decode, so start just before the end.
|
| 589 |
+
const at = Math.min(audio.duration - part.pause_after_seconds + SPOKEN_PAUSE[kind], audio.duration - .06);
|
| 590 |
spare.src = next.url; spare.load();
|
| 591 |
const token = handoff = { ...next, current: value.current, timer: 0 };
|
| 592 |
const wait = () => {
|