@tandryukha/aidemo 0.8.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/src/music.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { ensureDir } from "./util.js";
4
+
5
+ /**
6
+ * Synthesizes a background-music bed from scratch — pure math, no samples — so
7
+ * the track is **license-free by construction** (the repo ships no bundled bed,
8
+ * and "grab something off YouTube" isn't safe for a submission video). Soft
9
+ * ambient-corporate: warm detuned pad, gentle pluck arpeggio, round bass pulse.
10
+ * C–G–Am–F at 92 BPM, ~106 s, 44.1 kHz stereo WAV.
11
+ *
12
+ * Feed the result to a storyboard's `music.track`; it ducks under narration
13
+ * (sidechain) and swells in the gaps automatically.
14
+ */
15
+
16
+ const SR = 44100;
17
+ const BPM = 92;
18
+ const BEAT = 60 / BPM;
19
+ const BAR = BEAT * 4;
20
+ const CYCLES = 10; // 4-bar progression x10 = ~104 s
21
+ const BARS = CYCLES * 4;
22
+ const TAIL = 2.0;
23
+
24
+ const midiHz = (m: number) => 440 * 2 ** ((m - 69) / 12);
25
+ // C – G – Am – F, close voicings around C3–C4; bass roots an octave-ish down.
26
+ const PROG = [
27
+ { pad: [48, 52, 55, 60], bass: 36 }, // C
28
+ { pad: [47, 50, 55, 59], bass: 43 }, // G
29
+ { pad: [45, 52, 57, 60], bass: 45 }, // Am
30
+ { pad: [45, 53, 57, 60], bass: 41 }, // F
31
+ ];
32
+
33
+ // Deterministic per-oscillator phase so voices don't all start phase-aligned.
34
+ const phase0 = (a: number, b: number, c: number) =>
35
+ ((a * 12.9898 + b * 78.233 + c * 37.719) % 1) * 2 * Math.PI;
36
+ const smooth = (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x));
37
+
38
+ interface Tone {
39
+ ch: Float64Array;
40
+ f: number;
41
+ t0: number;
42
+ dur: number;
43
+ amp: number;
44
+ attack: number;
45
+ release: number;
46
+ partials: number[];
47
+ tau?: number;
48
+ }
49
+
50
+ /** Render a single enveloped additive tone into a channel buffer. */
51
+ function addTone(N: number, { ch, f, t0, dur, amp, attack, release, partials, tau }: Tone): void {
52
+ const s0 = Math.max(0, Math.round(t0 * SR));
53
+ const s1 = Math.min(N, Math.round((t0 + dur + release) * SR));
54
+ const ph = partials.map((_, k) => phase0(f, k, t0));
55
+ for (let s = s0; s < s1; s++) {
56
+ const t = s / SR - t0;
57
+ let env = smooth(t / attack);
58
+ if (t > dur) env *= 1 - smooth((t - dur) / release);
59
+ if (tau) env *= Math.exp(-t / tau);
60
+ if (env <= 0) continue;
61
+ let v = 0;
62
+ for (let k = 0; k < partials.length; k++) {
63
+ v += partials[k] * Math.sin(2 * Math.PI * f * (k + 1) * (s / SR) + ph[k]);
64
+ }
65
+ ch[s] += v * amp * env;
66
+ }
67
+ }
68
+
69
+ const PAD_PARTIALS = [1, 0.38, 0.14, 0.05];
70
+
71
+ /** Build the stereo PCM and write a 16-bit WAV to `outPath`. */
72
+ export async function synthesizeMusicBed(
73
+ outPath: string
74
+ ): Promise<{ durationSec: number }> {
75
+ const DUR = BARS * BAR + TAIL;
76
+ const N = Math.round(DUR * SR);
77
+ const L = new Float64Array(N);
78
+ const R = new Float64Array(N);
79
+
80
+ for (let b = 0; b < BARS; b++) {
81
+ const t0 = b * BAR;
82
+ const { pad, bass } = PROG[b % 4];
83
+
84
+ // Pad: two detuned voices per note, split wide L/R for chorus width.
85
+ for (const m of pad) {
86
+ const f = midiHz(m);
87
+ addTone(N, { ch: L, f: f * 0.9982, t0, dur: BAR, amp: 0.055, attack: 0.9, release: 1.6, partials: PAD_PARTIALS });
88
+ addTone(N, { ch: R, f: f * 1.0018, t0, dur: BAR, amp: 0.055, attack: 0.9, release: 1.6, partials: PAD_PARTIALS });
89
+ addTone(N, { ch: L, f: f * 1.0009, t0, dur: BAR, amp: 0.030, attack: 1.1, release: 1.6, partials: PAD_PARTIALS });
90
+ addTone(N, { ch: R, f: f * 0.9991, t0, dur: BAR, amp: 0.030, attack: 1.1, release: 1.6, partials: PAD_PARTIALS });
91
+ }
92
+
93
+ // Bass: round sine pulse on beats 1 and 3 (softer echo on 3).
94
+ const fb = midiHz(bass);
95
+ addTone(N, { ch: L, f: fb, t0, dur: BEAT * 1.7, amp: 0.16, attack: 0.05, release: 0.35, partials: [1, 0.25] });
96
+ addTone(N, { ch: R, f: fb, t0, dur: BEAT * 1.7, amp: 0.16, attack: 0.05, release: 0.35, partials: [1, 0.25] });
97
+ addTone(N, { ch: L, f: fb, t0: t0 + 2 * BEAT, dur: BEAT * 1.5, amp: 0.11, attack: 0.05, release: 0.35, partials: [1, 0.25] });
98
+ addTone(N, { ch: R, f: fb, t0: t0 + 2 * BEAT, dur: BEAT * 1.5, amp: 0.11, attack: 0.05, release: 0.35, partials: [1, 0.25] });
99
+
100
+ // Pluck arpeggio: eighth notes over chord tones one octave up, ping-pong pan.
101
+ const order = [0, 2, 1, 3, 2, 0, 3, 1];
102
+ for (let i = 0; i < 8; i++) {
103
+ if (i === 5) continue; // leave one eighth of air per bar
104
+ const m = pad[order[i]] + 12;
105
+ const f = midiHz(m);
106
+ const tN = t0 + i * (BEAT / 2);
107
+ const even = i % 2 === 0;
108
+ addTone(N, { ch: even ? L : R, f, t0: tN, dur: 0.9, amp: 0.055, attack: 0.004, release: 0.1, partials: [1, 0.3], tau: 0.17 });
109
+ addTone(N, { ch: even ? R : L, f, t0: tN, dur: 0.9, amp: 0.028, attack: 0.004, release: 0.1, partials: [1, 0.3], tau: 0.17 });
110
+ }
111
+ }
112
+
113
+ // Slow movement LFO on the whole bed + master fades.
114
+ for (let s = 0; s < N; s++) {
115
+ const t = s / SR;
116
+ const lfo = 1 + 0.07 * Math.sin(2 * Math.PI * 0.045 * t + 1.3);
117
+ let g = lfo * smooth(t / 1.5); // 1.5 s fade-in
118
+ const left = DUR - t;
119
+ if (left < 4) g *= smooth(left / 4); // 4 s fade-out
120
+ L[s] *= g;
121
+ R[s] *= g;
122
+ }
123
+
124
+ // Normalize to a comfortable bed level.
125
+ let peak = 0;
126
+ for (let s = 0; s < N; s++) peak = Math.max(peak, Math.abs(L[s]), Math.abs(R[s]));
127
+ const norm = 0.72 / (peak || 1);
128
+
129
+ const pcm = Buffer.alloc(N * 4);
130
+ for (let s = 0; s < N; s++) {
131
+ pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, L[s] * norm)) * 32767), s * 4);
132
+ pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, R[s] * norm)) * 32767), s * 4 + 2);
133
+ }
134
+
135
+ const hdr = Buffer.alloc(44);
136
+ hdr.write("RIFF", 0);
137
+ hdr.writeUInt32LE(36 + pcm.length, 4);
138
+ hdr.write("WAVE", 8);
139
+ hdr.write("fmt ", 12);
140
+ hdr.writeUInt32LE(16, 16);
141
+ hdr.writeUInt16LE(1, 20); // PCM
142
+ hdr.writeUInt16LE(2, 22); // stereo
143
+ hdr.writeUInt32LE(SR, 24);
144
+ hdr.writeUInt32LE(SR * 4, 28);
145
+ hdr.writeUInt16LE(4, 32);
146
+ hdr.writeUInt16LE(16, 34);
147
+ hdr.write("data", 36);
148
+ hdr.writeUInt32LE(pcm.length, 40);
149
+
150
+ await ensureDir(dirname(outPath));
151
+ await fs.writeFile(outPath, Buffer.concat([hdr, pcm]));
152
+ return { durationSec: DUR };
153
+ }
package/src/params.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { Storyboard } from "./types.js";
2
+
3
+ /**
4
+ * Placeholder substitution for parameterized storyboards.
5
+ *
6
+ * A storyboard may declare a top-level `params` map (name → default value). Any
7
+ * storyboard string may then reference a declared param as `{{name}}`. At LOAD
8
+ * time the engine resolves each param to a caller-provided override (CLI
9
+ * `--param`, MCP `params`, or a variant's params) else its declared default,
10
+ * and substitutes every `{{name}}` occurrence. The SAME resolved set is used
11
+ * across all pipeline stages of one run, so a param that changes narration
12
+ * re-voices + re-captions, and one that changes a url / typed text changes the
13
+ * recorded take.
14
+ *
15
+ * Substitution runs AFTER zod validation on the already-typed storyboard, so it
16
+ * only ever touches fields that are legitimately strings — a placeholder mistyped
17
+ * into a numeric field (e.g. `"scale": "{{s}}"`) fails schema validation first
18
+ * with a clear "expected number" error. Placeholders work in EVERY string leaf
19
+ * except the `params` block itself: narration, action `url`, `type` text, card
20
+ * title/subtitle, waitFor* `textMatches`, voice `instructions`, and even
21
+ * selectors if you want dynamic targeting.
22
+ *
23
+ * Two errors, both raised at load time before any stage runs:
24
+ * - a `{{name}}` that isn't declared → hard error listing the missing names
25
+ * - an explicit override whose key isn't declared → hard error (typo guard).
26
+ * Non-explicit (auto/persisted) params are filtered instead of erroring, so
27
+ * a stale generated/params.json can never break a param-less load.
28
+ */
29
+
30
+ /** {{ name }} — word chars, dot, dash; surrounding whitespace tolerated. */
31
+ const PLACEHOLDER_RE = /\{\{\s*([\w.-]+)\s*\}\}/g;
32
+
33
+ export type ParamResult =
34
+ | { ok: true; storyboard: Storyboard; resolved: Record<string, string> }
35
+ | { ok: false; message: string };
36
+
37
+ const has = (obj: object, key: string): boolean =>
38
+ Object.prototype.hasOwnProperty.call(obj, key);
39
+
40
+ /** Substitute {{name}} in every string leaf; record any undeclared reference. */
41
+ function substitute(
42
+ value: unknown,
43
+ resolved: Record<string, string>,
44
+ missing: Set<string>
45
+ ): unknown {
46
+ if (typeof value === "string") {
47
+ return value.replace(PLACEHOLDER_RE, (m, name: string) => {
48
+ if (has(resolved, name)) return resolved[name];
49
+ missing.add(name);
50
+ return m;
51
+ });
52
+ }
53
+ if (Array.isArray(value)) {
54
+ return value.map((v) => substitute(v, resolved, missing));
55
+ }
56
+ if (value && typeof value === "object") {
57
+ const out: Record<string, unknown> = {};
58
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
59
+ out[k] = substitute(v, resolved, missing);
60
+ }
61
+ return out;
62
+ }
63
+ return value;
64
+ }
65
+
66
+ export interface ApplyParamsOptions {
67
+ /** Caller-provided overrides (CLI --param / MCP params / variant params). */
68
+ provided?: Record<string, string>;
69
+ /**
70
+ * true = explicit user input: an override key that isn't declared is a hard
71
+ * error (typo guard). false = auto/persisted params: undeclared keys are
72
+ * silently dropped so a stale params.json can't break a load.
73
+ */
74
+ strict?: boolean;
75
+ }
76
+
77
+ /**
78
+ * Resolve params against a storyboard's declared defaults and substitute all
79
+ * placeholders. `storyboard` must already be zod-validated.
80
+ */
81
+ export function applyParams(
82
+ storyboard: Storyboard,
83
+ opts: ApplyParamsOptions = {}
84
+ ): ParamResult {
85
+ const declared = storyboard.params ?? {};
86
+ const provided = opts.provided ?? {};
87
+
88
+ const undeclared = Object.keys(provided).filter((k) => !has(declared, k));
89
+ let effective = provided;
90
+ if (undeclared.length) {
91
+ if (opts.strict) {
92
+ const names = Object.keys(declared);
93
+ return {
94
+ ok: false,
95
+ message:
96
+ `unknown param ${undeclared.map((n) => `"${n}"`).join(", ")}. ` +
97
+ `Declared params: ${
98
+ names.length
99
+ ? names.join(", ")
100
+ : '(none — add a top-level "params" block to the storyboard)'
101
+ }.`,
102
+ };
103
+ }
104
+ // Lenient (auto/persisted): keep only declared keys.
105
+ effective = Object.fromEntries(
106
+ Object.entries(provided).filter(([k]) => has(declared, k))
107
+ );
108
+ }
109
+
110
+ const resolved: Record<string, string> = { ...declared, ...effective };
111
+
112
+ // Substitute over the whole storyboard EXCEPT the declared params block.
113
+ const { params: _declaredBlock, ...body } = storyboard as Record<string, unknown>;
114
+ const missing = new Set<string>();
115
+ const substituted = substitute(body, resolved, missing) as Record<string, unknown>;
116
+ if (missing.size) {
117
+ return {
118
+ ok: false,
119
+ message:
120
+ `undeclared placeholder(s): ${[...missing]
121
+ .map((n) => `{{${n}}}`)
122
+ .join(", ")}. Add ${
123
+ missing.size > 1 ? "them" : "it"
124
+ } to the storyboard's "params" block (with a default) or pass via --param.`,
125
+ };
126
+ }
127
+
128
+ // Re-attach the declared params block unchanged so downstream stages and a
129
+ // persisted copy still see the declaration.
130
+ const result = storyboard.params
131
+ ? { ...substituted, params: storyboard.params }
132
+ : substituted;
133
+ return { ok: true, storyboard: result as unknown as Storyboard, resolved };
134
+ }