storyshot 0.4.2 → 0.5.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/README.md +50 -4
- package/package.json +7 -2
- package/skills/storyshot/SKILL.md +15 -3
- package/src/audio.mjs +2 -2
- package/src/check.mjs +11 -2
- package/src/compose.mjs +94 -8
- package/src/config.mjs +28 -14
- package/src/doctor.mjs +11 -0
- package/src/fonts/Virgil-LICENSE.md +45 -0
- package/src/fonts/Virgil.woff2 +0 -0
- package/src/hyperframes.mjs +536 -0
- package/src/kokoro-zh.mjs +204 -0
- package/src/record.mjs +4 -1
- package/src/voice.mjs +38 -8
- package/src/zh_g2p.py +118 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Mandarin narration with Kokoro v1.1-zh (voices zf_001…zf_099, zm_009…zm_100), free and local.
|
|
2
|
+
// Text → phonemes runs misaki's Chinese G2P (Python) in storyshot's own venv, once per render for all
|
|
3
|
+
// lines; phonemes → audio runs the v1.1-zh ONNX model through kokoro-js, like the English voice.
|
|
4
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export const ZH_DEFAULT_VOICE = 'zf_001';
|
|
11
|
+
export const isZhVoice = (voice) => /^z[fm]_\d{3}$/.test(String(voice ?? ''));
|
|
12
|
+
const REPO = 'onnx-community/Kokoro-82M-v1.1-zh-ONNX';
|
|
13
|
+
const CACHE = path.join(os.homedir(), '.cache', 'storyshot');
|
|
14
|
+
const MODELS = path.join(CACHE, 'models');
|
|
15
|
+
export const ZH_VENV = path.join(CACHE, 'zh-venv');
|
|
16
|
+
const HELPER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'zh_g2p.py');
|
|
17
|
+
// misaki 0.9.4 (the v1.1 frontend) needs Python <3.13; regex 2026.x needs >=3.10
|
|
18
|
+
const PY_RANGE = [10, 12];
|
|
19
|
+
const REQUIREMENTS = ['misaki[zh]==0.9.4', 'cn2an==0.5.24', 'proces==0.1.7', 'jieba==0.42.1', 'pypinyin==0.55.0',
|
|
20
|
+
'pypinyin-dict==0.9.0', 'ordered-set==4.1.0', 'phonemizer-fork==3.3.2', 'espeakng-loader==0.2.4', 'regex==2026.9.29', 'addict==2.4.0'];
|
|
21
|
+
// Part of every cached line's hash (with the rewritten text): change it when the model or the G2P
|
|
22
|
+
// (zh_g2p.py, REQUIREMENTS) changes.
|
|
23
|
+
export const ZH_TTS_ID = `${REPO}:q8|${REQUIREMENTS[0]}|g2p-1`;
|
|
24
|
+
// Pieces longer than this many phonemes are split at sentence ends (the model's hard limit is 510).
|
|
25
|
+
const TARGET = 300, MAX_PS = 510;
|
|
26
|
+
|
|
27
|
+
// --- text rewrites (TTS only: subtitles keep the original) ----------------------------------------
|
|
28
|
+
const MEASURE = `(?:${['个', '条', '只', '次(?!方|幂|函数|项|根)', '本', '位', '张', '种', '倍', '年(?!级)', '天', '点(?!\\d)', '分钟',
|
|
29
|
+
'分(?!之)', '小时', '秒', '周', '页', '行', '列', '步', '格', '遍', '份', '组', '句', '段', '件', '台', '层', '部分', '名', '项',
|
|
30
|
+
'类', '处', '边', '侧', '端', '根', '片', '块', '把', '支', '间', '元', '岁', '米', '公里', '千米', '厘米', '毫米', '公斤', '千克',
|
|
31
|
+
'克', '斤', '升', '毫升', '度', '万', '千', '百', '亿'].join('|')})`;
|
|
32
|
+
const NUM_SPACE = new RegExp(`(\\d)\\s+(?=${MEASURE})`, 'g');
|
|
33
|
+
const LIANG = new RegExp(`(?<![\\d.,/A-Za-z_\\-−+]|第\\s*|负\\s*)2(?=${MEASURE})`, 'g');
|
|
34
|
+
// 行 after a number is the measure word háng (第一行, 两行, 每一行, 三行代码), which the G2P reads xíng;
|
|
35
|
+
// 航 is a háng with no other reading. Not in 一行人 (a party), 统一行动, 行为, 行李 …
|
|
36
|
+
const HANG = /(?<=[\d一二三四五六七八九十百千两几])行(?![人动为走驶进程政使李星销善事])/g;
|
|
37
|
+
|
|
38
|
+
export function zhSpeakText(text) {
|
|
39
|
+
return String(text)
|
|
40
|
+
// a number reads as one word with the measure word after it: "3 个" → "3个" (no pause; 一 gets its tone change)
|
|
41
|
+
.replace(NUM_SPACE, '$1')
|
|
42
|
+
// and a lone 2 there is 两: "2 个单位" → "两个单位" (not 12个, 0.2个, 2.5个, 第2个, 负2个, 2次方, 2年级)
|
|
43
|
+
.replace(LIANG, '两')
|
|
44
|
+
.replace(HANG, '航')
|
|
45
|
+
// math symbols the G2P would drop
|
|
46
|
+
.replace(/\s*[==]\s*/g, '等于').replace(/\s*[++]\s*/g, '加').replace(/\s*×\s*/g, '乘').replace(/\s*÷\s*/g, '除以')
|
|
47
|
+
.replace(/(?<=[\w)²³]\s*)−\s*/g, '减').replace(/−\s*/g, '负').replace(/²/g, '的平方').replace(/³/g, '的立方');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Python for the G2P: found once, then a venv of storyshot's own --------------------------------
|
|
51
|
+
const venvPython = (dir) => (process.platform === 'win32' ? path.join(dir, 'Scripts', 'python.exe') : path.join(dir, 'bin', 'python'));
|
|
52
|
+
const STAMP = 'storyshot-requirements.txt';
|
|
53
|
+
const venvReady = () => {
|
|
54
|
+
try { return fs.readFileSync(path.join(ZH_VENV, STAMP), 'utf8') === REQUIREMENTS.join('\n') && fs.existsSync(venvPython(ZH_VENV)); } catch { return false; }
|
|
55
|
+
};
|
|
56
|
+
// (the real path: a venv made through a symlink to a relocatable build, like uv's ~/.local/bin/python3.12, can't find its stdlib)
|
|
57
|
+
function probe(cmd, args = []) {
|
|
58
|
+
try {
|
|
59
|
+
const [version, exe] = execFileSync(cmd, [...args, '-c', 'import os, sys; print("%d.%d.%d" % sys.version_info[:3]); print(os.path.realpath(sys.executable))'],
|
|
60
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }).toString().trim().split(/\r?\n/);
|
|
61
|
+
const [maj, min] = version.split('.').map(Number);
|
|
62
|
+
return { version, exe, ok: maj === 3 && min >= PY_RANGE[0] && min <= PY_RANGE[1] };
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
let found, seen = [];
|
|
66
|
+
// → { version, exe } of a Python 3.10–3.12, or null (then `seen` lists the unsuitable ones, for the error)
|
|
67
|
+
export function findPython() {
|
|
68
|
+
if (found !== undefined) return found;
|
|
69
|
+
const cands = [...(process.env.STORYSHOT_PYTHON ? [[process.env.STORYSHOT_PYTHON]] : []), ['python3.12'], ['python3.11'], ['python3.10'],
|
|
70
|
+
...(process.platform === 'win32' ? [['py', '-3.12'], ['py', '-3.11'], ['py', '-3.10']] : []), ['python3'], ['python']];
|
|
71
|
+
for (const [cmd, ...args] of cands) {
|
|
72
|
+
const p = probe(cmd, args);
|
|
73
|
+
if (p?.ok) return (found = p);
|
|
74
|
+
if (p) seen.push(`${[cmd, ...args].join(' ')} is ${p.version}`);
|
|
75
|
+
}
|
|
76
|
+
try { // a uv-managed interpreter (uv python install 3.12)
|
|
77
|
+
const exe = execFileSync('uv', ['python', 'find', '>=3.10,<3.13'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }).toString().trim();
|
|
78
|
+
const p = exe && probe(exe);
|
|
79
|
+
if (p?.ok) return (found = p);
|
|
80
|
+
} catch { /* no uv */ }
|
|
81
|
+
return (found = null);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const NO_PYTHON = () => 'the Chinese Kokoro voice needs Python 3.10–3.12 for its Mandarin text-to-phoneme step (misaki does not support ' +
|
|
85
|
+
`Python 3.13+ yet)${seen.length ? `; found only: ${seen.join(', ')}` : ''}. Install one — macOS: brew install python@3.12 · ` +
|
|
86
|
+
'Ubuntu/Debian: sudo apt install python3.12 python3.12-venv · any OS: uv python install 3.12 — or set STORYSHOT_PYTHON to one. ' +
|
|
87
|
+
`Storyshot then keeps its packages in its own venv (${ZH_VENV}); nothing is installed globally.`;
|
|
88
|
+
|
|
89
|
+
// For doctor and check: is the Chinese voice usable, and what's missing?
|
|
90
|
+
export function zhVoiceStatus() {
|
|
91
|
+
if (venvReady()) return { ready: true, venv: ZH_VENV, python: probe(venvPython(ZH_VENV)) };
|
|
92
|
+
const python = findPython();
|
|
93
|
+
return { ready: false, venv: ZH_VENV, python, problem: python ? null : NO_PYTHON() };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ensureVenv(log) {
|
|
97
|
+
if (venvReady()) return venvPython(ZH_VENV);
|
|
98
|
+
const py = findPython();
|
|
99
|
+
if (!py) throw new Error(NO_PYTHON());
|
|
100
|
+
log(`setting up the Chinese voice (one time): a Python ${py.version} venv in ${ZH_VENV} with misaki, the Mandarin G2P (~1 min)…`);
|
|
101
|
+
const run = (cmd, args, what) => {
|
|
102
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
103
|
+
if (r.status !== 0) {
|
|
104
|
+
throw new Error(`${what} failed: ${(r.stderr || r.stdout || r.error?.message || '').trim().split('\n').slice(-6).join('\n')}` +
|
|
105
|
+
(what.includes('venv') && process.platform === 'linux' ? '\n (Debian/Ubuntu: sudo apt install python3-venv, or the python3.X-venv package for your version)' : ''));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
fs.rmSync(ZH_VENV, { recursive: true, force: true });
|
|
109
|
+
fs.mkdirSync(CACHE, { recursive: true });
|
|
110
|
+
run(py.exe, ['-m', 'venv', ZH_VENV], `creating the venv with ${py.exe} -m venv`);
|
|
111
|
+
run(venvPython(ZH_VENV), ['-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '-q', ...REQUIREMENTS], 'installing misaki into the venv');
|
|
112
|
+
fs.writeFileSync(path.join(ZH_VENV, STAMP), REQUIREMENTS.join('\n')); // written last: a half-built venv is rebuilt next time
|
|
113
|
+
return venvPython(ZH_VENV);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// All lines in one Python run (jieba + espeak start once): → per line, a list of phoneme strings
|
|
117
|
+
function phonemize(lines, log) {
|
|
118
|
+
const py = ensureVenv(log);
|
|
119
|
+
const r = spawnSync(py, ['-I', HELPER], { input: JSON.stringify({ lines, target: TARGET, max: MAX_PS }), maxBuffer: 1 << 26 });
|
|
120
|
+
if (r.status !== 0) throw new Error(`Mandarin G2P failed (${py} ${HELPER}): ${String(r.stderr || r.error?.message).trim().split('\n').slice(-8).join('\n')}`);
|
|
121
|
+
return JSON.parse(r.stdout.toString('utf8')).pieces;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- model + voice --------------------------------------------------------------------------------
|
|
125
|
+
let model = null;
|
|
126
|
+
async function loadModel(log) {
|
|
127
|
+
if (model) return model;
|
|
128
|
+
let KokoroTTS;
|
|
129
|
+
try { ({ KokoroTTS } = await import('kokoro-js')); } catch {
|
|
130
|
+
throw new Error('the Chinese Kokoro voice needs the kokoro-js package: npm i kokoro-js');
|
|
131
|
+
}
|
|
132
|
+
const { env } = await import('@huggingface/transformers');
|
|
133
|
+
env.cacheDir = MODELS;
|
|
134
|
+
if (!fs.existsSync(path.join(MODELS, REPO, 'onnx', 'model_quantized.onnx'))) log(`downloading the Chinese Kokoro model (one time, ~130 MB) into ${MODELS}…`);
|
|
135
|
+
model = await KokoroTTS.from_pretrained(REPO, { dtype: 'q8', device: 'cpu' });
|
|
136
|
+
return model;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const packs = new Map();
|
|
140
|
+
async function voicePack(voice) {
|
|
141
|
+
if (packs.has(voice)) return packs.get(voice);
|
|
142
|
+
const file = path.join(MODELS, REPO, 'voices', `${voice}.bin`);
|
|
143
|
+
const SIZE = 510 * 256 * 4; // one 256-dim style vector per input length
|
|
144
|
+
if (!fs.existsSync(file) || fs.statSync(file).size !== SIZE) {
|
|
145
|
+
const res = await fetch(`https://huggingface.co/${REPO}/resolve/main/voices/${voice}.bin`);
|
|
146
|
+
if (res.status === 404) throw new Error(`Kokoro v1.1-zh has no voice "${voice}" (try zf_001, zf_002, zm_010; list: https://huggingface.co/${REPO}/tree/main/voices)`);
|
|
147
|
+
if (!res.ok) throw new Error(`downloading Kokoro voice ${voice} failed (${res.status})`);
|
|
148
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
149
|
+
if (buf.length !== SIZE) throw new Error(`Kokoro voice ${voice}: unexpected size ${buf.length}`);
|
|
150
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
151
|
+
fs.writeFileSync(`${file}.tmp`, buf);
|
|
152
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
153
|
+
}
|
|
154
|
+
const pack = new Float32Array(new Uint8Array(fs.readFileSync(file)).buffer);
|
|
155
|
+
packs.set(voice, pack);
|
|
156
|
+
return pack;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The model rushes inputs longer than its training lines (~100 tokens): slow those down, as hexgrad's make_zh.py does
|
|
160
|
+
export const zhSpeed = (n) => (n <= 83 ? 1 : n < 183 ? 1 - (n - 83) / 500 : 0.8) * 1.1;
|
|
161
|
+
|
|
162
|
+
const SR = 24000;
|
|
163
|
+
// Join a long line's pieces: trim each piece's edges, then a pause like a sentence break between them.
|
|
164
|
+
function join(chunks) {
|
|
165
|
+
if (chunks.length === 1) return chunks[0];
|
|
166
|
+
const trim = (a) => {
|
|
167
|
+
const th = 0.01;
|
|
168
|
+
let s = 0, e = a.length;
|
|
169
|
+
while (s < e && Math.abs(a[s]) < th) s++;
|
|
170
|
+
while (e > s && Math.abs(a[e - 1]) < th) e--;
|
|
171
|
+
return a.subarray(Math.max(0, s - SR * 0.02), Math.min(a.length, e + SR * 0.05));
|
|
172
|
+
};
|
|
173
|
+
const parts = chunks.map(trim), gap = Math.round(SR * 0.35);
|
|
174
|
+
const out = new Float32Array(parts.reduce((n, p) => n + p.length, 0) + gap * (parts.length - 1));
|
|
175
|
+
let o = 0;
|
|
176
|
+
for (const p of parts) { out.set(p, o); o += p.length + gap; }
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// entries: [{ text, file }] — synthesizes each line (already rewritten by zhSpeakText) into a WAV at `file`.
|
|
181
|
+
export async function synthZh(v, entries, { log = console.log } = {}) {
|
|
182
|
+
if (!entries.length) return;
|
|
183
|
+
const pieces = phonemize(entries.map((e) => e.text), log);
|
|
184
|
+
const tts = await loadModel(log);
|
|
185
|
+
const pack = await voicePack(v.voice);
|
|
186
|
+
const { Tensor, RawAudio } = await import('@huggingface/transformers');
|
|
187
|
+
for (const [i, e] of entries.entries()) {
|
|
188
|
+
log(` tts (kokoro/${v.voice}): "${e.text}"`);
|
|
189
|
+
const chunks = [];
|
|
190
|
+
for (const ps of pieces[i]) {
|
|
191
|
+
const { input_ids } = tts.tokenizer(ps, { truncation: true });
|
|
192
|
+
const off = 256 * Math.min(Math.max(input_ids.dims.at(-1) - 2, 0), 509);
|
|
193
|
+
const { waveform } = await tts.model({
|
|
194
|
+
input_ids,
|
|
195
|
+
style: new Tensor('float32', pack.slice(off, off + 256), [1, 256]),
|
|
196
|
+
speed: new Tensor('float32', [zhSpeed([...ps].length) * (v.speed ?? 1)], [1]),
|
|
197
|
+
});
|
|
198
|
+
chunks.push(waveform.data);
|
|
199
|
+
}
|
|
200
|
+
const tmp = `${e.file}.tmp.wav`;
|
|
201
|
+
await new RawAudio(chunks.length ? join(chunks) : new Float32Array(SR / 10), SR).save(tmp);
|
|
202
|
+
fs.renameSync(tmp, e.file);
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/record.mjs
CHANGED
|
@@ -719,6 +719,9 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
719
719
|
await locate(page, card.logo).screenshot({ path: path.join(cfg.work, file), omitBackground: true, timeout: 5000 })
|
|
720
720
|
.catch((e) => log(`warning: could not capture logo (${e.message.split('\n')[0]})`));
|
|
721
721
|
}
|
|
722
|
+
// where the intro's logo sits on the first page: a HyperFrames launch intro lands its logo there
|
|
723
|
+
const introLogo = cfg.intro?.logo && !cfg.intro.logoFile
|
|
724
|
+
? await locate(page, cfg.intro.logo).boundingBox({ timeout: 2000 }).catch(() => null) : null;
|
|
722
725
|
// an app window shows the app's icon and name: from the web app manifest, else the page's own
|
|
723
726
|
let app = null;
|
|
724
727
|
if (cfg.frame?.window === 'app' && !fast) {
|
|
@@ -824,7 +827,7 @@ export async function record(cfg, { log = console.log, inspectAfter = null } = {
|
|
|
824
827
|
await cdp.send('Page.stopScreencast');
|
|
825
828
|
await browser.close();
|
|
826
829
|
|
|
827
|
-
const timeline = { frames, steps, clicks, navs, gotos, urls, app, scale: cfg.scale };
|
|
830
|
+
const timeline = { frames, steps, clicks, navs, gotos, urls, app, introLogo, scale: cfg.scale };
|
|
828
831
|
fs.writeFileSync(path.join(cfg.work, 'timeline.json'), JSON.stringify(timeline, null, 1));
|
|
829
832
|
log(`recorded ${frames.length} frames`);
|
|
830
833
|
return timeline;
|
package/src/voice.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Voiceover: text-to-speech for each step's `say:` line, cached on disk.
|
|
2
|
-
// Providers: kokoro (local, free,
|
|
2
|
+
// Providers: kokoro (local, free: English, and Mandarin with the v1.1-zh voices), elevenlabs, openai, say (macOS).
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { isZhVoice, synthZh, zhSpeakText, ZH_DEFAULT_VOICE, ZH_TTS_ID } from './kokoro-zh.mjs';
|
|
8
9
|
|
|
9
10
|
const SR = 48000;
|
|
10
11
|
export const VOICE_DEFAULTS = {
|
|
@@ -14,10 +15,21 @@ export const VOICE_DEFAULTS = {
|
|
|
14
15
|
say: { voice: 'Samantha' },
|
|
15
16
|
};
|
|
16
17
|
|
|
18
|
+
// kokoro without a named voice: the Mandarin one when the narration is mostly Chinese (more Han
|
|
19
|
+
// characters than Latin words; no kana or hangul), or, with no narration to go by, `lang: zh`.
|
|
20
|
+
// English (af_heart) otherwise.
|
|
21
|
+
export function kokoroVoiceFor(raw) {
|
|
22
|
+
const says = [...(raw.steps ?? []).flatMap((s) => [s?.say, s?.card?.say]), raw.intro?.say, raw.end_card?.say].filter(Boolean).join(' ');
|
|
23
|
+
const han = (says.match(/[\u3400-\u9fff]/g) ?? []).length, latin = (says.match(/[A-Za-z]+/g) ?? []).length;
|
|
24
|
+
const zh = says.trim() ? han > latin && !/[\u3040-\u30ff\uac00-\ud7af]/.test(says) : /^zh/i.test(String(raw.lang ?? ''));
|
|
25
|
+
return zh ? ZH_DEFAULT_VOICE : VOICE_DEFAULTS.kokoro.voice;
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
let kokoro = null;
|
|
18
29
|
async function synth(v, text, file) {
|
|
19
30
|
switch (v.provider) {
|
|
20
31
|
case 'kokoro': {
|
|
32
|
+
if (/^z[fm]_/.test(v.voice)) throw new Error(`Kokoro voice "${v.voice}" is from v1.0, which storyshot does not run; for Mandarin use a v1.1-zh voice such as ${ZH_DEFAULT_VOICE}`);
|
|
21
33
|
if (!kokoro) {
|
|
22
34
|
let KokoroTTS;
|
|
23
35
|
try { ({ KokoroTTS } = await import('kokoro-js')); } catch {
|
|
@@ -74,18 +86,30 @@ function pronounce(v, text) {
|
|
|
74
86
|
return t;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
//
|
|
78
|
-
|
|
89
|
+
// The cache file for a line, and the text the engine is given. The Mandarin voice reads a rewritten
|
|
90
|
+
// text (2个 → 两个, …) and has its model and G2P version in the hash.
|
|
91
|
+
function cached(cfg, line) {
|
|
79
92
|
const v = cfg.voice;
|
|
80
|
-
const text = pronounce(v, line);
|
|
81
93
|
const dir = path.join(path.dirname(cfg.work), 'tts-cache');
|
|
82
94
|
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
const zh = v.provider === 'kokoro' && isZhVoice(v.voice);
|
|
96
|
+
const text = zh ? zhSpeakText(pronounce(v, line)) : pronounce(v, line);
|
|
83
97
|
const ext = { kokoro: 'wav', say: 'aiff' }[v.provider] ?? 'mp3';
|
|
84
|
-
const
|
|
85
|
-
const
|
|
98
|
+
const key = zh ? [v.provider, v.voice, ZH_TTS_ID, v.speed, text] : [v.provider, v.voice, v.model, v.speed, v.instructions, text];
|
|
99
|
+
const hash = crypto.createHash('sha1').update(JSON.stringify(key)).digest('hex').slice(0, 16);
|
|
100
|
+
return { text, file: path.join(dir, `${hash}.${ext}`), zh };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Returns stereo Float32 PCM at 48 kHz with leading/trailing silence trimmed.
|
|
104
|
+
export async function speak(cfg, line, { log = console.log } = {}) {
|
|
105
|
+
const v = cfg.voice;
|
|
106
|
+
const { text, file, zh } = cached(cfg, line);
|
|
86
107
|
if (!fs.existsSync(file)) {
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
if (zh) await synthZh(v, [{ text, file }], { log });
|
|
109
|
+
else {
|
|
110
|
+
log(` tts (${v.provider}): "${text}"`);
|
|
111
|
+
await synth(v, text, file);
|
|
112
|
+
}
|
|
89
113
|
}
|
|
90
114
|
const trim = 'silenceremove=start_periods=1:start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_threshold=-50dB,areverse';
|
|
91
115
|
const pcm = execFileSync('ffmpeg', ['-loglevel', 'error', '-i', file, '-af', trim, '-f', 'f32le', '-ac', '2', '-ar', `${SR}`, 'pipe:1'], { maxBuffer: 1 << 28 });
|
|
@@ -105,6 +129,12 @@ export function tempo(pcm, factor) {
|
|
|
105
129
|
export async function measureVoice(cfg, { log = console.log } = {}) {
|
|
106
130
|
const lens = { steps: {}, endCard: null, intro: null, cards: {} };
|
|
107
131
|
if (!cfg.voice) return lens;
|
|
132
|
+
if (cfg.voice.provider === 'kokoro' && isZhVoice(cfg.voice.voice)) {
|
|
133
|
+
// synthesize every uncached Mandarin line in one batch, so the Python G2P starts once
|
|
134
|
+
const lines = [...cfg.steps.filter((s) => !s.hidden).flatMap((s) => [s.say, s.card?.say]), cfg.intro?.say, cfg.endCard?.say].filter(Boolean);
|
|
135
|
+
const todo = new Map(lines.map((l) => cached(cfg, l)).filter((c) => !fs.existsSync(c.file)).map((c) => [c.file, c]));
|
|
136
|
+
await synthZh(cfg.voice, [...todo.values()], { log });
|
|
137
|
+
}
|
|
108
138
|
for (const [i, s] of cfg.steps.entries()) {
|
|
109
139
|
if (s.say && !s.hidden) lens.steps[i] = (await speak(cfg, s.say, { log })).length / 2 / SR;
|
|
110
140
|
if (s.card?.say && !s.hidden) lens.cards[i] = (await speak(cfg, s.card.say, { log })).length / 2 / SR;
|
package/src/zh_g2p.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Mandarin text -> Kokoro v1.1-zh phonemes, for storyshot's Chinese voice (src/kokoro-zh.mjs).
|
|
2
|
+
# Runs in storyshot's own venv (~/.cache/storyshot/zh-venv), once per render for every line.
|
|
3
|
+
# stdin: {"lines": ["...", ...], "target": 300, "max": 510}
|
|
4
|
+
# stdout: {"pieces": [["<phonemes>", ...], ...]} one list per line; long lines come back in pieces
|
|
5
|
+
# G2P: misaki ZHG2P(version='1.1'); English words and single letters go through misaki's espeak-ng
|
|
6
|
+
# fallback (letter names: "m x" -> ˈɛm ˈɛks), instead of being dropped as the stock frontend does.
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def espeak_data_path():
|
|
16
|
+
# espeak-ng truncates its data path at 160 characters; use a copy at a shorter path if needed
|
|
17
|
+
import espeakng_loader
|
|
18
|
+
data = espeakng_loader.get_data_path()
|
|
19
|
+
if len(data) <= 120:
|
|
20
|
+
return data
|
|
21
|
+
import tempfile
|
|
22
|
+
from importlib.metadata import version
|
|
23
|
+
for base in (os.path.join(os.path.expanduser('~'), '.cache', 'storyshot'), tempfile.gettempdir()):
|
|
24
|
+
short = os.path.join(base, f"espeak-ng-data-{version('espeakng-loader')}")
|
|
25
|
+
if len(short) > 120:
|
|
26
|
+
continue
|
|
27
|
+
if not os.path.exists(os.path.join(short, 'phontab')):
|
|
28
|
+
shutil.rmtree(short, ignore_errors=True)
|
|
29
|
+
shutil.copytree(data, short)
|
|
30
|
+
return short
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_g2p():
|
|
35
|
+
import jieba
|
|
36
|
+
jieba.setLogLevel(logging.ERROR)
|
|
37
|
+
from phonemizer.backend.espeak.wrapper import EspeakWrapper
|
|
38
|
+
import misaki.espeak # noqa: F401 (points phonemizer at the bundled espeak-ng library)
|
|
39
|
+
EspeakWrapper.set_data_path(espeak_data_path())
|
|
40
|
+
from misaki.espeak import EspeakFallback
|
|
41
|
+
from misaki import zh
|
|
42
|
+
|
|
43
|
+
esp = EspeakFallback(british=False)
|
|
44
|
+
|
|
45
|
+
class Token:
|
|
46
|
+
def __init__(self, text):
|
|
47
|
+
self.text = text
|
|
48
|
+
|
|
49
|
+
def en_callable(run): # the v1.1 frontend hands over Latin runs like "m x" or "GeoGebra"
|
|
50
|
+
return ' '.join(esp(Token(w))[0] or '' for w in re.findall(r"[A-Za-z']+", run))
|
|
51
|
+
|
|
52
|
+
g2p = zh.ZHG2P(version='1.1', en_callable=en_callable)
|
|
53
|
+
return lambda text: (g2p(text)[0] or '').strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
SENTENCE = re.compile(r'(?<=[。!?;!?;])')
|
|
57
|
+
CLAUSE = re.compile(r'(?<=[,、:,:])')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def units(g2p, text, target, hard):
|
|
61
|
+
"""Split text into (text, phoneme length) units no longer than `target`: sentences first, then clauses,
|
|
62
|
+
then (rarely) plain character slices."""
|
|
63
|
+
out = []
|
|
64
|
+
for sentence in [s for s in SENTENCE.split(text) if s.strip()]:
|
|
65
|
+
n = len(g2p(sentence))
|
|
66
|
+
if n <= target:
|
|
67
|
+
out.append((sentence, n))
|
|
68
|
+
continue
|
|
69
|
+
for clause in [c for c in CLAUSE.split(sentence) if c.strip()]:
|
|
70
|
+
m = len(g2p(clause))
|
|
71
|
+
if m <= hard:
|
|
72
|
+
out.append((clause, m))
|
|
73
|
+
continue
|
|
74
|
+
k = -(-m // target)
|
|
75
|
+
step = -(-len(clause) // k)
|
|
76
|
+
for i in range(0, len(clause), step):
|
|
77
|
+
part = clause[i:i + step]
|
|
78
|
+
out.append((part, len(g2p(part))))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pieces(g2p, text, target, hard):
|
|
83
|
+
text = text.strip()
|
|
84
|
+
if not text:
|
|
85
|
+
return []
|
|
86
|
+
ps = g2p(text)
|
|
87
|
+
if len(ps) <= target:
|
|
88
|
+
return [ps] if ps else []
|
|
89
|
+
us = units(g2p, text, target, hard)
|
|
90
|
+
total = sum(n for _, n in us)
|
|
91
|
+
k = -(-total // target)
|
|
92
|
+
goal = total / k
|
|
93
|
+
groups, cur, n = [], [], 0
|
|
94
|
+
for t, m in us:
|
|
95
|
+
# close the group when the next unit would overflow, or would land further from an even split
|
|
96
|
+
if cur and (n + m > target or abs(n + m - goal) > abs(n - goal)):
|
|
97
|
+
groups.append(''.join(cur))
|
|
98
|
+
cur, n = [], 0
|
|
99
|
+
cur.append(t)
|
|
100
|
+
n += m
|
|
101
|
+
if cur:
|
|
102
|
+
groups.append(''.join(cur))
|
|
103
|
+
return [p[:hard] for p in (g2p(g) for g in groups) if p]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
req = json.loads(sys.stdin.buffer.read().decode('utf-8'))
|
|
108
|
+
out = sys.stdout
|
|
109
|
+
sys.stdout = sys.stderr # keep any library chatter off the JSON channel
|
|
110
|
+
g2p = make_g2p()
|
|
111
|
+
target, hard = int(req.get('target', 300)), int(req.get('max', 510))
|
|
112
|
+
result = [pieces(g2p, line, target, hard) for line in req['lines']]
|
|
113
|
+
out.buffer.write(json.dumps({'pieces': result}, ensure_ascii=False).encode('utf-8'))
|
|
114
|
+
out.flush()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == '__main__':
|
|
118
|
+
main()
|