@glissade/narrate 0.14.0 → 0.15.0-pre.1
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/providers.d.ts +20 -1
- package/dist/providers.js +180 -10
- package/package.json +3 -3
package/dist/providers.d.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { NarrationTiming } from "./index.js";
|
|
2
2
|
|
|
3
|
+
//#region src/zh-g2p.d.ts
|
|
4
|
+
|
|
5
|
+
interface ZhG2p {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
/** the g2p identity, folded into kokoroProvider.version() → the cache key */
|
|
8
|
+
version(): string;
|
|
9
|
+
/** Mandarin text → a misaki[zh] phoneme string (custom-IPA + arrow tones) */
|
|
10
|
+
phonemize(text: string): string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Fork B: shell out to a pinned Python misaki[zh]. Mirrors piperProvider — same
|
|
14
|
+
* spawnSync, same ENOENT-is-the-only-true-absent feature detection, same
|
|
15
|
+
* version()-parse-and-fold-into-the-cache-key contract (piper even pip-installs
|
|
16
|
+
* its Python tool, so the precedent is exact).
|
|
17
|
+
*/
|
|
18
|
+
//#endregion
|
|
3
19
|
//#region src/providers.d.ts
|
|
4
20
|
|
|
5
21
|
interface TtsRequest {
|
|
@@ -71,10 +87,13 @@ declare function piperProvider(opts?: {
|
|
|
71
87
|
/** PCM16 mono WAV from float samples in [-1, 1]. Round-to-nearest → deterministic. */
|
|
72
88
|
declare function floatToWav(samples: Float32Array, sampleRate: number): Buffer;
|
|
73
89
|
type KokoroDtype = 'fp32' | 'fp16' | 'q8' | 'q4' | 'q4f16';
|
|
90
|
+
/** A z* (zf_/zm_) kokoro voice routes through misaki[zh] g2p (not espeak cmn). */
|
|
91
|
+
declare function isKokoroChineseVoice(voice: string): boolean;
|
|
74
92
|
declare function kokoroProvider(opts?: {
|
|
75
93
|
model?: string;
|
|
76
94
|
voice?: string;
|
|
77
95
|
dtype?: KokoroDtype;
|
|
96
|
+
zhG2p?: ZhG2p;
|
|
78
97
|
}): TtsProvider;
|
|
79
98
|
declare function providerById(id: string): TtsProvider;
|
|
80
99
|
interface AlignRequest {
|
|
@@ -207,4 +226,4 @@ declare function synthesizeScript(scriptPath: string, opts?: SynthesizeOptions):
|
|
|
207
226
|
/** Resolve `<scene>.narration.json` for a scene-module path (or accept the script itself). */
|
|
208
227
|
declare function scriptPathFor(input: string): string;
|
|
209
228
|
//#endregion
|
|
210
|
-
export { AlignRequest, Aligner, KokoroDtype, SynthesizeOptions, SynthesizeResult, TtsProvider, TtsRequest, TtsResult, VoskAlignWord, alignerById, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, kokoroProvider, mapAsrToScript, openaiProvider, piperProvider, providerById, resolvePiperVoice, scriptPathFor, stderrTail, synthesizeScript, voskAligner, wavDuration };
|
|
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 };
|
package/dist/providers.js
CHANGED
|
@@ -6,6 +6,159 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
6
6
|
import { homedir, tmpdir } from "node:os";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
|
+
//#region src/zh-g2p.ts
|
|
10
|
+
/**
|
|
11
|
+
* '@glissade/narrate/providers' Chinese g2p seam — Mandarin text → a misaki[zh]
|
|
12
|
+
* phoneme string (custom-IPA + arrow tone marks ↓→↗↘), the alphabet the kokoro
|
|
13
|
+
* z* (zf_/zm_) voices were trained on. Without this, kokoro-js routes Chinese
|
|
14
|
+
* through espeak-ng `cmn`, whose phonemes mismatch → garbled audio.
|
|
15
|
+
*
|
|
16
|
+
* Fork B = shell out to a PINNED Python misaki[zh], reusing the EXACT pattern
|
|
17
|
+
* piperProvider/espeakProvider ship: spawnSync + ENOENT-is-the-only-true-absent
|
|
18
|
+
* + a version() string that folds into the cache key. The shared oracle is the
|
|
19
|
+
* committed parity corpus (test/fixtures/misaki-zh-parity.json) — produced ONCE
|
|
20
|
+
* from this same Python reference (scripts/gen-misaki-parity.py); a future
|
|
21
|
+
* pure-TS Fork A can be checked against it offline.
|
|
22
|
+
*
|
|
23
|
+
* DETERMINISM: g2p runs at PREPARE time (gs narrate), never at render. Its
|
|
24
|
+
* identity — a PURE pin-based string `misaki=<MISAKI_PIN> jieba=<JIEBA_PIN>
|
|
25
|
+
* map=<PHONEME_MAP_VERSION>`, with NO Python (no spawn, no wheel introspection) —
|
|
26
|
+
* folds UNCONDITIONALLY into kokoroProvider.version(), which keys the segment
|
|
27
|
+
* cache. The pinned wheel version implies its bundled dict, so a live dict-hash is
|
|
28
|
+
* not needed in the identity. Bumping any pin/map invalidates stale Mandarin audio.
|
|
29
|
+
* The declared pins are ENFORCED against the installed wheel at synth time
|
|
30
|
+
* (phonemize, via importlib.metadata) — a divergent wheel fails LOUDLY there.
|
|
31
|
+
*/
|
|
32
|
+
/** The misaki wheel this seam is pinned to (matches scripts/gen-misaki-parity.py). */
|
|
33
|
+
const MISAKI_PIN = "0.9.4";
|
|
34
|
+
/** The jieba dict this seam is pinned to (its segmentation drives word boundaries). */
|
|
35
|
+
const JIEBA_PIN = "0.42.1";
|
|
36
|
+
/**
|
|
37
|
+
* The phoneme-map version: bump when our text→misaki invocation changes in a way
|
|
38
|
+
* that can move phoneme bytes (e.g. a normalization tweak), independent of the
|
|
39
|
+
* misaki wheel. Folds into the g2p identity → the cache key.
|
|
40
|
+
*/
|
|
41
|
+
const PHONEME_MAP_VERSION = "zh-misaki-1";
|
|
42
|
+
/** The default Python interpreter; override via opts.python or MISAKI_PYTHON. */
|
|
43
|
+
const DEFAULT_PYTHON = "python3";
|
|
44
|
+
/**
|
|
45
|
+
* On a missing misaki/jieba import the program exits with this exact code (not a
|
|
46
|
+
* raw traceback) so the TS side can raise the install hint — the Python-present-
|
|
47
|
+
* but-misaki-absent analogue of ENOENT. Picked to not collide with 0/1/2.
|
|
48
|
+
*/
|
|
49
|
+
const MISAKI_ABSENT_EXIT = 97;
|
|
50
|
+
/**
|
|
51
|
+
* On an installed-vs-PINNED version MISMATCH the phonemize program exits with this
|
|
52
|
+
* exact code so the TS side raises an actionable `installed X != pinned PIN` error
|
|
53
|
+
* (never a silent 'unknown'). Distinct from MISAKI_ABSENT_EXIT (97) and 0/1/2.
|
|
54
|
+
*/
|
|
55
|
+
const MISAKI_PIN_MISMATCH_EXIT = 96;
|
|
56
|
+
/**
|
|
57
|
+
* The phonemize Python program. It imports misaki[zh], ENFORCES the declared pins
|
|
58
|
+
* (resolving the installed versions via `importlib.metadata.version(...)` — the
|
|
59
|
+
* authoritative dist-info, present even when a wheel exposes no `__version__`, as
|
|
60
|
+
* jieba historically does not), reads the Mandarin text from stdin, and writes
|
|
61
|
+
* `<phonemes>` to stdout.
|
|
62
|
+
*
|
|
63
|
+
* Pin enforcement lives at SYNTH time (HERE), NOT in `version()`: `version()` is a
|
|
64
|
+
* PURE pin-based identity string (no Python at all). A divergent wheel is caught
|
|
65
|
+
* loudly here — on a mismatch it writes `<dist>:<installed>:<pinned>` to stderr and
|
|
66
|
+
* exits MISAKI_PIN_MISMATCH_EXIT so the TS side raises an actionable error. ENOENT
|
|
67
|
+
* (python absent) and ImportError (misaki absent → exit 97) remain the two
|
|
68
|
+
* "g2p unavailable" exits; any other failure surfaces the Python traceback tail.
|
|
69
|
+
*/
|
|
70
|
+
const ZH_G2P_PY = String.raw`
|
|
71
|
+
import sys
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
import misaki, jieba
|
|
75
|
+
from misaki import zh
|
|
76
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
77
|
+
except ImportError:
|
|
78
|
+
sys.exit(97)
|
|
79
|
+
|
|
80
|
+
# pins arrive as: --pins <misaki_pin> <jieba_pin>; enforce installed==pinned
|
|
81
|
+
def _check_pins():
|
|
82
|
+
args = sys.argv[1:]
|
|
83
|
+
if "--pins" not in args:
|
|
84
|
+
return
|
|
85
|
+
i = args.index("--pins")
|
|
86
|
+
pins = (("misaki", args[i + 1]), ("jieba", args[i + 2]))
|
|
87
|
+
for dist, pin in pins:
|
|
88
|
+
try:
|
|
89
|
+
installed = version(dist)
|
|
90
|
+
except PackageNotFoundError:
|
|
91
|
+
installed = "unknown"
|
|
92
|
+
if installed != pin:
|
|
93
|
+
sys.stderr.write("%s:%s:%s\n" % (dist, installed, pin))
|
|
94
|
+
sys.exit(96)
|
|
95
|
+
|
|
96
|
+
def _phonemize():
|
|
97
|
+
g = zh.ZHG2P()
|
|
98
|
+
text = sys.stdin.buffer.read().decode("utf-8")
|
|
99
|
+
phonemes, _ = g(text)
|
|
100
|
+
sys.stdout.buffer.write(phonemes.encode("utf-8"))
|
|
101
|
+
|
|
102
|
+
_check_pins()
|
|
103
|
+
_phonemize()
|
|
104
|
+
`;
|
|
105
|
+
/** The shared install hint — raised when Python is absent (ENOENT) OR present
|
|
106
|
+
* but misaki/jieba can't be imported (the MISAKI_ABSENT_EXIT sentinel). */
|
|
107
|
+
function installHint(python, why) {
|
|
108
|
+
return new NarrationError(`${why === "python" ? `Python ('${python}') not found on PATH for misaki[zh] g2p` : `misaki[zh] is not importable from '${python}'`} — the kokoro Chinese (z*) route needs it. Install: \`pip install 'misaki[zh]==${MISAKI_PIN}' 'jieba==${JIEBA_PIN}'\` into that interpreter (or set MISAKI_PYTHON to one that has it), or use --provider piper for Chinese.`);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Raised when the installed misaki/jieba wheel diverges from the declared pins
|
|
112
|
+
* (the MISAKI_PIN_MISMATCH_EXIT sentinel at synth time). The Python side wrote
|
|
113
|
+
* `<dist>:<installed>:<pinned>` to stderr; we surface it as an actionable error so
|
|
114
|
+
* a divergent wheel is caught LOUDLY rather than silently hashing to 'unknown'.
|
|
115
|
+
*/
|
|
116
|
+
function pinMismatchError(python, stderr) {
|
|
117
|
+
const line = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim().split("\n").pop() ?? "";
|
|
118
|
+
const [dist, installed, pinned] = line.split(":");
|
|
119
|
+
return new NarrationError(`misaki[zh] g2p pin mismatch via '${python}': ${dist && installed && pinned ? `installed ${dist} ${installed} != pinned ${dist === "misaki" ? "MISAKI_PIN" : "JIEBA_PIN"} ${pinned}` : `installed misaki/jieba wheel diverges from the declared pins (${line || "no detail"})`} — the segment cache identity is keyed on the pins, so a divergent wheel would produce uncacheable/cross-machine-divergent audio. Install the pinned wheels: \`pip install 'misaki[zh]==${MISAKI_PIN}' 'jieba==${JIEBA_PIN}'\` into that interpreter (or set MISAKI_PYTHON to one that has them).`);
|
|
120
|
+
}
|
|
121
|
+
/** Surface a real (non-ENOENT) spawn error as a NarrationError, else null.
|
|
122
|
+
* ENOENT (python absent) becomes the install hint. */
|
|
123
|
+
function spawnFailure(r, python) {
|
|
124
|
+
if (!r.error) return null;
|
|
125
|
+
if (r.error.code === "ENOENT") return installHint(python, "python");
|
|
126
|
+
return new NarrationError(`could not run misaki[zh] g2p via '${python}': ${r.error.message}`);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Fork B: shell out to a pinned Python misaki[zh]. Mirrors piperProvider — same
|
|
130
|
+
* spawnSync, same ENOENT-is-the-only-true-absent feature detection, same
|
|
131
|
+
* version()-parse-and-fold-into-the-cache-key contract (piper even pip-installs
|
|
132
|
+
* its Python tool, so the precedent is exact).
|
|
133
|
+
*/
|
|
134
|
+
function misakiZhG2p(opts = {}) {
|
|
135
|
+
const python = opts.python ?? process.env["MISAKI_PYTHON"] ?? DEFAULT_PYTHON;
|
|
136
|
+
return {
|
|
137
|
+
id: "misaki-zh",
|
|
138
|
+
version: () => `misaki-zh misaki=${MISAKI_PIN} jieba=${JIEBA_PIN} map=${PHONEME_MAP_VERSION}`,
|
|
139
|
+
phonemize: (text) => {
|
|
140
|
+
const r = spawnSync(python, [
|
|
141
|
+
"-c",
|
|
142
|
+
ZH_G2P_PY,
|
|
143
|
+
"--pins",
|
|
144
|
+
MISAKI_PIN,
|
|
145
|
+
JIEBA_PIN
|
|
146
|
+
], {
|
|
147
|
+
input: text,
|
|
148
|
+
maxBuffer: 8 * 1024 * 1024
|
|
149
|
+
});
|
|
150
|
+
const fail = spawnFailure(r, python);
|
|
151
|
+
if (fail) throw fail;
|
|
152
|
+
if (r.status === MISAKI_ABSENT_EXIT) throw installHint(python, "misaki");
|
|
153
|
+
if (r.status === MISAKI_PIN_MISMATCH_EXIT) throw pinMismatchError(python, r.stderr);
|
|
154
|
+
if (r.status !== 0) throw new NarrationError(`misaki[zh] g2p failed: ${stderrTail(r.stderr)}`);
|
|
155
|
+
const out = r.stdout?.toString("utf8") ?? "";
|
|
156
|
+
if (out.length === 0) throw new NarrationError(`misaki[zh] g2p produced no phonemes for text: ${JSON.stringify(text.slice(0, 60))}`);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
9
162
|
//#region src/providers.ts
|
|
10
163
|
/**
|
|
11
164
|
* '@glissade/narrate/providers' — the Node-only prepare side. Provider calls
|
|
@@ -304,9 +457,14 @@ function resolveKokoro() {
|
|
|
304
457
|
}
|
|
305
458
|
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`);
|
|
306
459
|
}
|
|
460
|
+
/** A z* (zf_/zm_) kokoro voice routes through misaki[zh] g2p (not espeak cmn). */
|
|
461
|
+
function isKokoroChineseVoice(voice) {
|
|
462
|
+
return voice.startsWith("zf_") || voice.startsWith("zm_");
|
|
463
|
+
}
|
|
307
464
|
function kokoroProvider(opts = {}) {
|
|
308
465
|
const modelId = opts.model ?? KOKORO_MODEL;
|
|
309
466
|
const dtype = opts.dtype ?? "q8";
|
|
467
|
+
const zhG2p = opts.zhG2p ?? misakiZhG2p();
|
|
310
468
|
let loaded = null;
|
|
311
469
|
const loadLib = async () => {
|
|
312
470
|
const { entryUrl } = resolveKokoro();
|
|
@@ -329,21 +487,33 @@ function kokoroProvider(opts = {}) {
|
|
|
329
487
|
id: "kokoro",
|
|
330
488
|
version: () => {
|
|
331
489
|
const { version } = resolveKokoro();
|
|
332
|
-
|
|
490
|
+
const v = `kokoro-js ${version} ${basename(modelId)} dtype=${dtype} g2p=[${zhG2p.version()}]`;
|
|
491
|
+
return Promise.resolve(v);
|
|
333
492
|
},
|
|
334
493
|
synthesize: async (req) => {
|
|
335
494
|
const voice = req.voice ?? opts.voice ?? KOKORO_DEFAULT_VOICE;
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
const genOpts = req.rate !== void 0 && req.rate > 0 ? {
|
|
495
|
+
const speed = req.rate !== void 0 && req.rate > 0 ? req.rate : void 0;
|
|
496
|
+
const genOpts = speed !== void 0 ? {
|
|
339
497
|
voice,
|
|
340
|
-
speed
|
|
498
|
+
speed
|
|
341
499
|
} : { voice };
|
|
342
500
|
let audio;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
501
|
+
if (isKokoroChineseVoice(voice)) {
|
|
502
|
+
const phonemes = zhG2p.phonemize(req.text);
|
|
503
|
+
const tts = await getModel();
|
|
504
|
+
try {
|
|
505
|
+
const { input_ids } = tts.tokenizer(phonemes, { truncation: true });
|
|
506
|
+
audio = await tts.generate_from_ids(input_ids, genOpts);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
throw new NarrationError(`kokoro Chinese synthesis failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
const tts = await getModel();
|
|
512
|
+
try {
|
|
513
|
+
audio = await tts.generate(req.text, genOpts);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
throw new NarrationError(`kokoro synthesis failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
516
|
+
}
|
|
347
517
|
}
|
|
348
518
|
const wav = floatToWav(audio.audio, audio.sampling_rate);
|
|
349
519
|
return {
|
|
@@ -691,4 +861,4 @@ function scriptPathFor(input) {
|
|
|
691
861
|
return candidate;
|
|
692
862
|
}
|
|
693
863
|
//#endregion
|
|
694
|
-
export { alignerById, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, kokoroProvider, mapAsrToScript, openaiProvider, piperProvider, providerById, resolvePiperVoice, scriptPathFor, stderrTail, synthesizeScript, voskAligner, wavDuration };
|
|
864
|
+
export { alignerById, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, isKokoroChineseVoice, kokoroProvider, mapAsrToScript, openaiProvider, piperProvider, providerById, 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.15.0-pre.1",
|
|
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.15.0-pre.1",
|
|
26
|
+
"@glissade/scene": "0.15.0-pre.1"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"kokoro-js": "^1.2.0"
|