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/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
- // Returns stereo Float32 PCM at 48 kHz with leading/trailing silence trimmed.
122
- export async function speak(cfg, line, { log = console.log } = {}) {
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 })).length / 2 / SR;
161
- if (s.card?.say && !s.hidden) lens.cards[i] = (await speak(cfg, s.card.say, { log })).length / 2 / SR;
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 })).length / 2 / SR;
164
- if (cfg.endCard?.say) lens.endCard = (await speak(cfg, cfg.endCard.say, { log })).length / 2 / SR;
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
  }