@glissade/narrate 0.14.0 → 0.15.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.
@@ -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,134 @@ 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 (engine-id + jieba-dict hash + phoneme-map version + the pinned
25
+ * misaki wheel version) folds into kokoroProvider.version(), which keys the
26
+ * segment cache — so any g2p change invalidates stale/cross-machine audio.
27
+ */
28
+ /** The misaki wheel this seam is pinned to (matches scripts/gen-misaki-parity.py). */
29
+ const MISAKI_PIN = "0.9.4";
30
+ /** The jieba dict this seam is pinned to (its segmentation drives word boundaries). */
31
+ const JIEBA_PIN = "0.42.1";
32
+ /**
33
+ * The phoneme-map version: bump when our text→misaki invocation changes in a way
34
+ * that can move phoneme bytes (e.g. a normalization tweak), independent of the
35
+ * misaki wheel. Folds into the g2p identity → the cache key.
36
+ */
37
+ const PHONEME_MAP_VERSION = "zh-misaki-1";
38
+ /** The default Python interpreter; override via opts.python or MISAKI_PYTHON. */
39
+ const DEFAULT_PYTHON = "python3";
40
+ /**
41
+ * The Python program we shell out to. It imports misaki[zh], builds the g2p once,
42
+ * reads the Mandarin text from stdin, prints `<phonemes>\n` to stdout, and — when
43
+ * asked with `--id` — prints a stable identity line
44
+ * `misaki=<ver> jieba=<ver> dict=<sha12>`
45
+ * (jieba dict hash included: its segmentation determines word boundaries, so a
46
+ * dict change can move phonemes). ENOENT (python absent) is the only true
47
+ * "g2p unavailable"; any other failure surfaces the Python traceback tail.
48
+ */
49
+ /**
50
+ * On a missing misaki/jieba import the program exits with this exact code (not a
51
+ * raw traceback) so the TS side can raise the install hint — the Python-present-
52
+ * but-misaki-absent analogue of ENOENT. Picked to not collide with 0/1/2.
53
+ */
54
+ const MISAKI_ABSENT_EXIT = 97;
55
+ const ZH_G2P_PY = String.raw`
56
+ import sys, hashlib, os
57
+
58
+ try:
59
+ import misaki, jieba
60
+ from misaki import zh
61
+ except ImportError:
62
+ sys.exit(97)
63
+
64
+ def _ident():
65
+ mv = getattr(misaki, "__version__", "unknown")
66
+ jv = getattr(jieba, "__version__", "unknown")
67
+ # hash jieba's prefix dict — its segmentation drives the phoneme boundaries
68
+ dpath = os.path.join(os.path.dirname(jieba.__file__), "dict.txt")
69
+ try:
70
+ with open(dpath, "rb") as f:
71
+ dh = hashlib.sha256(f.read()).hexdigest()[:12]
72
+ except OSError:
73
+ dh = "nodict"
74
+ sys.stdout.write("misaki=%s jieba=%s dict=%s\n" % (mv, jv, dh))
75
+
76
+ def _phonemize():
77
+ g = zh.ZHG2P()
78
+ text = sys.stdin.buffer.read().decode("utf-8")
79
+ phonemes, _ = g(text)
80
+ sys.stdout.buffer.write(phonemes.encode("utf-8"))
81
+
82
+ if "--id" in sys.argv[1:]:
83
+ _ident()
84
+ else:
85
+ _phonemize()
86
+ `;
87
+ /** The shared install hint — raised when Python is absent (ENOENT) OR present
88
+ * but misaki/jieba can't be imported (the MISAKI_ABSENT_EXIT sentinel). */
89
+ function installHint(python, why) {
90
+ 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.`);
91
+ }
92
+ /** Surface a real (non-ENOENT) spawn error as a NarrationError, else null.
93
+ * ENOENT (python absent) becomes the install hint. */
94
+ function spawnFailure(r, python) {
95
+ if (!r.error) return null;
96
+ if (r.error.code === "ENOENT") return installHint(python, "python");
97
+ return new NarrationError(`could not run misaki[zh] g2p via '${python}': ${r.error.message}`);
98
+ }
99
+ /**
100
+ * Fork B: shell out to a pinned Python misaki[zh]. Mirrors piperProvider — same
101
+ * spawnSync, same ENOENT-is-the-only-true-absent feature detection, same
102
+ * version()-parse-and-fold-into-the-cache-key contract (piper even pip-installs
103
+ * its Python tool, so the precedent is exact).
104
+ */
105
+ function misakiZhG2p(opts = {}) {
106
+ const python = opts.python ?? process.env["MISAKI_PYTHON"] ?? DEFAULT_PYTHON;
107
+ return {
108
+ id: "misaki-zh",
109
+ version: () => {
110
+ const r = spawnSync(python, [
111
+ "-c",
112
+ ZH_G2P_PY,
113
+ "--id"
114
+ ], { encoding: "utf8" });
115
+ const fail = spawnFailure(r, python);
116
+ if (fail) throw fail;
117
+ if (r.status === MISAKI_ABSENT_EXIT) throw installHint(python, "misaki");
118
+ if (r.status !== 0) throw new NarrationError(`misaki[zh] g2p not available via '${python}' (exit ${String(r.status)}): ${stderrTail(r.stderr)} — \`pip install 'misaki[zh]==${MISAKI_PIN}' 'jieba==${JIEBA_PIN}'\` into that interpreter.`);
119
+ return `misaki-zh ${(r.stdout ?? "").trim()} map=${PHONEME_MAP_VERSION}`;
120
+ },
121
+ phonemize: (text) => {
122
+ const r = spawnSync(python, ["-c", ZH_G2P_PY], {
123
+ input: text,
124
+ maxBuffer: 8 * 1024 * 1024
125
+ });
126
+ const fail = spawnFailure(r, python);
127
+ if (fail) throw fail;
128
+ if (r.status === MISAKI_ABSENT_EXIT) throw installHint(python, "misaki");
129
+ if (r.status !== 0) throw new NarrationError(`misaki[zh] g2p failed: ${stderrTail(r.stderr)}`);
130
+ const out = r.stdout?.toString("utf8") ?? "";
131
+ if (out.length === 0) throw new NarrationError(`misaki[zh] g2p produced no phonemes for text: ${JSON.stringify(text.slice(0, 60))}`);
132
+ return out;
133
+ }
134
+ };
135
+ }
136
+ //#endregion
9
137
  //#region src/providers.ts
