vomebook commited on
Commit
802a68d
·
1 Parent(s): 11affd0

fix(tts): hand off at sentence ends too; ramp clips by 1.45x of the previous clip

Browse files
Files changed (1) hide show
  1. 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
- const RAMP = [[16, 32], [36, 56], [64, 90]];
 
 
 
 
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 : Math.min(20, goal / 2)) && value.end <= ceiling &&
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
- const sizes = first ? [...RAMP.slice(ends.length), [90, 120]] : [[90, 120]];
 
 
 
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 ? 200 : 480;
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
- const at = audio.duration - part.pause_after_seconds + SPOKEN_PAUSE[kind];
581
- if (at >= audio.duration - .03) return;
 
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 = () => {