storyshot 0.6.0 → 0.7.2

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/src/inspect.mjs CHANGED
@@ -39,12 +39,25 @@ function collect() {
39
39
  const pos = (el) => {
40
40
  const r = el.getBoundingClientRect();
41
41
  return { x: Math.round(r.x + scrollX), y: Math.round(r.y + scrollTop), w: Math.round(r.width), h: Math.round(r.height),
42
- cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2), css: cssOf(el) };
42
+ vx: Math.round(r.x), vy: Math.round(r.y), cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2), css: cssOf(el) };
43
+ };
44
+ // what to list first when there are more than inspect shows: an open dialog, menu or popover (0), what
45
+ // is on screen and not covered by something else (1), below the fold (2), covered (3)
46
+ const LAYER = 'dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true], [role=menu], [role=listbox], [popover], [role=tooltip]';
47
+ const rank = (el) => {
48
+ if (el.closest(LAYER)) return 0;
49
+ const r = el.getBoundingClientRect(), x = r.x + r.width / 2, y = r.y + r.height / 2;
50
+ if (y < 0 || y > innerHeight || x < 0 || x > innerWidth) return 2;
51
+ const hit = document.elementFromPoint(x, y), label = el.closest('label');
52
+ // (inside a shadow root the hit is its host; storyshot's own cursor and highlights don't count)
53
+ const clear = !hit || el.contains(hit) || hit.contains(el) || (label && hit.closest('label') === label) || el.getRootNode() !== document || hit.closest('[id^=__demo]');
54
+ return clear ? 1 : 3;
43
55
  };
44
56
  const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
45
57
  // a short CSS selector (tag + classes, or #id) — the last-resort target when names don't resolve
46
58
  const cssOf = (el) => {
47
- if (el.id && !/^[0-9:]|:/.test(el.id)) return `#${CSS.escape(el.id)}`;
59
+ const id = el.getAttribute('id'); // not el.id: a form with an <input name=id> has that input as its .id
60
+ if (id && !/^[0-9:]|:/.test(id)) return `#${CSS.escape(id)}`;
48
61
  const cls = [...el.classList].filter((c) => !/^(css|sc|jsx|svelte|tw)-|\d{3,}|__/.test(c)).slice(0, 2).map((c) => `.${CSS.escape(c)}`).join('');
49
62
  return cls ? `${el.tagName.toLowerCase()}${cls}` : null;
50
63
  };
@@ -68,28 +81,35 @@ function collect() {
68
81
  // a label's own text, without the links/buttons inside it (e.g. a "Clear" link)
69
82
  const labelText = (lab) => { if (!lab) return null; const c = lab.cloneNode(true); c.querySelectorAll('a,button,select,input,textarea').forEach((x) => x.remove()); return clean(c.innerText) || null; };
70
83
  // styled checkboxes and radios hide the real input and draw their label instead: list them where the label is
84
+ // a slider's range and value, when it says (an <input type=range>, or aria-valuemin/max/now)
85
+ const sliderRange = (el) => {
86
+ const n = (a) => (el.getAttribute(a) == null ? NaN : Number(el.getAttribute(a)));
87
+ const [lo, hi, now] = el.type === 'range' ? [Number(el.min || 0), Number(el.max || 100), Number(el.value)] : [n('aria-valuemin'), n('aria-valuemax'), n('aria-valuenow')];
88
+ return [lo, hi, now].every(Number.isFinite) ? { lo, hi, now } : null;
89
+ };
71
90
  const shownBy = (el) => (el.tagName === 'INPUT' && ['checkbox', 'radio'].includes(el.type) && !vis(el) ? [...(el.labels ?? [])].find(vis) : null);
72
91
  const interactive = [...document.querySelectorAll(NATIVE)]
73
92
  .filter((el) => (vis(el) || shownBy(el)) && el.tagName !== 'LABEL' && !isAd(el) && !(el.isContentEditable && el.parentElement?.isContentEditable))
74
93
  .map((el) => ({ kind: 'role', role: roleOf(el), name: (el.isContentEditable || el.tagName === 'TEXTAREA' ? editorName(el) : nameOf(el)).slice(0, 80), shows: clean(el.innerText).slice(0, 60), alt: clean(el.querySelector('img[alt]')?.alt) || null, href: el.getAttribute('href'), label: labelText(el.labels?.[0]),
75
- options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, ...pos(shownBy(el) ?? el) }));
94
+ range: roleOf(el) === 'slider' ? sliderRange(el) : null,
95
+ options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ text: clean(o.text), on: o.selected })).filter((o) => o.text).slice(0, 12) : null, rank: rank(shownBy(el) ?? el), ...pos(shownBy(el) ?? el) }));
76
96
  // custom clickables: outermost cursor:pointer elements that aren't native controls