10
138
  /**
11
139
  * '@glissade/narrate/providers' — the Node-only prepare side. Provider calls
@@ -304,9 +432,15 @@ function resolveKokoro() {
304
432
  }
305
433
  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
434
  }
435
+ /** A z* (zf_/zm_) kokoro voice routes through misaki[zh] g2p (not espeak cmn). */
436
+ function isKokoroChineseVoice(voice) {
437
+ return voice.startsWith("zf_") || voice.startsWith("zm_");
438
+ }
307
439
  function kokoroProvider(opts = {}) {
308
440
  const modelId = opts.model ?? KOKORO_MODEL;
309
441
  const dtype = opts.dtype ?? "q8";
442
+ const zhG2p = opts.zhG2p ?? misakiZhG2p();
443
+ const usesChinese = isKokoroChineseVoice(opts.voice ?? KOKORO_DEFAULT_VOICE);
310
444
  let loaded = null;
311
445
  const loadLib = async () => {
312
446
  const { entryUrl } = resolveKokoro();
@@ -329,21 +463,34 @@ function kokoroProvider(opts = {}) {
329
463
  id: "kokoro",
330
464
  version: () => {
331
465
  const { version } = resolveKokoro();
332
- return Promise.resolve(`kokoro-js ${version} ${basename(modelId)} dtype=${dtype}`);
466
+ let v = `kokoro-js ${version} ${basename(modelId)} dtype=${dtype}`;
467
+ if (usesChinese) v += ` g2p=[${zhG2p.version()}]`;
468
+ return Promise.resolve(v);
333
469
  },
334
470
  synthesize: async (req) => {
335
471
  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 ? {
472
+ const speed = req.rate !== void 0 && req.rate > 0 ? req.rate : void 0;
473
+ const genOpts = speed !== void 0 ? {
339
474
  voice,
340
- speed: req.rate
475
+ speed
341
476
  } : { voice };
342
477
  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)}`);
478
+ if (isKokoroChineseVoice(voice)) {
479
+ const phonemes = zhG2p.phonemize(req.text);
480
+ const tts = await getModel();
481
+ try {
482
+ const { input_ids } = tts.tokenizer(phonemes, { truncation: true });
483
+ audio = await tts.generate_from_ids(input_ids, genOpts);
484
+ } catch (e) {
485
+ throw new NarrationError(`kokoro Chinese synthesis failed: ${e instanceof Error ? e.message : String(e)}`);
486
+ }
487
+ } else {
488
+ const tts = await getModel();
489
+ try {
490
+ audio = await tts.generate(req.text, genOpts);
491
+ } catch (e) {
492
+ throw new NarrationError(`kokoro synthesis failed: ${e instanceof Error ? e.message : String(e)}`);
493
+ }
347
494
  }
348
495
  const wav = floatToWav(audio.audio, audio.sampling_rate);
349
496
  return {
@@ -691,4 +838,4 @@ function scriptPathFor(input) {
691
838
  return candidate;
692
839
  }
693
840
  //#endregion
694
- export { alignerById, cacheKey, espeakProvider, fakeProvider, floatToWav, heuristicAligner, heuristicWords, interpolateMissing, kokoroProvider, mapAsrToScript, openaiProvider, piperProvider, providerById, resolvePiperVoice, scriptPathFor, stderrTail, synthesizeScript, voskAligner, wavDuration };
841
+ 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.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.14.0",
26
- "@glissade/scene": "0.14.0"
25
+ "@glissade/core": "0.15.0-pre.0",
26
+ "@glissade/scene": "0.15.0-pre.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "kokoro-js": "^1.2.0"