Hydra — live-coded video from chains of functions
prompts/pkg/llms/hydra.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks for live-coded visuals, Hydra, a video synth, VJ visuals for a
set or a party, algorithmic or generative video, feedback tunnels and
kaleidoscopes, visuals that move with the music, or a page where a model
rewrites the picture on request, this is the recipe. Hydra is a video synth
that runs on the GPU and is played as text:
osc(8, 0.1, 1.2).kaleid(5).rotate(() => time * 0.1).out() is a whole moving
picture, and a sketch can be typed by a person, written by knobs, saved as a
document, remixed, or produced by callAI from a sentence. It is the visual
side of the live-coding scene that Strudel and Tidal serve for music, and the
two play together (the third example below).
Hydra is the right call when the picture is a chain of video functions
(sources, geometry, colour, blending, modulation, feedback). A 3D scene with
meshes and a camera is the three-js skill's; a sketch that draws shapes
frame by frame with a pen is p5's.
The worked examples are jchris/hydra-pad (an editor, example chips, a
prompt box that rewrites the sketch, saved sketches in a shared gallery),
jchris/video-synth (knobs that write the sketch, and the camera as a
source) and jchris/beat-visuals (a Strudel pattern whose beat drives a
Hydra sketch).
The engine arrives after first paint
Import the engine with its full pinned esm.sh URL inside a memoized
module-level loader: await import("https://esm.sh/[email protected]"),
called from the stage's mount effect, so the page paints first and a skeleton
holds the stage until the first frame (measured 2026-11-11: about 1.2 s for
the engine in Chromium, then frames at once). Visuals need no press, unlike
sound; the microphone and the camera each wait for theirs.
Construct it with makeGlobal: false, autoLoop: false and
detectAudio: false. The defaults put osc, src, o0, time and fifty
more names on window (where a Strudel engine on the same page defines its
own sine, saw and friends), run a frame loop nobody can stop, and ask for
the microphone the moment the page opens. With all three off, the names stay
on hydra.synth, the app runs its own requestAnimationFrame loop calling
hydra.tick(dt) (stopped when the stage unmounts), and the microphone waits
for a "Listen" press.
Hydra is AGPL-licensed. The app links to the engine at its own address, the way a browser links to any page, so the two programs stay separate: credit Hydra visibly (a footer line with a link to hydra.ojack.xyz is the house shape), and keep its code at its URL rather than copied into the app.
Run a sketch inside with blocks over the synth, never with
hydra.eval(). Hydra's own eval runs code at global scope, so with
makeGlobal: false every name in the sketch is undefined.
new Function("synth", "extras", "with (synth) { with (extras) {" + code + "} }")
lets a sketch name osc, src, o0 and time bare, sees the app's own
values (a.fft) first, and lets speed = 2 or bpm = 120 reach the synth
(measured: both land on hydra.synth). Arrow functions in the sketch keep
the scope, so () => time * 0.1 reads the live clock every frame.
A sketch that does not run throws, and the last good one keeps playing.
A syntax error throws from new Function ("missing ) after argument list"),
an unknown function throws when the chain runs ("osc(...).wobble is not a
function"); run() returns { ok: false, message } for both and the app
shows the message beside the editor. A custom GLSL function that fails to
compile is logged to the console by the engine and the picture holds.
Project shape
App.jsx the interface: the stage, an editor or knobs, examples, the credit line
lib/hydra.js the loader, attach(), run(), hush(), listen(), startCamera(), hit(), EXAMPLES
lib/sketch-prompt.js the text-to-sketch system prompt for callAI
access.js who may publish a sketch to the shared gallery
Relative imports include the extension (./lib/hydra.js). Everything that
touches the engine lives in the module, so App.jsx reads as an interface.
Complete starter — a sketch pad
The engine module. attach(el) puts the one canvas in the stage and starts
the loop; run(code) shows a sketch; audio.fft is what sketches read as
a.fft[0..3], filled by the microphone (listen()) or by the app (hit());
startCamera() feeds the camera into s0.
lib/hydra.js
js// lib/hydra.js — the video synth: one engine, one canvas, a loop the app owns.
const ENGINE_URL = "https://esm.sh/[email protected]";
const WIDTH = 1280;
const HEIGHT = 720;
let enginePromise;
let raf = 0;
let owner = 0; // which attach() holds the canvas now; an older one's cleanup leaves it alone
let micStop = null;
let micStarting; // a listen() still waiting for permission, so a second press joins it
/** What sketches read as `a.fft[0..3]`: four bands from 0 to about 1, filled by listen() or by the app. */
export const audio = { fft: [0, 0, 0, 0] };
/** One download and one canvas however many times it is asked for. Never throws: null when it failed. */
export function loadHydra() {
enginePromise ??= (async () => {
const { default: Hydra } = await import(ENGINE_URL);
const canvas = document.createElement("canvas");
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.cssText = "display:block;width:100%;height:100%;object-fit:cover";
const hydra = new Hydra({
canvas,
width: WIDTH,
height: HEIGHT,
makeGlobal: false, // osc, src, o0… stay on hydra.synth, not on window
autoLoop: false, // the loop below is ours, so it stops when the stage leaves
detectAudio: false, // no microphone prompt on load; listen() asks on a press
enableStreamCapture: false,
});
return { hydra, canvas };
})().catch((error) => {
enginePromise = undefined; // a later attempt may fetch again
console.warn("hydra failed to load", error);
return null;
});
return enginePromise;
}
/**
* Put the canvas in `el` and start the frame loop; the returned function takes both away.
* Resolves null when the engine did not load, so the stage can say so instead of going live.
*/
export async function attach(el) {
const engine = await loadHydra();
if (!engine || !el) return null;
const { hydra, canvas } = engine;
const mine = ++owner;
el.appendChild(canvas);
cancelAnimationFrame(raf);
let last = performance.now();
const frame = (now) => {
if (!micStop) for (let i = 0; i < 4; i++) audio.fft[i] *= 0.9; // hits fade unless the mic is filling the bands
hydra.tick(Math.min(now - last, 100));
last = now;
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => {
if (owner !== mine) return; // a newer attach owns the canvas and the loop
cancelAnimationFrame(raf);
canvas.remove();
};
}
/**
* Run a sketch. Hydra code is JavaScript that names its functions bare (osc, src, o0, time),
* so it runs inside `with` blocks over the synth and the app's own values; `speed = 2` and
* `bpm = 120` reach the synth. Returns { ok: true } or { ok: false, message } — the last good
* sketch keeps playing either way.
*/
export async function run(code, extras = {}) {
const engine = await loadHydra();
if (!engine) return { ok: false, message: "the video engine did not load — check the connection and run again" };
try {
const sketch = new Function("synth", "extras", `with (synth) { with (extras) {\n${code}\n} }`);
sketch(engine.hydra.synth, { a: audio, ...extras });
return { ok: true };
} catch (error) {
return { ok: false, message: String(error?.message || error) };
}
}
/** Blank every output and forget update(); the canvas stays, black. */
export async function hush() {
const engine = await enginePromise;
engine?.hydra.hush();
}
/** Kick one band of a.fft (0 bass … 3 treble) to `amount`; it fades on its own over a few frames. */
export function hit(band, amount = 1) {
audio.fft[band] = Math.max(audio.fft[band], amount);
}
/**
* Feed the camera into a source slot (s0 by default) from a press. Throws when the person says no,
* so the app can say so; returns a function that turns the camera off.
*/
export async function startCamera(slot = "s0") {
const engine = await loadHydra();
if (!engine) throw new Error("the video engine did not load");
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
const video = document.createElement("video");
video.muted = true;
video.playsInline = true; // iOS plays inline only with this set
video.srcObject = stream;
await video.play();
engine.hydra.synth[slot].init({ src: video, dynamic: true });
return () => {
stream.getTracks().forEach((t) => t.stop());
engine.hydra.synth[slot].clear();
};
}
/** Fill audio.fft from the microphone. Call from a press; returns a stop function. */
export async function listen() {
if (micStop) return micStop;
micStarting ??= openMic().finally(() => {
micStarting = undefined;
});
return micStarting;
}
async function openMic() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ctx = new AudioContext();
await ctx.resume();
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.6;
ctx.createMediaStreamSource(stream).connect(analyser);
const bins = new Uint8Array(analyser.frequencyBinCount);
const bands = [[1, 4], [4, 12], [12, 40], [40, 100]];
let id = 0;
const read = () => {
analyser.getByteFrequencyData(bins);
bands.forEach(([from, to], i) => {
let sum = 0;
for (let k = from; k < to; k++) sum += bins[k];
audio.fft[i] = sum / (to - from) / 255;
});
id = requestAnimationFrame(read);
};
read();
micStop = () => {
cancelAnimationFrame(id);
stream.getTracks().forEach((t) => t.stop());
ctx.close();
audio.fft.fill(0);
micStop = null;
};
return micStop;
}
export const EXAMPLES = [
{ name: "kaleido bloom", code: `osc(8, 0.1, 1.2)\n .kaleid(5)\n .rotate(() => time * 0.1)\n .modulate(noise(2), 0.2)\n .out()` },
{ name: "feedback tunnel", code: `src(o0)\n .scale(1.01)\n .rotate(0.01)\n .blend(shape(4, 0.4, 0.01).color(1, 0.4, 0.8), 0.1)\n .out()` },
{ name: "voronoi tide", code: `voronoi(5, 0.3, 0.3)\n .color(0.2, 0.6, 1)\n .modulateScrollX(osc(3), 0.2)\n .out()` },
{ name: "pixel rain", code: `noise(4, 0.2)\n .thresh(0.5)\n .color(0.2, 1, 0.6)\n .pixelate(40, 30)\n .scrollY(0, -0.1)\n .out()` },
{ name: "sound bloom", code: `shape(6, () => 0.25 + a.fft[0] * 0.5, 0.01)\n .repeat(3, 3)\n .modulate(osc(6), () => a.fft[1] * 0.3)\n .color(1, () => 0.4 + a.fft[2], 0.9)\n .out()` },
];
The prompt the rewrite box sends. It is the verbatim system prompt for
turning a sentence into a sketch (its own section below); the module exports
it so any app can hand it to callAI.
lib/sketch-prompt.js
js// lib/sketch-prompt.js — the system prompt for turning a sentence into a Hydra sketch.
// Hand it to callAI with the person's request and the current sketch; read back { name, code }.
export const SKETCH_PROMPT = `You write Hydra sketches. Hydra is a live-coding video synth: a sketch is a chain of functions that builds a moving image, ending in .out().
SOURCES start a chain: osc(frequency, sync, offset) stripes, noise(scale, offset), voronoi(scale, speed, blending), shape(sides, radius, smoothing), gradient(speed), solid(r, g, b, a), src(o0) reads an output back (feedback).
GEOMETRY: .rotate(angle, speed), .scale(amount, xMult, yMult), .kaleid(sides), .pixelate(x, y), .repeat(x, y), .scroll(x, y, speedX, speedY), .scrollX(amount, speed), .scrollY(amount, speed).
COLOR: .color(r, g, b), .colorama(amount), .hue(amount), .saturate(amount), .contrast(amount), .brightness(amount), .invert(), .luma(threshold), .thresh(threshold), .posterize(bins, gamma), .shift(r, g, b).
BLENDING with a second chain: .add(chain, amount), .blend(chain, amount), .diff(chain), .mult(chain, amount), .mask(chain), .layer(chain).
MODULATE moves pixels by a second chain: .modulate(chain, amount), .modulateRotate(chain, multiple), .modulateScale(chain, multiple), .modulateKaleid(chain, sides), .modulateScrollX(chain, amount), .modulatePixelate(chain, multiple).
OUTPUTS: .out() draws to o0, which is what shows. src(o0).scale(1.01).blend(…, 0.1).out() makes trails and tunnels.
MOTION. Any number can be a function of time: () => time * 0.1, () => Math.sin(time) * 0.5. An array steps through values: shape([3, 4, 5]).
SOUND. a.fft[0] (bass) to a.fft[3] (treble) run from 0 to about 1 while sound is on: () => 0.2 + a.fft[0] * 0.5.
OUTPUT. Only Hydra code: one chain ending in .out(), or a few lines each ending in .out(oN) with render() or one final .out(). No imports, no comments, no prose. Keep amounts small: rotate speeds under 0.5, modulate amounts under 0.5, scale feedback between 0.95 and 1.05.
EXAMPLES
Request: slow purple kaleidoscope
Sketch: osc(6, 0.05, 1).color(0.7, 0.2, 1).kaleid(6).rotate(() => time * 0.05).out()
Request: a glowing tunnel that pulls you in
Sketch: src(o0).scale(1.02).rotate(0.005).blend(shape(32, 0.3, 0.02).color(0.2, 0.8, 1), 0.08).out()
Request: glitchy green rain
Sketch: noise(8, 0.3).thresh(0.6).color(0, 1, 0.4).pixelate(60, 20).scrollY(0, -0.2).out()
Request: pulse with the bass
Sketch: shape(4, () => 0.2 + a.fft[0] * 0.6, 0.02).rotate(() => time * 0.2).modulate(noise(3), () => a.fft[1] * 0.2).out()`;
/** The full prompt for one request: the spec, the ask, and the sketch that is showing now. */
export function rewritePrompt(request, currentCode) {
return `${SKETCH_PROMPT}\n\nRequest: ${request}\nCurrent sketch (change it, or replace it when the request asks for something new):\n${currentCode}\n\nReturn JSON with "name" (two or three words) and "code" (the sketch).`;
}
The interface: the stage, an editor, example chips, a prompt box that asks
callAI to rewrite the sketch, a "Listen to the room" button that makes
a.fft follow the microphone, full screen, and the saved sketches from
Fireproof with every write gated on can.
App.jsx
jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { callAI, useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/ui";
import { EXAMPLES, attach, listen, run } from "./lib/hydra.js";
import { rewritePrompt } from "./lib/sketch-prompt.js";
export default function App() {
const { database, useLiveQuery } = useFireproof("sketchPad");
const { me, can, ready } = useVibe("sketchPad");
const { requestLogin, ViewerTag } = useViewer();
const { docs } = useLiveQuery("type", { key: "sketch" });
const saved = useMemo(() => [...docs].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)), [docs]);
const stage = useRef(null);
const [code, setCode] = useState(EXAMPLES[0].code);
const [live, setLive] = useState(false);
const [problem, setProblem] = useState(null);
const [ask, setAsk] = useState("");
const [asking, setAsking] = useState(false);
const [name, setName] = useState("");
const [hearing, setHearing] = useState(null); // the stop function while the mic is on
const alive = useRef(true); // false once the pad unmounts, so a late microphone is closed at once
// The engine arrives after first paint; the stage shows a skeleton until the first frame.
useEffect(() => {
let gone = false;
let detach = () => {};
attach(stage.current).then(async (d) => {
if (gone) return d?.();
if (!d) return setProblem("the video engine did not load — check the connection and reload the page");
detach = d;
const result = await run(code);
if (!result.ok) setProblem(result.message);
setLive(true);
});
return () => {
gone = true;
detach();
};
}, []);
useEffect(() => () => hearing?.(), [hearing]);
useEffect(() => () => void (alive.current = false), []);
async function show(source = code) {
const result = await run(source);
setProblem(result.ok ? null : result.message);
}
function pick(next) {
setCode(next);
show(next);
}
async function rewrite() {
if (!ask.trim() || asking) return;
setAsking(true);
try {
const reply = await callAI(rewritePrompt(ask, code), {
schema: { properties: { name: { type: "string" }, code: { type: "string" } } },
});
const next = JSON.parse(reply);
setAsk("");
pick(next.code);
} catch {
setProblem("the rewrite did not come back — press Rewrite to ask again");
} finally {
setAsking(false);
}
}
async function toggleListening() {
if (hearing) {
hearing();
setHearing(null);
return;
}
try {
const stop = await listen();
if (!alive.current) return stop(); // the pad closed while the permission prompt was up
setHearing(() => stop);
} catch {
setProblem("the microphone stayed off, so a.fft reads zero — sketches still play");
}
}
const draft = { type: "sketch", name: name || "untitled", code, authorHandle: me?.userHandle };
const verdict = can.create(draft);
async function keep() {
setName("");
const { ok } = await database.put({ ...draft, createdAt: Date.now() });
if (!ok) setName((now) => now || name);
}
return (
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
<Stack gap={4}>
<Row gap={2} align="center" wrap>
<Heading level={1}>Sketch Pad</Heading>
<Spacer />
<Badge tone={live ? "up" : "idle"}>{live ? "Live" : "Warming up the synth…"}</Badge>
</Row>
<div ref={stage} className="relative aspect-video w-full overflow-hidden rounded-md bg-neutral">
{!live && <div aria-busy="true" className="absolute inset-0"><Skeleton variant="card" /></div>}
</div>
<Row gap={2} align="center" wrap>
<Button onClick={() => show()}>Run</Button>
<Button variant="quiet" onClick={() => stage.current?.requestFullscreen?.()}>Full screen</Button>
<Button variant="quiet" onClick={toggleListening}>{hearing ? "Stop listening" : "Listen to the room"}</Button>
<Text size="sm" muted>Ctrl+Enter runs the editor</Text>
</Row>
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); show(); } }}
spellCheck={false}
rows={7}
aria-label="Hydra sketch"
className="w-full rounded-md border border-border bg-card p-3 font-mono text-sm text-foreground outline-none"
/>
{problem && <Text size="sm" tone="down">That sketch needs a tweak: {problem}. The last good one keeps playing.</Text>}
<Row gap={2} wrap>
{EXAMPLES.map((ex) => (
<Button key={ex.name} size="sm" variant="quiet" onClick={() => pick(ex.code)}>{ex.name}</Button>
))}
</Row>
<Card variant="tight">
<Row gap={2} align="top" wrap>
<Field label="Ask for a change">{(id, d) => <Input id={id} describedBy={d} value={ask} onChange={setAsk} placeholder="slower, and more purple" />}</Field>
<Button size="sm" onClick={rewrite} disabled={asking}>{asking ? "Rewriting…" : "Rewrite"}</Button>
</Row>
</Card>
<Card variant="tight">
<Stack gap={3}>
<Heading level={2}>Keep this sketch</Heading>
{!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
{ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your sketches stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in to keep sketches</Button></Row>}
{ready && me && verdict.ok && (
<Row gap={2} align="top" wrap>
<Field label="Name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="midnight bloom" />}</Field>
<Button size="sm" onClick={keep}>Keep</Button>
</Row>
)}
{saved.map((s) => (
<Row key={s._id} gap={2} align="center" wrap>
<Button size="sm" variant="quiet" onClick={() => pick(s.code)}>{s.name}</Button>
{s.authorHandle && <ViewerTag userHandle={s.authorHandle} />}
<Spacer />
{can.delete(s).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(s._id)}>Remove</Button>}
</Row>
))}
</Stack>
</Card>
<Text size="sm" muted>
Video by <a className="underline" href="https://hydra.ojack.xyz" target="_blank" rel="noreferrer">Hydra</a> (AGPL), fetched from its own address when the page opens.
</Text>
</Stack>
</main>
);
}
access.js
js// sketch: a saved Hydra sketch. Anyone signed in keeps their own; everyone can read the gallery.
export function sketchPad(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to keep sketches" };
if (doc.type === "sketch" || (doc._deleted && oldDoc && oldDoc.type === "sketch")) {
const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
if (author !== user.userHandle || (!doc._deleted && doc.authorHandle !== user.userHandle)) throw { forbidden: "sign your sketch with your own handle" };
return { channels: ["sketches"], grant: { public: ["sketches"] } };
}
throw { forbidden: "unknown document type" };
}
The language in one table
A sketch is a chain: one source, then any number of transforms, ending in
.out(). Every argument can be a number, a function of time
(() => Math.sin(time) * 0.5), or an array that steps through values
(shape([3, 4, 5]), with .fast(2) to step twice as often).
| kind | functions | what they do |
|---|---|---|
| source | osc(freq, sync, offset), noise(scale, offset), voronoi(scale, speed, blend), shape(sides, radius, smoothing), gradient(speed), solid(r, g, b, a), src(o0) |
start a picture; src reads an output or a source slot |
| geometry | .rotate(angle, speed), .scale(amount), .kaleid(sides), .pixelate(x, y), .repeat(x, y), .scroll(x, y, speedX, speedY), .scrollX, .scrollY |
move and fold the pixels |
| colour | .color(r, g, b), .colorama(amount), .hue(amount), .saturate, .contrast, .brightness, .invert(), .luma(threshold), .thresh(threshold), .posterize(bins), .shift(r, g, b) |
change the colours |
| blend | .add(chain, amount), .blend(chain, amount), .diff(chain), .mult(chain, amount), .mask(chain), .layer(chain) |
combine with a second chain |
| modulate | .modulate(chain, amount), .modulateRotate, .modulateScale, .modulateKaleid, .modulateScrollX, .modulatePixelate |
move these pixels by a second chain's colours |
| output | .out() (o0), .out(o1), render() shows all four, render(o2) shows one |
where the chain draws |
Feedback is src(o0) inside the chain that draws to o0:
src(o0).scale(1.01).rotate(0.01).blend(shape(4), 0.1).out() leaves trails,
tunnels and smears. Keep the scale near 1 (0.95 to 1.05) and the blend small,
or the frame floods to one colour in a second.
Four outputs (o0–o3) and four sources (s0–s3). An output is a
chain's canvas; a source is outside pixels: s0 holds the camera after
startCamera("s0"), s1.initImage(url) an image, s2.initVideo(url) a
looping video (both need a CORS-open URL, or the frame stays black).
speed = 0.5 slows every clock in the sketch; bpm = 120 sets the rate an
array steps at. Both are plain assignments in the sketch, and both land on
the synth through the with block.
The interface writes the sketch
The pad above is the editor shape. The other shape is the one most people want: knobs, buttons and grids that compile to a sketch string and re-run it as they move. A knob is a number dropped into the string; the effect that runs the sketch depends on the string, so every move swaps the picture without a gap. Show the string the knobs wrote, so the person can carry it into any Hydra editor, and learn the language by watching it change.
A video synth whose knobs write the sketch, with the camera as a source:
App.jsx
jsximport React, { useEffect, useRef, useState } from "react";
import { Badge, Button, Card, Heading, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/ui";
import { Slider } from "@vibes.diy/ui/interactive";
import { attach, run, startCamera } from "./lib/hydra.js";
// Each knob is a number dropped into one sketch string; every move re-runs it.
const KNOBS = [
{ key: "sides", label: "Mirror", min: 1, max: 12, step: 1 },
{ key: "warp", label: "Warp", min: 0, max: 0.6, step: 0.01 },
{ key: "trail", label: "Trails", min: 0, max: 0.95, step: 0.01 },
{ key: "spin", label: "Spin", min: -0.5, max: 0.5, step: 0.01 },
{ key: "hue", label: "Colour drift", min: 0, max: 0.3, step: 0.01 },
];
const START = { sides: 4, warp: 0.2, trail: 0.6, spin: 0.1, hue: 0.05 };
function toSketch(k, camera) {
const source = camera ? "src(s0).saturate(1.4)" : "osc(10, 0.08, 1.2)";
return [
`${source}`,
` .modulate(noise(2.5), ${k.warp})`,
` .kaleid(${k.sides})`,
` .rotate(() => time * ${k.spin})`,
` .colorama(${k.hue})`,
` .blend(src(o0).scale(1.01), ${k.trail})`,
` .out()`,
].join("\n");
}
export default function App() {
const stage = useRef(null);
const [knobs, setKnobs] = useState(START);
const [camera, setCamera] = useState(false);
const [live, setLive] = useState(false);
const [note, setNote] = useState(null);
const cameraOff = useRef(null);
const alive = useRef(true); // false once the synth unmounts, so a late camera is stopped at once
const asking = useRef(false); // one camera request at a time
const code = toSketch(knobs, camera);
useEffect(() => {
let gone = false;
let detach = () => {};
attach(stage.current).then((d) => {
if (gone) return d?.();
if (!d) return setNote("The video engine did not load. Check the connection and reload the page.");
detach = d;
setLive(true);
});
return () => {
gone = true;
alive.current = false;
detach();
cameraOff.current?.();
};
}, []);
// The interface writes the sketch: any change re-runs it, and the synth swaps without a gap.
useEffect(() => {
if (live) run(code).then((r) => setNote(r.ok ? null : r.message));
}, [live, code]);
async function cameraOn() {
if (asking.current) return;
asking.current = true;
try {
const off = await startCamera("s0"); // asks from this press; the sketch reads it as s0
if (!alive.current) return off(); // the synth closed while the permission prompt was up
cameraOff.current = off;
setCamera(true);
setNote(null);
} catch {
setNote("The camera stayed off, so the synth plays its own stripes. Allow the camera and press again.");
} finally {
asking.current = false;
}
}
function cameraBack() {
cameraOff.current?.();
cameraOff.current = null;
setCamera(false);
}
return (
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
<Stack gap={4}>
<Row gap={2} align="center" wrap>
<Heading level={1}>Video Synth</Heading>
<Spacer />
<Badge tone={live ? "up" : "idle"}>{live ? (camera ? "Camera in" : "Live") : "Warming up…"}</Badge>
</Row>
<div ref={stage} className="relative aspect-video w-full overflow-hidden rounded-md bg-neutral">
{!live && <div aria-busy="true" className="absolute inset-0"><Skeleton variant="card" /></div>}
</div>
<Card variant="tight">
<Stack gap={3}>
{KNOBS.map((k) => (
<Row key={k.key} gap={3} align="center">
<Slider value={[knobs[k.key]]} onChange={([v]) => setKnobs((now) => ({ ...now, [k.key]: v }))} min={k.min} max={k.max} step={k.step} label={k.label} />
<Text size="sm" muted>{k.label} {knobs[k.key]}</Text>
</Row>
))}
</Stack>
</Card>
<Row gap={2} wrap>
<Button onClick={camera ? cameraBack : cameraOn}>{camera ? "Back to stripes" : "Use my camera"}</Button>
<Button variant="quiet" onClick={() => setKnobs(START)}>Reset knobs</Button>
<Button variant="quiet" onClick={() => stage.current?.requestFullscreen?.()}>Full screen</Button>
</Row>
{note && <Text size="sm" tone="down">{note}</Text>}
<Card variant="tight">
<Stack gap={1}>
<Text size="sm" muted>The sketch these knobs write — paste it into any Hydra editor</Text>
<pre className="whitespace-pre-wrap font-mono text-sm">{code}</pre>
</Stack>
</Card>
<Text size="sm" muted>
Video by <a className="underline" href="https://hydra.ojack.xyz" target="_blank" rel="noreferrer">Hydra</a> (AGPL), fetched from its own address when the page opens.
</Text>
</Stack>
</main>
);
}
The camera is a press, then s0. startCamera("s0") asks for the
camera from the press that called it, plays it into a <video> with
playsInline (iOS shows a camera inline only with it), and hands it to the
slot; the sketch reads it as src(s0). When the person says no, the call
throws, and the app says the synth plays its own stripes until the camera is
allowed. The function it returns stops the camera; call it on the way back
and in the unmount cleanup, so the camera light goes off with the page.
Sound makes it move
A sketch reads sound as a.fft[0] (bass) to a.fft[3] (treble), each from
0 to about 1, the same names Hydra's own editor uses, so sketches people
share in the Hydra community run as written. The engine's own audio reader
asks for the microphone when the page opens, so it stays off
(detectAudio: false) and lib/hydra.js owns a instead. Two things fill it:
- The room, through
listen(): from a "Listen" press it opens the microphone, reads four bands from anAnalyserNodeevery frame, and returns the function that closes the microphone. A sketch such asshape(6, () => 0.25 + a.fft[0] * 0.5).out()breathes with whatever is playing in the room. - The app's own music, through
hit(band, amount): each event the app plays kicks one band toamount, and the frame loop lets it fall back (each band keeps 90% per frame), so a kick is a flash that fades in about half a second.
A Strudel pattern driving a Hydra sketch is the second case. Strudel's
onTrigger hands every sounding event to the app at the moment it is
scheduled; a setTimeout of (targetTime - time) * 1000 lands the hit on
the beat, and the event's sound name picks the band (kick to a.fft[0],
snare and clap to a.fft[1], hats to a.fft[2], notes to a.fft[3]). The
pattern side is the Strudel doc's lib/strudel.js, unchanged; this is the
visual half, and a set is the pair:
lib/sets.js
js// lib/sets.js — a set is a Strudel pattern and the Hydra sketch it drives.
// The beat reaches the sketch as a.fft: kick → a.fft[0], snare and clap → a.fft[1], hats → a.fft[2], notes → a.fft[3].
export const SETS = [
{
name: "kick bloom",
pattern: `stack(\n s("bd*4, ~ cp ~ cp, hh*8").bank("RolandTR909"),\n note("<c2 c2 eb2 g1>").s("sawtooth").lpf(700).gain(0.5)\n).cpm(124/4)`,
sketch: `shape(4, () => 0.15 + a.fft[0] * 0.35, 0.02)\n .color(1, 0.3, () => 0.5 + a.fft[1] * 0.5)\n .rotate(() => time * 0.2)\n .modulate(noise(3), () => a.fft[2] * 0.15)\n .out()`,
},
{
name: "tunnel break",
pattern: `s("bd ~ [~ bd] ~, ~ sd ~ sd, hh*8").bank("RolandTR808").cpm(92/4)`,
sketch: `src(o0)\n .scale(() => 1.01 + a.fft[0] * 0.05)\n .rotate(0.004)\n .blend(osc(20, 0.05, 1).kaleid(4).mask(shape(4, 0.2, 0.05)), () => 0.05 + a.fft[1] * 0.3)\n .out()`,
},
{
name: "acid voronoi",
pattern: `stack(\n s("bd*4"),\n note("c2 [eb2 c2] g1 [c2 bb1]").s("sawtooth").lpf(sine.range(300, 2000).slow(4)).resonance(10).gain(0.6)\n).cpm(130/4)`,
sketch: `voronoi(6, 0.3, 0.2)\n .color(0.2, () => 0.6 + a.fft[3] * 0.4, 1)\n .modulateScale(osc(4), () => a.fft[0] * 0.4)\n .colorama(() => a.fft[3] * 0.1)\n .out()`,
},
];
/** Which band of a.fft a sounding event kicks. */
export function bandFor(value) {
const s = String(value?.s ?? "");
if (/bd|kick/.test(s)) return 0;
if (/sd|cp|rim|snare|clap/.test(s)) return 1;
if (/hh|oh|cr|hat/.test(s)) return 2;
return 3;
}
App.jsx
jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/ui";
import { attach, hit, run } from "./lib/hydra.js";
import { onPatternError, play, stop } from "./lib/strudel.js";
import { SETS, bandFor } from "./lib/sets.js";
const editorClass = "w-full rounded-md border border-border bg-card p-3 font-mono text-sm text-foreground outline-none";
export default function App() {
const { database, useLiveQuery } = useFireproof("beatVisuals");
const { me, can, ready } = useVibe("beatVisuals");
const { requestLogin, ViewerTag } = useViewer();
const { docs } = useLiveQuery("type", { key: "set" });
const saved = useMemo(() => [...docs].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)), [docs]);
const stage = useRef(null);
const timers = useRef([]);
const [pattern, setPattern] = useState(SETS[0].pattern);
const [sketch, setSketch] = useState(SETS[0].sketch);
const [live, setLive] = useState(false);
const [sound, setSound] = useState("idle"); // idle | loading | playing
const [problem, setProblem] = useState(null);
const [name, setName] = useState("");
useEffect(() => {
let gone = false;
let detach = () => {};
attach(stage.current).then(async (d) => {
if (gone) return d?.();
if (!d) return setProblem("the video engine, which did not load — check the connection and reload the page");
detach = d;
await run(sketch);
setLive(true);
});
return () => {
gone = true;
detach();
stop();
timers.current.forEach(clearTimeout);
};
}, []);
useEffect(() => onPatternError((e) => setProblem(`the pattern: ${e.message || e}`)), []);
// Every sounding event kicks a band of a.fft at the moment it sounds.
function onEvent(hap, time, cps, target) {
const delay = Math.max(0, (target - time) * 1000);
timers.current.push(setTimeout(() => hit(bandFor(hap.value), hap.value?.gain ?? 1), delay));
if (timers.current.length > 256) timers.current = timers.current.slice(-128);
}
async function go(nextPattern = pattern, nextSketch = sketch) {
setProblem(null);
const wasPlaying = sound === "playing";
if (!wasPlaying) setSound("loading");
// play() first: it unlocks audio before its first await, while this is still the press.
const playing = play(nextPattern, onEvent);
const shown = await run(nextSketch);
if (!shown.ok) setProblem(`the sketch: ${shown.message}`);
const ok = await playing;
setSound(ok ? "playing" : wasPlaying ? "playing" : "idle");
}
async function halt() {
await stop();
timers.current.forEach(clearTimeout);
timers.current = [];
setSound("idle");
}
function pick(set) {
setPattern(set.pattern);
setSketch(set.sketch);
if (sound === "playing") go(set.pattern, set.sketch);
else run(set.sketch);
}
const draft = { type: "set", name: name || "untitled", pattern, sketch, authorHandle: me?.userHandle };
const verdict = can.create(draft);
async function keep() {
setName("");
const { ok } = await database.put({ ...draft, createdAt: Date.now() });
if (!ok) setName((now) => now || name);
}
const runKeys = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
go();
}
};
return (
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
<Stack gap={4}>
<Row gap={2} align="center" wrap>
<Heading level={1}>Beat Visuals</Heading>
<Spacer />
<Badge tone={sound === "playing" ? "up" : "idle"}>{sound === "playing" ? "Playing" : sound === "loading" ? "Fetching the drum kit…" : live ? "Press play" : "Warming up the synth…"}</Badge>
</Row>
<div ref={stage} className="relative aspect-video w-full overflow-hidden rounded-md bg-neutral">
{!live && <div aria-busy="true" className="absolute inset-0"><Skeleton variant="card" /></div>}
</div>
<Row gap={2} align="center" wrap>
<Button onClick={() => (sound === "playing" ? halt() : go())}>{sound === "playing" ? "Stop" : "Play"}</Button>
<Button variant="quiet" onClick={() => go()}>Update</Button>
<Button variant="quiet" onClick={() => stage.current?.requestFullscreen?.()}>Full screen</Button>
{SETS.map((s) => (
<Button key={s.name} size="sm" variant="quiet" onClick={() => pick(s)}>{s.name}</Button>
))}
</Row>
<div className="grid gap-4 md:grid-cols-2">
<Stack gap={1}>
<Text size="sm" muted>Pattern (Strudel)</Text>
<textarea aria-label="Strudel pattern" value={pattern} onChange={(e) => setPattern(e.target.value)} onKeyDown={runKeys} spellCheck={false} rows={7} className={editorClass} />
</Stack>
<Stack gap={1}>
<Text size="sm" muted>Sketch (Hydra) — kick is a.fft[0], snare a.fft[1], hats a.fft[2], notes a.fft[3]</Text>
<textarea aria-label="Hydra sketch" value={sketch} onChange={(e) => setSketch(e.target.value)} onKeyDown={runKeys} spellCheck={false} rows={7} className={editorClass} />
</Stack>
</div>
{problem && <Text size="sm" tone="down">Needs a tweak in {problem}. The last good one keeps going.</Text>}
<Card variant="tight">
<Stack gap={3}>
<Heading level={2}>Keep this set</Heading>
{!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
{ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in to keep sets and share them here.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
{ready && me && verdict.ok && (
<Row gap={2} align="top" wrap>
<Field label="Set name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="friday opener" />}</Field>
<Button size="sm" onClick={keep}>Keep</Button>
</Row>
)}
{saved.map((s) => (
<Row key={s._id} gap={2} align="center" wrap>
<Button size="sm" variant="quiet" onClick={() => pick(s)}>{s.name}</Button>
{s.authorHandle && <ViewerTag userHandle={s.authorHandle} />}
<Spacer />
{can.delete(s).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(s._id)}>Remove</Button>}
</Row>
))}
</Stack>
</Card>
<Text size="sm" muted>
Sound by <a className="underline" href="https://strudel.cc" target="_blank" rel="noreferrer">Strudel</a> and video by <a className="underline" href="https://hydra.ojack.xyz" target="_blank" rel="noreferrer">Hydra</a> (both AGPL), each fetched from its own address.
</Text>
</Stack>
</main>
);
}
The sets are a public gallery with author-owned writes, the same rule as the pad's:
access.js
js// set: a Strudel pattern and the Hydra sketch it drives. Anyone signed in keeps their own; everyone can read the gallery.
export function beatVisuals(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to keep sets" };
if (doc.type === "set" || (doc._deleted && oldDoc && oldDoc.type === "set")) {
const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
if (author !== user.userHandle || (!doc._deleted && doc.authorHandle !== user.userHandle)) throw { forbidden: "sign your set with your own handle" };
return { channels: ["sets"], grant: { public: ["sets"] } };
}
throw { forbidden: "unknown document type" };
}
The text-to-sketch prompt, verbatim
lib/sketch-prompt.js above is the system prompt for a text box that turns a
sentence into a sketch, or an app that rewrites the sketch on request. Use it
as written: it names only functions the engine has, with their argument
order, its four examples were run on the engine as the doc was written, and
it keeps the amounts in the range that stays watchable (a feedback scale past
1.05 floods the frame). rewritePrompt(request, code) appends the ask and
the sketch that is showing; the callAI schema is
{ name: string, code: string }, and the app shows code through run(), so
a sketch the model got wrong surfaces as a message while the last good one
plays on.
The stage and its lifecycle
- One canvas, made once by the module, moved into the stage.
attach(el)appends it and starts the loop; the function it returns cancels the loop and takes the canvas out. A remount appends the same canvas again, so the WebGL context and every output survive (a secondnew Hydraon a second canvas is a second GPU context and the first one's sketch is lost). - The mount effect guards the async attach: when the component unmounts
before the engine arrives, the late
detachruns at once (thegoneflag in the examples), so a fast remount never leaves two loops. - The canvas renders at 1280×720 and CSS scales it into a 16:9 stage
(
aspect-video). That is sharp on a laptop and light enough for a phone;hydra.setResolution(w, h)changes it when an app needs more. hush()blanks every output and forgetsupdate; a "Blank" button and a scene change between sets call it. The loop keeps running, so the nextrun()shows at once.- Full screen is the stage element's own
requestFullscreen(), which the vibe frame allows; the canvas fills it and keeps its aspect.
Mobile
The stage sits at the top at full width, and the editor or knobs follow
under it. Typing a sketch on a phone is slow, so a phone-first app leads with
example chips, knobs and a "describe it" box, and keeps the editor one tap
away. The camera button names what it does ("Use my camera"); the front camera
is the default. Phones render Hydra at highp precision (the engine picks it
on iOS); a 1280×720 canvas keeps frames smooth on a recent phone.
Persistence — the sketch text is the document
Save the sketch text itself (code, or the knob values a sketch compiles
from) with a name, the author's handle and a timestamp; the engine rebuilds
the picture from it on every run, so a saved document is tiny and remixable.
A set that pairs music and picture saves both strings. The shared gallery is
one useLiveQuery over the sketch type, and the access rule above makes it
public to read and author-owned to write; a signed-out visitor plays with the
pad and is invited to sign in at "Keep".
Shapes a Hydra vibe takes
- A sketch pad — stage, editor, examples, a prompt box that rewrites the sketch, saved sketches. The starter above.
- A video synth — knobs that write the sketch, the camera as a source, the written sketch shown underneath. The second example above.
- Beat visuals — a Strudel pattern and a Hydra sketch played together,
the beat kicking
a.fft. The third example above. - A VJ deck — a row of saved sketches as pads,
hush()between them, a full-screen stage on a second screen, the room's sound throughlisten(). - A photo booth — the camera through kaleidoscopes and feedback, and a
"Keep this frame" press. Add a
snapshot()tolib/hydra.jsthat wraps the engine'shydra.getScreenImage((blob) => …), which captures the next frame as a PNG blob; a plaincanvas.toDataURL()reads blank, because the WebGL canvas is cleared after every frame. - A jam room — the sketch document is shared, every member's edit is a version, and the gallery doubles as the set list.
Common gotchas
- Every name in the sketch is undefined — the sketch went through
hydra.eval()withmakeGlobal: false. Run it throughrun()and itswithblocks. - The microphone prompt appears on load —
detectAudiowas left on. Set it false and calllisten()from a press. a.fftreads undefined — the sketch ran outsiderun(), which passes the module'saudioasa.- The picture floods to one colour — a feedback chain scales or blends
too much. Keep
src(o0).scalewithin 0.95 to 1.05 and the blend under 0.2. - An image or video source stays black — the URL is not CORS-open. Use a
host that sends
Access-Control-Allow-Origin(jsDelivr, a vibe's own assets), or the camera. - The camera light stays on after leaving — the stop function from
startCamera()was dropped. Keep it in a ref and call it in the cleanup. - Two pictures fight or the frame rate halves — two loops or two engines.
One memoized loader, one canvas, one
attachper stage. - A sketch built from typed text breaks on a quote — strip or escape the quotes before dropping a person's text into a sketch string.