@glissade/narrate 0.14.0-pre.1 → 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.
- package/dist/providers.d.ts +20 -1
- package/dist/providers.js +157 -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,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
|
-
|
|
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
|
-
|
|
337
|
-
const
|
|
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
|
|
475
|
+
speed
|
|
341
476
|
} : { voice };
|
|
342
477
|
let audio;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
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.
|
|
26
|
-
"@glissade/scene": "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"
|