77
97
  const pointer = [];
78
98
  for (const el of document.querySelectorAll('body *')) {
79
- if (pointer.length >= 40) break;
99
+ if (pointer.length >= 60) break;
80
100
  if (el.closest(NATIVE) || el.querySelector(NATIVE) || !vis(el) || isAd(el)) continue;
81
101
  if (getComputedStyle(el).cursor !== 'pointer' || (el.parentElement && getComputedStyle(el.parentElement).cursor === 'pointer')) continue;
82
102
  const img = el.tagName === 'IMG' ? el : el.querySelector('img[alt]');
83
- pointer.push({ kind: 'pointer', alt: img?.alt ? clean(img.alt).slice(0, 80) : null, name: clean(el.innerText).slice(0, 80), ...pos(el) });
103
+ pointer.push({ kind: 'pointer', alt: img?.alt ? clean(img.alt).slice(0, 80) : null, name: clean(el.innerText).slice(0, 80), rank: rank(el), ...pos(el) });
84
104
  }
85
105
  return {
86
106
  title: document.title,
87
107
  height: scroller ? scroller.scrollHeight : document.documentElement.scrollHeight,
88
- scroller: scroller ? (scroller.id ? `#${scroller.id}` : scroller.tagName.toLowerCase() + (scroller.className && typeof scroller.className === 'string' ? '.' + scroller.className.trim().split(/\s+/)[0] : '')) : null,
108
+ scroller: scroller ? (scroller.getAttribute('id') ? `#${scroller.getAttribute('id')}` : scroller.tagName.toLowerCase() + (scroller.className && typeof scroller.className === 'string' ? '.' + scroller.className.trim().split(/\s+/)[0] : '')) : null,
89
109
  headings: [...document.querySelectorAll('h1, h2, h3')].filter(vis).map((h) => ({ tag: h.tagName.toLowerCase(), text: clean(h.innerText).slice(0, 100), ...pos(h) })),
90
110
  anchors: [...document.querySelectorAll('[id]')].filter((el) => vis(el) && el !== scroller && el.getBoundingClientRect().height > 150 &&
91
- el.getBoundingClientRect().height < document.documentElement.scrollHeight * 0.8 && !/^(__|radix|headlessui|:)/.test(el.id))
92
- .map((el) => ({ id: el.id, ...pos(el) })).slice(0, 30),
111
+ el.getBoundingClientRect().height < document.documentElement.scrollHeight * 0.8 && !/^(__|radix|headlessui|:)/.test(el.getAttribute('id')))
112
+ .map((el) => ({ id: el.getAttribute('id'), ...pos(el) })).slice(0, 30),
93
113
  images: [...document.querySelectorAll('img[alt]')].filter((el) => vis(el) && el.alt.trim()).map((el) => ({ alt: clean(el.alt).slice(0, 80), ...pos(el) })).slice(0, 30),
94
114
  interactive: [...interactive, ...pointer],
95
115
  };
