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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/SPEC.md +372 -0
  4. package/cli/bundle.mjs +388 -0
  5. package/cli/decklight.mjs +95 -0
  6. package/cli/edit.mjs +142 -0
  7. package/cli/init.mjs +215 -0
  8. package/cli/rec.mjs +538 -0
  9. package/dist/decklight.css +1068 -0
  10. package/dist/decklight.js +239 -0
  11. package/dist/decklight.js.map +7 -0
  12. package/docs/architecture.svg +94 -0
  13. package/docs/demo.svg +124 -0
  14. package/package.json +54 -0
  15. package/themes/README.md +61 -0
  16. package/themes/aliens.css +84 -0
  17. package/themes/apple2.css +126 -0
  18. package/themes/aurora.css +84 -0
  19. package/themes/berry.css +80 -0
  20. package/themes/blade-runner.css +83 -0
  21. package/themes/c64.css +85 -0
  22. package/themes/carbon.css +82 -0
  23. package/themes/citrus.css +80 -0
  24. package/themes/coastal.css +80 -0
  25. package/themes/cosmos.css +86 -0
  26. package/themes/dune.css +80 -0
  27. package/themes/eclipse.css +82 -0
  28. package/themes/ember.css +80 -0
  29. package/themes/fjord.css +80 -0
  30. package/themes/friends.css +82 -0
  31. package/themes/gallery.html +203 -0
  32. package/themes/gameboy.css +118 -0
  33. package/themes/genesis.css +84 -0
  34. package/themes/glacier.css +82 -0
  35. package/themes/godfather.css +82 -0
  36. package/themes/graphite.css +80 -0
  37. package/themes/harvest.css +80 -0
  38. package/themes/ibm-modern.css +83 -0
  39. package/themes/ibm-oldschool.css +84 -0
  40. package/themes/ink.css +82 -0
  41. package/themes/latte.css +80 -0
  42. package/themes/linen.css +80 -0
  43. package/themes/meadow.css +80 -0
  44. package/themes/metropolis.css +85 -0
  45. package/themes/miami-vice.css +84 -0
  46. package/themes/midnight.css +80 -0
  47. package/themes/mint.css +82 -0
  48. package/themes/moss.css +82 -0
  49. package/themes/obsidian.css +82 -0
  50. package/themes/orchid.css +82 -0
  51. package/themes/packs.json +89 -0
  52. package/themes/paper.css +83 -0
  53. package/themes/peony.css +84 -0
  54. package/themes/porcelain.css +82 -0
  55. package/themes/pulp-fiction.css +84 -0
  56. package/themes/reveal-beige.css +87 -0
  57. package/themes/reveal-black.css +83 -0
  58. package/themes/reveal-blood.css +84 -0
  59. package/themes/reveal-dracula.css +83 -0
  60. package/themes/reveal-league.css +88 -0
  61. package/themes/reveal-moon.css +83 -0
  62. package/themes/reveal-night.css +86 -0
  63. package/themes/reveal-serif.css +82 -0
  64. package/themes/reveal-simple.css +84 -0
  65. package/themes/reveal-sky.css +85 -0
  66. package/themes/reveal-solarized.css +83 -0
  67. package/themes/reveal-white.css +82 -0
  68. package/themes/sepia.css +81 -0
  69. package/themes/seriph.css +82 -0
  70. package/themes/severance.css +82 -0
  71. package/themes/slate.css +80 -0
  72. package/themes/snes.css +82 -0
  73. package/themes/star-wars.css +85 -0
  74. package/themes/storm.css +80 -0
  75. package/themes/stranger-things.css +84 -0
  76. package/themes/synthwave.css +90 -0
  77. package/themes/terminator.css +84 -0
  78. package/themes/velvet.css +80 -0
  79. package/tools/gemini-tts.mjs +140 -0
  80. package/tools/publish-site-voices.mjs +71 -0
  81. package/tools/voiceover-server.mjs +0 -0
  82. 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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)}` : ''));