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/README.md +60 -26
- package/package.json +1 -2
- package/skills/storyshot/SKILL.md +93 -14
- package/src/audio.mjs +9 -4
- package/src/check.mjs +43 -3
- package/src/compose.mjs +33 -9
- package/src/config.mjs +15 -12
- package/src/doctor.mjs +2 -2
- package/src/hyperframes.mjs +155 -35
- package/src/init.mjs +11 -2
- package/src/inspect.mjs +63 -19
- package/src/qwen-tts.mjs +40 -15
- package/src/qwen_tts.py +144 -13
- package/src/record.mjs +208 -64
- package/src/voice.mjs +107 -11
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
|
-
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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 =
|
|
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-
|
|
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;
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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", "
|
|
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
|
|
9
|
-
# scientific number like 2e-105 does it) or stops early.
|
|
10
|
-
#
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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[
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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__':
|