storyshot 0.4.2 → 0.6.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 +66 -4
- package/package.json +7 -2
- package/skills/storyshot/SKILL.md +20 -3
- package/src/audio.mjs +2 -2
- package/src/check.mjs +17 -2
- package/src/compose.mjs +94 -8
- package/src/config.mjs +42 -15
- package/src/doctor.mjs +17 -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/qwen-tts.mjs +134 -0
- package/src/qwen_tts.py +70 -0
- package/src/record.mjs +4 -1
- package/src/voice.mjs +61 -10
- 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/qwen-tts.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Narration with Qwen3-TTS 0.6B CustomVoice (Apache-2.0): nine preset voices, five of them Chinese
|
|
2
|
+
// (serena, vivian, uncle_fu, dylan with a Beijing accent, eric with a Sichuan one), free and local.
|
|
3
|
+
// It runs through mlx-audio, so on Apple Silicon only; elsewhere config.mjs falls back to kokoro.
|
|
4
|
+
// Python (qwen_tts.py) runs in storyshot's own venv, once per render for all uncached lines.
|
|
5
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { findPython } from './kokoro-zh.mjs';
|
|
12
|
+
|
|
13
|
+
export const QWEN_VOICES = ['serena', 'vivian', 'uncle_fu', 'dylan', 'eric', 'ryan', 'aiden', 'ono_anna', 'sohee'];
|
|
14
|
+
export const qwenSupported = () => process.platform === 'darwin' && process.arch === 'arm64';
|
|
15
|
+
// `Uncle Fu`, `Serena` → uncle_fu, serena (null when it isn't one of the nine)
|
|
16
|
+
export const qwenVoice = (name) => {
|
|
17
|
+
const v = String(name ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
18
|
+
return QWEN_VOICES.includes(v) ? v : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const REPO = 'mlx-community/Qwen3-TTS-12Hz-0.6B-CustomVoice-bf16';
|
|
22
|
+
const REVISION = '6415d95f88be018ff9e46813119dc3bc12261328';
|
|
23
|
+
// 0.9 (the model's default) now and then runs on into made-up words; 0.7 keeps the same voice steadier
|
|
24
|
+
const TEMPERATURE = 0.7;
|
|
25
|
+
const REQUIREMENTS = ['mlx-audio==0.6.3', 'mlx==0.32.4', 'mlx-metal==0.32.4', 'transformers==5.22.0', 'huggingface-hub==2.4.0',
|
|
26
|
+
'tokenizers==0.23.3', 'safetensors==0.8.0', 'numpy==2.5.4'];
|
|
27
|
+
// Part of every cached line's hash: change it when the model, the packages or qwen_tts.py's sampling change.
|
|
28
|
+
export const QWEN_TTS_ID = `${REPO}@${REVISION.slice(0, 7)}|${REQUIREMENTS[0]}|t${TEMPERATURE}|guard-1`;
|
|
29
|
+
const CACHE = path.join(os.homedir(), '.cache', 'storyshot');
|
|
30
|
+
export const QWEN_VENV = path.join(CACHE, 'qwen-venv');
|
|
31
|
+
const HELPER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'qwen_tts.py');
|
|
32
|
+
const HUB = process.env.HF_HUB_CACHE ?? path.join(process.env.HF_HOME ?? path.join(os.homedir(), '.cache', 'huggingface'), 'hub');
|
|
33
|
+
const MODEL_FILE = path.join(HUB, `models--${REPO.replace('/', '--')}`, 'snapshots', REVISION, 'model.safetensors');
|
|
34
|
+
|
|
35
|
+
// --- text (TTS only: subtitles keep the original) --------------------------------------------------
|
|
36
|
+
// The model reads = + 2个 第2行 x² itself; a minus sign before a number it drops, and a number like
|
|
37
|
+
// 2e-105 sets it babbling, so those are spelled out in Chinese lines.
|
|
38
|
+
export function qwenSpeakText(text, lang = qwenLang(text)) {
|
|
39
|
+
let t = String(text).replace(/\s*\n+\s*/g, ' ');
|
|
40
|
+
if (lang === 'chinese') {
|
|
41
|
+
t = t.replace(/(\d(?:\.\d+)?)[eE]\s*([-−+]?)\s*(\d+)/g, (_, m, s, e) => `${m}乘10的${s && s !== '+' ? '负' : ''}${e}次方`)
|
|
42
|
+
.replace(/(?<![\w)²³]\s*)−\s*(?=\d)/g, '负');
|
|
43
|
+
}
|
|
44
|
+
return t;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The language the model is told: the script decides; Latin text is left to the model.
|
|
48
|
+
export function qwenLang(text) {
|
|
49
|
+
const s = String(text).replace(/\d+(?:\.\d+)?(?:[eE][-−+]?\d+)?/g, ' '); // the e in 2e-105 is no English word
|
|
50
|
+
if (/[-ヿ]/.test(s)) return 'japanese';
|
|
51
|
+
if (/[가-]/.test(s)) return 'korean';
|
|
52
|
+
const han = (s.match(/[㐀-鿿]/g) ?? []).length, latin = (s.match(/[A-Za-z]+/g) ?? []).length;
|
|
53
|
+
return han > latin ? 'chinese' : 'auto';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- the venv ---------------------------------------------------------------------------------------
|
|
57
|
+
const venvPython = (dir) => path.join(dir, 'bin', 'python');
|
|
58
|
+
const STAMP = 'storyshot-requirements.txt';
|
|
59
|
+
const venvReady = () => {
|
|
60
|
+
try { return fs.readFileSync(path.join(QWEN_VENV, STAMP), 'utf8') === REQUIREMENTS.join('\n') && fs.existsSync(venvPython(QWEN_VENV)); } catch { return false; }
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const NO_PYTHON = () => 'the Qwen3-TTS voice needs Python 3.10–3.12 to run (brew install python@3.12, or uv python install 3.12, ' +
|
|
64
|
+
`or set STORYSHOT_PYTHON); storyshot then keeps its packages in its own venv (${QWEN_VENV}), nothing is installed globally`;
|
|
65
|
+
|
|
66
|
+
// For doctor and check: can this machine use it, and what's still to come on first use?
|
|
67
|
+
export function qwenStatus() {
|
|
68
|
+
if (!qwenSupported()) return { supported: false };
|
|
69
|
+
const model = fs.existsSync(MODEL_FILE);
|
|
70
|
+
if (venvReady()) return { supported: true, ready: true, venv: QWEN_VENV, model };
|
|
71
|
+
const python = findPython();
|
|
72
|
+
return { supported: true, ready: false, venv: QWEN_VENV, model, python, problem: python ? null : NO_PYTHON() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureVenv(log) {
|
|
76
|
+
if (venvReady()) return venvPython(QWEN_VENV);
|
|
77
|
+
const py = findPython();
|
|
78
|
+
if (!py) throw new Error(NO_PYTHON());
|
|
79
|
+
log(`setting up the Qwen3-TTS voice (one time): a Python ${py.version} venv in ${QWEN_VENV} with mlx-audio (~400 MB, 1–2 min)…`);
|
|
80
|
+
const run = (cmd, args, what) => {
|
|
81
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
82
|
+
if (r.status !== 0) throw new Error(`${what} failed: ${(r.stderr || r.stdout || r.error?.message || '').trim().split('\n').slice(-6).join('\n')}`);
|
|
83
|
+
};
|
|
84
|
+
fs.rmSync(QWEN_VENV, { recursive: true, force: true });
|
|
85
|
+
fs.mkdirSync(CACHE, { recursive: true });
|
|
86
|
+
run(py.exe, ['-m', 'venv', QWEN_VENV], `creating the venv with ${py.exe} -m venv`);
|
|
87
|
+
run(venvPython(QWEN_VENV), ['-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '-q', ...REQUIREMENTS], 'installing mlx-audio into the venv');
|
|
88
|
+
fs.writeFileSync(path.join(QWEN_VENV, STAMP), REQUIREMENTS.join('\n')); // written last: a half-built venv is rebuilt next time
|
|
89
|
+
return venvPython(QWEN_VENV);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- synthesis --------------------------------------------------------------------------------------
|
|
93
|
+
// entries: [{ text, file }] (text already through qwenSpeakText) → a WAV at each `file`.
|
|
94
|
+
export async function synthQwen(v, entries, { log = console.log } = {}) {
|
|
95
|
+
if (!entries.length) return;
|
|
96
|
+
if (!qwenSupported()) throw new Error('the Qwen3-TTS voice runs on Apple Silicon Macs only (it uses MLX)');
|
|
97
|
+
const py = ensureVenv(log);
|
|
98
|
+
if (!fs.existsSync(MODEL_FILE)) log(`downloading the Qwen3-TTS model (one time, ~2.3 GB) into ${HUB}…`);
|
|
99
|
+
const lines = entries.map((e) => ({
|
|
100
|
+
text: e.text, file: `${e.file}.raw.wav`, voice: v.voice, lang: qwenLang(e.text),
|
|
101
|
+
// the same line gets the same take: a seed from the text
|
|
102
|
+
seed: parseInt(crypto.createHash('sha1').update(e.text).digest('hex').slice(0, 7), 16),
|
|
103
|
+
}));
|
|
104
|
+
const job = JSON.stringify({ repo: REPO, revision: REVISION, temperature: TEMPERATURE, lines });
|
|
105
|
+
const { code, stdout, tail } = await new Promise((resolve, reject) => {
|
|
106
|
+
const p = spawn(py, ['-I', HELPER], { env: { ...process.env, HF_HUB_DISABLE_TELEMETRY: '1', TRANSFORMERS_VERBOSITY: 'error' } });
|
|
107
|
+
let out = '', err = '', buf = '';
|
|
108
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
109
|
+
p.stderr.on('data', (d) => {
|
|
110
|
+
err = (err + d).slice(-4000);
|
|
111
|
+
buf += d;
|
|
112
|
+
let nl;
|
|
113
|
+
while ((nl = buf.search(/[\r\n]/)) >= 0) {
|
|
114
|
+
const m = buf.slice(0, nl).match(/^line (\d+)\/\d+ (\S+)( \d+ tries)?/);
|
|
115
|
+
buf = buf.slice(nl + 1);
|
|
116
|
+
if (m) log(` tts (qwen/${v.voice}, ${m[2]}${m[3] ? `,${m[3]}` : ''}): "${entries[m[1] - 1].text}"`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
p.on('error', reject);
|
|
120
|
+
p.on('close', (c) => resolve({ code: c, stdout: out, tail: err }));
|
|
121
|
+
p.stdin.end(job);
|
|
122
|
+
});
|
|
123
|
+
if (code !== 0) throw new Error(`Qwen3-TTS failed (${py} ${HELPER}): ${tail.trim().split('\n').slice(-8).join('\n')}`);
|
|
124
|
+
const { results } = JSON.parse(stdout);
|
|
125
|
+
for (const [i, e] of entries.entries()) {
|
|
126
|
+
if (results[i]?.suspect) log(` ⚠ qwen: "${e.text}" still sounds off after ${results[i].tries} tries (${results[i].seconds.toFixed(1)}s) — listen to it, or rephrase the line / add a pronounce entry`);
|
|
127
|
+
const raw = lines[i].file, tmp = `${e.file}.tmp.wav`;
|
|
128
|
+
// the model has no speed control: ffmpeg's pitch-preserving atempo does it
|
|
129
|
+
if ((v.speed ?? 1) !== 1) execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-i', raw, '-af', `atempo=${v.speed}`, tmp]);
|
|
130
|
+
else fs.renameSync(raw, tmp);
|
|
131
|
+
fs.rmSync(raw, { force: true });
|
|
132
|
+
fs.renameSync(tmp, e.file);
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/qwen_tts.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Qwen3-TTS CustomVoice through mlx-audio (Apple Silicon), for `voice: { provider: qwen }`. Runs in
|
|
2
|
+
# storyshot's own venv (qwen-tts.mjs), one process per render: the model loads once for all lines.
|
|
3
|
+
#
|
|
4
|
+
# stdin: {"repo", "revision", "temperature", "lines": [{"text", "file", "voice", "lang", "seed"}]}
|
|
5
|
+
# stderr: "line i/n <seconds>s [<tries> tries]" per line, plus whatever the libraries log
|
|
6
|
+
# stdout: {"results": [{"seconds", "tries", "suspect"}]}
|
|
7
|
+
#
|
|
8
|
+
# The model samples its speech tokens, so now and then a line runs on into made-up words (a
|
|
9
|
+
# scientific number like 2e-105 does it) or stops early. A take far longer or shorter than the text
|
|
10
|
+
# should need is sampled again with another seed, and the closest one is kept.
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
os.environ.setdefault('HF_HUB_DISABLE_TELEMETRY', '1')
|
|
18
|
+
os.environ.setdefault('TRANSFORMERS_VERBOSITY', 'error')
|
|
19
|
+
|
|
20
|
+
TRIES = 4
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def expected(text):
|
|
24
|
+
"""Rough speaking time in seconds: ~3.6 Chinese characters a second, Latin words and numbers."""
|
|
25
|
+
han = len(re.findall(r'[㐀-鿿]', text))
|
|
26
|
+
words = len(re.findall(r'[A-Za-z]+', text))
|
|
27
|
+
nums = len(re.findall(r'\d+(?:\.\d+)?', text))
|
|
28
|
+
return han / 3.6 + words * 0.4 + nums * 0.5 + 0.3
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plausible(seconds, est):
|
|
32
|
+
return 0.35 * est - 0.5 <= seconds <= 2.0 * est + 2.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main():
|
|
36
|
+
job = json.load(sys.stdin)
|
|
37
|
+
out = sys.stdout
|
|
38
|
+
# the libraries print their own notes on stdout: keep it for the JSON answer
|
|
39
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
40
|
+
import math
|
|
41
|
+
import mlx.core as mx
|
|
42
|
+
import numpy as np
|
|
43
|
+
from huggingface_hub import snapshot_download
|
|
44
|
+
from mlx_audio.audio_io import write
|
|
45
|
+
from mlx_audio.tts.utils import load_model
|
|
46
|
+
|
|
47
|
+
model = load_model(snapshot_download(job['repo'], revision=job['revision']))
|
|
48
|
+
results, n = [], len(job['lines'])
|
|
49
|
+
for i, line in enumerate(job['lines']):
|
|
50
|
+
est = expected(line['text'])
|
|
51
|
+
best = None
|
|
52
|
+
for k in range(TRIES):
|
|
53
|
+
mx.random.seed(line['seed'] + k * 7919)
|
|
54
|
+
parts = model.generate(text=line['text'], voice=line['voice'], lang_code=line['lang'],
|
|
55
|
+
temperature=job['temperature'])
|
|
56
|
+
audio = np.concatenate([np.array(r.audio, dtype=np.float32) for r in parts])
|
|
57
|
+
seconds = len(audio) / model.sample_rate
|
|
58
|
+
score = abs(math.log(max(seconds, 0.1) / est))
|
|
59
|
+
if best is None or score < best[0]:
|
|
60
|
+
best = (score, audio, seconds)
|
|
61
|
+
if plausible(seconds, est):
|
|
62
|
+
break
|
|
63
|
+
write(line['file'], best[1], model.sample_rate)
|
|
64
|
+
results.append({'seconds': best[2], 'tries': k + 1, 'suspect': not plausible(best[2], est)})
|
|
65
|
+
print(f'line {i + 1}/{n} {best[2]:.1f}s' + (f' {k + 1} tries' if k else ''), file=sys.stderr, flush=True)
|
|
66
|
+
out.write(json.dumps({'results': results}))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == '__main__':
|
|
70
|
+
main()
|
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,23 +1,52 @@
|
|
|
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), qwen (local, free, Apple
|
|
3
|
+
// Silicon: Qwen3-TTS, best for Chinese), elevenlabs, openai, say (macOS).
|
|
3
4
|
import { execFileSync } from 'node:child_process';
|
|
4
5
|
import crypto from 'node:crypto';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import os from 'node:os';
|
|
7
8
|
import path from 'node:path';
|
|
9
|
+
import { isZhVoice, synthZh, zhSpeakText, ZH_DEFAULT_VOICE, ZH_TTS_ID } from './kokoro-zh.mjs';
|
|
10
|
+
import { qwenSpeakText, QWEN_TTS_ID, synthQwen } from './qwen-tts.mjs';
|
|
8
11
|
|
|
9
12
|
const SR = 48000;
|
|
10
13
|
export const VOICE_DEFAULTS = {
|
|
11
14
|
kokoro: { voice: 'af_heart' },
|
|
15
|
+
qwen: { voice: 'dylan' },
|
|
12
16
|
elevenlabs: { voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_multilingual_v2' },
|
|
13
17
|
openai: { voice: 'alloy', model: 'gpt-4o-mini-tts' },
|
|
14
18
|
say: { voice: 'Samantha' },
|
|
15
19
|
};
|
|
16
20
|
|
|
21
|
+
// kokoro without a named voice: the Mandarin one when the narration is mostly Chinese (more Han
|
|
22
|
+
// characters than Latin words; no kana or hangul), or, with no narration to go by, `lang: zh`.
|
|
23
|
+
// English (af_heart) otherwise.
|
|
24
|
+
export function kokoroVoiceFor(raw) {
|
|
25
|
+
return narrationIsChinese(raw) ? ZH_DEFAULT_VOICE : VOICE_DEFAULTS.kokoro.voice;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// qwen without a named voice, for Chinese: dylan (a steady teacher's voice) in tutorials, lessons and
|
|
29
|
+
// docs, serena (warmer) in promos; ono_anna for Japanese, sohee for Korean, aiden otherwise
|
|
30
|
+
export function qwenVoiceFor(raw) {
|
|
31
|
+
const says = narration(raw);
|
|
32
|
+
if (/[\u3040-\u30ff]/.test(says) || /^ja/i.test(String(raw.lang ?? ''))) return 'ono_anna';
|
|
33
|
+
if (/[\uac00-\ud7af]/.test(says) || /^ko/i.test(String(raw.lang ?? ''))) return 'sohee';
|
|
34
|
+
if (!narrationIsChinese(raw)) return 'aiden';
|
|
35
|
+
return (raw.purpose ?? 'pitch') === 'pitch' ? 'serena' : VOICE_DEFAULTS.qwen.voice;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const narration = (raw) => [...(raw.steps ?? []).flatMap((s) => [s?.say, s?.card?.say]), raw.intro?.say, raw.end_card?.say].filter(Boolean).join(' ');
|
|
39
|
+
function narrationIsChinese(raw) {
|
|
40
|
+
const says = narration(raw);
|
|
41
|
+
const han = (says.match(/[\u3400-\u9fff]/g) ?? []).length, latin = (says.match(/[A-Za-z]+/g) ?? []).length;
|
|
42
|
+
return says.trim() ? han > latin && !/[\u3040-\u30ff\uac00-\ud7af]/.test(says) : /^zh/i.test(String(raw.lang ?? ''));
|
|
43
|
+
}
|
|
44
|
+
|
|
17
45
|
let kokoro = null;
|
|
18
46
|
async function synth(v, text, file) {
|
|
19
47
|
switch (v.provider) {
|
|
20
48
|
case 'kokoro': {
|
|
49
|
+
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
50
|
if (!kokoro) {
|
|
22
51
|
let KokoroTTS;
|
|
23
52
|
try { ({ KokoroTTS } = await import('kokoro-js')); } catch {
|
|
@@ -60,7 +89,7 @@ async function synth(v, text, file) {
|
|
|
60
89
|
execFileSync('say', ['-v', v.voice, '-r', String(Math.round(185 * v.speed)), '-o', file, text]);
|
|
61
90
|
return;
|
|
62
91
|
default:
|
|
63
|
-
throw new Error(`unknown voice provider "${v.provider}" (use kokoro, elevenlabs, openai or say)`);
|
|
92
|
+
throw new Error(`unknown voice provider "${v.provider}" (use kokoro, qwen, elevenlabs, openai or say)`);
|
|
64
93
|
}
|
|
65
94
|
}
|
|
66
95
|
|
|
@@ -74,18 +103,32 @@ function pronounce(v, text) {
|
|
|
74
103
|
return t;
|
|
75
104
|
}
|
|
76
105
|
|
|
77
|
-
//
|
|
78
|
-
|
|
106
|
+
// The cache file for a line, and the text the engine is given. The Mandarin Kokoro voice reads a
|
|
107
|
+
// rewritten text (2个 → 两个, …) and has its model and G2P version in the hash; so does qwen.
|
|
108
|
+
function cached(cfg, line) {
|
|
79
109
|
const v = cfg.voice;
|
|
80
|
-
const text = pronounce(v, line);
|
|
81
110
|
const dir = path.join(path.dirname(cfg.work), 'tts-cache');
|
|
82
111
|
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
112
|
+
const zh = v.provider === 'kokoro' && isZhVoice(v.voice), qwen = v.provider === 'qwen';
|
|
113
|
+
const text = zh ? zhSpeakText(pronounce(v, line)) : qwen ? qwenSpeakText(pronounce(v, line)) : pronounce(v, line);
|
|
114
|
+
const ext = { kokoro: 'wav', qwen: 'wav', say: 'aiff' }[v.provider] ?? 'mp3';
|
|
115
|
+
const key = zh ? [v.provider, v.voice, ZH_TTS_ID, v.speed, text] : qwen ? [v.provider, v.voice, QWEN_TTS_ID, v.speed, text]
|
|
116
|
+
: [v.provider, v.voice, v.model, v.speed, v.instructions, text];
|
|
117
|
+
const hash = crypto.createHash('sha1').update(JSON.stringify(key)).digest('hex').slice(0, 16);
|
|
118
|
+
return { text, file: path.join(dir, `${hash}.${ext}`), zh, qwen };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Returns stereo Float32 PCM at 48 kHz with leading/trailing silence trimmed.
|
|
122
|
+
export async function speak(cfg, line, { log = console.log } = {}) {
|
|
123
|
+
const v = cfg.voice;
|
|
124
|
+
const { text, file, zh, qwen } = cached(cfg, line);
|
|
86
125
|
if (!fs.existsSync(file)) {
|
|
87
|
-
|
|
88
|
-
await
|
|
126
|
+
if (zh) await synthZh(v, [{ text, file }], { log });
|
|
127
|
+
else if (qwen) await synthQwen(v, [{ text, file }], { log });
|
|
128
|
+
else {
|
|
129
|
+
log(` tts (${v.provider}): "${text}"`);
|
|
130
|
+
await synth(v, text, file);
|
|
131
|
+
}
|
|
89
132
|
}
|
|
90
133
|
const trim = 'silenceremove=start_periods=1:start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_threshold=-50dB,areverse';
|
|
91
134
|
const pcm = execFileSync('ffmpeg', ['-loglevel', 'error', '-i', file, '-af', trim, '-f', 'f32le', '-ac', '2', '-ar', `${SR}`, 'pipe:1'], { maxBuffer: 1 << 28 });
|
|
@@ -105,6 +148,14 @@ export function tempo(pcm, factor) {
|
|
|
105
148
|
export async function measureVoice(cfg, { log = console.log } = {}) {
|
|
106
149
|
const lens = { steps: {}, endCard: null, intro: null, cards: {} };
|
|
107
150
|
if (!cfg.voice) return lens;
|
|
151
|
+
if (cfg.voice.fallback) log(`note: ${cfg.voice.fallback}`);
|
|
152
|
+
const zh = cfg.voice.provider === 'kokoro' && isZhVoice(cfg.voice.voice);
|
|
153
|
+
if (zh || cfg.voice.provider === 'qwen') {
|
|
154
|
+
// synthesize every uncached line in one batch, so Python (the G2P, or the Qwen model) starts once
|
|
155
|
+
const lines = [...cfg.steps.filter((s) => !s.hidden).flatMap((s) => [s.say, s.card?.say]), cfg.intro?.say, cfg.endCard?.say].filter(Boolean);
|
|
156
|
+
const todo = new Map(lines.map((l) => cached(cfg, l)).filter((c) => !fs.existsSync(c.file)).map((c) => [c.file, c]));
|
|
157
|
+
await (zh ? synthZh : synthQwen)(cfg.voice, [...todo.values()], { log });
|
|
158
|
+
}
|
|
108
159
|
for (const [i, s] of cfg.steps.entries()) {
|
|
109
160
|
if (s.say && !s.hidden) lens.steps[i] = (await speak(cfg, s.say, { log })).length / 2 / SR;
|
|
110
161
|
if (s.card?.say && !s.hidden) lens.cards[i] = (await speak(cfg, s.card.say, { log })).length / 2 / SR;
|