@@ -97,24 +117,38 @@ function collect() {
97
117
 
98
118
  const yaml = (t) => (typeof t === 'string' ? JSON.stringify(t) : `{ ${Object.entries(t).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`);
99
119
 
120
+ // What can be clicked right now, without verifying targets: `inspect --after N` compares the page before and
121
+ // after step N, to list what the step opened first.
122
+ const keyOf = (el) => `${el.kind}|${el.role ?? ''}|${el.name}|${el.css ?? ''}`;
123
+ export async function clickableKeys(page) {
124
+ const info = await page.evaluate(collect).catch(() => null);
125
+ return info && new Set(info.interactive.map(keyOf));
126
+ }
127
+
100
128
  // Describe the page as it is right now. Shared by `inspect` and failure diagnostics.
101
- export async function describePage(page, viewport, { maxElements = 80 } = {}) {
129
+ // before: clickableKeys() of an earlier state; what is new since then is listed first and marked.
130
+ export async function describePage(page, viewport, { maxElements = 150, before = null } = {}) {
102
131
  const info = await page.evaluate(collect);
103
- const seen = new Set(), elements = [];
132
+ const seen = new Set(), targets = new Set(), elements = [];
104
133
  const verify = async (locator, spec, el) => {
105
134
  const visible = locator.filter({ visible: true });
106
135
  const n = await visible.count().catch(() => 0);
107
136
  if (n === 1) return spec;
108
137
  if (n > 1) {
109
- // pick the match whose box is where we saw the element
110
- const xs = await visible.evaluateAll((els) => els.map((e) => { const r = e.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width)]; })).catch(() => []);
111
- const k = xs.findIndex(([x, , w]) => Math.abs(x - el.x) <= 2 && Math.abs(w - el.w) <= 2);
112
- if (k >= 0) return { ...spec, nth: k };
138
+ // pick the match whose box is where we saw the element (all four sides: a column of same-named
139
+ // buttons shares x and width)
140
+ const xs = await visible.evaluateAll((els) => els.map((e) => { const r = e.getBoundingClientRect(); return [r.x, r.y, r.width, r.height].map(Math.round); })).catch(() => []);
141
+ const at = xs.map(([x, y, w, h], k) => [k, Math.abs(x - el.vx) + Math.abs(y - el.vy) + Math.abs(w - el.w) + Math.abs(h - el.h)]).filter(([, d]) => d <= 4);
142
+ if (at.length === 1) return { ...spec, nth: at[0][0] };
113
143
  }
114
144
  return null;
115
145
  };
116
- for (const el of info.interactive) {
117
- if (elements.length >= maxElements) break;
146
+ const fresh = (el) => !!before && !before.has(keyOf(el));
147
+ // the most useful first (new since `before`, open dialogs and menus, on screen), then the page's order
148
+ const order = info.interactive.map((el, k) => ({ el, k })).sort((a, b) => (fresh(b.el) - fresh(a.el)) || (a.el.rank - b.el.rank) || (a.k - b.k));
149
+ let left = 0;
150
+ for (const { el, k } of order) {
151
+ if (elements.length >= maxElements) { left++; continue; }
118
152
  const key = `${el.role ?? el.alt}|${el.name}|${el.x}|${el.y}`;
119
153
  if (seen.has(key)) continue;
120
154
  seen.add(key);
@@ -135,8 +169,15 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
135
169
  if (!target && el.name) target = await verify(page.getByText(el.name, { exact: true }), { text: el.name, exact: true }, el);
136
170
  }
137
171
  if (!target && el.css) target = await verify(page.locator(el.css), { css: el.css }, el);
138
- if (target) elements.push({ ...el, target });
172
+ // two elements given the same target would both click the first: list neither as verified
173
+ if (target && targets.has(JSON.stringify(target))) {
174
+ const dup = elements.findIndex((e) => JSON.stringify(e.target) === JSON.stringify(target));
175
+ if (dup >= 0) elements.splice(dup, 1);
176
+ continue;
177
+ }
178
+ if (target) { targets.add(JSON.stringify(target)); elements.push({ ...el, k, fresh: fresh(el), target }); }
139
179
  }
180
+ elements.sort((a, b) => a.k - b.k);
140
181
 
141
182
  const url = page.url(), origin = new URL(url).origin;
142
183
  const internal = [...new Set(info.interactive.filter((e) => e.href && !e.href.startsWith('#'))
@@ -160,7 +201,10 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
160
201
  '',
161
202
  '## Clickable elements — verified targets (each matches exactly one visible element) for click/move/zoom',
162
203
  ' Copy targets exactly; `name` is the accessible name, which may differ from the on-screen label.',
163
- ...elements.map((e) => `${fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? ' (slider — `slide: { target: …, to: 0..1 }`)' : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}`),
204
+ ...(before && elements.some((e) => e.fresh) ? [' ★ = new since the step before (what this step opened)'] : []),
205
+ ...elements.map((e) => `${e.fresh ? '★' : fold(e.y)} y=${e.y}\tat (${e.cx}, ${e.cy})\t${yaml(e.target)}${e.role === 'textbox' ? ` (text input ${e.w}×${e.h} — click it, then \`type: …\`)` : e.role === 'slider' ? (e.range ? ` (slider ${e.range.lo}…${e.range.hi}, now ${e.range.now} — \`slide: { target: …, value: ${e.range.now} }\`)` : ' (slider — `slide: { target: …, to: 0..1 }`)') : e.options ? ` (dropdown — \`select: { target: …, option: "…" }\`; options: ${e.options.map((o) => `"${o.text}"${o.on ? ' (selected)' : ''}`).join(', ')})` : shows(e)}${e.href ? ` → ${e.href}` : ''}${e.kind === 'pointer' ? ' (custom clickable)' : ''}${e.rank === 3 ? ' (covered by something on top right now)' : ''}`),
206
+ ...(left ? [`… and ${left} more not listed (the first ${maxElements} are: ${before ? 'new ones, ' : ''}open dialogs and menus, then what's on screen). ` +
207
+ 'For a panel or menu, open it with a step and run `inspect <storyboard> --after <that step>`; its elements are listed first.'] : []),
164
208
  '',
165
209
  '## Images with alt text (target: { alt: "..." })',
166
210
  ...(info.images.length ? info.images.map((i) => `${fold(i.y)} y=${i.y}\t"${i.alt}"`) : ['(none)']),
@@ -171,7 +215,7 @@ export async function describePage(page, viewport, { maxElements = 80 } = {}) {
171
215
  return { text: lines.join('\n'), height: info.height, title: info.title, url, headings: info.headings, anchors: info.anchors, elements };
172
216
  }
173
217
 
174
- export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, ignoreHTTPSErrors = false, outDir = '.storyshot/inspect', maxElements = 80 } = {}) {
218
+ export async function inspect(url, { viewport = { width: 1600, height: 900 }, storageState, ignoreHTTPSErrors = false, outDir = '.storyshot/inspect', maxElements = 150 } = {}) {
175
219
  const browser = await chromium.launch();
176
220
  const context = await sessionContext(browser, { viewport, storageState, ignoreHTTPSErrors });
177
221
  const page = await context.newPage();
package/src/qwen-tts.mjs CHANGED
@@ -22,15 +22,20 @@ const REPO = 'mlx-community/Qwen3-TTS-12Hz-0.6B-CustomVoice-bf16';
22
22
  const REVISION = '6415d95f88be018ff9e46813119dc3bc12261328';
23
23
  // 0.9 (the model's default) now and then runs on into made-up words; 0.7 keeps the same voice steadier
24
24
  const TEMPERATURE = 0.7;
25
+ // Each take is checked by ear: Qwen3-ASR (Apache-2.0, ~1 GB) transcribes it, and a line it hears
26
+ // differently is sampled again (pypinyin compares Chinese by sound, so homophones don't count)
27
+ const ASR_REPO = 'mlx-community/Qwen3-ASR-0.6B-8bit';
28
+ const ASR_REVISION = '89e96d92ba34aca20b3e29fb10cc284097d1219f';
25
29
  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'];
30
+ 'tokenizers==0.23.3', 'safetensors==0.8.0', 'numpy==2.5.4', 'pypinyin==0.55.0'];
27
31
  // 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`;
32
+ export const QWEN_TTS_ID = `${REPO}@${REVISION.slice(0, 7)}|${REQUIREMENTS[0]}|t${TEMPERATURE}|guard-2|${ASR_REPO}@${ASR_REVISION.slice(0, 7)}`;
29
33
  const CACHE = path.join(os.homedir(), '.cache', 'storyshot');
30
34
  export const QWEN_VENV = path.join(CACHE, 'qwen-venv');
31
35
  const HELPER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'qwen_tts.py');
32
36
  const HUB = process.env.HF_HUB_CACHE ?? path.join(process.env.HF_HOME ?? path.join(os.homedir(), '.cache', 'huggingface'), 'hub');
33
37
  const MODEL_FILE = path.join(HUB, `models--${REPO.replace('/', '--')}`, 'snapshots', REVISION, 'model.safetensors');
38
+ const ASR_FILE = path.join(HUB, `models--${ASR_REPO.replace('/', '--')}`, 'snapshots', ASR_REVISION, 'model.safetensors');
34
39
 
35
40
  // --- text (TTS only: subtitles keep the original) --------------------------------------------------
36
41
  // The model reads = + 2个 第2行 x² itself; a minus sign before a number it drops, and a number like
@@ -44,13 +49,16 @@ export function qwenSpeakText(text, lang = qwenLang(text)) {
44
49
  return t;
45
50
  }
46
51
 
47
- // The language the model is told: the script decides; Latin text is left to the model.
48
- export function qwenLang(text) {
52
+ // The language the model is told: the script decides; a line in Latin letters gets the storyboard's
53
+ // `lang` (es → spanish) when the model knows it, else it's left to the model.
54
+ const LATIN_LANGS = { en: 'english', es: 'spanish', fr: 'french', de: 'german', it: 'italian', pt: 'portuguese' };
55
+ export function qwenLang(text, lang) {
49
56
  const s = String(text).replace(/\d+(?:\.\d+)?(?:[eE][-−+]?\d+)?/g, ' '); // the e in 2e-105 is no English word
50
57
  if (/[぀-ヿ]/.test(s)) return 'japanese';
51
58
  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';
59
+ if (/[А-яЁё]/.test(s)) return 'russian';
60
+ const han = (s.match(/[㐀-鿿]/g) ?? []).length, latin = (s.match(/[A-Za-zÀ-ÿ]+/g) ?? []).length;
61
+ return han > latin ? 'chinese' : LATIN_LANGS[String(lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? 'auto';
54
62
  }
55
63
 
56
64
  // --- the venv ---------------------------------------------------------------------------------------
@@ -66,7 +74,7 @@ const NO_PYTHON = () => 'the Qwen3-TTS voice needs Python 3.10–3.12 to run (br
66
74
  // For doctor and check: can this machine use it, and what's still to come on first use?
67
75
  export function qwenStatus() {
68
76
  if (!qwenSupported()) return { supported: false };
69
- const model = fs.existsSync(MODEL_FILE);
77
+ const model = fs.existsSync(MODEL_FILE) && fs.existsSync(ASR_FILE);
70
78
  if (venvReady()) return { supported: true, ready: true, venv: QWEN_VENV, model };
71
79
  const python = findPython();
72
80
  return { supported: true, ready: false, venv: QWEN_VENV, model, python, problem: python ? null : NO_PYTHON() };
@@ -74,13 +82,22 @@ export function qwenStatus() {
74
82
 
75
83
  function ensureVenv(log) {
76
84
  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
85
  const run = (cmd, args, what) => {
81
86
  const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
82
87
  if (r.status !== 0) throw new Error(`${what} failed: ${(r.stderr || r.stdout || r.error?.message || '').trim().split('\n').slice(-6).join('\n')}`);
83
88
  };
89
+ if (fs.existsSync(venvPython(QWEN_VENV))) {
90
+ // a venv from an older storyshot: bring its packages up to date in place, rebuild it only if that fails
91
+ log(`updating the Qwen3-TTS venv in ${QWEN_VENV}…`);
92
+ try {
93
+ run(venvPython(QWEN_VENV), ['-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '-q', ...REQUIREMENTS], 'updating the venv');
94
+ fs.writeFileSync(path.join(QWEN_VENV, STAMP), REQUIREMENTS.join('\n'));
95
+ return venvPython(QWEN_VENV);
96
+ } catch (e) { log(` ${e.message.split('\n')[0]} — rebuilding it`); }
97
+ }
98
+ const py = findPython();
99
+ if (!py) throw new Error(NO_PYTHON());
100
+ 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)…`);
84
101
  fs.rmSync(QWEN_VENV, { recursive: true, force: true });
85
102
  fs.mkdirSync(CACHE, { recursive: true });
86
103
  run(py.exe, ['-m', 'venv', QWEN_VENV], `creating the venv with ${py.exe} -m venv`);
@@ -90,18 +107,20 @@ function ensureVenv(log) {
90
107
  }
91
108
 
92
109
  // --- synthesis --------------------------------------------------------------------------------------
93
- // entries: [{ text, file }] (text already through qwenSpeakText) → a WAV at each `file`.
110
+ // entries: [{ text, written?, file }] (text already through qwenSpeakText; written: the line before
111
+ // `pronounce`, which the take check also accepts) → a WAV at each `file`.
94
112
  export async function synthQwen(v, entries, { log = console.log } = {}) {
95
113
  if (!entries.length) return;
96
114
  if (!qwenSupported()) throw new Error('the Qwen3-TTS voice runs on Apple Silicon Macs only (it uses MLX)');
97
115
  const py = ensureVenv(log);
98
116
  if (!fs.existsSync(MODEL_FILE)) log(`downloading the Qwen3-TTS model (one time, ~2.3 GB) into ${HUB}…`);
117
+ if (!fs.existsSync(ASR_FILE)) log(`downloading the Qwen3-ASR model that listens to each take (one time, ~1 GB) into ${HUB}…`);
99
118
  const lines = entries.map((e) => ({
100
- text: e.text, file: `${e.file}.raw.wav`, voice: v.voice, lang: qwenLang(e.text),
119
+ text: e.text, written: e.written ?? e.text, file: `${e.file}.raw.wav`, voice: v.voice, lang: qwenLang(e.text, v.lang),
101
120
  // the same line gets the same take: a seed from the text
102
121
  seed: parseInt(crypto.createHash('sha1').update(e.text).digest('hex').slice(0, 7), 16),
103
122
  }));
104
- const job = JSON.stringify({ repo: REPO, revision: REVISION, temperature: TEMPERATURE, lines });
123
+ const job = JSON.stringify({ repo: REPO, revision: REVISION, temperature: TEMPERATURE, asr: { repo: ASR_REPO, revision: ASR_REVISION }, lines });
105
124
  const { code, stdout, tail } = await new Promise((resolve, reject) => {
106
125
  const p = spawn(py, ['-I', HELPER], { env: { ...process.env, HF_HUB_DISABLE_TELEMETRY: '1', TRANSFORMERS_VERBOSITY: 'error' } });
107
126
  let out = '', err = '', buf = '';
@@ -121,9 +140,15 @@ export async function synthQwen(v, entries, { log = console.log } = {}) {
121
140
  p.stdin.end(job);
122
141
  });
123
142
  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);
143
+ const { results, asr } = JSON.parse(stdout);
144
+ if (asr) log(` ⚠ qwen: the speech check couldn't load (${asr}), so these takes were checked by length only`);
125
145
  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`);
146
+ const r = results[i];
147
+ if (r?.suspect) {
148
+ log(r.heard != null && r.mismatch > 0
149
+ ? ` ⚠ qwen: "${e.text}" still doesn't come out right after ${r.tries} tries — it sounds like "${r.heard}". Listen to it; rephrase the line or add a pronounce entry`
150
+ : ` ⚠ qwen: "${e.text}" still sounds off after ${r.tries} tries (${r.seconds.toFixed(1)}s) — listen to it, or rephrase the line / add a pronounce entry`);
151
+ }
127
152
  const raw = lines[i].file, tmp = `${e.file}.tmp.wav`;
128
153
  // the model has no speed control: ffmpeg's pitch-preserving atempo does it
129
154
  if ((v.speed ?? 1) !== 1) execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-i', raw, '-af', `atempo=${v.speed}`, tmp]);
package/src/qwen_tts.py CHANGED
@@ -1,23 +1,31 @@
1
1
  # Qwen3-TTS CustomVoice through mlx-audio (Apple Silicon), for `voice: { provider: qwen }`. Runs in
2
2
  # storyshot's own venv (qwen-tts.mjs), one process per render: the model loads once for all lines.
3
3
  #
4
- # stdin: {"repo", "revision", "temperature", "lines": [{"text", "file", "voice", "lang", "seed"}]}
4
+ # stdin: {"repo", "revision", "temperature", "asr": {"repo", "revision"} | null,
5
+ # "lines": [{"text", "written", "file", "voice", "lang", "seed"}]}
6
+ # (text: what the model reads; written: the line as the storyboard has it, before `pronounce`)
5
7
  # stderr: "line i/n <seconds>s [<tries> tries]" per line, plus whatever the libraries log
6
- # stdout: {"results": [{"seconds", "tries", "suspect"}]}
8
+ # stdout: {"results": [{"seconds", "tries", "suspect", "heard", "mismatch"}], "asr": null | "why it didn't load"}
7
9
  #
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.
10
+ # The model samples its speech tokens, so now and then a take garbles a few words, drops one, runs on
11
+ # into made-up words (a scientific number like 2e-105 does it) or stops early. Each take is checked:
12
+ # its length against the text, and what a speech recognizer (Qwen3-ASR) hears in it against the text.
13
+ # A take that fails is sampled again with another seed, and the best one is kept.
11
14
  import contextlib
12
15
  import json
13
16
  import os
14
17
  import re
15
18
  import sys
19
+ import unicodedata
16
20
 
17
21
  os.environ.setdefault('HF_HUB_DISABLE_TELEMETRY', '1')
18
22
  os.environ.setdefault('TRANSFORMERS_VERBOSITY', 'error')
19
23
 
20
24
  TRIES = 4
25
+ # a take is sampled again when the recognizer hears this share of the line differently, or this long a run
26
+ # of mistakes in a row (a garble: 负 2 x → 负二长方荡 is 2.5); a warning if every take is still off
27
+ RETRY_AT, RETRY_RUN = 0.10, 2
28
+ WARN_AT, WARN_RUN = 0.15, 2
21
29
 
22
30
 
23
31
  def expected(text):
@@ -32,6 +40,95 @@ def plausible(seconds, est):
32
40
  return 0.35 * est - 0.5 <= seconds <= 2.0 * est + 2.0
33
41
 
34
42
 
43
+ # --- what was said vs. what was meant ---------------------------------------------------------------
44
+ # Chinese is compared by sound (toneless pinyin), so a homophone the recognizer picks (交点/焦点) is no
45
+ # mistake; numbers are read out first (0.5 → 零点五, 2 → 二), since the recognizer writes either form.
46
+ DIGITS = '零一二三四五六七八九'
47
+
48
+
49
+ def cn_int(n):
50
+ if n >= 10 ** 8:
51
+ return ''.join(DIGITS[int(c)] for c in str(n))
52
+
53
+ def four(x):
54
+ out, zero = '', False
55
+ for i, c in enumerate(f'{x:04d}'):
56
+ d = int(c)
57
+ if d == 0:
58
+ zero = bool(out)
59
+ else:
60
+ out += ('零' if zero else '') + DIGITS[d] + ['千', '百', '十', ''][i]
61
+ zero = False
62
+ return out
63
+
64
+ if n < 10:
65
+ return DIGITS[n]
66
+ hi, lo = divmod(n, 10000)
67
+ out = (four(hi) + '万' if hi else '') + (('零' if hi and lo < 1000 else '') + four(lo) if lo else '')
68
+ return re.sub('^一十', '十', out)
69
+
70
+
71
+ def cn_number(m):
72
+ whole, _, frac = m.group().partition('.')
73
+ return cn_int(int(whole)) + ('点' + ''.join(DIGITS[int(c)] for c in frac) if frac else '')
74
+
75
+
76
+ def units(text, chinese, pinyin):
77
+ """The line as a list of sounds: pinyin syllables, digits, and Latin words as '#word' without accents
78
+ (m x and mx as '#m', '#x')."""
79
+ t = ''.join(c for c in unicodedata.normalize('NFD', text.lower().replace('−', '-')) if unicodedata.category(c) != 'Mn')
80
+ if chinese:
81
+ t = re.sub(r'\d+(?:\.\d+)?', cn_number, t)
82
+ out = []
83
+ for tok in re.findall(r'[㐀-鿿]|[^\W\d_㐀-鿿]+|\d+', t): # Han one by one, words (with accents), numbers
84
+ if chinese and re.match(r'[㐀-鿿]', tok):
85
+ out += [{'liang': 'er'}.get(p, p) for p in pinyin(tok)]
86
+ elif tok.isdigit():
87
+ out += list(tok) # digit by digit: a wrong number (260 → 45) is several mistakes in a row, not one
88
+ else:
89
+ out += ['#' + c for c in tok] if len(tok) <= 2 else ['#' + tok]
90
+ return out
91
+
92
+
93
+ def mismatch(text, heard, chinese, pinyin):
94
+ """(share of the line heard differently, the longest run of mistakes in a row), both in units of the
95
+ text. A garbled take has a run of several; a homophone or a misheard letter is one on its own."""
96
+ a, b = units(text, chinese, pinyin), units(heard, chinese, pinyin)
97
+ letter = lambda u: len(u) == 2 and u[0] == '#'
98
+ glue = lambda us: ''.join(u.lstrip('#') for u in us)
99
+ d = {(0, 0): (0, None)}
100
+ for i in range(len(a) + 1):
101
+ for j in range(len(b) + 1):
102
+ if i == j == 0:
103
+ continue
104
+ ways = []
105
+ if i:
106
+ ways.append((d[i - 1, j][0] + 1, (i - 1, j, 1)))
107
+ if j:
108
+ ways.append((d[i, j - 1][0] + 1, (i, j - 1, 1)))
109
+ if i and j:
110
+ x, y = a[i - 1], b[j - 1]
111
+ # a variable (m, b, x) the recognizer wrote as a character, or a number word it wrote in digits
112
+ # (tres → 3), is half a mistake
113
+ cost = 0 if x == y else 0.5 if letter(x) or letter(y) or x.isdigit() != y.isdigit() else 1
114
+ ways.append((d[i - 1, j - 1][0] + cost, (i - 1, j - 1, cost)))
115
+ # a name in Latin letters in a Chinese line (Desmos) can come back as a few syllables: one mistake
116
+ if chinese and len(x) > 3 and x[0] == '#':
117
+ ways += [(d[i - 1, j - k][0] + 1, (i - 1, j - k, 1)) for k in range(2, min(5, j) + 1)]
118
+ # one word written as two or three (escribámosla / escribamos la): no mistake
119
+ if len(x) > 3 and x[0] == '#':
120
+ ways += [(d[i - 1, j - k][0], (i - 1, j - k, 0)) for k in range(2, min(4, j) + 1) if glue(b[j - k:j]) == x[1:]]
121
+ if len(y) > 3 and y[0] == '#':
122
+ ways += [(d[i - k, j - 1][0], (i - k, j - 1, 0)) for k in range(2, min(4, i) + 1) if glue(a[i - k:i]) == y[1:]]
123
+ d[i, j] = min(ways, key=lambda w: w[0])
124
+ i, j, run, worst = len(a), len(b), 0, 0
125
+ while (i, j) != (0, 0):
126
+ i, j, cost = d[i, j][1]
127
+ run = run + cost if cost else 0
128
+ worst = max(worst, run)
129
+ return d[len(a), len(b)][0] / max(1, len(a)), worst
130
+
131
+
35
132
  def main():
36
133
  job = json.load(sys.stdin)
37
134
  out = sys.stdout
@@ -45,6 +142,33 @@ def main():
45
142
  from mlx_audio.tts.utils import load_model
46
143
 
47
144
  model = load_model(snapshot_download(job['repo'], revision=job['revision']))
145
+ asr, asr_error, pinyin = None, None, None
146
+ if job.get('asr'):
147
+ try:
148
+ from mlx_audio.stt.utils import load_model as load_stt
149
+ from pypinyin import lazy_pinyin as pinyin
150
+ asr = load_stt(snapshot_download(job['asr']['repo'], revision=job['asr']['revision']))
151
+ except Exception as e: # offline before the first download, say: check by length only
152
+ asr_error = f'{type(e).__name__}: {e}'.splitlines()[0][:300]
153
+
154
+ def hear(audio, line):
155
+ """(what the recognizer heard, mismatch), or (None, None) for a line it can't check."""
156
+ chinese = line['lang'] == 'chinese'
157
+ latin = {'english': 'English', 'spanish': 'Spanish', 'french': 'French', 'german': 'German', 'italian': 'Italian',
158
+ 'portuguese': 'Portuguese'}.get(line['lang'], 'English' if line['lang'] == 'auto' else None)
159
+ if asr is None or not (chinese or (latin and not re.search(r'[㐀-鿿]', line['text']))):
160
+ return None, None
161
+ tmp = line['file'] + '.check.wav'
162
+ write(tmp, audio, model.sample_rate)
163
+ try:
164
+ heard = asr.generate(tmp, language='Chinese' if chinese else latin).text.strip()
165
+ finally:
166
+ os.remove(tmp)
167
+ # against the line as written too: `pronounce` respells words for the voice (y → ye), and the
168
+ # recognizer writes what it hears the usual way
169
+ ms = [mismatch(t, heard, chinese, pinyin) for t in dict.fromkeys([line['text'], line.get('written') or line['text']])]
170
+ return heard, min(ms, key=lambda m: max(m[0] / RETRY_AT, m[1] / RETRY_RUN))
171
+
48
172
  results, n = [], len(job['lines'])
49
173
  for i, line in enumerate(job['lines']):
50
174
  est = expected(line['text'])
@@ -55,15 +179,22 @@ def main():
55
179
  temperature=job['temperature'])
56
180
  audio = np.concatenate([np.array(r.audio, dtype=np.float32) for r in parts])
57
181
  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):
182
+ fits = plausible(seconds, est)
183
+ heard, m = hear(audio, line) if fits else (None, None)
184
+ # how far off the recognizer heard it: up to 1 is fine (0 when it couldn't check)
185
+ worse = max(m[0] / RETRY_AT, m[1] / RETRY_RUN) if m else 0
186
+ # a take of the right length that says the line beats one that doesn't; then the closest length
187
+ rank = (not fits, worse if worse > 1 else 0, abs(math.log(max(seconds, 0.1) / est)))
188
+ if best is None or rank < best['rank']:
189
+ best = {'rank': rank, 'audio': audio, 'seconds': seconds, 'fits': fits, 'heard': heard, 'm': m}
190
+ if fits and worse <= 1:
62
191
  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}))
192
+ write(line['file'], best['audio'], model.sample_rate)
193
+ m = best['m']
194
+ results.append({'seconds': best['seconds'], 'tries': k + 1, 'heard': best['heard'], 'mismatch': m and m[0],
195
+ 'suspect': not best['fits'] or bool(m and (m[0] > WARN_AT or m[1] > WARN_RUN))})
196
+ print(f'line {i + 1}/{n} {best["seconds"]:.1f}s' + (f' {k + 1} tries' if k else ''), file=sys.stderr, flush=True)
197
+ out.write(json.dumps({'results': results, 'asr': asr_error}))
67
198
 
68
199
 
69
200
  if __name__ == '__main__':