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 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.5.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). For other languages, use
92
- ElevenLabs.
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 voice follows the narration's language (zf_001 for Chinese, af_heart otherwise)
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
- voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...(provider === 'kokoro' ? { voice: kokoroVoice } : {}), ...raw.voice, provider };
154
- if (draft && provider !== 'kokoro') {
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`
@@ -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
+ }
@@ -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), elevenlabs, openai, say (macOS).
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
- const says = [...(raw.steps ?? []).flatMap((s) => [s?.say, s?.card?.say]), raw.intro?.say, raw.end_card?.say].filter(Boolean).join(' ');
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
- 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;
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 rewritten
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, v.model, v.speed, v.instructions, text];
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.provider === 'kokoro' && isZhVoice(cfg.voice.voice)) {
133
- // synthesize every uncached Mandarin line in one batch, so the Python G2P starts once
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;