storyshot 0.5.0 → 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 +17 -1
- package/package.json +1 -1
- package/skills/storyshot/SKILL.md +7 -2
- package/src/check.mjs +6 -0
- package/src/config.mjs +17 -4
- package/src/doctor.mjs +6 -0
- package/src/qwen-tts.mjs +134 -0
- package/src/qwen_tts.py +70 -0
- package/src/voice.mjs +37 -16
package/README.md
CHANGED
|
@@ -153,6 +153,7 @@ Rendering is free and runs locally. Only the voiceover can cost money:
|
|
|
153
153
|
|---|---|---|
|
|
154
154
|
| none | free | captions only |
|
|
155
155
|
| `kokoro` | free | local, English and Mandarin Chinese (`zf_001`); downloads a ~90 MB model on first use (Chinese: ~130 MB, and needs Python 3.10–3.12) |
|
|
156
|
+
| `qwen` | free | local, Apple Silicon Macs only; the most natural free Chinese voice (`dylan`, `serena`); downloads a ~2.3 GB model and needs Python 3.10–3.12 |
|
|
156
157
|
| ElevenLabs | your credits | the most natural voice; many languages including Chinese. Put `ELEVENLABS_API_KEY` in `.env`. |
|
|
157
158
|
| OpenAI | your credits | `OPENAI_API_KEY` |
|
|
158
159
|
|
|
@@ -394,9 +395,10 @@ mix is normalized to -14 LUFS.
|
|
|
394
395
|
| provider | key (env or `.env`) | notes |
|
|
395
396
|
|---|---|---|
|
|
396
397
|
| `kokoro` (default) | none | Local and free. English voices (`af_heart`, `am_michael`, `bf_emma`, …) and Mandarin Chinese ones (`zf_001`, see below). Needs `npm i kokoro-js`; the first run downloads an ~90 MB model. |
|
|
398
|
+
| `qwen` | none | Local and free, Apple Silicon Macs only: Qwen3-TTS, the most natural free Chinese voice here (`dylan`, `serena`, see below). |
|
|
397
399
|
| `elevenlabs` | `ELEVENLABS_API_KEY` | Most natural, many languages including Chinese. `voice` is a voice ID; `model` defaults to `eleven_multilingual_v2`. The key needs Text to Speech access, plus Voices → Read to list voices. |
|
|
398
400
|
| `openai` | `OPENAI_API_KEY` | `voice: alloy`, `model: gpt-4o-mini-tts`; optional `instructions` for tone. |
|
|
399
|
-
| `say` | none | macOS built-in voices, robotic; fine for drafts. For Chinese, use `kokoro` with `zf_001` rather than `Tingting`. |
|
|
401
|
+
| `say` | none | macOS built-in voices, robotic; fine for drafts. For Chinese, use `qwen` (Apple Silicon) or `kokoro` with `zf_001` rather than `Tingting`. |
|
|
400
402
|
|
|
401
403
|
**Chinese narration.** `voice: { provider: kokoro, voice: zf_001 }` speaks Mandarin with Kokoro
|
|
402
404
|
v1.1-zh, free and local, and is what `kokoro` uses when the `say:` lines are Chinese and no voice is
|
|
@@ -409,6 +411,20 @@ whether it's ready. English words and letters in a line (`GeoGebra`, `y 等于 m
|
|
|
409
411
|
English, a 2 before a measure word is read 两 (`2 个单位`), and `=`, `+`, `×`, `÷`, `²` are spoken;
|
|
410
412
|
subtitles keep the line as written. Known issue: with every Kokoro voice, 逗号 can sound like 度号.
|
|
411
413
|
|
|
414
|
+
**Chinese on an Apple Silicon Mac: Qwen3-TTS.** `voice: { provider: qwen }` speaks with
|
|
415
|
+
[Qwen3-TTS 0.6B CustomVoice](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice) (Apache-2.0)
|
|
416
|
+
through [mlx-audio](https://github.com/Blaizzy/mlx-audio). It reads English words, letters and numbers
|
|
417
|
+
in a Chinese line more reliably than `zf_001`. Voices: `dylan` (male, Beijing accent; the default
|
|
418
|
+
for Chinese tutorials, lessons and docs), `serena` (female, warm; the default for Chinese promos),
|
|
419
|
+
`vivian` (female, bright), `uncle_fu` (male, mellow), `eric` (male, Sichuan accent), and `ryan`, `aiden` (English), `ono_anna` (Japanese), `sohee` (Korean). On
|
|
420
|
+
first use Storyshot creates a venv in `~/.cache/storyshot/qwen-venv` (Python 3.10–3.12, about 400 MB)
|
|
421
|
+
and downloads the ~2.3 GB model into the Hugging Face cache. It speaks at about 2x real time on an M4 Pro
|
|
422
|
+
and peaks at about 8 GB of memory. The model samples its speech, so a line now and then runs on into
|
|
423
|
+
invented words or stops short. Storyshot regenerates such a take (it judges by length) and warns if
|
|
424
|
+
one still looks off. A scientific number like `2e-105` is spelled out in Chinese, because it sets the
|
|
425
|
+
model babbling. On other machines the same storyboard renders with `kokoro` (`zf_001`, or `draftVoice`),
|
|
426
|
+
with a note.
|
|
427
|
+
|
|
412
428
|
Other fields: `speed` (default 1), `volume` (default 1), and `pronounce`, a
|
|
413
429
|
map of word → spoken form used only for TTS, e.g.
|
|
414
430
|
`pronounce: { pxGenius: P X Genius, PPTX: P P T X }`. Subtitles keep the real
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
|
|
5
5
|
"author": "boheling <boheling@gmail.com>",
|
|
6
6
|
"repository": {
|
|
@@ -88,8 +88,13 @@ frames or stock footage presented as a recording of their app.
|
|
|
88
88
|
and Mandarin Chinese: for Chinese lines use `voice: { provider: kokoro,
|
|
89
89
|
voice: zf_001 }` (the default for Chinese `say:` lines), not macOS `say -v
|
|
90
90
|
Tingting`, which sounds robotic. It needs Python 3.10–3.12 and sets up its
|
|
91
|
-
own venv on first use (`doctor` reports it).
|
|
92
|
-
|
|
91
|
+
own venv on first use (`doctor` reports it). On an Apple Silicon Mac, the
|
|
92
|
+
best free Chinese voice is `voice: { provider: qwen }` (Qwen3-TTS: `dylan`,
|
|
93
|
+
a male teacher's voice, for tutorials and lessons; `serena`, female, for
|
|
94
|
+
promos; those are the defaults): clearer than
|
|
95
|
+
zf_001 with English words and letters, about 2x real time, a ~2.3 GB model
|
|
96
|
+
on first use. Elsewhere a `qwen` storyboard renders with kokoro. For other
|
|
97
|
+
languages, use ElevenLabs.
|
|
93
98
|
3. **Login.** If the flow needs a logged-in page:
|
|
94
99
|
- Tell the user a browser window will open and that they should log in
|
|
95
100
|
there, preferably with a demo account (the video shows whatever is on
|
package/src/check.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import YAML from 'yaml';
|
|
|
4
4
|
import { loadConfig } from './config.mjs';
|
|
5
5
|
import { gitIgnored } from './auth.mjs';
|
|
6
6
|
import { isZhVoice, zhVoiceStatus } from './kokoro-zh.mjs';
|
|
7
|
+
import { qwenStatus } from './qwen-tts.mjs';
|
|
7
8
|
|
|
8
9
|
const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
|
|
9
10
|
'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes'];
|
|
@@ -163,6 +164,11 @@ export function check(file, { draft = false } = {}) {
|
|
|
163
164
|
const zh = zhVoiceStatus();
|
|
164
165
|
if (zh.problem) errors.push(zh.problem);
|
|
165
166
|
}
|
|
167
|
+
if (v?.provider === 'qwen') {
|
|
168
|
+
const q = qwenStatus();
|
|
169
|
+
if (q.problem) errors.push(q.problem);
|
|
170
|
+
}
|
|
171
|
+
if (v?.fallback) warnings.push(v.fallback);
|
|
166
172
|
if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
|
|
167
173
|
const env = cfg.voice.provider === 'elevenlabs' ? 'ELEVENLABS_API_KEY' : 'OPENAI_API_KEY';
|
|
168
174
|
if (!process.env[env]) errors.push(`voice provider ${cfg.voice.provider} needs ${env} (environment or a .env file next to the storyboard) — or render with draft mode to use the free local voice`);
|
package/src/config.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
-
import { VOICE_DEFAULTS, kokoroVoiceFor } from './voice.mjs';
|
|
5
|
+
import { VOICE_DEFAULTS, kokoroVoiceFor, qwenVoiceFor } from './voice.mjs';
|
|
6
|
+
import { QWEN_VOICES, qwenSupported, qwenVoice } from './qwen-tts.mjs';
|
|
6
7
|
|
|
7
8
|
// Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
|
|
8
9
|
export const LABELS = {
|
|
@@ -148,10 +149,22 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
148
149
|
const provider = raw.voice.provider ?? 'kokoro';
|
|
149
150
|
if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
|
|
150
151
|
const pace0 = PACES[pick('pace')] ?? PACES.normal;
|
|
151
|
-
// kokoro's default
|
|
152
|
+
// kokoro's and qwen's default voices follow the narration's language (zf_001 / serena for Chinese)
|
|
152
153
|
const kokoroVoice = kokoroVoiceFor(raw);
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
const byLang = { kokoro: kokoroVoice, qwen: qwenVoiceFor(raw) }[provider];
|
|
155
|
+
voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...(byLang ? { voice: byLang } : {}), ...raw.voice, provider };
|
|
156
|
+
if (provider === 'qwen') {
|
|
157
|
+
const v = qwenVoice(voice.voice);
|
|
158
|
+
if (!v) throw new Error(`voice: qwen has no voice "${voice.voice}" (one of ${QWEN_VOICES.join(', ')})`);
|
|
159
|
+
voice.voice = v;
|
|
160
|
+
// MLX needs Apple Silicon: anywhere else the same storyboard renders with kokoro
|
|
161
|
+
if (!qwenSupported()) {
|
|
162
|
+
const k = raw.voice.draftVoice ?? kokoroVoice;
|
|
163
|
+
voice = { ...voice, provider: 'kokoro', voice: k, fallback: `the qwen voice needs an Apple Silicon Mac; using kokoro ${k} instead` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// free local voices stay; a paid one is swapped for kokoro
|
|
167
|
+
if (draft && !['kokoro', 'qwen'].includes(voice.provider)) {
|
|
155
168
|
voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? kokoroVoice, model: undefined };
|
|
156
169
|
}
|
|
157
170
|
}
|
package/src/doctor.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { hasFilter, hasEncoder, NEEDED, OPTIONAL } from './ffmpeg.mjs';
|
|
9
9
|
import { zhVoiceStatus } from './kokoro-zh.mjs';
|
|
10
|
+
import { qwenStatus } from './qwen-tts.mjs';
|
|
10
11
|
import { hyperframesCli, HF_VERSION } from './hyperframes.mjs';
|
|
11
12
|
|
|
12
13
|
const has = (cmd, args = ['--version']) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
@@ -78,6 +79,11 @@ export async function doctor({ log = console.log } = {}) {
|
|
|
78
79
|
lines.push(zh.ready ? `✔ Chinese voice (kokoro zf_001): Python ${zh.python?.version ?? '?'} venv in ${zh.venv}`
|
|
79
80
|
: zh.python ? `· Chinese voice (kokoro zf_001): Python ${zh.python.version} found; storyshot sets up its venv in ${zh.venv} on first use`
|
|
80
81
|
: '· Chinese voice (kokoro zf_001) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
|
|
82
|
+
const q = qwenStatus();
|
|
83
|
+
lines.push(!q.supported ? '· Qwen3-TTS voice (provider: qwen) needs an Apple Silicon Mac; storyboards that use it fall back to kokoro here'
|
|
84
|
+
: q.ready ? `✔ Qwen3-TTS voice (provider: qwen): venv in ${q.venv}${q.model ? ', model downloaded' : '; the model (~2.3 GB) downloads on first use'}`
|
|
85
|
+
: q.python ? `· Qwen3-TTS voice (provider: qwen): Python ${q.python.version} found; storyshot sets up its venv in ${q.venv} and downloads the model (~2.3 GB) on first use`
|
|
86
|
+
: '· Qwen3-TTS voice (provider: qwen) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
|
|
81
87
|
const hf = hyperframesCli();
|
|
82
88
|
lines.push(hf.error ? `· HyperFrames cards (hyperframes: true) unavailable — optional: ${hf.error}`
|
|
83
89
|
: hf.missing ? `· HyperFrames cards (hyperframes: true): ${HF_VERSION} installs itself on first use`
|
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/voice.mjs
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// Voiceover: text-to-speech for each step's `say:` line, cached on disk.
|
|
2
|
-
// Providers: kokoro (local, free: English, and Mandarin with the v1.1-zh voices),
|
|
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';
|
|
8
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';
|
|
9
11
|
|
|
10
12
|
const SR = 48000;
|
|
11
13
|
export const VOICE_DEFAULTS = {
|
|
12
14
|
kokoro: { voice: 'af_heart' },
|
|
15
|
+
qwen: { voice: 'dylan' },
|
|
13
16
|
elevenlabs: { voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_multilingual_v2' },
|
|
14
17
|
openai: { voice: 'alloy', model: 'gpt-4o-mini-tts' },
|
|
15
18
|
say: { voice: 'Samantha' },
|
|
@@ -19,10 +22,24 @@ export const VOICE_DEFAULTS = {
|
|
|
19
22
|
// characters than Latin words; no kana or hangul), or, with no narration to go by, `lang: zh`.
|
|
20
23
|
// English (af_heart) otherwise.
|
|
21
24
|
export function kokoroVoiceFor(raw) {
|
|
22
|
-
|
|
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);
|
|
23
41
|
const han = (says.match(/[\u3400-\u9fff]/g) ?? []).length, latin = (says.match(/[A-Za-z]+/g) ?? []).length;
|
|
24
|
-
|
|
25
|
-
return zh ? ZH_DEFAULT_VOICE : VOICE_DEFAULTS.kokoro.voice;
|
|
42
|
+
return says.trim() ? han > latin && !/[\u3040-\u30ff\uac00-\ud7af]/.test(says) : /^zh/i.test(String(raw.lang ?? ''));
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
let kokoro = null;
|
|
@@ -72,7 +89,7 @@ async function synth(v, text, file) {
|
|
|
72
89
|
execFileSync('say', ['-v', v.voice, '-r', String(Math.round(185 * v.speed)), '-o', file, text]);
|
|
73
90
|
return;
|
|
74
91
|
default:
|
|
75
|
-
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)`);
|
|
76
93
|
}
|
|
77
94
|
}
|
|
78
95
|
|
|
@@ -86,26 +103,28 @@ function pronounce(v, text) {
|
|
|
86
103
|
return t;
|
|
87
104
|
}
|
|
88
105
|
|
|
89
|
-
// The cache file for a line, and the text the engine is given. The Mandarin voice reads a
|
|
90
|
-
// text (2个 → 两个, …) and has its model and G2P version in the hash.
|
|
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.
|
|
91
108
|
function cached(cfg, line) {
|
|
92
109
|
const v = cfg.voice;
|
|
93
110
|
const dir = path.join(path.dirname(cfg.work), 'tts-cache');
|
|
94
111
|
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);
|
|
97
|
-
const ext = { kokoro: 'wav', say: 'aiff' }[v.provider] ?? 'mp3';
|
|
98
|
-
const key = zh ? [v.provider, v.voice, ZH_TTS_ID, v.speed, text] : [v.provider, v.voice,
|
|
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];
|
|
99
117
|
const hash = crypto.createHash('sha1').update(JSON.stringify(key)).digest('hex').slice(0, 16);
|
|
100
|
-
return { text, file: path.join(dir, `${hash}.${ext}`), zh };
|
|
118
|
+
return { text, file: path.join(dir, `${hash}.${ext}`), zh, qwen };
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
// Returns stereo Float32 PCM at 48 kHz with leading/trailing silence trimmed.
|
|
104
122
|
export async function speak(cfg, line, { log = console.log } = {}) {
|
|
105
123
|
const v = cfg.voice;
|
|
106
|
-
const { text, file, zh } = cached(cfg, line);
|
|
124
|
+
const { text, file, zh, qwen } = cached(cfg, line);
|
|
107
125
|
if (!fs.existsSync(file)) {
|
|
108
126
|
if (zh) await synthZh(v, [{ text, file }], { log });
|
|
127
|
+
else if (qwen) await synthQwen(v, [{ text, file }], { log });
|
|
109
128
|
else {
|
|
110
129
|
log(` tts (${v.provider}): "${text}"`);
|
|
111
130
|
await synth(v, text, file);
|
|
@@ -129,11 +148,13 @@ export function tempo(pcm, factor) {
|
|
|
129
148
|
export async function measureVoice(cfg, { log = console.log } = {}) {
|
|
130
149
|
const lens = { steps: {}, endCard: null, intro: null, cards: {} };
|
|
131
150
|
if (!cfg.voice) return lens;
|
|
132
|
-
if (cfg.voice.
|
|
133
|
-
|
|
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
|
|
134
155
|
const lines = [...cfg.steps.filter((s) => !s.hidden).flatMap((s) => [s.say, s.card?.say]), cfg.intro?.say, cfg.endCard?.say].filter(Boolean);
|
|
135
156
|
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 });
|
|
157
|
+
await (zh ? synthZh : synthQwen)(cfg.voice, [...todo.values()], { log });
|
|
137
158
|
}
|
|
138
159
|
for (const [i, s] of cfg.steps.entries()) {
|
|
139
160
|
if (s.say && !s.hidden) lens.steps[i] = (await speak(cfg, s.say, { log })).length / 2 / SR;
|