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.
@@ -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, English), elevenlabs, openai, say (macOS).
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
- // Returns stereo Float32 PCM at 48 kHz with leading/trailing silence trimmed.
78
- export async function speak(cfg, line, { log = console.log } = {}) {
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 hash = crypto.createHash('sha1').update(JSON.stringify([v.provider, v.voice, v.model, v.speed, v.instructions, text])).digest('hex').slice(0, 16);
85
- const file = path.join(dir, `${hash}.${ext}`);
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
- log(` tts (${v.provider}): "${text}"`);
88
- await synth(v, text, file);
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()