decklight 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/LICENSE +21 -0
- package/README.md +257 -0
- package/SPEC.md +372 -0
- package/cli/bundle.mjs +388 -0
- package/cli/decklight.mjs +95 -0
- package/cli/edit.mjs +142 -0
- package/cli/init.mjs +215 -0
- package/cli/rec.mjs +538 -0
- package/dist/decklight.css +1068 -0
- package/dist/decklight.js +239 -0
- package/dist/decklight.js.map +7 -0
- package/docs/architecture.svg +94 -0
- package/docs/demo.svg +124 -0
- package/package.json +54 -0
- package/themes/README.md +61 -0
- package/themes/aliens.css +84 -0
- package/themes/apple2.css +126 -0
- package/themes/aurora.css +84 -0
- package/themes/berry.css +80 -0
- package/themes/blade-runner.css +83 -0
- package/themes/c64.css +85 -0
- package/themes/carbon.css +82 -0
- package/themes/citrus.css +80 -0
- package/themes/coastal.css +80 -0
- package/themes/cosmos.css +86 -0
- package/themes/dune.css +80 -0
- package/themes/eclipse.css +82 -0
- package/themes/ember.css +80 -0
- package/themes/fjord.css +80 -0
- package/themes/friends.css +82 -0
- package/themes/gallery.html +203 -0
- package/themes/gameboy.css +118 -0
- package/themes/genesis.css +84 -0
- package/themes/glacier.css +82 -0
- package/themes/godfather.css +82 -0
- package/themes/graphite.css +80 -0
- package/themes/harvest.css +80 -0
- package/themes/ibm-modern.css +83 -0
- package/themes/ibm-oldschool.css +84 -0
- package/themes/ink.css +82 -0
- package/themes/latte.css +80 -0
- package/themes/linen.css +80 -0
- package/themes/meadow.css +80 -0
- package/themes/metropolis.css +85 -0
- package/themes/miami-vice.css +84 -0
- package/themes/midnight.css +80 -0
- package/themes/mint.css +82 -0
- package/themes/moss.css +82 -0
- package/themes/obsidian.css +82 -0
- package/themes/orchid.css +82 -0
- package/themes/packs.json +89 -0
- package/themes/paper.css +83 -0
- package/themes/peony.css +84 -0
- package/themes/porcelain.css +82 -0
- package/themes/pulp-fiction.css +84 -0
- package/themes/reveal-beige.css +87 -0
- package/themes/reveal-black.css +83 -0
- package/themes/reveal-blood.css +84 -0
- package/themes/reveal-dracula.css +83 -0
- package/themes/reveal-league.css +88 -0
- package/themes/reveal-moon.css +83 -0
- package/themes/reveal-night.css +86 -0
- package/themes/reveal-serif.css +82 -0
- package/themes/reveal-simple.css +84 -0
- package/themes/reveal-sky.css +85 -0
- package/themes/reveal-solarized.css +83 -0
- package/themes/reveal-white.css +82 -0
- package/themes/sepia.css +81 -0
- package/themes/seriph.css +82 -0
- package/themes/severance.css +82 -0
- package/themes/slate.css +80 -0
- package/themes/snes.css +82 -0
- package/themes/star-wars.css +85 -0
- package/themes/storm.css +80 -0
- package/themes/stranger-things.css +84 -0
- package/themes/synthwave.css +90 -0
- package/themes/terminator.css +84 -0
- package/themes/velvet.css +80 -0
- package/tools/gemini-tts.mjs +140 -0
- package/tools/publish-site-voices.mjs +71 -0
- package/tools/voiceover-server.mjs +0 -0
- package/tools/voiceover.mjs +155 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sync the locally generated voice tracks (demo/voiceover-site/<dir>/) to the
|
|
3
|
+
// decklight.github.io repo under voices/<dir>/, in ONE commit via the Git
|
|
4
|
+
// Data API. Only files whose git blob sha differs from the remote are
|
|
5
|
+
// uploaded, so a regen that skipped unchanged slides uploads nothing for
|
|
6
|
+
// them either. Audio lives ONLY in the site repo; this repo keeps the
|
|
7
|
+
// staging dir gitignored.
|
|
8
|
+
//
|
|
9
|
+
// node tools/publish-site-voices.mjs [--repo decklight/decklight.github.io]
|
|
10
|
+
//
|
|
11
|
+
// Requires gh (authenticated) on PATH.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const opt = (flag, dflt) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : dflt; };
|
|
20
|
+
const REPO = opt('--repo', 'decklight/decklight.github.io');
|
|
21
|
+
const STAGING = 'demo/voiceover-site';
|
|
22
|
+
|
|
23
|
+
const gh = (argv, input) =>
|
|
24
|
+
JSON.parse(execFileSync('gh', ['api', ...argv], { encoding: 'utf8', input, maxBuffer: 1 << 28 }));
|
|
25
|
+
|
|
26
|
+
// git blob sha1: sha1("blob <len>\0" + content)
|
|
27
|
+
const blobSha = (buf) =>
|
|
28
|
+
createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex');
|
|
29
|
+
|
|
30
|
+
const { voices } = JSON.parse(readFileSync('site/voices.json', 'utf8'));
|
|
31
|
+
|
|
32
|
+
const head = gh([`repos/${REPO}/git/ref/heads/main`]).object.sha;
|
|
33
|
+
const baseTree = gh([`repos/${REPO}/git/commits/${head}`]).tree.sha;
|
|
34
|
+
const remote = new Map(
|
|
35
|
+
gh([`repos/${REPO}/git/trees/${baseTree}?recursive=1`]).tree
|
|
36
|
+
.filter((e) => e.type === 'blob')
|
|
37
|
+
.map((e) => [e.path, e.sha]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const changed = [];
|
|
41
|
+
for (const v of voices) {
|
|
42
|
+
const local = join(STAGING, v.dir.split('/').pop());
|
|
43
|
+
if (!existsSync(local)) { console.warn(`skip ${v.voice}: ${local} missing (not generated yet)`); continue; }
|
|
44
|
+
for (const f of readdirSync(local).sort()) {
|
|
45
|
+
if (!/\.(m4a|json)$/.test(f)) continue; // .txt is a regen artifact, stays local
|
|
46
|
+
const buf = readFileSync(join(local, f));
|
|
47
|
+
const path = `${v.dir}/${f}`;
|
|
48
|
+
if (remote.get(path) !== blobSha(buf)) changed.push({ path, buf });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!changed.length) {
|
|
53
|
+
console.log('site voices are up to date, nothing to upload');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(`uploading ${changed.length} changed file(s):`);
|
|
58
|
+
const tree = [];
|
|
59
|
+
for (const { path, buf } of changed) {
|
|
60
|
+
const { sha } = gh([`repos/${REPO}/git/blobs`, '-f', `content=${buf.toString('base64')}`, '-f', 'encoding=base64']);
|
|
61
|
+
tree.push({ path, mode: '100644', type: 'blob', sha });
|
|
62
|
+
console.log(` ${path}`);
|
|
63
|
+
}
|
|
64
|
+
const newTree = gh([`repos/${REPO}/git/trees`, '--input', '-'], JSON.stringify({ base_tree: baseTree, tree }));
|
|
65
|
+
const commit = gh([`repos/${REPO}/git/commits`, '--input', '-'], JSON.stringify({
|
|
66
|
+
message: `voices: sync ${changed.length} file(s) from demo/voiceover-site`,
|
|
67
|
+
tree: newTree.sha,
|
|
68
|
+
parents: [head],
|
|
69
|
+
}));
|
|
70
|
+
gh([`repos/${REPO}/git/refs/heads/main`, '-X', 'PATCH', '-f', `sha=${commit.sha}`]);
|
|
71
|
+
console.log(`done → ${REPO}@${commit.sha.slice(0, 7)}`);
|
|
Binary file
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Voice-over generator: per-slide narration audio from a deck's speaker notes.
|
|
3
|
+
//
|
|
4
|
+
// node tools/voiceover.mjs <deck.html> [-o <dir>] [--engine piper|gemini]
|
|
5
|
+
// [--voice <name>] [--data-dir <dir>]
|
|
6
|
+
// [--project <id>] [--location global]
|
|
7
|
+
// [--tts-model gemini-2.5-pro-tts]
|
|
8
|
+
// [--model qwen3:30b-a3b] [--no-llm] [--reuse-text]
|
|
9
|
+
//
|
|
10
|
+
// Engines (the built-in macOS voices were dropped: not good enough):
|
|
11
|
+
// piper — neural local TTS, fully offline. --voice takes a piper model
|
|
12
|
+
// name (default en_US-ryan-high, a natural US male). Install:
|
|
13
|
+
// uv tool install piper-tts
|
|
14
|
+
// python -m piper.download_voices en_US-ryan-high (in --data-dir)
|
|
15
|
+
// gemini — gemini-2.5-pro-tts on Vertex AI. Set the GCP project with
|
|
16
|
+
// --project or $GOOGLE_CLOUD_PROJECT. --voice takes a prebuilt voice name (default Alnilam;
|
|
17
|
+
// also Charon, Puck, Fenrir, Iapetus, Kore…). --style sets the
|
|
18
|
+
// delivery instruction prepended to every slide (default: warm,
|
|
19
|
+
// welcoming battle-hardened senior engineer).
|
|
20
|
+
// Auth: gcloud auth application-default login.
|
|
21
|
+
//
|
|
22
|
+
// Pipeline: extract each slide's notes (HTML asides or markdown Note: blocks,
|
|
23
|
+
// ⟨CLICK⟩ markers removed) → optionally rewrite into flowing narration with a
|
|
24
|
+
// LOCAL Ollama model (LLMs write text; they don't speak) → synthesize → AAC
|
|
25
|
+
// .m4a per slide + manifest.json. --reuse-text skips the LLM pass and
|
|
26
|
+
// re-voices the existing slide-NN.txt files, so switching voices or engines
|
|
27
|
+
// doesn't re-roll the narration. Audio is a build artifact, not source.
|
|
28
|
+
|
|
29
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { resolve, join, basename } from 'node:path';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { createSynth } from './gemini-tts.mjs';
|
|
35
|
+
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
const deckPath = args.find((a) => !a.startsWith('-'));
|
|
38
|
+
if (!deckPath) { console.error('usage: voiceover.mjs <deck.html> [-o dir] [--engine piper|gemini] [--voice name] [--no-llm] [--reuse-text]'); process.exit(1); }
|
|
39
|
+
const opt = (flag, dflt) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : dflt; };
|
|
40
|
+
const outDir = resolve(opt('-o', join(resolve(deckPath, '..'), 'voiceover')));
|
|
41
|
+
const engine = opt('--engine', 'piper');
|
|
42
|
+
const voice = opt('--voice', engine === 'gemini' ? 'Alnilam' : 'en_US-ryan-high');
|
|
43
|
+
const style = opt('--style',
|
|
44
|
+
'Read in a warm, welcoming tone, like a friendly battle-hardened senior ' +
|
|
45
|
+
'engineer who is still curious about new technology.');
|
|
46
|
+
const dataDir = resolve(opt('--data-dir', join(homedir(), '.local', 'share', 'piper')));
|
|
47
|
+
const project = opt('--project', process.env.GOOGLE_CLOUD_PROJECT);
|
|
48
|
+
const model = opt('--model', 'qwen3:30b-a3b');
|
|
49
|
+
const useLlm = !args.includes('--no-llm');
|
|
50
|
+
const reuseText = args.includes('--reuse-text');
|
|
51
|
+
if (engine === 'gemini' && !project) {
|
|
52
|
+
console.error('gemini engine needs a GCP project — pass --project <id> or set GOOGLE_CLOUD_PROJECT'); process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
const synthGemini = engine === 'gemini'
|
|
55
|
+
? createSynth({ project, ttsModel: opt('--tts-model'), location: opt('--location') })
|
|
56
|
+
: null;
|
|
57
|
+
|
|
58
|
+
if (engine === 'piper') {
|
|
59
|
+
try { execFileSync('piper', ['--help'], { stdio: 'ignore' }); }
|
|
60
|
+
catch { console.error('piper not found — install with: uv tool install piper-tts'); process.exit(1); }
|
|
61
|
+
} else if (engine !== 'gemini') {
|
|
62
|
+
console.error(`unknown engine '${engine}' — use piper or gemini`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── extract per-slide narration text ─────────────────────────────────────────
|
|
67
|
+
const html = readFileSync(deckPath, 'utf8');
|
|
68
|
+
const sections = html.split(/<section\b/).slice(1);
|
|
69
|
+
const clean = (s) => s
|
|
70
|
+
.replace(/⟨CLICK⟩/g, ' ')
|
|
71
|
+
.replace(/<[^>]+>/g, ' ')
|
|
72
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
73
|
+
.replace(/\s+/g, ' ')
|
|
74
|
+
.trim();
|
|
75
|
+
const slides = sections.map((sec, i) => {
|
|
76
|
+
const aside = sec.match(/<aside class="notes">([\s\S]*?)<\/aside>/);
|
|
77
|
+
if (aside) return clean(aside[1]);
|
|
78
|
+
const md = sec.match(/^Note:\s*$([\s\S]*?)(?=^Rehearse:\s*$|<\/script>)/m);
|
|
79
|
+
if (md) return clean(md[1]);
|
|
80
|
+
return '';
|
|
81
|
+
});
|
|
82
|
+
console.log(`${basename(deckPath)}: ${slides.length} slides, ${slides.filter(Boolean).length} with notes`);
|
|
83
|
+
|
|
84
|
+
// ── optional narration pass through a local model ────────────────────────────
|
|
85
|
+
function narrate(text, slideNo) {
|
|
86
|
+
if (!useLlm || !text) return text;
|
|
87
|
+
const prompt =
|
|
88
|
+
'Rewrite these presentation speaker notes as a single flowing voice-over ' +
|
|
89
|
+
'narration paragraph. Natural spoken English, roughly the same length, no ' +
|
|
90
|
+
'headings, no stage directions, no markdown, plain text only. Notes: ' +
|
|
91
|
+
text + ' /no_think';
|
|
92
|
+
try {
|
|
93
|
+
const out = execFileSync('ollama', ['run', model, prompt], { encoding: 'utf8', timeout: 180000 });
|
|
94
|
+
const cleaned = out.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
95
|
+
return cleaned || text;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.warn(` slide ${slideNo}: ollama failed (${String(e).slice(0, 60)}) — using raw notes`);
|
|
98
|
+
return text;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── synthesize ────────────────────────────────────────────────────────────────
|
|
103
|
+
// Incremental: the manifest stores a hash of (engine, voice, style, text) per
|
|
104
|
+
// slide, so a rerun only synthesizes slides whose narration actually changed.
|
|
105
|
+
mkdirSync(outDir, { recursive: true });
|
|
106
|
+
let totalCost = 0;
|
|
107
|
+
const slideHash = (text) =>
|
|
108
|
+
createHash('sha256').update(`${engine}|${voice}|${style}|${text}`).digest('hex').slice(0, 16);
|
|
109
|
+
let prev = null;
|
|
110
|
+
try {
|
|
111
|
+
const m = JSON.parse(readFileSync(join(outDir, 'manifest.json'), 'utf8'));
|
|
112
|
+
if (m && Array.isArray(m.slides)) prev = m;
|
|
113
|
+
} catch { /* no previous manifest, or the old array format: regenerate all */ }
|
|
114
|
+
let skipped = 0;
|
|
115
|
+
const manifest = [];
|
|
116
|
+
for (let i = 0; i < slides.length; i++) {
|
|
117
|
+
const n = String(i + 1).padStart(2, '0');
|
|
118
|
+
if (!slides[i]) { manifest.push(null); continue; }
|
|
119
|
+
const txt = join(outDir, `slide-${n}.txt`);
|
|
120
|
+
// --reuse-text falls back to the deck's default voiceover/ scripts so a
|
|
121
|
+
// second take (other engine/voice) narrates the SAME text, not a re-roll
|
|
122
|
+
const prior = [txt, join(resolve(deckPath, '..'), 'voiceover', `slide-${n}.txt`)]
|
|
123
|
+
.find((f) => reuseText && existsSync(f));
|
|
124
|
+
const text = prior ? readFileSync(prior, 'utf8').trim() : narrate(slides[i], i + 1);
|
|
125
|
+
const wav = join(outDir, `slide-${n}.wav`);
|
|
126
|
+
const m4a = join(outDir, `slide-${n}.m4a`);
|
|
127
|
+
writeFileSync(txt, text);
|
|
128
|
+
const hash = slideHash(text);
|
|
129
|
+
manifest.push({ file: `slide-${n}.m4a`, hash });
|
|
130
|
+
if (prev?.slides?.[i]?.hash === hash && existsSync(m4a)) {
|
|
131
|
+
skipped++;
|
|
132
|
+
console.log(` slide ${n}: unchanged — kept`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
let costNote = '';
|
|
136
|
+
if (engine === 'gemini') {
|
|
137
|
+
const { wav: buf, usage } = await synthGemini(text, { voice, style });
|
|
138
|
+
writeFileSync(wav, buf);
|
|
139
|
+
totalCost += usage.cost;
|
|
140
|
+
costNote = ` · ~$${usage.cost.toFixed(4)}`;
|
|
141
|
+
} else {
|
|
142
|
+
execFileSync('piper', ['-m', voice, '--data-dir', dataDir, '-f', wav], { input: text });
|
|
143
|
+
}
|
|
144
|
+
execFileSync('afconvert', ['-f', 'm4af', '-d', 'aac', wav, m4a]);
|
|
145
|
+
rmSync(wav);
|
|
146
|
+
console.log(` slide ${n}: ${text.length} chars → ${basename(m4a)}${costNote}`);
|
|
147
|
+
// crash-safe: persist progress after every slide so an interrupted run
|
|
148
|
+
// resumes incrementally instead of re-synthesizing everything
|
|
149
|
+
writeFileSync(join(outDir, 'manifest.json'),
|
|
150
|
+
JSON.stringify({ engine, voice, style, slides: manifest }, null, 1));
|
|
151
|
+
}
|
|
152
|
+
writeFileSync(join(outDir, 'manifest.json'),
|
|
153
|
+
JSON.stringify({ engine, voice, style, slides: manifest }, null, 1));
|
|
154
|
+
console.log(`done → ${outDir}${skipped ? ` (${skipped} unchanged, skipped)` : ''}`
|
|
155
|
+
+ (totalCost ? ` · estimated cost ~$${totalCost.toFixed(4)}` : ''));
|