@reelscript/cli 0.1.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/dist/tts.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Narration: text-to-speech engines and a cache of synthesized clips.
3
+ *
4
+ * The default engine is Kokoro-82M through the optional `kokoro-js`
5
+ * dependency: open weights, runs on CPU faster than real time, no account.
6
+ * Any object implementing TtsEngine can be passed instead (ElevenLabs,
7
+ * OpenAI, a cloud service).
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ export const DEFAULT_VOICE = "af_heart";
14
+ export const KOKORO_MODEL = "onnx-community/Kokoro-82M-v1.0-ONNX";
15
+ /** Root of reelscript's on-disk cache (models, synthesized clips). */
16
+ export function cacheDir() {
17
+ return process.env.REELSCRIPT_CACHE ?? join(homedir(), ".cache", "reelscript");
18
+ }
19
+ /** Split narration into sentences; TTS models prefer short inputs. */
20
+ export function splitSentences(text) {
21
+ return text
22
+ .replace(/\s+/g, " ")
23
+ .trim()
24
+ .split(/(?<=[.!?])\s+/)
25
+ .map((s) => s.trim())
26
+ .filter(Boolean);
27
+ }
28
+ /**
29
+ * Point transformers.js (kokoro-js's model loader) at reelscript's cache dir
30
+ * instead of its default inside node_modules, so the model survives
31
+ * reinstalls and can be cached in CI.
32
+ *
33
+ * Node keeps separate module instances for the ESM and CJS builds, each with
34
+ * its own `env`, so this must configure the exact file kokoro-js imports: the
35
+ * ESM build resolved from kokoro-js's own location (nested or hoisted).
36
+ */
37
+ async function redirectModelCache() {
38
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
39
+ const { dirname, join: pjoin } = await import("node:path");
40
+ let url = "@huggingface/transformers";
41
+ try {
42
+ let dir = dirname(fileURLToPath(import.meta.resolve("kokoro-js")));
43
+ for (;;) {
44
+ const candidate = pjoin(dir, "node_modules", "@huggingface", "transformers", "dist", "transformers.node.mjs");
45
+ if (existsSync(candidate)) {
46
+ url = pathToFileURL(candidate).href;
47
+ break;
48
+ }
49
+ const parent = dirname(dir);
50
+ if (parent === dir)
51
+ break;
52
+ dir = parent;
53
+ }
54
+ }
55
+ catch {
56
+ /* fall back to bare specifier */
57
+ }
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ const mod = await import(url);
60
+ if (!mod.env)
61
+ throw new Error("reelscript: could not configure the transformers.js model cache");
62
+ mod.env.cacheDir = join(cacheDir(), "models") + "/";
63
+ }
64
+ /** Kokoro-82M via kokoro-js, loaded lazily on first use. */
65
+ export function kokoro(model = KOKORO_MODEL) {
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ let loading = null;
68
+ const load = () => (loading ??= (async () => {
69
+ const spec = "kokoro-js";
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ let mod;
72
+ try {
73
+ mod = await import(spec);
74
+ }
75
+ catch {
76
+ throw new Error("reelscript: narration needs the optional dependency kokoro-js.\n" +
77
+ " npm install kokoro-js\n" +
78
+ "or pass your own engine via createDemo({ tts })");
79
+ }
80
+ await redirectModelCache();
81
+ return mod.KokoroTTS.from_pretrained(model, { dtype: "q8", device: "cpu" });
82
+ })());
83
+ return {
84
+ id: `kokoro:${model}:q8`,
85
+ async synthesize(text, { voice = DEFAULT_VOICE, speed = 1 } = {}) {
86
+ const tts = await load();
87
+ const parts = [];
88
+ let sampleRate = 24000;
89
+ for (const sentence of splitSentences(text)) {
90
+ const out = await tts.generate(sentence, { voice, speed });
91
+ parts.push(out.audio);
92
+ sampleRate = out.sampling_rate;
93
+ }
94
+ const total = parts.reduce((n, p) => n + p.length, 0);
95
+ const audio = new Float32Array(total);
96
+ let offset = 0;
97
+ for (const p of parts) {
98
+ audio.set(p, offset);
99
+ offset += p.length;
100
+ }
101
+ return { audio, sampleRate };
102
+ },
103
+ };
104
+ }
105
+ /** Encode mono float samples as a 16-bit PCM WAV file. */
106
+ export function toWav({ audio, sampleRate }) {
107
+ const n = audio.length;
108
+ const buf = Buffer.alloc(44 + n * 2);
109
+ buf.write("RIFF", 0);
110
+ buf.writeUInt32LE(36 + n * 2, 4);
111
+ buf.write("WAVE", 8);
112
+ buf.write("fmt ", 12);
113
+ buf.writeUInt32LE(16, 16); // chunk size
114
+ buf.writeUInt16LE(1, 20); // PCM
115
+ buf.writeUInt16LE(1, 22); // mono
116
+ buf.writeUInt32LE(sampleRate, 24);
117
+ buf.writeUInt32LE(sampleRate * 2, 28); // byte rate
118
+ buf.writeUInt16LE(2, 32); // block align
119
+ buf.writeUInt16LE(16, 34); // bits per sample
120
+ buf.write("data", 36);
121
+ buf.writeUInt32LE(n * 2, 40);
122
+ for (let i = 0; i < n; i++) {
123
+ const s = Math.max(-1, Math.min(1, audio[i]));
124
+ buf.writeInt16LE(Math.round(s < 0 ? s * 0x8000 : s * 0x7fff), 44 + i * 2);
125
+ }
126
+ return buf;
127
+ }
128
+ /** Synthesize (or fetch from cache) one narration clip. */
129
+ export async function synthesizeClip(engine, text, opts) {
130
+ const voice = opts.voice ?? DEFAULT_VOICE;
131
+ const speed = opts.speed ?? 1;
132
+ const key = createHash("sha1").update([engine.id, voice, String(speed), text].join("\0")).digest("hex");
133
+ const dir = join(cacheDir(), "tts");
134
+ mkdirSync(dir, { recursive: true });
135
+ const file = join(dir, `${key}.wav`);
136
+ const meta = `${file}.json`;
137
+ if (existsSync(file) && existsSync(meta)) {
138
+ return { file, seconds: JSON.parse(readFileSync(meta, "utf8")).seconds };
139
+ }
140
+ const audio = await engine.synthesize(text, { voice, speed });
141
+ const seconds = audio.audio.length / audio.sampleRate;
142
+ writeFileSync(file, toWav(audio));
143
+ writeFileSync(meta, JSON.stringify({ seconds, text, voice, speed, engine: engine.id }));
144
+ return { file, seconds };
145
+ }
146
+ /** Replace words the TTS mispronounces, e.g. { Reelscript: "Reel script" }. */
147
+ export function applyPronunciations(text, map) {
148
+ if (!map)
149
+ return text;
150
+ let out = text;
151
+ for (const [word, spoken] of Object.entries(map))
152
+ out = out.split(word).join(spoken);
153
+ return out;
154
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@reelscript/cli",
3
+ "version": "0.1.0",
4
+ "description": "Product demos as code. Write a script, render a demo, re-run it in CI when your UI changes.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Trevin Lee",
8
+ "keywords": [
9
+ "demo",
10
+ "screencast",
11
+ "screen-recording",
12
+ "video",
13
+ "automation",
14
+ "playwright",
15
+ "demo-as-code"
16
+ ],
17
+ "bin": {
18
+ "reelscript": "./dist/cli.js"
19
+ },
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "assets"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "dev": "tsc --watch",
35
+ "typecheck": "tsc --noEmit",
36
+ "example": "npm run build && tsx examples/basic.ts",
37
+ "preview": "tsx src/cli.ts preview examples/basic.ts",
38
+ "test": "node --import tsx --test test/*.test.ts",
39
+ "prepublishOnly": "npm run build && npm test",
40
+ "example:terminal": "npm run build && tsx examples/terminal.ts",
41
+ "example:desktop": "npm run build && tsx examples/desktop.ts"
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "dependencies": {
47
+ "@xterm/addon-fit": "^0.11.0",
48
+ "@xterm/xterm": "^6.0.0",
49
+ "ffmpeg-static": "^5.3.0",
50
+ "playwright": "^1.63.0",
51
+ "sharp": "^0.34.5",
52
+ "tsx": "^4.23.15"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^26.6.2",
56
+ "typescript": "^7.0.2"
57
+ },
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/trevin-lee/reelscript.git"
61
+ },
62
+ "homepage": "https://github.com/trevin-lee/reelscript#readme",
63
+ "bugs": {
64
+ "url": "https://github.com/trevin-lee/reelscript/issues"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "optionalDependencies": {
70
+ "kokoro-js": "^1.2.1"
71
+ }
72
+ }