@tensordoc/prism 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.
@@ -0,0 +1,70 @@
1
+ // registry.ts — share-token lookup for catalog content.
2
+ //
3
+ // Every catalog entry on prism.scott.ai has a 6-char base62 token (the
4
+ // short_id). Consumers pass one to `player.load(...)` or via the URL
5
+ // param `?g=<short_id>` and the player resolves it to a PrismGraph
6
+ // offline — no network call. The map is generated from the catalog at
7
+ // build-index time; see scripts/prism/commands/build-index.ts.
8
+
9
+ import registryData from "./registry.generated.json";
10
+ import { SCHEMA_VERSION, type NodeDef, type NodeType, type PrismGraph } from "./types";
11
+
12
+ export interface RegistryEntry {
13
+ name: string;
14
+ source_type: "milkdrop" | "shadertoy" | "isf" | "wgsl";
15
+ source_loader: "url" | "npm-butterchurn-presets";
16
+ source_url?: string;
17
+ source_ref?: string;
18
+ default_image?: string;
19
+ }
20
+
21
+ const registry = registryData as Record<string, RegistryEntry>;
22
+
23
+ /** Resolve a 6-char short_id to a registry entry. Returns null when
24
+ * the id isn't known — callers decide whether to fall back, error,
25
+ * or just keep the synthetic cold-open running. */
26
+ export function lookup(shortId: string): RegistryEntry | null {
27
+ return registry[shortId] ?? null;
28
+ }
29
+
30
+ /** All known short_ids. Mostly useful for tests + the rotation pool. */
31
+ export function shortIds(): string[] {
32
+ return Object.keys(registry);
33
+ }
34
+
35
+ /** Build a minimal PrismGraph that plays the entry behind `shortId`.
36
+ * Returns null when the id isn't in the registry. */
37
+ export function shortIdToGraph(shortId: string): PrismGraph | null {
38
+ const entry = lookup(shortId);
39
+ if (!entry) return null;
40
+ return entryToGraph(shortId, entry);
41
+ }
42
+
43
+ function entryToGraph(shortId: string, entry: RegistryEntry): PrismGraph {
44
+ const mainParams: Record<string, string> = {};
45
+ let mainType: NodeType;
46
+ if (entry.source_type === "shadertoy") {
47
+ mainType = "lf.shadertoy";
48
+ if (entry.source_url) mainParams.shader_url = entry.source_url;
49
+ if (entry.default_image) mainParams.image_url = entry.default_image;
50
+ } else {
51
+ mainType = "lf.milkdrop";
52
+ if (entry.source_loader === "url" && entry.source_url) {
53
+ mainParams.preset_url = entry.source_url;
54
+ } else if (entry.source_ref) {
55
+ mainParams.preset_name = entry.source_ref;
56
+ }
57
+ }
58
+ const nodes: Record<string, NodeDef> = {
59
+ audio: { type: "signal.audio" },
60
+ main: { type: mainType, params: mainParams, inputs: { audio: "audio.signal" } },
61
+ screen: { type: "sink.display", inputs: { frame: "main.frame" } },
62
+ };
63
+ return {
64
+ schema: SCHEMA_VERSION,
65
+ id: `g:${shortId}`,
66
+ intent: entry.name,
67
+ nodes,
68
+ output: "screen",
69
+ };
70
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,100 @@
1
+ // runtime.ts — graph executor for prism.graph/0.1.
2
+ //
3
+ // Walks a PrismGraph, finds the light-field generator node, dispatches
4
+ // to the appropriate backend (milkdrop / shadertoy / ...). Future M9
5
+ // will add op.* nodes (blend / displace) and a real compositor; today
6
+ // it's single-backend-at-a-time and swaps as needed.
7
+ //
8
+ // The runtime owns visibility of the two background canvases — only the
9
+ // active backend's canvas is shown, the other is hidden. URL-loaded
10
+ // presets (favorites + shadertoys) load asynchronously; runtime.apply
11
+ // returns synchronously after kicking off the load and updating state.
12
+
13
+ import type { MilkdropBg } from "./backends/milkdrop";
14
+ import type { ShadertoyBg } from "./backends/shadertoy";
15
+ import { nodesByRole, type PrismGraph } from "./types";
16
+
17
+ export interface RuntimeContext {
18
+ milkdrop: MilkdropBg;
19
+ shadertoy: ShadertoyBg;
20
+ /** Called to toggle which backend's canvas is visible.
21
+ * Implementations set CSS opacity/display on the two canvases. */
22
+ setActiveBackend: (which: "milkdrop" | "shadertoy") => void;
23
+ }
24
+
25
+ export interface ApplyResult {
26
+ ok: boolean;
27
+ error?: string;
28
+ /** Display name of the preset that was loaded, if any. */
29
+ presetName?: string;
30
+ /** Which backend handled this graph. */
31
+ backend?: "milkdrop" | "shadertoy";
32
+ }
33
+
34
+ export class GraphRuntime {
35
+ private active: PrismGraph | null = null;
36
+
37
+ constructor(private readonly ctx: RuntimeContext) {}
38
+
39
+ apply(graph: PrismGraph, blendSecondsOverride?: number): ApplyResult {
40
+ const lfNodes = nodesByRole(graph, "lf");
41
+ if (lfNodes.length === 0) {
42
+ return { ok: false, error: "graph has no light-field generator" };
43
+ }
44
+ const [, node] = lfNodes[0];
45
+
46
+ if (node.type === "lf.milkdrop") {
47
+ const presetName = node.params?.preset_name;
48
+ const presetUrl = node.params?.preset_url;
49
+ const blendSeconds =
50
+ blendSecondsOverride ??
51
+ (typeof node.params?.blend_seconds === "number" ? node.params.blend_seconds : 2.5);
52
+ this.ctx.setActiveBackend("milkdrop");
53
+ // URL takes precedence (favorites). Fall back to name (npm bundle / legacy).
54
+ if (typeof presetUrl === "string") {
55
+ void this.ctx.milkdrop.loadFromUrl(presetUrl, blendSeconds).catch((err: Error) => {
56
+ console.warn("[runtime] milkdrop loadFromUrl failed:", err.message);
57
+ });
58
+ this.active = graph;
59
+ const name = presetUrl.split("/").pop()?.replace(/\.milk$/, "") ?? presetUrl;
60
+ return { ok: true, presetName: name, backend: "milkdrop" };
61
+ }
62
+ if (typeof presetName !== "string") {
63
+ return { ok: false, error: "lf.milkdrop missing preset_name or preset_url" };
64
+ }
65
+ const loaded = this.ctx.milkdrop.loadByName(presetName, blendSeconds);
66
+ if (loaded === null) {
67
+ return { ok: false, error: `preset not found: ${presetName}` };
68
+ }
69
+ this.active = graph;
70
+ return { ok: true, presetName: loaded, backend: "milkdrop" };
71
+ }
72
+
73
+ if (node.type === "lf.shadertoy") {
74
+ const url = node.params?.shader_url;
75
+ if (typeof url !== "string") {
76
+ return { ok: false, error: "lf.shadertoy missing shader_url" };
77
+ }
78
+ const imageUrl = node.params?.image_url;
79
+ this.ctx.setActiveBackend("shadertoy");
80
+ void this.ctx.shadertoy.loadFromUrl(url).catch((err: Error) => {
81
+ console.warn("[runtime] shadertoy loadFromUrl failed:", err.message);
82
+ });
83
+ // Bind the entry's default image to iChannel1 if specified. The
84
+ // shader has a 1x1 grey placeholder until this resolves.
85
+ void this.ctx.shadertoy.bindImage(typeof imageUrl === "string" ? imageUrl : null)
86
+ .catch((err: Error) => {
87
+ console.warn("[runtime] shadertoy bindImage failed:", err.message);
88
+ });
89
+ this.active = graph;
90
+ const name = url.split("/").pop()?.replace(/\.glsl$/, "") ?? url;
91
+ return { ok: true, presetName: name, backend: "shadertoy" };
92
+ }
93
+
94
+ return { ok: false, error: `unsupported lf type: ${node.type}` };
95
+ }
96
+
97
+ get current(): PrismGraph | null {
98
+ return this.active;
99
+ }
100
+ }
package/src/synth.ts ADDED
@@ -0,0 +1,251 @@
1
+ // synthetic-signal.ts — silent "fractal" audio driver.
2
+ // Generates a pink-noise-like spectrum (octave-spaced oscillators, amplitude
3
+ // ∝ 1/√f) plus periodic beat envelopes and slow frequency mutations. The
4
+ // signal is read by butterchurn's analyser to keep the Milkdrop preset
5
+ // animated while no real audio is shared. Output never reaches ctx.destination,
6
+ // so nothing is audible.
7
+
8
+ type Band = "bass" | "mid" | "treble";
9
+ type Voice = {
10
+ base: number;
11
+ baseGain: number;
12
+ band: Band;
13
+ osc: OscillatorNode;
14
+ lfo: OscillatorNode;
15
+ lfoGain: GainNode;
16
+ gain: GainNode;
17
+ };
18
+
19
+ export class SyntheticSignal {
20
+ private readonly ctx: AudioContext;
21
+ private readonly output: GainNode;
22
+ private readonly voices: Voice[] = [];
23
+ private readonly analyser: AnalyserNode;
24
+ private readonly fft: Uint8Array;
25
+ private beatTimer: number | null = null;
26
+ private mutationTimer: number | null = null;
27
+ private stopped = false;
28
+ private lastCursorBeatAt = 0;
29
+
30
+ constructor(ctx: AudioContext) {
31
+ this.ctx = ctx;
32
+ this.output = ctx.createGain();
33
+ this.output.gain.value = 0.65;
34
+
35
+ // Octave-spaced voices producing a 1/√f spectrum — convincingly "musical"
36
+ // to the analyser without being recognisable as anything in particular.
37
+ // freqHz, type, baseGain, lfoHz, lfoDepth
38
+ const seeds: Array<[number, OscillatorType, number, number, number]> = [
39
+ [ 50, "sine", 0.55, 0.06, 18 ],
40
+ [ 110, "sine", 0.40, 0.09, 24 ],
41
+ [ 230, "triangle", 0.28, 0.12, 60 ],
42
+ [ 460, "sawtooth", 0.18, 0.17, 140 ],
43
+ [ 950, "triangle", 0.12, 0.21, 280 ],
44
+ [ 1900, "sine", 0.08, 0.27, 520 ],
45
+ [ 3900, "triangle", 0.05, 0.33, 1100 ],
46
+ ];
47
+ for (const [f, type, g, lfoHz, lfoD] of seeds) this.addVoice(f, type, g, lfoHz, lfoD);
48
+
49
+ // Internal analyser so callers can read our energy without colliding with
50
+ // butterchurn's own analyser.
51
+ this.analyser = ctx.createAnalyser();
52
+ this.analyser.fftSize = 256;
53
+ this.analyser.smoothingTimeConstant = 0.78;
54
+ this.output.connect(this.analyser);
55
+ this.fft = new Uint8Array(this.analyser.frequencyBinCount);
56
+
57
+ this.scheduleBeats();
58
+ this.scheduleMutations();
59
+ }
60
+
61
+ /** The node to feed into a consumer (e.g. butterchurn.connectAudio). */
62
+ getOutput(): AudioNode {
63
+ return this.output;
64
+ }
65
+
66
+ /** Returns a smoothed 0..1 energy reading from our own analyser. */
67
+ readEnergy(): number {
68
+ if (this.stopped) return 0;
69
+ this.analyser.getByteFrequencyData(this.fft as unknown as Uint8Array<ArrayBuffer>);
70
+ let s = 0;
71
+ for (let i = 0; i < this.fft.length; i++) s += this.fft[i];
72
+ return s / 255 / this.fft.length;
73
+ }
74
+
75
+ /** Returns smoothed bass/mid/treble bands (0..1 each). */
76
+ readBands(): { bass: number; mid: number; treble: number } {
77
+ if (this.stopped) return { bass: 0, mid: 0, treble: 0 };
78
+ this.analyser.getByteFrequencyData(this.fft as unknown as Uint8Array<ArrayBuffer>);
79
+ const n = this.fft.length;
80
+ const bassEnd = Math.floor(n * 0.10);
81
+ const midEnd = Math.floor(n * 0.45);
82
+ let b = 0, m = 0, t = 0;
83
+ for (let i = 0; i < n; i++) {
84
+ const v = this.fft[i] / 255;
85
+ if (i < bassEnd) b += v;
86
+ else if (i < midEnd) m += v;
87
+ else t += v;
88
+ }
89
+ return {
90
+ bass: b / Math.max(1, bassEnd),
91
+ mid: m / Math.max(1, midEnd - bassEnd),
92
+ treble: t / Math.max(1, n - midEnd),
93
+ };
94
+ }
95
+
96
+ stop(): void {
97
+ if (this.stopped) return;
98
+ this.stopped = true;
99
+ if (this.beatTimer != null) clearTimeout(this.beatTimer);
100
+ if (this.mutationTimer != null) clearTimeout(this.mutationTimer);
101
+ for (const v of this.voices) {
102
+ try { v.osc.stop(); v.lfo.stop(); } catch { /* ignore */ }
103
+ try { v.osc.disconnect(); v.lfo.disconnect(); v.lfoGain.disconnect(); v.gain.disconnect(); }
104
+ catch { /* ignore */ }
105
+ }
106
+ try { this.output.disconnect(); } catch { /* ignore */ }
107
+ try { this.analyser.disconnect(); } catch { /* ignore */ }
108
+ }
109
+
110
+ private addVoice(
111
+ freq: number,
112
+ type: OscillatorType,
113
+ gain: number,
114
+ lfoHz: number,
115
+ lfoDepth: number,
116
+ ): void {
117
+ const ctx = this.ctx;
118
+ const osc = ctx.createOscillator();
119
+ osc.frequency.value = freq;
120
+ osc.type = type;
121
+ const g = ctx.createGain();
122
+ g.gain.value = gain;
123
+ osc.connect(g).connect(this.output);
124
+
125
+ const lfo = ctx.createOscillator();
126
+ lfo.frequency.value = lfoHz;
127
+ lfo.type = "sine";
128
+ const lfoGain = ctx.createGain();
129
+ lfoGain.gain.value = lfoDepth;
130
+ lfo.connect(lfoGain).connect(osc.frequency);
131
+
132
+ lfo.start();
133
+ osc.start();
134
+ const band: Band = freq < 200 ? "bass" : freq < 1100 ? "mid" : "treble";
135
+ this.voices.push({ base: freq, baseGain: gain, band, osc, lfo, lfoGain, gain: g });
136
+ }
137
+
138
+ /**
139
+ * Modulate the synth from the cursor. Position controls the bass/treble
140
+ * balance (top-right = bright/airy; bottom-left = bassy/warm); velocity
141
+ * triggers a beat envelope on the master gain. The cursor becomes the
142
+ * instrument driving the milkdrop preset.
143
+ *
144
+ * @param x01 cursor X normalised to 0..1 (left .. right)
145
+ * @param y01 cursor Y normalised to 0..1 (top .. bottom)
146
+ * @param vel01 cursor speed normalised to 0..1
147
+ */
148
+ setCursorModulation(x01: number, y01: number, vel01: number): void {
149
+ if (this.stopped) return;
150
+ const now = this.ctx.currentTime;
151
+ const ramp = 0.12;
152
+ const x = Math.max(0, Math.min(1, x01));
153
+ const y = Math.max(0, Math.min(1, y01));
154
+ const v = Math.max(0, Math.min(1, vel01));
155
+
156
+ // bass: bottom of screen = louder
157
+ const bassMul = 0.55 + (1 - y) * 0.90 + v * 0.15;
158
+ // treble: right of screen = brighter
159
+ const trebleMul = 0.55 + x * 0.90 + v * 0.20;
160
+ // mid: rises with velocity (gives "presence" to motion)
161
+ const midMul = 0.7 + v * 0.7;
162
+
163
+ for (const voice of this.voices) {
164
+ const target =
165
+ voice.band === "bass" ? voice.baseGain * bassMul :
166
+ voice.band === "treble" ? voice.baseGain * trebleMul :
167
+ voice.baseGain * midMul;
168
+ voice.gain.gain.cancelScheduledValues(now);
169
+ voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
170
+ voice.gain.gain.linearRampToValueAtTime(target, now + ramp);
171
+ }
172
+
173
+ // Velocity-burst → master gain envelope (rate-limited so a steady fast
174
+ // glide doesn't constantly hammer the analyser).
175
+ if (v > 0.45 && now - this.lastCursorBeatAt > 0.22) {
176
+ const peak = 0.95 + v * 0.45;
177
+ this.output.gain.cancelScheduledValues(now);
178
+ this.output.gain.setValueAtTime(this.output.gain.value, now);
179
+ this.output.gain.linearRampToValueAtTime(peak, now + 0.035);
180
+ this.output.gain.exponentialRampToValueAtTime(0.52, now + 0.22);
181
+ this.lastCursorBeatAt = now;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Fire a bass-kick envelope — for hooking up heartbeats, MIDI clicks, or
187
+ * any external rhythmic trigger. Sharp attack + bass voice swell so the
188
+ * spectrum butterchurn sees registers as a clean bass beat.
189
+ *
190
+ * @param intensity 0..1, scales the peak
191
+ */
192
+ pulseBeat(intensity = 0.8): void {
193
+ if (this.stopped) return;
194
+ const now = this.ctx.currentTime;
195
+ const i = Math.max(0, Math.min(1, intensity));
196
+
197
+ // Master gain — sharp attack, exponential decay (kick-drum shape)
198
+ const masterPeak = 0.95 + i * 0.55;
199
+ this.output.gain.cancelScheduledValues(now);
200
+ this.output.gain.setValueAtTime(this.output.gain.value, now);
201
+ this.output.gain.linearRampToValueAtTime(masterPeak, now + 0.035);
202
+ this.output.gain.exponentialRampToValueAtTime(0.42, now + 0.26);
203
+
204
+ // Bass voices get an extra swell so the kick lands in the low band
205
+ // (which is where butterchurn's preset reactivity usually lives).
206
+ for (const voice of this.voices) {
207
+ if (voice.band !== "bass") continue;
208
+ const peak = voice.baseGain * (1.6 + i * 1.4);
209
+ voice.gain.gain.cancelScheduledValues(now);
210
+ voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
211
+ voice.gain.gain.linearRampToValueAtTime(peak, now + 0.025);
212
+ voice.gain.gain.exponentialRampToValueAtTime(
213
+ Math.max(0.01, voice.baseGain), now + 0.34,
214
+ );
215
+ }
216
+ }
217
+
218
+ /** Periodic "beat" — short amplitude burst on the master gain. */
219
+ private scheduleBeats(): void {
220
+ const tick = (): void => {
221
+ if (this.stopped) return;
222
+ const now = this.ctx.currentTime;
223
+ const peak = 0.95 + Math.random() * 0.35;
224
+ this.output.gain.cancelScheduledValues(now);
225
+ this.output.gain.setValueAtTime(this.output.gain.value, now);
226
+ this.output.gain.linearRampToValueAtTime(peak, now + 0.04);
227
+ this.output.gain.exponentialRampToValueAtTime(0.45, now + 0.28);
228
+ // BPM-ish range 60–110, jittered
229
+ const nextMs = 600 + Math.random() * 1000;
230
+ this.beatTimer = window.setTimeout(tick, nextMs);
231
+ };
232
+ this.beatTimer = window.setTimeout(tick, 900);
233
+ }
234
+
235
+ /** Every ~20–35 s, smoothly mutate one voice's base frequency over 8 s. */
236
+ private scheduleMutations(): void {
237
+ const tick = (): void => {
238
+ if (this.stopped) return;
239
+ const v = this.voices[Math.floor(Math.random() * this.voices.length)];
240
+ const now = this.ctx.currentTime;
241
+ const ratio = 0.65 + Math.random() * 0.7; // 0.65× .. 1.35×
242
+ const target = Math.max(20, Math.min(6000, v.base * ratio));
243
+ v.osc.frequency.cancelScheduledValues(now);
244
+ v.osc.frequency.setValueAtTime(v.osc.frequency.value, now);
245
+ v.osc.frequency.linearRampToValueAtTime(target, now + 7.5);
246
+ v.base = target;
247
+ this.mutationTimer = window.setTimeout(tick, 18000 + Math.random() * 16000);
248
+ };
249
+ this.mutationTimer = window.setTimeout(tick, 6000);
250
+ }
251
+ }
package/src/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ // prism.graph/0.1 — node-graph schema for visualizations.
2
+ //
3
+ // Every Prism visualization is a JSON document of this shape. Nodes fall
4
+ // into five role-tagged categories that mirror the fundamental abstraction:
5
+ //
6
+ // signal source → signal xform → light field generator → lf operator → sink
7
+ //
8
+ // In English: Prism computes light fields from signals.
9
+ //
10
+ // M1 only exercises the minimal graph (audio → milkdrop → display); the
11
+ // schema is intentionally roomy so M8/M9 can add more generators and
12
+ // compositor ops without rewriting it.
13
+
14
+ export const SCHEMA_VERSION = "prism.graph/0.1" as const;
15
+
16
+ export type NodeRole = "signal" | "xform" | "lf" | "op" | "sink";
17
+
18
+ export type NodeType =
19
+ // signal sources — produce a streaming signal
20
+ | "signal.audio"
21
+ | "signal.cursor"
22
+ | "signal.heartbeat"
23
+ | "signal.synth"
24
+ // signal transformers — signal → signal
25
+ | "xform.gain"
26
+ | "xform.beat"
27
+ // light field generators — produce a frame from signals
28
+ | "lf.milkdrop"
29
+ | "lf.shadertoy"
30
+ | "lf.isf"
31
+ // light field operators — frame(s) → frame
32
+ | "op.blend"
33
+ | "op.displace"
34
+ | "op.feedback"
35
+ // sinks — terminate the graph
36
+ | "sink.display"
37
+ | "sink.recorder";
38
+
39
+ export type NodeParam =
40
+ | string
41
+ | number
42
+ | boolean
43
+ | string[]
44
+ | number[]
45
+ | Record<string, string | number | boolean>;
46
+
47
+ /** A single node in the graph. Inputs reference upstream node outputs by
48
+ * the string "<node_id>.<output_name>". The runtime resolves these. */
49
+ export interface NodeDef {
50
+ type: NodeType;
51
+ params?: Record<string, NodeParam>;
52
+ inputs?: Record<string, string>;
53
+ }
54
+
55
+ export interface PrismGraph {
56
+ schema: typeof SCHEMA_VERSION;
57
+ /** Stable id — enables share-by-URL in M6. */
58
+ id: string;
59
+ /** Human-readable summary of what the graph does. Shown in the SKILL
60
+ * readout; lets the AI explain its choice. */
61
+ intent: string;
62
+ nodes: Record<string, NodeDef>;
63
+ /** Node id of the terminal sink. */
64
+ output: string;
65
+ }
66
+
67
+ /** Extract the role prefix from a node type. */
68
+ export function roleOf(type: NodeType): NodeRole {
69
+ return type.split(".")[0] as NodeRole;
70
+ }
71
+
72
+ /** Iterate nodes of a given role. */
73
+ export function nodesByRole(graph: PrismGraph, role: NodeRole): Array<[string, NodeDef]> {
74
+ return Object.entries(graph.nodes).filter(([, n]) => roleOf(n.type) === role);
75
+ }
76
+
77
+ /** Build the M1 minimal graph: audio_in → lf.milkdrop(preset) → sink.display. */
78
+ export function makeMilkdropGraph(
79
+ presetName: string,
80
+ intent: string,
81
+ blendSeconds = 2.5,
82
+ ): PrismGraph {
83
+ return {
84
+ schema: SCHEMA_VERSION,
85
+ id: `g_${Math.random().toString(36).slice(2, 10)}`,
86
+ intent,
87
+ nodes: {
88
+ audio_in: { type: "signal.audio", params: {} },
89
+ main: {
90
+ type: "lf.milkdrop",
91
+ params: { preset_name: presetName, blend_seconds: blendSeconds },
92
+ inputs: { audio: "audio_in.signal" },
93
+ },
94
+ out: { type: "sink.display", inputs: { frame: "main.frame" } },
95
+ },
96
+ output: "out",
97
+ };
98
+ }