@glissade/narrate 0.15.0-pre.0 → 0.15.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.
Files changed (2) hide show
  1. package/dist/providers.js +65 -42
  2. package/package.json +3 -3
package/dist/providers.js CHANGED
@@ -21,9 +21,13 @@ import { pathToFileURL } from "node:url";
21
21
  * pure-TS Fork A can be checked against it offline.
22
22
  *
23
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.
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.
27
31
  */
28
32
  /** The misaki wheel this seam is pinned to (matches scripts/gen-misaki-parity.py). */
29
33
  const MISAKI_PIN = "0.9.4";
@@ -38,40 +42,56 @@ const PHONEME_MAP_VERSION = "zh-misaki-1";
38
42
  /** The default Python interpreter; override via opts.python or MISAKI_PYTHON. */
39
43
  const DEFAULT_PYTHON = "python3";
40
44
  /**
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
45
  * On a missing misaki/jieba import the program exits with this exact code (not a
51
46
  * raw traceback) so the TS side can raise the install hint — the Python-present-
52
47
  * but-misaki-absent analogue of ENOENT. Picked to not collide with 0/1/2.
53
48
  */
54
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
+ */
55
70
  const ZH_G2P_PY = String.raw`
56
- import sys, hashlib, os
71
+ import sys
57
72
 
58
73
  try:
59
74
  import misaki, jieba
60
75
  from misaki import zh
76
+ from importlib.metadata import version, PackageNotFoundError
61
77
  except ImportError:
62
78
  sys.exit(97)
63
79
 
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))
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)
75
95
 
76
96
  def _phonemize():
77
97
  g = zh.ZHG2P()
@@ -79,16 +99,25 @@ def _phonemize():
79
99
  phonemes, _ = g(text)
80
100
  sys.stdout.buffer.write(phonemes.encode("utf-8"))
81
101
 
82
- if "--id" in sys.argv[1:]:
83
- _ident()
84
- else:
85
- _phonemize()
102
+ _check_pins()
103
+ _phonemize()
86
104
  `;
87
105
  /** The shared install hint — raised when Python is absent (ENOENT) OR present
88
106
  * but misaki/jieba can't be imported (the MISAKI_ABSENT_EXIT sentinel). */
89
107
  function installHint(python, why) {
90
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.`);
91
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
+ }
92
121
  /** Surface a real (non-ENOENT) spawn error as a NarrationError, else null.
93
122
  * ENOENT (python absent) becomes the install hint. */
94
123
  function spawnFailure(r, python) {
@@ -106,26 +135,22 @@ function misakiZhG2p(opts = {}) {
106
135
  const python = opts.python ?? process.env["MISAKI_PYTHON"] ?? DEFAULT_PYTHON;
107
136
  return {
108
137
  id: "misaki-zh",
109
- version: () => {
138
+ version: () => `misaki-zh misaki=${MISAKI_PIN} jieba=${JIEBA_PIN} map=${PHONEME_MAP_VERSION}`,
139
+ phonemize: (text) => {
110
140
  const r = spawnSync(python, [
111
141
  "-c",
112
142
  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], {
143
+ "--pins",
144
+ MISAKI_PIN,
145
+ JIEBA_PIN
146
+ ], {
123
147
  input: text,
124
148
  maxBuffer: 8 * 1024 * 1024
125
149
  });
126
150
  const fail = spawnFailure(r, python);
127
151
  if (fail) throw fail;
128
152
  if (r.status === MISAKI_ABSENT_EXIT) throw installHint(python, "misaki");
153
+ if (r.status === MISAKI_PIN_MISMATCH_EXIT) throw pinMismatchError(python, r.stderr);
129
154
  if (r.status !== 0) throw new NarrationError(`misaki[zh] g2p failed: ${stderrTail(r.stderr)}`);
130
155
  const out = r.stdout?.toString("utf8") ?? "";
131
156
  if (out.length === 0) throw new NarrationError(`misaki[zh] g2p produced no phonemes for text: ${JSON.stringify(text.slice(0, 60))}`);
@@ -440,7 +465,6 @@ function kokoroProvider(opts = {}) {
440
465
  const modelId = opts.model ?? KOKORO_MODEL;
441
466
  const dtype = opts.dtype ?? "q8";
442
467
  const zhG2p = opts.zhG2p ?? misakiZhG2p();
443
- const usesChinese = isKokoroChineseVoice(opts.voice ?? KOKORO_DEFAULT_VOICE);
444
468
  let loaded = null;
445
469
  const loadLib = async () => {
446
470
  const { entryUrl } = resolveKokoro();
@@ -463,8 +487,7 @@ function kokoroProvider(opts = {}) {
463
487
  id: "kokoro",
464
488
  version: () => {
465
489
  const { version } = resolveKokoro();
466
- let v = `kokoro-js ${version} ${basename(modelId)} dtype=${dtype}`;
467
- if (usesChinese) v += ` g2p=[${zhG2p.version()}]`;
490
+ const v = `kokoro-js ${version} ${basename(modelId)} dtype=${dtype} g2p=[${zhG2p.version()}]`;
468
491
  return Promise.resolve(v);
469
492
  },
470
493
  synthesize: async (req) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/narrate",
3
- "version": "0.15.0-pre.0",
3
+ "version": "0.15.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-pre.0",
26
- "@glissade/scene": "0.15.0-pre.0"
25
+ "@glissade/core": "0.15.0",
26
+ "@glissade/scene": "0.15.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "kokoro-js": "^1.2.0"