@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 +29 -3
- package/dist/index.js +5 -1
- package/dist/providers.d.ts +113 -5
- package/dist/providers.js +205 -14
- package/package.json +3 -3
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?:
|
|
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?:
|
|
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 };
|
package/dist/providers.d.ts
CHANGED
|
@@ -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
|
|
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?:
|
|
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?:
|
|
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 (
|
|
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:
|
|
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
|
|
628
|
+
return {
|
|
629
|
+
lib,
|
|
630
|
+
entry
|
|
631
|
+
};
|
|
481
632
|
};
|
|
482
|
-
const getModel = () => loaded ??= loadLib().then((
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
26
|
-
"@glissade/scene": "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"
|