storyshot 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/voice.mjs CHANGED
@@ -201,12 +201,116 @@ export function sentenceGaps(pcm, sents) {
201
201
  });
202
202
  }
203
203
 
204
+ // The phrases of a line, for scenes that animate on the words: its sentences, each split after a
205
+ // comma-like mark (, ; : , 、 ; :), which stays with the phrase before it.
206
+ export const PHRASE_BREAK = /(?<=[,;:,、;:])\s*/;
207
+ export function phrases(text) {
208
+ return sentences(text).flatMap((s) => s.split(PHRASE_BREAK).map((p) => p.trim()).filter(Boolean));
209
+ }
210
+
211
+ // When each phrase of a spoken line starts and ends, in seconds into the line. parts: speak()'s
212
+ // sentences (exact: their pauses were cut and widened). Inside a sentence each comma's expected time,
213
+ // from the letters spoken before it, takes the nearest quiet stretch of 60 ms or more; a comma the
214
+ // voice runs straight through keeps its estimate.
215
+ export function phraseTimes(pcm, parts) {
216
+ const n = Math.floor(pcm.length / 2 / WIN), env = new Float32Array(n);
217
+ let peak = 0;
218
+ for (let w = 0; w < n; w++) {
219
+ let e = 0;
220
+ for (let i = w * WIN; i < (w + 1) * WIN; i++) e = Math.max(e, Math.abs(pcm[2 * i]), Math.abs(pcm[2 * i + 1]));
221
+ env[w] = e; peak = Math.max(peak, e);
222
+ }
223
+ const thr = peak * 0.02, out = [];
224
+ for (const p of parts) {
225
+ // (a part can hold two sentences the voice ran together: they are still two phrases)
226
+ const ph = phrases(p.text);
227
+ if (ph.length < 2) { out.push({ text: p.text, t: p.start, end: p.end }); continue; }
228
+ const a = Math.max(1, Math.floor(p.start * 100)), b = Math.min(n - 1, Math.ceil(p.end * 100)), qs = [];
229
+ for (let w = a; w < b;) {
230
+ if (env[w] >= thr) { w++; continue; }
231
+ const q0 = w;
232
+ while (w < b && env[w] < thr) w++;
233
+ if (w - q0 >= 6) qs.push([q0 / 100, w / 100]);
234
+ }
235
+ const total = letters(p.text) || 1;
236
+ let before = 0, used = -1, t = p.start;
237
+ for (let k = 0; k < ph.length; k++) {
238
+ before += letters(ph[k]);
239
+ if (k === ph.length - 1) { out.push({ text: ph[k], t, end: p.end }); break; }
240
+ const est = p.start + (before / total) * (p.end - p.start);
241
+ let best = null;
242
+ qs.forEach((q, j) => {
243
+ const mid = (q[0] + q[1]) / 2;
244
+ if (j > used && Math.abs(mid - est) < 0.6 && (!best || Math.abs(mid - est) < Math.abs(best.mid - est))) best = { j, mid, q };
245
+ });
246
+ if (best) { used = best.j; out.push({ text: ph[k], t, end: best.q[0] }); t = best.q[1]; }
247
+ else { out.push({ text: ph[k], t, end: est }); t = est; }
248
+ }
249
+ }
250
+ return out.map((c) => ({ text: c.text, t: +c.t.toFixed(3), end: +Math.max(c.t, c.end).toFixed(3) }));
251
+ }
252
+
204
253
  // A line as stereo Float32 PCM at 48 kHz, spoken in one go (so the voice keeps its flow from sentence
205
254
  // to sentence), with each pause between sentences widened to at least `voice.pause` seconds: voices
206
255
  // often run sentences together with barely a breath. parts: where each sentence is, in seconds (for
207
256
  // subtitles).
