storyshot 0.6.0 → 0.7.2
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 +60 -26
- package/package.json +1 -2
- package/skills/storyshot/SKILL.md +93 -14
- package/src/audio.mjs +9 -4
- package/src/check.mjs +43 -3
- package/src/compose.mjs +33 -9
- package/src/config.mjs +15 -12
- package/src/doctor.mjs +2 -2
- package/src/hyperframes.mjs +155 -35
- package/src/init.mjs +11 -2
- package/src/inspect.mjs +63 -19
- package/src/qwen-tts.mjs +40 -15
- package/src/qwen_tts.py +144 -13
- package/src/record.mjs +208 -64
- package/src/voice.mjs +107 -11
package/src/voice.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { isZhVoice, synthZh, zhSpeakText, ZH_DEFAULT_VOICE, ZH_TTS_ID } from './kokoro-zh.mjs';
|
|
10
|
-
import { qwenSpeakText, QWEN_TTS_ID, synthQwen } from './qwen-tts.mjs';
|
|
10
|
+
import { qwenLang, qwenSpeakText, QWEN_TTS_ID, synthQwen } from './qwen-tts.mjs';
|
|
11
11
|
|
|
12
12
|
const SR = 48000;
|
|
13
13
|
export const VOICE_DEFAULTS = {
|
|
@@ -112,19 +112,40 @@ function cached(cfg, line) {
|
|
|
112
112
|
const zh = v.provider === 'kokoro' && isZhVoice(v.voice), qwen = v.provider === 'qwen';
|
|
113
113
|
const text = zh ? zhSpeakText(pronounce(v, line)) : qwen ? qwenSpeakText(pronounce(v, line)) : pronounce(v, line);
|
|
114
114
|
const ext = { kokoro: 'wav', qwen: 'wav', say: 'aiff' }[v.provider] ?? 'mp3';
|
|
115
|
-
const key = zh ? [v.provider, v.voice, ZH_TTS_ID, v.speed, text] : qwen ? [v.provider, v.voice, QWEN_TTS_ID, v.speed, text]
|
|
115
|
+
const key = zh ? [v.provider, v.voice, ZH_TTS_ID, v.speed, text] : qwen ? [v.provider, v.voice, QWEN_TTS_ID, v.speed, text, ...(qwenLang(text, v.lang) !== qwenLang(text) ? [qwenLang(text, v.lang)] : [])]
|
|
116
116
|
: [v.provider, v.voice, v.model, v.speed, v.instructions, text];
|
|
117
117
|
const hash = crypto.createHash('sha1').update(JSON.stringify(key)).digest('hex').slice(0, 16);
|
|
118
|
-
return { text, file: path.join(dir, `${hash}.${ext}`), zh, qwen };
|
|
118
|
+
return { text, written: line, file: path.join(dir, `${hash}.${ext}`), zh, qwen };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
//
|
|
122
|
-
|
|
121
|
+
// A line's sentences, so each is spoken on its own with a pause after it (TTS engines run the
|
|
122
|
+
// sentences of one line together with barely a breath). Latin text breaks after . ! ? when a capital,
|
|
123
|
+
// digit or quote follows, but not after e.g., Dr., a lone initial (N. P. X.), 2.5 or myapp.com;
|
|
124
|
+
// Chinese and Japanese after 。!?.
|
|
125
|
+
const ABBREV = /^(e\.g|i\.e|etc|vs|approx|incl|cf|Mr|Mrs|Ms|Dr|Prof|St|No|Fig)\.$/i;
|
|
126
|
+
export function sentences(text) {
|
|
127
|
+
const s = String(text).replace(/\s+/g, ' ').trim(), out = [];
|
|
128
|
+
const re = /([.!?…]+["'”’)\]]*) +(?=["'“‘(\[]?[\p{Lu}\p{N}\p{Lo}])|([。!?]+[”’」』))]*) */gu;
|
|
129
|
+
let from = 0;
|
|
130
|
+
for (let m; (m = re.exec(s));) {
|
|
131
|
+
if (m[1]) {
|
|
132
|
+
const word = s.slice(from, m.index + 1).match(/(\S+)$/)?.[1] ?? '';
|
|
133
|
+
if (ABBREV.test(word) || /^\p{Lu}\.$/u.test(word)) continue;
|
|
134
|
+
}
|
|
135
|
+
const piece = s.slice(from, m.index + (m[1] ?? m[2]).length).trim();
|
|
136
|
+
if (piece) out.push(piece);
|
|
137
|
+
from = re.lastIndex;
|
|
138
|
+
}
|
|
139
|
+
if (s.slice(from).trim()) out.push(s.slice(from).trim());
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
// One line as stereo Float32 PCM at 48 kHz, leading and trailing silence trimmed.
|
|
143
|
+
async function speakLine(cfg, line, log) {
|
|
123
144
|
const v = cfg.voice;
|
|
124
|
-
const { text, file, zh, qwen } = cached(cfg, line);
|
|
145
|
+
const { text, written, file, zh, qwen } = cached(cfg, line);
|
|
125
146
|
if (!fs.existsSync(file)) {
|
|
126
147
|
if (zh) await synthZh(v, [{ text, file }], { log });
|
|
127
|
-
else if (qwen) await synthQwen(v, [{ text, file }], { log });
|
|
148
|
+
else if (qwen) await synthQwen(v, [{ text, written, file }], { log });
|
|
128
149
|
else {
|
|
129
150
|
log(` tts (${v.provider}): "${text}"`);
|
|
130
151
|
await synth(v, text, file);
|
|
@@ -135,6 +156,80 @@ export async function speak(cfg, line, { log = console.log } = {}) {
|
|
|
135
156
|
return new Float32Array(pcm.buffer, pcm.byteOffset, pcm.length / 4);
|
|
136
157
|
}
|
|
137
158
|
|
|
159
|
+
// Where a line's sentences meet in its audio. Every break in the text (sentence ends, and commas,
|
|
160
|
+
// which voices pause at too) gets an expected time from the letters spoken before it; each quiet
|
|
161
|
+
// stretch belongs to the break it is nearest, and a sentence end takes the longest stretch that is
|
|
162
|
+
// its own, so a long pause at a comma is never mistaken for it. [start, end] in samples per channel,
|
|
163
|
+
// or null when the voice runs straight on (no quiet there to widen).
|
|
164
|
+
const WIN = SR / 100; // 10 ms
|
|
165
|
+
const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
|
|
166
|
+
export function sentenceGaps(pcm, sents) {
|
|
167
|
+
const n = Math.floor(pcm.length / 2 / WIN), env = new Float32Array(n);
|
|
168
|
+
let peak = 0;
|
|
169
|
+
for (let w = 0; w < n; w++) {
|
|
170
|
+
let e = 0;
|
|
171
|
+
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]));
|
|
172
|
+
env[w] = e; peak = Math.max(peak, e);
|
|
173
|
+
}
|
|
174
|
+
const quiet = [], thr = peak * 0.02;
|
|
175
|
+
for (let w = 0; w < n;) {
|
|
176
|
+
if (env[w] >= thr) { w++; continue; }
|
|
177
|
+
const a = w;
|
|
178
|
+
while (w < n && env[w] < thr) w++;
|
|
179
|
+
if (w - a >= 4 && a > 0 && w < n) quiet.push([a, w]); // ≥ 40 ms, not the trimmed ends
|
|
180
|
+
}
|
|
181
|
+
// breaks in the text, at the share of letters spoken before them: { at, end: k } for the end of sentence k
|
|
182
|
+
const total = letters(sents.join(' ')) || 1, breaks = [];
|
|
183
|
+
let before = 0;
|
|
184
|
+
for (const [k, s] of sents.entries()) {
|
|
185
|
+
for (const m of s.matchAll(/[,;:,、;:]/g)) breaks.push({ at: (before + letters(s.slice(0, m.index))) / total });
|
|
186
|
+
before += letters(s);
|
|
187
|
+
if (k < sents.length - 1) breaks.push({ at: before / total, end: k });
|
|
188
|
+
}
|
|
189
|
+
const mine = sents.slice(0, -1).map(() => []);
|
|
190
|
+
for (const q of quiet) {
|
|
191
|
+
const mid = (q[0] + q[1]) / 2 / n;
|
|
192
|
+
const b = breaks.reduce((x, y) => (Math.abs(y.at - mid) < Math.abs(x.at - mid) ? y : x));
|
|
193
|
+
if (b.end != null && Math.abs(b.at - mid) < 0.25) mine[b.end].push(q);
|
|
194
|
+
}
|
|
195
|
+
let after = 0;
|
|
196
|
+
return mine.map((qs) => {
|
|
197
|
+
const best = qs.filter((q) => q[0] >= after).reduce((x, q) => (!x || q[1] - q[0] > x[1] - x[0] ? q : x), null);
|
|
198
|
+
if (!best) return null;
|
|
199
|
+
after = best[1];
|
|
200
|
+
return [best[0] * WIN, best[1] * WIN];
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A line as stereo Float32 PCM at 48 kHz, spoken in one go (so the voice keeps its flow from sentence
|
|
205
|
+
// to sentence), with each pause between sentences widened to at least `voice.pause` seconds: voices
|
|
206
|
+
// often run sentences together with barely a breath. parts: where each sentence is, in seconds (for
|
|
207
|
+
// subtitles).
|
|
208
|
+
export async function speak(cfg, line, { log = console.log } = {}) {
|
|
209
|
+
const raw = await speakLine(cfg, line, log), pause = cfg.voice.pause ?? 0;
|
|
210
|
+
const sents = pause > 0 ? sentences(line) : [];
|
|
211
|
+
if (sents.length < 2) return { pcm: raw, parts: [{ text: line, start: 0, end: raw.length / 2 / SR }] };
|
|
212
|
+
const gaps = sentenceGaps(raw, sents), want = Math.round(pause * SR);
|
|
213
|
+
const add = gaps.map((g) => (g ? Math.max(0, want - (g[1] - g[0])) : 0));
|
|
214
|
+
const pcm = new Float32Array(raw.length + 2 * add.reduce((a, b) => a + b, 0));
|
|
215
|
+
const parts = [];
|
|
216
|
+
let from = 0, to = 0, startAt = 0;
|
|
217
|
+
for (const [k, g] of gaps.entries()) {
|
|
218
|
+
if (!g) continue; // no quiet stretch: this sentence runs on into the next
|
|
219
|
+
const mid = Math.round((g[0] + g[1]) / 2);
|
|
220
|
+
pcm.set(raw.subarray(2 * from, 2 * mid), 2 * to);
|
|
221
|
+
to += mid - from; from = mid;
|
|
222
|
+
const end = to - (mid - g[0]);
|
|
223
|
+
to += add[k]; // silence: the array is zero-filled
|
|
224
|
+
parts.push({ text: sents.slice(parts.length ? parts.at(-1).next : 0, k + 1).join(' '), start: startAt / SR, end: end / SR, next: k + 1 });
|
|
225
|
+
startAt = to + (g[1] - mid);
|
|
226
|
+
}
|
|
227
|
+
pcm.set(raw.subarray(2 * from), 2 * to);
|
|
228
|
+
const last = parts.length ? parts.at(-1).next : 0;
|
|
229
|
+
parts.push({ text: sents.slice(last).join(' '), start: startAt / SR, end: pcm.length / 2 / SR, next: sents.length });
|
|
230
|
+
return { pcm, parts: parts.map(({ next, ...p }) => p) };
|
|
231
|
+
}
|
|
232
|
+
|
|
138
233
|
// Speed a clip up (factor > 1) with ffmpeg's pitch-preserving atempo.
|
|
139
234
|
export function tempo(pcm, factor) {
|
|
140
235
|
if (!(factor >= 0.5 && factor <= 100)) throw new Error(`internal: speed factor ${factor} is outside what ffmpeg's atempo accepts (0.5–100)`);
|
|
@@ -146,6 +241,7 @@ export function tempo(pcm, factor) {
|
|
|
146
241
|
// Synthesize every narration line up front and return their lengths (seconds),
|
|
147
242
|
// so `duration: auto` steps can be timed to the voice.
|
|
148
243
|
export async function measureVoice(cfg, { log = console.log } = {}) {
|
|
244
|
+
const seconds = ({ pcm }) => pcm.length / 2 / SR;
|
|
149
245
|
const lens = { steps: {}, endCard: null, intro: null, cards: {} };
|
|
150
246
|
if (!cfg.voice) return lens;
|
|
151
247
|
if (cfg.voice.fallback) log(`note: ${cfg.voice.fallback}`);
|
|
@@ -157,10 +253,10 @@ export async function measureVoice(cfg, { log = console.log } = {}) {
|
|
|
157
253
|
await (zh ? synthZh : synthQwen)(cfg.voice, [...todo.values()], { log });
|
|
158
254
|
}
|
|
159
255
|
for (const [i, s] of cfg.steps.entries()) {
|
|
160
|
-
if (s.say && !s.hidden) lens.steps[i] = (await speak(cfg, s.say, { log }))
|
|
161
|
-
if (s.card?.say && !s.hidden) lens.cards[i] = (await speak(cfg, s.card.say, { log }))
|
|
256
|
+
if (s.say && !s.hidden) lens.steps[i] = seconds(await speak(cfg, s.say, { log }));
|
|
257
|
+
if (s.card?.say && !s.hidden) lens.cards[i] = seconds(await speak(cfg, s.card.say, { log }));
|
|
162
258
|
}
|
|
163
|
-
if (cfg.intro?.say) lens.intro = (await speak(cfg, cfg.intro.say, { log }))
|
|
164
|
-
if (cfg.endCard?.say) lens.endCard = (await speak(cfg, cfg.endCard.say, { log }))
|
|
259
|
+
if (cfg.intro?.say) lens.intro = seconds(await speak(cfg, cfg.intro.say, { log }));
|
|
260
|
+
if (cfg.endCard?.say) lens.endCard = seconds(await speak(cfg, cfg.endCard.say, { log }));
|
|
165
261
|
return lens;
|
|
166
262
|
}
|