@glissade/narrate 0.15.0 → 0.16.0-pre.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/dist/index.d.ts CHANGED
@@ -3,10 +3,36 @@ import { FilterSpec, Text } from "@glissade/scene";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
+ /**
7
+ * One weighted base voice in a blend: `[<voiceName>, <weight>]`. Weights are
8
+ * NORMALIZED to sum to 1 at resolution (so `[["a",1],["b",1]]` is 50/50, not
9
+ * 2×). Each `weight` must be finite and > 0.
10
+ */
11
+ type VoiceBlendEntry = readonly [voice: string, weight: number];
12
+ /**
13
+ * A blended kokoro voice — a weighted sum of two-or-more named base voices'
14
+ * `[510×256]` style vectors, computed ONCE at prepare time into a derived
15
+ * custom voice (the HF "Make Custom Voices With KokoroTTS" recipe). All base
16
+ * voices must share a language front-end (all Chinese `zf_`/`zm_` OR all
17
+ * English; a mixed-language blend throws — different g2p). Both names + weights
18
+ * fold into the provider `version()`, so the segment cache invalidates whenever
19
+ * any weight or base voice changes. kokoro-only.
20
+ */
21
+ interface VoiceBlend {
22
+ /** ≥2 weighted base voices; weights normalized to sum to 1. */
23
+ blend: readonly VoiceBlendEntry[];
24
+ }
25
+ /**
26
+ * A voice is EITHER a provider voice name (the string form, unchanged) OR a
27
+ * `VoiceBlend` spec (kokoro only — a weighted sum of named base voices).
28
+ */
29
+ type VoiceSpec = string | VoiceBlend;
30
+ /** A `VoiceSpec` is a blend when it carries a `blend` array (vs a plain string). */
31
+ declare function isVoiceBlend(voice: VoiceSpec | undefined): voice is VoiceBlend;
6
32
  interface NarrationSegment {
7
33
  id: string;
8
34
  text: string;
9
- voice?: string;
35
+ voice?: VoiceSpec;
10
36
  /** speaking rate multiplier; 1 = provider default */
11
37
  rate?: number;
12
38
  /** silence after THIS segment (s); overrides the script default */
@@ -45,7 +71,7 @@ declare function isPause(el: NarrationElement): el is NarrationPause;
45
71
  interface NarrationScript {
46
72
  narrationVersion: 1;
47
73
  provider?: string;
48
- voice?: string;
74
+ voice?: VoiceSpec;
49
75
  rate?: number;
50
76
  /** silence between segments (s); default 0.35 */
51
77
  gap?: number;
@@ -327,4 +353,4 @@ declare function music(timing: MusicTiming, at?: number): MusicAnchors;
327
353
  declare function toSrt(timing: NarrationTiming): string;
328
354
  declare function toVtt(timing: NarrationTiming): string;
329
355
  //#endregion
330
- export { BedMode, CaptionCue, CaptionStyle, CaptionTrackOptions, DuckOptions, MusicAnchors, MusicClipOptions, MusicTiming, NarrationAnchors, NarrationElement, NarrationError, NarrationPause, NarrationScript, NarrationSegment, NarrationTiming, TimedPause, TimedSegment, TimedWord, captionNode, captionTrack, duckEnvelope, isPause, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
356
+ export { BedMode, CaptionCue, CaptionStyle, CaptionTrackOptions, DuckOptions, MusicAnchors, MusicClipOptions, MusicTiming, NarrationAnchors, NarrationElement, NarrationError, NarrationPause, NarrationScript, NarrationSegment, NarrationTiming, TimedPause, TimedSegment, TimedWord, VoiceBlend, VoiceBlendEntry, VoiceSpec, captionNode, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ import { Text, breakLines, estimatingMeasurer, glow, quantize } from "@glissade/
8
8
  * offline and deterministic. Captions are a plain string track driving a
9
9
  * Text node — they live in the timeline JSON and golden-frame CI covers them.
10
10
  */
11
+ /** A `VoiceSpec` is a blend when it carries a `blend` array (vs a plain string). */
12
+ function isVoiceBlend(voice) {
13
+ return typeof voice === "object" && voice !== null && Array.isArray(voice.blend);
14
+ }
11
15
  /** A pause element is the one carrying a numeric `pause` field. */
12
16
  function isPause(el) {
13
17
  return typeof el.pause === "number";
@@ -363,4 +367,4 @@ function toVtt(timing) {
363
367
  return "WEBVTT\n\n" + timing.segments.flatMap((s) => splitCaption(s, timing.captionSplit?.maxChars)).map((c) => `${srtTime(c.start, ".")} --> ${srtTime(c.end, ".")}\n${c.text}`).join("\n\n") + "\n";
364
368
  }
365
369
  //#endregion
366
- export { NarrationError, captionNode, captionTrack, duckEnvelope, isPause, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
370
+ export { NarrationError, captionNode, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
@@ -1,4 +1,4 @@
1
- import { NarrationTiming } from "./index.js";
1
+ import { NarrationTiming, VoiceBlend, VoiceSpec } from "./index.js";
2
2
 
3
3
  //#region src/zh-g2p.d.ts
4
4
 
@@ -20,7 +20,8 @@ interface ZhG2p {
20
20
 
21
21
  interface TtsRequest {
22
22
  text: string;
23
- voice?: string;
23
+ /** a provider voice NAME (string) or a kokoro `VoiceBlend` (weighted sum) */
24
+ voice?: VoiceSpec;
24
25
  rate?: number;
25
26
  }
26
27
  interface TtsResult {
@@ -43,6 +44,13 @@ interface TtsProvider {
43
44
  }
44
45
  /** Parse duration from a RIFF/WAV header (PCM): data bytes / byte-rate. */
45
46
  declare function wavDuration(wav: Buffer): number;
47
+ /**
48
+ * Coerce a request voice to a plain NAME string for the providers that only
49
+ * speak named voices (espeak / openai / piper). A `VoiceBlend` is kokoro-only,
50
+ * so it is a clear authoring error to hand one to any other provider — throw
51
+ * rather than silently stringify `[object Object]` into the request.
52
+ */
53
+ declare function requireStringVoice(voice: VoiceSpec | undefined, provider: string): string | undefined;
46
54
  /**
47
55
  * A pure function of the request: a quiet tone whose duration models reading
48
56
  * speed (≈170 wpm + lead-out). Same text → identical bytes, every machine.
@@ -87,11 +95,111 @@ declare function piperProvider(opts?: {
87
95
  /** PCM16 mono WAV from float samples in [-1, 1]. Round-to-nearest → deterministic. */
88
96
  declare function floatToWav(samples: Float32Array, sampleRate: number): Buffer;
89
97
  type KokoroDtype = 'fp32' | 'fp16' | 'q8' | 'q4' | 'q4f16';
98
+ interface KokoroAudio {
99
+ audio: Float32Array;
100
+ sampling_rate: number;
101
+ }
102
+ /** the StyleTextToSpeech2 ONNX module call: {input_ids, style, speed} → {waveform}. */
103
+ type KokoroOnnxModel = (inputs: {
104
+ input_ids: unknown;
105
+ style: unknown;
106
+ speed: unknown;
107
+ }) => Promise<{
108
+ waveform: {
109
+ data: Float32Array;
110
+ };
111
+ }>;
112
+ interface KokoroModel {
113
+ generate(text: string, opts: {
114
+ voice?: string;
115
+ speed?: number;
116
+ }): Promise<KokoroAudio>;
117
+ /** char-isolated tokenizer over kokoro's phoneme alphabet → model input ids */
118
+ tokenizer(text: string, opts?: {
119
+ truncation?: boolean;
120
+ }): {
121
+ input_ids: KokoroInputIds;
122
+ };
123
+ /** the g2p BYPASS: pre-tokenized phoneme ids straight to the model (no espeak) */
124
+ generate_from_ids(input_ids: unknown, opts: {
125
+ voice?: string;
126
+ speed?: number;
127
+ }): Promise<KokoroAudio>;
128
+ /** the underlying StyleTextToSpeech2 ONNX module — the BLEND path calls it
129
+ * directly with a SUMMED style tensor (generate_from_ids only accepts a
130
+ * named voice it loads from disk; a blend has no registered name). */
131
+ model: KokoroOnnxModel;
132
+ }
133
+ /** the tokenizer output we read: token count drives the style-vector slice. */
134
+ interface KokoroInputIds {
135
+ dims: number[];
136
+ }
137
+ /** the @huggingface/transformers Tensor ctor (kokoro-js's own dep) — used to
138
+ * wrap the summed style Float32Array as the model's `style` input. */
139
+ type TensorCtor = new (type: 'float32', data: Float32Array | number[], dims: number[]) => unknown;
140
+ /**
141
+ * Blend-spec identity version. Bump when the blend MATH or canonicalization
142
+ * changes in a way that can move synthesized bytes (independent of kokoro-js /
143
+ * model / dtype). Folds into kokoroProvider.version() → the segment cache key.
144
+ */
145
+ declare const BLEND_SPEC_VERSION = "blend-1";
146
+ /** Resolved, validated blend: normalized weights, in SPEC ORDER (preserved). */
147
+ interface ResolvedBlend {
148
+ /** `[name, normalizedWeight]` in the author's spec order (NOT re-sorted). */
149
+ entries: readonly (readonly [string, number])[];
150
+ /** 'zh' (all zf_/zm_) or 'en' (all other / English) — the g2p front-end. */
151
+ language: 'zh' | 'en';
152
+ }
153
+ /**
154
+ * Validate + normalize a `VoiceBlend`: ≥2 entries, finite positive weights,
155
+ * weights NORMALIZED to sum to 1 (so `[["a",1],["b",1]]` = 50/50). The entry
156
+ * ORDER is PRESERVED from the spec (the weighted sum is commutative, so order
157
+ * does not affect the result — but a stable order keeps version() deterministic
158
+ * and the identity readable). Language is inferred from the base-voice prefixes:
159
+ * all `zf_`/`zm_` → 'zh'; all English → 'en'; a MIXED-language blend throws
160
+ * (different g2p front-ends can't be blended).
161
+ */
162
+ declare function resolveBlend(spec: VoiceBlend): ResolvedBlend;
163
+ /**
164
+ * The blend's cache identity — a PURE deterministic string (no async, no model
165
+ * load) folded into kokoroProvider.version(). Names are kept in spec order; the
166
+ * normalized weights are fixed-precision so float formatting never drifts the
167
+ * key. Any base voice or weight change moves this string → invalidates the
168
+ * segment cache, EXACTLY the 0.15 g2p-identity pattern.
169
+ */
170
+ declare function blendIdentity(spec: VoiceBlend): string;
171
+ /**
172
+ * Compute the normalized weighted sum of base-voice style tensors into one
173
+ * `[510 × 256]` Float32Array (the derived blended voice). Each loader returns a
174
+ * base voice's raw `[510 × 256]` float32 data; the weights are already
175
+ * normalized. Deterministic: a fixed pass over the spec-ordered entries, plain
176
+ * float adds. Validates every base tensor's length so a wrong-shape voice fails
177
+ * loudly rather than producing a silently-truncated blend.
178
+ */
179
+ declare function blendStyleVectors(entries: readonly (readonly [string, number])[], loadVoice: (name: string) => Float32Array): Float32Array;
180
+ /**
181
+ * Load a base voice's raw `[510×256]` float32 style tensor straight from the
182
+ * kokoro-js package's committed `voices/<name>.bin` (the same file kokoro-js
183
+ * reads internally for a named voice). Resolved relative to the kokoro-js entry
184
+ * (`<pkg>/dist/kokoro.* → ../voices/<name>.bin`), so the blend uses the SAME
185
+ * Apache-2.0 voice bytes the model was packaged with — no download, fully
186
+ * deterministic at prepare. A `.bin` whose size isn't 510×256×4 fails loudly.
187
+ */
188
+ declare function loadKokoroVoiceData(entry: string, name: string): Float32Array;
189
+ /**
190
+ * Drive a SUMMED style vector through the model — the blend analogue of
191
+ * generate_from_ids (which only accepts a NAMED voice it loads from disk). This
192
+ * replicates kokoro-js's exact style-slicing: row = min(max(tokenCount-2, 0),
193
+ * 509), style = blended[row*256 : row*256+256] → Tensor[1,256], then the same
194
+ * `model({input_ids, style, speed})` call. Deterministic: a fixed slice + the
195
+ * model's own forward pass (no sampling).
196
+ */
197
+ declare function kokoroGenerateFromBlend(tts: KokoroModel, Tensor: TensorCtor, inputIds: KokoroInputIds, blended: Float32Array, speed: number): Promise<KokoroAudio>;
90
198
  /** A z* (zf_/zm_) kokoro voice routes through misaki[zh] g2p (not espeak cmn). */
91
199
  declare function isKokoroChineseVoice(voice: string): boolean;
92
200
  declare function kokoroProvider(opts?: {
93
201
  model?: string;
94
- voice?: string;
202
+ voice?: VoiceSpec;
95
203
  dtype?: KokoroDtype;
96
204
  zhG2p?: ZhG2p;
97
205
  }): TtsProvider;
@@ -214,7 +322,7 @@ interface SynthesizeResult {
214
322
  }
215
323
  declare function cacheKey(seg: {
216
324
  text: string;
217
- voice?: string;
325
+ voice?: VoiceSpec;
218
326
  rate?: number;
219
327
  }, provider: string, providerVersion: string): string;
220
328
  /**
@@ -226,4 +334,4 @@ declare function synthesizeScript(scriptPath: string, opts?: SynthesizeOptions):
226
334
  /** Resolve `<scene>.narration.json` for a scene-module path (or accept the script itself). */
227
335
  declare function scriptPathFor(input: string): string;
228
336
  //#endregion
229
- export { AlignRequest, Aligner, KokoroDtype, SynthesizeOptions, SynthesizeResult, TtsProvider, TtsRequest, TtsResult, VoskAlignWord, alignerById, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, isKokoroChineseVoice, kokoroProvider, mapAsrToScript, openaiProvider, piperProvider, providerById, resolvePiperVoice, scriptPathFor, stderrTail, synthesizeScript, voskAligner, wavDuration };
337
+ export { AlignRequest, Aligner, BLEND_SPEC_VERSION, KokoroDtype, ResolvedBlend, SynthesizeOptions, SynthesizeResult, TtsProvider, TtsRequest, TtsResult, VoskAlignWord, alignerById, blendIdentity, blendStyleVectors, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, isKokoroChineseVoice, kokoroGenerateFromBlend, kokoroProvider, loadKokoroVoiceData, mapAsrToScript, openaiProvider, piperProvider, providerById, requireStringVoice, resolveBlend, resolvePiperVoice, scriptPathFor, stderrTail, synthesizeScript, voskAligner, wavDuration };
package/dist/providers.js CHANGED
@@ -1,4 +1,4 @@
1
- import { NarrationError, isPause } from "./index.js";
1
+ import { NarrationError, isPause, isVoiceBlend } from "./index.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
@@ -187,6 +187,16 @@ function wavDuration(wav) {
187
187
  throw new NarrationError("WAV has no data chunk");
188
188
  }
189
189
  /**
190
+ * Coerce a request voice to a plain NAME string for the providers that only
191
+ * speak named voices (espeak / openai / piper). A `VoiceBlend` is kokoro-only,
192
+ * so it is a clear authoring error to hand one to any other provider — throw
193
+ * rather than silently stringify `[object Object]` into the request.
194
+ */
195
+ function requireStringVoice(voice, provider) {
196
+ if (isVoiceBlend(voice)) throw new NarrationError(`provider '${provider}' does not support voice blends — only --provider kokoro can blend voices`);
197
+ return voice;
198
+ }
199
+ /**
190
200
  * A pure function of the request: a quiet tone whose duration models reading
191
201
  * speed (≈170 wpm + lead-out). Same text → identical bytes, every machine.
192
202
  */
@@ -243,8 +253,9 @@ function espeakProvider() {
243
253
  return Promise.resolve(r.stdout.trim().split("\n")[0] ?? "espeak-ng");
244
254
  },
245
255
  synthesize: (req) => {
256
+ const voice = requireStringVoice(req.voice, "espeak");
246
257
  const args = ["--stdout"];
247
- if (req.voice) args.push("-v", req.voice);
258
+ if (voice) args.push("-v", voice);
248
259
  args.push("-s", String(Math.round(175 * (req.rate ?? 1))));
249
260
  args.push(req.text);
250
261
  const r = spawnSync("espeak-ng", args, { maxBuffer: 64 * 1024 * 1024 });
@@ -263,6 +274,7 @@ function openaiProvider(opts = {}) {
263
274
  id: "openai",
264
275
  version: () => Promise.resolve(model),
265
276
  synthesize: async (req) => {
277
+ const voice = requireStringVoice(req.voice, "openai");
266
278
  const key = process.env["OPENAI_API_KEY"];
267
279
  if (!key) throw new NarrationError("OPENAI_API_KEY is not set — required for --provider openai");
268
280
  const res = await fetch("https://api.openai.com/v1/audio/speech", {
@@ -273,7 +285,7 @@ function openaiProvider(opts = {}) {
273
285
  },
274
286
  body: JSON.stringify({
275
287
  model,
276
- voice: req.voice ?? "alloy",
288
+ voice: voice ?? "alloy",
277
289
  input: req.text,
278
290
  response_format: "wav",
279
291
  ...req.rate !== void 0 ? { speed: req.rate } : {}
@@ -347,7 +359,7 @@ function piperProvider(opts = {}) {
347
359
  ].filter(Boolean).join(" "));
348
360
  },
349
361
  synthesize: (req) => {
350
- const raw = req.voice ?? opts.model;
362
+ const raw = requireStringVoice(req.voice, "piper") ?? opts.model;
351
363
  if (!raw) throw new NarrationError("piper needs a voice model (.onnx) — pass { model }, or set the segment voice to its path or name");
352
364
  const model = resolvePiperVoice(raw, opts.voicesDir);
353
365
  const tag = createHash("sha256").update(req.text).digest("hex").slice(0, 8);
@@ -406,6 +418,86 @@ function floatToWav(samples, sampleRate) {
406
418
  const KOKORO_MODEL = "onnx-community/Kokoro-82M-v1.0-ONNX";
407
419
  const KOKORO_DEFAULT_VOICE = "af_heart";
408
420
  /**
421
+ * A kokoro voice style vector is a `[510 × 256]` float32 tensor (one 256-d
422
+ * style row per phoneme-token-count bucket); a WEIGHTED SUM of two-or-more such
423
+ * tensors is a valid custom voice (the HF "Make Custom Voices With KokoroTTS"
424
+ * recipe). Both base voices are Apache-2.0, so the derived blend is a derived
425
+ * Apache-2.0 voice (provenance is surfaced in the synth log + folded into
426
+ * version() so the artifact is auditable). The blend is computed ONCE at
427
+ * prepare and driven through the model directly (generate_from_ids only accepts
428
+ * a NAMED voice it loads from disk — a blend has no registered name).
429
+ */
430
+ const KOKORO_VOICE_ROWS = 510;
431
+ const KOKORO_STYLE_DIM = 256;
432
+ const KOKORO_VOICE_FLOATS = KOKORO_VOICE_ROWS * KOKORO_STYLE_DIM;
433
+ /**
434
+ * Blend-spec identity version. Bump when the blend MATH or canonicalization
435
+ * changes in a way that can move synthesized bytes (independent of kokoro-js /
436
+ * model / dtype). Folds into kokoroProvider.version() → the segment cache key.
437
+ */
438
+ const BLEND_SPEC_VERSION = "blend-1";
439
+ /**
440
+ * Validate + normalize a `VoiceBlend`: ≥2 entries, finite positive weights,
441
+ * weights NORMALIZED to sum to 1 (so `[["a",1],["b",1]]` = 50/50). The entry
442
+ * ORDER is PRESERVED from the spec (the weighted sum is commutative, so order
443
+ * does not affect the result — but a stable order keeps version() deterministic
444
+ * and the identity readable). Language is inferred from the base-voice prefixes:
445
+ * all `zf_`/`zm_` → 'zh'; all English → 'en'; a MIXED-language blend throws
446
+ * (different g2p front-ends can't be blended).
447
+ */
448
+ function resolveBlend(spec) {
449
+ const raw = spec.blend;
450
+ if (!Array.isArray(raw) || raw.length < 2) throw new NarrationError(`voice blend needs ≥2 base voices (got ${Array.isArray(raw) ? raw.length : "none"})`);
451
+ let sum = 0;
452
+ for (const entry of raw) {
453
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string" || typeof entry[1] !== "number") throw new NarrationError(`voice blend entries must be ["<voice>", <weight>]; got ${JSON.stringify(entry)}`);
454
+ const [name, weight] = entry;
455
+ if (!name) throw new NarrationError("voice blend entry has an empty voice name");
456
+ if (!Number.isFinite(weight) || weight <= 0) throw new NarrationError(`voice blend weight for '${name}' must be a finite number > 0 (got ${weight})`);
457
+ sum += weight;
458
+ }
459
+ const chinese = raw.map(([name]) => isKokoroChineseVoice(name));
460
+ const allZh = chinese.every(Boolean);
461
+ const allEn = chinese.every((c) => !c);
462
+ if (!allZh && !allEn) {
463
+ const zh = raw.filter((_, i) => chinese[i]).map(([n]) => n);
464
+ const en = raw.filter((_, i) => !chinese[i]).map(([n]) => n);
465
+ throw new NarrationError(`voice blend mixes languages — Chinese [${zh.join(", ")}] with non-Chinese [${en.join(", ")}]; a blend must share one g2p front-end (all zf_/zm_ OR all English)`);
466
+ }
467
+ return {
468
+ entries: raw.map(([name, weight]) => [name, weight / sum]),
469
+ language: allZh ? "zh" : "en"
470
+ };
471
+ }
472
+ /**
473
+ * The blend's cache identity — a PURE deterministic string (no async, no model
474
+ * load) folded into kokoroProvider.version(). Names are kept in spec order; the
475
+ * normalized weights are fixed-precision so float formatting never drifts the
476
+ * key. Any base voice or weight change moves this string → invalidates the
477
+ * segment cache, EXACTLY the 0.15 g2p-identity pattern.
478
+ */
479
+ function blendIdentity(spec) {
480
+ const { entries, language } = resolveBlend(spec);
481
+ return `blend=[${entries.map(([name, w]) => `${name}:${w.toFixed(6)}`).join(",")} lang=${language} v${BLEND_SPEC_VERSION}]`;
482
+ }
483
+ /**
484
+ * Compute the normalized weighted sum of base-voice style tensors into one
485
+ * `[510 × 256]` Float32Array (the derived blended voice). Each loader returns a
486
+ * base voice's raw `[510 × 256]` float32 data; the weights are already
487
+ * normalized. Deterministic: a fixed pass over the spec-ordered entries, plain
488
+ * float adds. Validates every base tensor's length so a wrong-shape voice fails
489
+ * loudly rather than producing a silently-truncated blend.
490
+ */
491
+ function blendStyleVectors(entries, loadVoice) {
492
+ const out = new Float32Array(KOKORO_VOICE_FLOATS);
493
+ for (const [name, weight] of entries) {
494
+ const vec = loadVoice(name);
495
+ if (vec.length !== KOKORO_VOICE_FLOATS) throw new NarrationError(`kokoro voice '${name}' has ${vec.length} floats, expected ${KOKORO_VOICE_FLOATS} (${KOKORO_VOICE_ROWS}×${KOKORO_STYLE_DIM}) — not a valid style vector`);
496
+ for (let i = 0; i < KOKORO_VOICE_FLOATS; i++) out[i] += vec[i] * weight;
497
+ }
498
+ return out;
499
+ }
500
+ /**
409
501
  * Apache-2.0 82M neural TTS — markedly more natural than espeak/piper, fully
410
502
  * offline on CPU via onnxruntime, no API key. Pure-Node through `kokoro-js`
411
503
  * (Transformers.js), so unlike piper there is no `pip install` / external
@@ -449,6 +541,7 @@ function resolveKokoro() {
449
541
  for (const base of bases) try {
450
542
  const entry = createRequire(base).resolve("kokoro-js");
451
543
  return {
544
+ entry,
452
545
  entryUrl: pathToFileURL(entry).href,
453
546
  version: kokoroVersionFrom(entry)
454
547
  };
@@ -457,6 +550,61 @@ function resolveKokoro() {
457
550
  }
458
551
  throw new NarrationError(`kokoro-js could not be resolved from ${process.cwd()} (${lastErr?.code ?? "error"}: ${lastErr?.message ?? "not found"}) — install it in your project (npm / pnpm / yarn add kokoro-js; pnpm users must also allow its native build scripts — see the narration docs), or use --provider piper/espeak/openai`);
459
552
  }
553
+ /**
554
+ * Load a base voice's raw `[510×256]` float32 style tensor straight from the
555
+ * kokoro-js package's committed `voices/<name>.bin` (the same file kokoro-js
556
+ * reads internally for a named voice). Resolved relative to the kokoro-js entry
557
+ * (`<pkg>/dist/kokoro.* → ../voices/<name>.bin`), so the blend uses the SAME
558
+ * Apache-2.0 voice bytes the model was packaged with — no download, fully
559
+ * deterministic at prepare. A `.bin` whose size isn't 510×256×4 fails loudly.
560
+ */
561
+ function loadKokoroVoiceData(entry, name) {
562
+ const path = resolve(dirname(entry), "..", "voices", `${name}.bin`);
563
+ if (!existsSync(path)) throw new NarrationError(`kokoro voice '${name}' not found at ${path} — blend base voices must be kokoro built-in voices (e.g. zf_xiaoni, zf_xiaoxiao)`);
564
+ const buf = readFileSync(path);
565
+ if (buf.byteLength !== KOKORO_VOICE_FLOATS * 4) throw new NarrationError(`kokoro voice '${name}' is ${buf.byteLength} bytes, expected ${KOKORO_VOICE_FLOATS * 4} (${KOKORO_VOICE_ROWS}×${KOKORO_STYLE_DIM} float32)`);
566
+ return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
567
+ }
568
+ /** Resolve the @huggingface/transformers `Tensor` ctor (kokoro-js's own dep) —
569
+ * used to wrap the summed style vector as the model's `style` input. Resolved
570
+ * RELATIVE to the kokoro-js entry so we get the exact version kokoro-js uses. */
571
+ async function loadTensorCtor(entry) {
572
+ const req = createRequire(pathToFileURL(entry).href);
573
+ let resolved;
574
+ try {
575
+ resolved = req.resolve("@huggingface/transformers");
576
+ } catch (e) {
577
+ const err = e;
578
+ throw new NarrationError(`@huggingface/transformers could not be resolved from kokoro-js (${err?.code ?? "error"}: ${err?.message ?? "not found"}) — it is a kokoro-js dependency; reinstall kokoro-js, or use a single (non-blended) voice`);
579
+ }
580
+ const mod = await import(pathToFileURL(resolved).href);
581
+ const Tensor = mod["Tensor"] ?? mod["default"]?.["Tensor"];
582
+ if (!Tensor) throw new NarrationError("@huggingface/transformers loaded but exposes no Tensor export");
583
+ return Tensor;
584
+ }
585
+ /**
586
+ * Drive a SUMMED style vector through the model — the blend analogue of
587
+ * generate_from_ids (which only accepts a NAMED voice it loads from disk). This
588
+ * replicates kokoro-js's exact style-slicing: row = min(max(tokenCount-2, 0),
589
+ * 509), style = blended[row*256 : row*256+256] → Tensor[1,256], then the same
590
+ * `model({input_ids, style, speed})` call. Deterministic: a fixed slice + the
591
+ * model's own forward pass (no sampling).
592
+ */
593
+ async function kokoroGenerateFromBlend(tts, Tensor, inputIds, blended, speed) {
594
+ const tokens = inputIds.dims.at(-1) ?? 0;
595
+ const row = Math.min(Math.max(tokens - 2, 0), KOKORO_VOICE_ROWS - 1);
596
+ const styleTensor = new Tensor("float32", blended.slice(row * KOKORO_STYLE_DIM, row * KOKORO_STYLE_DIM + KOKORO_STYLE_DIM), [1, KOKORO_STYLE_DIM]);
597
+ const speedTensor = new Tensor("float32", [speed], [1]);
598
+ const { waveform } = await tts.model({
599
+ input_ids: inputIds,
600
+ style: styleTensor,
601
+ speed: speedTensor
602
+ });
603
+ return {
604
+ audio: waveform.data,
605
+ sampling_rate: 24e3
606
+ };
607
+ }
460
608
  /** A z* (zf_/zm_) kokoro voice routes through misaki[zh] g2p (not espeak cmn). */
461
609
  function isKokoroChineseVoice(voice) {
462
610
  return voice.startsWith("zf_") || voice.startsWith("zm_");
@@ -467,7 +615,7 @@ function kokoroProvider(opts = {}) {
467
615
  const zhG2p = opts.zhG2p ?? misakiZhG2p();
468
616
  let loaded = null;
469
617
  const loadLib = async () => {
470
- const { entryUrl } = resolveKokoro();
618
+ const { entry, entryUrl } = resolveKokoro();
471
619
  let mod;
472
620
  try {
473
621
  mod = await import(entryUrl);
@@ -477,22 +625,53 @@ function kokoroProvider(opts = {}) {
477
625
  }
478
626
  const lib = mod["KokoroTTS"] ? mod : mod["default"];
479
627
  if (!lib?.KokoroTTS) throw new NarrationError(`kokoro-js loaded but exposes no KokoroTTS export (from ${entryUrl})`);
480
- return lib;
628
+ return {
629
+ lib,
630
+ entry
631
+ };
481
632
  };
482
- const getModel = () => loaded ??= loadLib().then((k) => k.KokoroTTS.from_pretrained(modelId, {
483
- dtype,
484
- device: "cpu"
633
+ const getModel = () => loaded ??= loadLib().then(async ({ lib, entry }) => ({
634
+ tts: await lib.KokoroTTS.from_pretrained(modelId, {
635
+ dtype,
636
+ device: "cpu"
637
+ }),
638
+ entry
485
639
  }));
486
640
  return {
487
641
  id: "kokoro",
488
642
  version: () => {
489
643
  const { version } = resolveKokoro();
490
- const v = `kokoro-js ${version} ${basename(modelId)} dtype=${dtype} g2p=[${zhG2p.version()}]`;
644
+ const blendSuffix = isVoiceBlend(opts.voice) ? ` ${blendIdentity(opts.voice)}` : "";
645
+ const v = `kokoro-js ${version} ${basename(modelId)} dtype=${dtype} g2p=[${zhG2p.version()}]${blendSuffix}`;
491
646
  return Promise.resolve(v);
492
647
  },
493
648
  synthesize: async (req) => {
494
649
  const voice = req.voice ?? opts.voice ?? KOKORO_DEFAULT_VOICE;
495
650
  const speed = req.rate !== void 0 && req.rate > 0 ? req.rate : void 0;
651
+ if (isVoiceBlend(voice)) {
652
+ const resolved = resolveBlend(voice);
653
+ const { tts, entry } = await getModel();
654
+ const blended = blendStyleVectors(resolved.entries, (name) => loadKokoroVoiceData(entry, name));
655
+ const Tensor = await loadTensorCtor(entry);
656
+ const recipe = resolved.entries.map(([n, w]) => `${n}*${w.toFixed(4)}`).join(" + ");
657
+ console.log(`[narrate] kokoro blended voice (Apache-2.0, derived) lang=${resolved.language}: ${recipe}`);
658
+ let inputIds;
659
+ try {
660
+ if (resolved.language === "zh") {
661
+ const phonemes = zhG2p.phonemize(req.text);
662
+ inputIds = tts.tokenizer(phonemes, { truncation: true }).input_ids;
663
+ } else throw new NarrationError("English voice blends are not yet supported (the z*/Chinese blend is the shipped path) — use a single English voice, or a Chinese (zf_/zm_) blend; see gh#2 for the English follow-up");
664
+ const audio = await kokoroGenerateFromBlend(tts, Tensor, inputIds, blended, speed ?? 1);
665
+ const wav = floatToWav(audio.audio, audio.sampling_rate);
666
+ return {
667
+ wav,
668
+ duration: wavDuration(wav)
669
+ };
670
+ } catch (e) {
671
+ if (e instanceof NarrationError) throw e;
672
+ throw new NarrationError(`kokoro blended synthesis failed: ${e instanceof Error ? e.message : String(e)}`);
673
+ }
674
+ }
496
675
  const genOpts = speed !== void 0 ? {
497
676
  voice,
498
677
  speed
@@ -500,7 +679,7 @@ function kokoroProvider(opts = {}) {
500
679
  let audio;
501
680
  if (isKokoroChineseVoice(voice)) {
502
681
  const phonemes = zhG2p.phonemize(req.text);
503
- const tts = await getModel();
682
+ const { tts } = await getModel();
504
683
  try {
505
684
  const { input_ids } = tts.tokenizer(phonemes, { truncation: true });
506
685
  audio = await tts.generate_from_ids(input_ids, genOpts);
@@ -508,7 +687,7 @@ function kokoroProvider(opts = {}) {
508
687
  throw new NarrationError(`kokoro Chinese synthesis failed: ${e instanceof Error ? e.message : String(e)}`);
509
688
  }
510
689
  } else {
511
- const tts = await getModel();
690
+ const { tts } = await getModel();
512
691
  try {
513
692
  audio = await tts.generate(req.text, genOpts);
514
693
  } catch (e) {
@@ -704,10 +883,22 @@ function alignerById(id) {
704
883
  default: throw new NarrationError(`unknown aligner '${id}' (have: heuristic, vosk, none)`);
705
884
  }
706
885
  }
886
+ /**
887
+ * Canonicalize a voice for the cache key. A plain string is itself; a BLEND
888
+ * spec collapses to its `blendIdentity()` — normalized weights + language +
889
+ * `BLEND_SPEC_VERSION`. This is what folds the blend into the cache key on the
890
+ * real CLI path (where the blend is a per-REQUEST voice and the provider is
891
+ * built with no opts): any weight / base-voice / spec-version change moves the
892
+ * identity → re-synthesizes; the same blend (even re-serialized) stays stable.
893
+ */
894
+ function voiceCacheIdentity(voice) {
895
+ if (voice === void 0) return null;
896
+ return isVoiceBlend(voice) ? blendIdentity(voice) : voice;
897
+ }
707
898
  function cacheKey(seg, provider, providerVersion) {
708
899
  return createHash("sha256").update(JSON.stringify({
709
900
  text: seg.text,
710
- voice: seg.voice ?? null,
901
+ voice: voiceCacheIdentity(seg.voice),
711
902
  rate: seg.rate ?? 1,
712
903
  provider,
713
904
  providerVersion
@@ -861,4 +1052,4 @@ function scriptPathFor(input) {
861
1052
  return candidate;
862
1053
  }
863
1054
  //#endregion
864
- export { alignerById, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, isKokoroChineseVoice, kokoroProvider, mapAsrToScript, openaiProvider, piperProvider, providerById, resolvePiperVoice, scriptPathFor, stderrTail, synthesizeScript, voskAligner, wavDuration };
1055
+ export { BLEND_SPEC_VERSION, alignerById, blendIdentity, blendStyleVectors, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, isKokoroChineseVoice, kokoroGenerateFromBlend, kokoroProvider, loadKokoroVoiceData, mapAsrToScript, openaiProvider, piperProvider, providerById, requireStringVoice, resolveBlend, resolvePiperVoice, scriptPathFor, stderrTail, synthesizeScript, voskAligner, wavDuration };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/narrate",
3
- "version": "0.15.0",
3
+ "version": "0.16.0-pre.0",
4
4
  "description": "glissade narration + captions: TTS at prepare time (gs narrate), deterministic caching, narration-anchored timeline beats, and captions as plain tracks. Render stays offline.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -22,8 +22,8 @@
22
22
  "dist"
23
23
  ],
24
24
  "dependencies": {
25
- "@glissade/core": "0.15.0",
26
- "@glissade/scene": "0.15.0"
25
+ "@glissade/core": "0.16.0-pre.0",
26
+ "@glissade/scene": "0.16.0-pre.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "kokoro-js": "^1.2.0"