208
- export async function speak(cfg, line, { log = console.log } = {}) {
209
- const raw = await speakLine(cfg, line, log), pause = cfg.voice.pause ?? 0;
257
+ // A scene's narration can run to a paragraph, longer than a voice reads well in one take: it is spoken in
258
+ // chunks of whole sentences (up to about 100 Chinese characters or 45 words each), joined with the line's
259
+ // sentence pause. Short lines are one chunk.
260
+ const CHUNK_CJK = 100, CHUNK_WORDS = 45;
261
+ const weight = (t) => { const c = (t.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g) ?? []).length; return c / CHUNK_CJK + t.replace(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/g, ' ').split(/\s+/).filter((w) => /\w/.test(w)).length / CHUNK_WORDS; };
262
+ export function chunks(line) {
263
+ const out = [];
264
+ for (const s of sentences(line)) {
265
+ const cjk = /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]$/.test(out.at(-1) ?? '') || /^[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(s);
266
+ if (out.length && weight(out.at(-1)) + weight(s) <= 1) out[out.length - 1] += (cjk ? '' : ' ') + s;
267
+ else out.push(s);
268
+ }
269
+ return out.length ? out : [line];
270
+ }
271
+ export async function speak(cfg, line, { log = console.log, chunk = false } = {}) {
272
+ const pieces = chunk ? chunks(line) : [line];
273
+ if (pieces.length < 2) return speakOne(cfg, line, { log });
274
+ const said = [];
275
+ for (const p of pieces) said.push(await speakOne(cfg, p, { log }));
276
+ const gap = Math.round(Math.max(cfg.voice.pause ?? 0, 0.3) * SR);
277
+ const n = said.reduce((a, x) => a + x.pcm.length / 2, 0) + gap * (said.length - 1);
278
+ const pcm = new Float32Array(2 * n), parts = [];
279
+ let at = 0;
280
+ for (const [k, x] of said.entries()) {
281
+ pcm.set(x.pcm, 2 * at);
282
+ for (const q of x.parts) parts.push({ text: q.text, start: q.start + at / SR, end: q.end + at / SR });
283
+ at += x.pcm.length / 2 + (k < said.length - 1 ? gap : 0);
284
+ }
285
+ return { pcm, parts };
286
+ }
287
+ // `voice.maxPause` (seconds): no pause inside a line lasts longer than this (some voices stop for a second
288
+ // at every comma); the sentence pauses are then widened to `voice.pause` as usual.
289
+ export function capPauses(pcm, maxPause) {
290
+ if (!(maxPause > 0)) return pcm;
291
+ const n = Math.floor(pcm.length / 2 / WIN), env = new Float32Array(n);
292
+ let peak = 0;
293
+ for (let w = 0; w < n; w++) {
294
+ let e = 0;
295
+ for (let i = w * WIN; i < (w + 1) * WIN; i++) e = Math.max(e, Math.abs(pcm[2 * i]), Math.abs(pcm[2 * i + 1]));
296
+ env[w] = e; peak = Math.max(peak, e);
297
+ }
298
+ const thr = peak * 0.02, keep = Math.round(maxPause * 100), cuts = [];
299
+ for (let w = 1; w < n;) {
300
+ if (env[w] >= thr) { w++; continue; }
301
+ const a = w;
302
+ while (w < n && env[w] < thr) w++;
303
+ if (w < n && w - a > keep) cuts.push([(a + Math.floor(keep / 2)) * WIN, (w - Math.ceil(keep / 2)) * WIN]);
304
+ }
305
+ if (!cuts.length) return pcm;
306
+ const out = new Float32Array(pcm.length - 2 * cuts.reduce((x, [a, b]) => x + b - a, 0));
307
+ let from = 0, to = 0;
308
+ for (const [a, b] of cuts) { out.set(pcm.subarray(2 * from, 2 * a), 2 * to); to += a - from; from = b; }
309
+ out.set(pcm.subarray(2 * from), 2 * to);
310
+ return out;
311
+ }
312
+ async function speakOne(cfg, line, { log = console.log } = {}) {
313
+ const raw = capPauses(await speakLine(cfg, line, log), cfg.voice.maxPause), pause = cfg.voice.pause ?? 0;
210
314
  const sents = pause > 0 ? sentences(line) : [];
211
315
  if (sents.length < 2) return { pcm: raw, parts: [{ text: line, start: 0, end: raw.length / 2 / SR }] };
212
316
  const gaps = sentenceGaps(raw, sents), want = Math.round(pause * SR);
@@ -242,18 +346,23 @@ export function tempo(pcm, factor) {
242
346
  // so `duration: auto` steps can be timed to the voice.
243
347
  export async function measureVoice(cfg, { log = console.log } = {}) {
244
348
  const seconds = ({ pcm }) => pcm.length / 2 / SR;
245
- const lens = { steps: {}, endCard: null, intro: null, cards: {} };
349
+ const lens = { steps: {}, endCard: null, intro: null, cards: {}, cues: {} };
246
350
  if (!cfg.voice) return lens;
247
351
  if (cfg.voice.fallback) log(`note: ${cfg.voice.fallback}`);
248
352
  const zh = cfg.voice.provider === 'kokoro' && isZhVoice(cfg.voice.voice);
249
353
  if (zh || cfg.voice.provider === 'qwen') {
250
354
  // synthesize every uncached line in one batch, so Python (the G2P, or the Qwen model) starts once
251
- const lines = [...cfg.steps.filter((s) => !s.hidden).flatMap((s) => [s.say, s.card?.say]), cfg.intro?.say, cfg.endCard?.say].filter(Boolean);
355
+ const lines = [...cfg.steps.filter((s) => !s.hidden).flatMap((s) => [...(s.scene && s.say ? chunks(s.say) : [s.say]), s.card?.say]), cfg.intro?.say, cfg.endCard?.say].filter(Boolean);
252
356
  const todo = new Map(lines.map((l) => cached(cfg, l)).filter((c) => !fs.existsSync(c.file)).map((c) => [c.file, c]));
253
357
  await (zh ? synthZh : synthQwen)(cfg.voice, [...todo.values()], { log });
254
358
  }
255
359
  for (const [i, s] of cfg.steps.entries()) {
256
- if (s.say && !s.hidden) lens.steps[i] = seconds(await speak(cfg, s.say, { log }));
360
+ if (s.say && !s.hidden) {
361
+ const sp = await speak(cfg, s.say, { log, chunk: !!s.scene });
362
+ lens.steps[i] = seconds(sp);
363
+ // a scene animates on the words: when each phrase is spoken
364
+ if (s.scene) lens.cues[i] = phraseTimes(sp.pcm, sp.parts);
365
+ }
257
366
  if (s.card?.say && !s.hidden) lens.cards[i] = seconds(await speak(cfg, s.card.say, { log }));
258
367
  }
259
368
  if (cfg.intro?.say) lens.intro = seconds(await speak(cfg, cfg.intro.say, { log }));