@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.
@@ -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
- return Promise.resolve(`kokoro-js ${version} ${basename(modelId)} dtype=${dtype}`);
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
- if (voice.startsWith("zf_") || voice.startsWith("zm_")) throw new NarrationError("kokoro Chinese voices (z*) need misaki[zh] g2p (pinyin+jieba), which is not wired — kokoro-js routes zh through espeak-ng cmn (mismatched phonemes → garbled). Use --provider piper for Chinese. (tracked: card 24vKUw2HVC6D)");
337
- const tts = await getModel();
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: req.rate
498
+ speed
341
499
  } : { voice };
342
500
  let audio;
343
- try {
344
- audio = await tts.generate(req.text, genOpts);
345
- } catch (e) {
346
- throw new NarrationError(`kokoro synthesis failed: ${e instanceof Error ? e.message : String(e)}`);
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.14.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.14.0",
26
- "@glissade/scene": "0.14.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"