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/README.md +65 -3
- package/bin/storyshot.mjs +27 -2
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +261 -3
- package/src/audio.mjs +27 -2
- package/src/build.mjs +15 -1
- package/src/check.mjs +42 -5
- package/src/compose.mjs +34 -10
- package/src/config.mjs +34 -4
- package/src/hyperframes.mjs +63 -17
- package/src/init.mjs +70 -0
- package/src/mcp.mjs +43 -5
- package/src/outputs.mjs +22 -12
- package/src/record.mjs +1 -0
- package/src/scene-tools.mjs +123 -0
- package/src/scenes.mjs +234 -0
- package/src/voice.mjs +114 -5
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
|
-
|
|
209
|
-
|
|
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)
|
|
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 }));
|