@glissade/narrate 0.22.0-pre.3 → 0.22.0-pre.5

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.
@@ -16,6 +16,21 @@ interface ZhG2p {
16
16
  * its Python tool, so the precedent is exact).
17
17
  */
18
18
  //#endregion
19
+ //#region src/en-g2p.d.ts
20
+
21
+ interface EnG2p {
22
+ readonly id: string;
23
+ /** the g2p identity, folded into kokoroProvider.version() → the cache key */
24
+ version(): string;
25
+ /** English text → a misaki[en] phoneme string (US English) */
26
+ phonemize(text: string): string;
27
+ }
28
+ /**
29
+ * Fork B: shell out to a pinned Python misaki[en]. Mirrors {@link misakiZhG2p} —
30
+ * same spawnSync, same ENOENT-is-the-only-true-absent feature detection, same
31
+ * PURE pin-based version()-fold-into-the-cache-key contract.
32
+ */
33
+ //#endregion
19
34
  //#region src/providers.d.ts
20
35
 
21
36
  interface TtsRequest {
@@ -202,6 +217,7 @@ declare function kokoroProvider(opts?: {
202
217
  voice?: VoiceSpec;
203
218
  dtype?: KokoroDtype;
204
219
  zhG2p?: ZhG2p;
220
+ enG2p?: EnG2p;
205
221
  }): TtsProvider;
206
222
  declare function providerById(id: string): TtsProvider;
207
223
  interface AlignRequest {
package/dist/providers.js CHANGED
@@ -40,19 +40,19 @@ const JIEBA_PIN = "0.42.1";
40
40
  */
41
41
  const PHONEME_MAP_VERSION = "zh-misaki-1";
42
42
  /** The default Python interpreter; override via opts.python or MISAKI_PYTHON. */
43
- const DEFAULT_PYTHON = "python3";
43
+ const DEFAULT_PYTHON$1 = "python3";
44
44
  /**
45
45
  * On a missing misaki/jieba import the program exits with this exact code (not a
46
46
  * raw traceback) so the TS side can raise the install hint — the Python-present-
47
47
  * but-misaki-absent analogue of ENOENT. Picked to not collide with 0/1/2.
48
48
  */
49
- const MISAKI_ABSENT_EXIT = 97;
49
+ const MISAKI_ABSENT_EXIT$1 = 97;
50
50
  /**
51
51
  * On an installed-vs-PINNED version MISMATCH the phonemize program exits with this
52
52
  * exact code so the TS side raises an actionable `installed X != pinned PIN` error
53
53
  * (never a silent 'unknown'). Distinct from MISAKI_ABSENT_EXIT (97) and 0/1/2.
54
54
  */
55
- const MISAKI_PIN_MISMATCH_EXIT = 96;
55
+ const MISAKI_PIN_MISMATCH_EXIT$1 = 96;
56
56
  /**
57
57
  * The phonemize Python program. It imports misaki[zh], ENFORCES the declared pins
58
58
  * (resolving the installed versions via `importlib.metadata.version(...)` — the
@@ -104,7 +104,7 @@ _phonemize()
104
104
  `;
105
105
  /** The shared install hint — raised when Python is absent (ENOENT) OR present
106
106
  * but misaki/jieba can't be imported (the MISAKI_ABSENT_EXIT sentinel). */
107
- function installHint(python, why) {
107
+ function installHint$1(python, why) {
108
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
109
  }
110
110
  /**
@@ -113,16 +113,16 @@ function installHint(python, why) {
113
113
  * `<dist>:<installed>:<pinned>` to stderr; we surface it as an actionable error so
114
114
  * a divergent wheel is caught LOUDLY rather than silently hashing to 'unknown'.
115
115
  */
116
- function pinMismatchError(python, stderr) {
116
+ function pinMismatchError$1(python, stderr) {
117
117
  const line = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim().split("\n").pop() ?? "";
118
118
  const [dist, installed, pinned] = line.split(":");
119
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
120
  }
121
121
  /** Surface a real (non-ENOENT) spawn error as a NarrationError, else null.
122
122
  * ENOENT (python absent) becomes the install hint. */
123
- function spawnFailure(r, python) {
123
+ function spawnFailure$1(r, python) {
124
124
  if (!r.error) return null;
125
- if (r.error.code === "ENOENT") return installHint(python, "python");
125
+ if (r.error.code === "ENOENT") return installHint$1(python, "python");
126
126
  return new NarrationError(`could not run misaki[zh] g2p via '${python}': ${r.error.message}`);
127
127
  }
128
128
  /**
@@ -132,7 +132,7 @@ function spawnFailure(r, python) {
132
132
  * its Python tool, so the precedent is exact).
133
133
  */
134
134
  function misakiZhG2p(opts = {}) {
135
- const python = opts.python ?? process.env["MISAKI_PYTHON"] ?? DEFAULT_PYTHON;
135
+ const python = opts.python ?? process.env["MISAKI_PYTHON"] ?? DEFAULT_PYTHON$1;
136
136
  return {
137
137
  id: "misaki-zh",
138
138
  version: () => `misaki-zh misaki=${MISAKI_PIN} jieba=${JIEBA_PIN} map=${PHONEME_MAP_VERSION}`,
@@ -147,13 +147,160 @@ function misakiZhG2p(opts = {}) {
147
147
  input: text,
148
148
  maxBuffer: 8 * 1024 * 1024
149
149
  });
150
+ const fail = spawnFailure$1(r, python);
151
+ if (fail) throw fail;
152
+ if (r.status === MISAKI_ABSENT_EXIT$1) throw installHint$1(python, "misaki");
153
+ if (r.status === MISAKI_PIN_MISMATCH_EXIT$1) throw pinMismatchError$1(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
162
+ //#region src/en-g2p.ts
163
+ /**
164
+ * '@glissade/narrate/providers' English g2p seam — English text → a misaki[en]
165
+ * phoneme string, the alphabet the kokoro English (`af_`/`am_`/`bf_`/`bm_`)
166
+ * voices were trained on. The gh#2 ENGLISH BLEND follow-up: a blended voice has
167
+ * no registered name, so it must drive `generate_from_ids` with a SUMMED style
168
+ * tensor — which bypasses kokoro-js's internal English phonemizer. So, exactly as
169
+ * the shipped Chinese blend runs misaki[zh] itself, this runs misaki[en] itself.
170
+ *
171
+ * Fork B = shell out to a PINNED Python misaki[en], the SAME pattern as
172
+ * {@link './zh-g2p'} (spawnSync + ENOENT-is-the-only-true-absent + a pure
173
+ * pin-based `version()` that folds into the cache key). misaki[en]'s G2P uses a
174
+ * spaCy tagger (`en_core_web_sm`) + an espeak fallback for out-of-dictionary
175
+ * words — so the install surface is heavier than zh's (misaki[en] + the spaCy
176
+ * model + espeak-ng); each missing piece exits with a distinct code → an
177
+ * actionable hint.
178
+ *
179
+ * DIALECT: US English (`british=False`) — the `af_`/`am_` (American) voices.
180
+ * GB-voice (`bf_`/`bm_`) blends are a documented follow-up; the dialect folds
181
+ * into `version()` so US and GB never share a cache entry.
182
+ *
183
+ * DETERMINISM: g2p runs at PREPARE time, never at render. Its identity — a PURE
184
+ * pin string `misaki=<PIN> map=<MAP> dialect=us` (NO Python, no spawn) — folds
185
+ * UNCONDITIONALLY into kokoroProvider.version() → the segment cache key. The
186
+ * misaki dictionary (the vast majority of words) is fully pinned/deterministic;
187
+ * OOV words route through the espeak fallback, whose phonemes depend on the local
188
+ * espeak-ng (the one OOV-determinism caveat, shared with Kokoro's own English
189
+ * g2p — noted, not silently swept). The pin is ENFORCED against the installed
190
+ * wheel at synth time.
191
+ */
192
+ /**
193
+ * The phoneme-map version: bump when our text→misaki[en] invocation changes in a
194
+ * way that can move phoneme bytes (normalization, dialect default, fallback
195
+ * wiring), independent of the misaki wheel. Folds into the g2p identity.
196
+ */
197
+ const EN_PHONEME_MAP_VERSION = "en-misaki-1";
198
+ /** The default Python interpreter; override via opts.python or MISAKI_PYTHON
199
+ * (shared with the zh seam — one interpreter has both extras). */
200
+ const DEFAULT_PYTHON = "python3";
201
+ /** misaki[en] not importable (the Python-present-but-misaki-absent analogue of ENOENT). */
202
+ const MISAKI_ABSENT_EXIT = 97;
203
+ /** installed misaki wheel != the declared pin (caught loudly at synth). */
204
+ const MISAKI_PIN_MISMATCH_EXIT = 96;
205
+ /** the espeak fallback (espeakng) can't load — OOV words would be dropped. */
206
+ const ESPEAK_ABSENT_EXIT = 95;
207
+ /** the spaCy English tagger model (`en_core_web_sm`) isn't installed. */
208
+ const SPACY_MODEL_ABSENT_EXIT = 94;
209
+ /**
210
+ * The phonemize Python program: import misaki[en], ENFORCE the misaki pin, build
211
+ * the US-English G2P (spaCy `en_core_web_sm` tagger + espeak fallback for OOV),
212
+ * read text from stdin, write `<phonemes>` to stdout. Each missing dependency
213
+ * gets its OWN exit code so the TS side raises a specific install hint.
214
+ */
215
+ const EN_G2P_PY = String.raw`
216
+ import sys
217
+
218
+ try:
219
+ import misaki
220
+ from misaki import en
221
+ from importlib.metadata import version, PackageNotFoundError
222
+ except ImportError:
223
+ sys.exit(97)
224
+
225
+ # pins arrive as: --pins <misaki_pin>; enforce installed==pinned
226
+ def _check_pins():
227
+ args = sys.argv[1:]
228
+ if "--pins" not in args:
229
+ return
230
+ i = args.index("--pins")
231
+ pin = args[i + 1]
232
+ try:
233
+ installed = version("misaki")
234
+ except PackageNotFoundError:
235
+ installed = "unknown"
236
+ if installed != pin:
237
+ sys.stderr.write("misaki:%s:%s\n" % (installed, pin))
238
+ sys.exit(96)
239
+
240
+ def _g2p():
241
+ try:
242
+ from misaki import espeak
243
+ fallback = espeak.EspeakFallback(british=False)
244
+ except Exception:
245
+ sys.exit(95)
246
+ try:
247
+ g = en.G2P(trf=False, british=False, fallback=fallback)
248
+ except OSError:
249
+ # spaCy en_core_web_sm model not downloaded
250
+ sys.exit(94)
251
+ text = sys.stdin.buffer.read().decode("utf-8")
252
+ phonemes, _ = g(text)
253
+ sys.stdout.buffer.write((phonemes or "").encode("utf-8"))
254
+
255
+ _check_pins()
256
+ _g2p()
257
+ `;
258
+ /** Install hint — Python absent (ENOENT), misaki[en] absent, or a missing
259
+ * sub-dependency (espeak fallback / spaCy model). */
260
+ function installHint(python, why) {
261
+ return new NarrationError(`${why === "python" ? `Python ('${python}') not found on PATH for misaki[en] g2p` : why === "misaki" ? `misaki[en] is not importable from '${python}'` : why === "espeak" ? `misaki[en]'s espeak fallback is unavailable from '${python}' (needed for out-of-dictionary words)` : `misaki[en]'s spaCy tagger model 'en_core_web_sm' is not installed for '${python}'`} — the kokoro ENGLISH voice-blend route (gh#2) needs it. Install: ${why === "spacy" ? `\`${python} -m spacy download en_core_web_sm\`` : why === "espeak" ? `\`pip install misaki[en]\` AND install the espeak-ng system library (e.g. \`apt-get install espeak-ng\`)` : `\`pip install 'misaki[en]==${MISAKI_PIN}'\` AND \`${python} -m spacy download en_core_web_sm\` AND the espeak-ng system library`} into that interpreter (or set MISAKI_PYTHON to one that has it). A single English voice (the named path) does NOT need this; only English BLENDS do.`);
262
+ }
263
+ /** The installed misaki wheel diverges from the declared pin (caught at synth). */
264
+ function pinMismatchError(python, stderr) {
265
+ const line = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim().split("\n").pop() ?? "";
266
+ const [, installed, pinned] = line.split(":");
267
+ return new NarrationError(`misaki[en] g2p pin mismatch via '${python}': ${installed && pinned ? `installed misaki ${installed} != pinned ${pinned}` : `installed misaki wheel diverges from the declared pin (${line || "no detail"})`} — the segment cache identity is keyed on the pin, so a divergent wheel would produce uncacheable/cross-machine-divergent audio. Install the pinned wheel: \`pip install 'misaki[en]==${MISAKI_PIN}'\` into that interpreter.`);
268
+ }
269
+ /** Surface a real (non-ENOENT) spawn error, else null. */
270
+ function spawnFailure(r, python) {
271
+ if (!r.error) return null;
272
+ if (r.error.code === "ENOENT") return installHint(python, "python");
273
+ return new NarrationError(`could not run misaki[en] g2p via '${python}': ${r.error.message}`);
274
+ }
275
+ /**
276
+ * Fork B: shell out to a pinned Python misaki[en]. Mirrors {@link misakiZhG2p} —
277
+ * same spawnSync, same ENOENT-is-the-only-true-absent feature detection, same
278
+ * PURE pin-based version()-fold-into-the-cache-key contract.
279
+ */
280
+ function misakiEnG2p(opts = {}) {
281
+ const python = opts.python ?? process.env["MISAKI_PYTHON"] ?? DEFAULT_PYTHON;
282
+ return {
283
+ id: "misaki-en",
284
+ version: () => `misaki-en misaki=${MISAKI_PIN} map=${EN_PHONEME_MAP_VERSION} dialect=us`,
285
+ phonemize: (text) => {
286
+ const r = spawnSync(python, [
287
+ "-c",
288
+ EN_G2P_PY,
289
+ "--pins",
290
+ MISAKI_PIN
291
+ ], {
292
+ input: text,
293
+ maxBuffer: 8 * 1024 * 1024
294
+ });
150
295
  const fail = spawnFailure(r, python);
151
296
  if (fail) throw fail;
152
297
  if (r.status === MISAKI_ABSENT_EXIT) throw installHint(python, "misaki");
298
+ if (r.status === ESPEAK_ABSENT_EXIT) throw installHint(python, "espeak");
299
+ if (r.status === SPACY_MODEL_ABSENT_EXIT) throw installHint(python, "spacy");
153
300
  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)}`);
301
+ if (r.status !== 0) throw new NarrationError(`misaki[en] g2p failed: ${stderrTail(r.stderr)}`);
155
302
  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))}`);
303
+ if (out.length === 0) throw new NarrationError(`misaki[en] g2p produced no phonemes for text: ${JSON.stringify(text.slice(0, 60))}`);
157
304
  return out;
158
305
  }
159
306
  };
@@ -613,6 +760,7 @@ function kokoroProvider(opts = {}) {
613
760
  const modelId = opts.model ?? KOKORO_MODEL;
614
761
  const dtype = opts.dtype ?? "q8";
615
762
  const zhG2p = opts.zhG2p ?? misakiZhG2p();
763
+ const enG2p = opts.enG2p ?? misakiEnG2p();
616
764
  let loaded = null;
617
765
  const loadLib = async () => {
618
766
  const { entry, entryUrl } = resolveKokoro();
@@ -641,7 +789,11 @@ function kokoroProvider(opts = {}) {
641
789
  id: "kokoro",
642
790
  version: () => {
643
791
  const { version } = resolveKokoro();
644
- const blendSuffix = isVoiceBlend(opts.voice) ? ` ${blendIdentity(opts.voice)}` : "";
792
+ let blendSuffix = "";
793
+ if (isVoiceBlend(opts.voice)) {
794
+ blendSuffix = ` ${blendIdentity(opts.voice)}`;
795
+ if (resolveBlend(opts.voice).language === "en") blendSuffix += ` en-g2p=[${enG2p.version()}]`;
796
+ }
645
797
  const v = `kokoro-js ${version} ${basename(modelId)} dtype=${dtype} g2p=[${zhG2p.version()}]${blendSuffix}`;
646
798
  return Promise.resolve(v);
647
799
  },
@@ -660,7 +812,10 @@ function kokoroProvider(opts = {}) {
660
812
  if (resolved.language === "zh") {
661
813
  const phonemes = zhG2p.phonemize(req.text);
662
814
  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");
815
+ } else {
816
+ const phonemes = enG2p.phonemize(req.text);
817
+ inputIds = tts.tokenizer(phonemes, { truncation: true }).input_ids;
818
+ }
664
819
  const audio = await kokoroGenerateFromBlend(tts, Tensor, inputIds, blended, speed ?? 1);
665
820
  const wav = floatToWav(audio.audio, audio.sampling_rate);
666
821
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/narrate",
3
- "version": "0.22.0-pre.3",
3
+ "version": "0.22.0-pre.5",
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.22.0-pre.3",
26
- "@glissade/scene": "0.22.0-pre.3"
25
+ "@glissade/core": "0.22.0-pre.5",
26
+ "@glissade/scene": "0.22.0-pre.5"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "kokoro-js": "^1.2.0"