Music Visualizerbeta

Build the visuals. In code.

Write your own visualizer styles, particle fields, backgrounds, lyric treatments, and effects as small plugins. Upload one, and it renders identically in the live editor preview and in every exported video.

npm i @vocaler-ai/composer-sdk

The SDK gives you six kinds of component to build — spectrum (frequency-reactive styles), particle (fields of moving particles), background (generated backdrops), lyric (timed lyric styles), effect (post-processing passes), and overlay (borders and chrome).

Quickstart

A plugin is one file. It exports a descriptor with a draw(frame) function that paints one layer. Here is a complete, valid spectrum plugin that draws audio-reactive bars:

import { defineStyle, type FrameContext } from "@vocaler-ai/composer-sdk";

const MAX_BARS = 128; // a hard cap keeps a frame cheap

function draw(frame: FrameContext): void {
  const { ctx, layout, frequencyData, accent } = frame;
  const cfg = (frame.spectrumConfig ?? {}) as Record<string, number | undefined>;
  const bars = Math.min(MAX_BARS, Math.round(cfg.bars ?? 64)); // ?? not || — honour 0
  const w = layout.width / bars;
  ctx.save(); ctx.fillStyle = accent;
  for (let i = 0; i < bars; i++) {
    const mag = frequencyData[(i * frequencyData.length / bars) | 0] / 255;
    const h = mag * layout.height * 0.9;
    ctx.fillRect(i * w + 1, layout.baselineY - h, w - 2, h);
  }
  ctx.restore();
}

export default defineStyle({
  id: "pulse-bars",
  name: "Pulse Bars",
  knobs: { bars: { min: 8, max: 128, default: 64, step: 2, label: "Bars" } },
  draw,
});

That is the entire shape. Every kind is authored the same way — define<Kind>({ id, name, knobs, draw }) — so once you know one, you know all six. What changes per kind is which frame fields you read.

Core concepts

One draw call per frame. The engine calls your draw(frame) once for every output frame and hands you a 2D canvas plus everything you need to paint that instant — the clock, the audio, the layout, and your knob values. You draw one layer; the engine composites all the layers together.

Deterministic by contract. Your plugin must be a pure function of the frame's inputs — the same time and audio must always produce the same pixels. That is what lets a long video render split across machines and stitch back seamlessly, and what keeps the live preview identical to the exported file. No Math.random(), no Date.now(), no wall clock. (Particles get their seed from the engine — see below.)

Knob-driven. Everything a user should be able to tune is a declared knob. You give each knob a range and a default; the editor renders the sliders and color pickers automatically and passes the chosen values back to your draw.

Gated before it ships. Every uploaded plugin runs through an automated gate that checks it is deterministic, stays within a render-cost budget, and can't be broken by extreme knob values. Passing is the bar for going live — and it's easy to pass if you follow the rules in this guide.

The six kinds

Each kind is a layer of the composition. They share the descriptor shape and the draw(frame) signature; they differ in the frame fields they read.

Kind Factory Reads (beyond the shared fields)
spectrum defineStyle frequencyData, waveformData, energy, bass
particle defineParticle particleSeed, particleStrength, particleConfig
background defineBackground backgroundSettings, energy
lyric defineLyric lyricCues, lyricTimeMs, lyricStylePlan
effect defineEffect the drawn pixels, effectConfig
overlay defineOverlay overlayConfig

Spectrum — a visualizer style: bars, rings, orbits, waveforms that react to the audio spectrum. Reads frequencyData (per-frame FFT, 0..255) and the smoothed energy/bass scalars. Must change with both time and audio. Spectrum knobs are numeric only.

Particle — a field of particles: snow, dust, embers. A particle plugin is seeded rather than pure-of-time: the engine gives you frame.particleSeed, and you derive every particle's motion from that seed plus its index and the clock. That lets the renderer draw any frame in isolation and still reproduce the field a from-the-start render would show. Always seed from frame.particleSeed — never generate your own seed, never call Math.random(). Fold frame.particleStrength into count, size, and alpha.

Background — a generated backdrop painted behind everything: gradients, auroras, fields. Reads its knob values from frame.backgroundSettings (numbers and color arrays) and may react to energy.

Lyric — a lyric rendering style: karaoke fills, word pops, glows. Reads the engine-parsed lyricCues, the current position in milliseconds (lyricTimeMs), the text direction, and a compiled lyricStylePlan. Lyric plugins are the one kind that doesn't read audio — their liveness comes from the lyric clock.

Effect — a post-processing pass that runs after the scene is composited: tints, scanlines, grain, blooms. Reads the already-drawn pixels from frame.ctx and its effectConfig, and repaints. Keep it cheap; it touches the whole frame.

Overlay — decorative chrome drawn on top: borders, frames, vignettes. Reads its overlayConfig. The lightest kind: draw your frame furniture and return.

The frame

The engine calls draw(frame) with a FrameContext. These are the only fields on it. The shared fields mean the same thing for every kind; the kind-specific fields appear only when a matching plugin is selected.

Field Type Meaning
ctx CanvasRenderingContext2D The 2D canvas you draw into.
time number Seconds since the render started — your motion clock.
energy number Smoothed broadband energy, 0..1.
bass number Low-band energy, 0..1.
layout Layout Canvas geometry (see below).
accent string Resolved accent color, "#rrggbb".
frequencyData Uint8Array Per-frame FFT magnitudes, 0..255.
waveformData Uint8Array Per-frame waveform, 0..255, centered at 128.
intensity number User intensity, 0..2.4 (default 0.9).
smoothing number User smoothing, 0.35..0.95.
spectrumConfig? object spectrum — your knob values by name.
particleSeed? number particle — engine-injected seed.
particleStrength? number particle — user intensity scalar.
particleConfig? object particle — knobs plus count / size / offset.
backgroundSettings? object background — knob values.
lyricCues? LyricCue[] lyric — parsed timed cues.
lyricTimeMs? number lyric — position in milliseconds.
lyricStylePlan? object lyric — compiled style plan.
effectConfig? object effect — config bag.
overlayConfig? object overlay — config bag.

Layout gives geometry: width, height, centerX, centerY, baselineY (bottom of the visualizer band), and halfWidth. It also carries the user's transform, which you should honor: read it as layout.scale ?? 1, layout.shiftX ?? 0, layout.shiftY ?? 0.

Idiom: read every knob and optional field with ??, never ||. A slider set to 0 is a real value — 0 || d would wrongly discard it. Always fall back to your declared default.

Knobs

Knobs are how users tune your plugin. Declare them in the descriptor; the editor builds the controls and passes the values back through the frame's config field for your kind.

A numeric knob:

{
  min?: number;     // optional in the type — always declare it
  max?: number;     // optional in the type — always declare it
  default: number;  // required, finite, within [min, max]
  step?: number;    // slider granularity
  label?: string;   // friendly name
  type?: "slider" | "color"; // default "slider"
}

A type: "color" knob carries an integer RGB value (0..16777215). Read and format it as:

const hex = "#" + ((cfg.color ?? 0x22d3ee) & 0xffffff).toString(16).padStart(6, "0");

A color-list knob — every kind except spectrum can declare a palette of 1–5 hex colors:

{
  type: "colorList";          // the discriminant
  default: readonly string[]; // 1..(max ?? 5) entries, each "#rrggbb"
  max?: number;               // count bound, default 5
  label?: string;
}

Cost knobs. A knob that scales render cost — a particle count, a bar count, a trail length — is a cost knob. Give it a sane max and clamp it in your draw so no value can blow up the frame. The gate probes cost knobs at their maximum, so this is what earns a pass:

const MAX_SPOKES = 256; // hard cap in code
const n = Math.min(MAX_SPOKES, Math.max(8, Math.round(cfg.spokes ?? 72)));

Worked example

A complete, gate-passing spectrum plugin: radial neon spokes that pulse and rotate with the audio. It shows every idiom — defensive knob reads, a clamped cost knob, an integer-RGB color knob, and reactivity driven purely by time and frequencyData.

import { defineStyle, sampleFrequency, type FrameContext } from "@vocaler-ai/composer-sdk";

const MAX_SPOKES = 256;

function draw(frame: FrameContext): void {
  const { ctx, layout, frequencyData, time, intensity } = frame;
  const cfg = (frame.spectrumConfig ?? {}) as Record<string, number | undefined>;
  const W = layout.width, H = layout.height, scaleF = layout.scale ?? 1;
  const cx = W / 2 + (layout.shiftX ?? 0), cy = H / 2 + (layout.shiftY ?? 0);
  const n = Math.min(MAX_SPOKES, Math.max(8, Math.round(cfg.spokes ?? 72)));
  const spin = cfg.spin ?? 0.25, reach = cfg.reach ?? 0.34;
  const col = "#" + ((cfg.color ?? 0x22d3ee) & 0xffffff).toString(16).padStart(6, "0");
  const baseLen = Math.min(W, H) * reach * scaleF;

  ctx.save(); ctx.lineCap = "round"; ctx.strokeStyle = col; ctx.shadowColor = col;
  for (let i = 0; i < n; i++) {
    const t = i / n, ang = t * Math.PI * 2 + time * spin;
    const band = sampleFrequency(frequencyData, 0.03 + t * 0.9); // audio-reactive
    const len = baseLen * (0.35 + band * intensity * 0.9);
    ctx.globalAlpha = 0.35 + band * 0.6;
    ctx.lineWidth = Math.max(0.5, (cfg.thickness ?? 2) * (0.5 + band));
    ctx.shadowBlur = (cfg.glow ?? 16) * (0.4 + band * 0.6);
    ctx.beginPath();
    ctx.moveTo(cx + Math.cos(ang) * baseLen * 0.12, cy + Math.sin(ang) * baseLen * 0.12);
    ctx.lineTo(cx + Math.cos(ang) * len, cy + Math.sin(ang) * len);
    ctx.stroke();
  }
  ctx.restore();
}

export default defineStyle({
  id: "neon-starburst", name: "Neon Starburst", swatch: "#22d3ee",
  knobs: {
    spokes:    { min: 8, max: 200, default: 72, step: 4, label: "Spokes" }, // cost knob
    spin:      { min: 0, max: 1.2, default: 0.25, step: 0.01, label: "Spin" },
    reach:     { min: 0.1, max: 0.5, default: 0.34, step: 0.01, label: "Reach" },
    thickness: { min: 0.5, max: 6, default: 2, step: 0.5, label: "Thickness" },
    glow:      { min: 0, max: 40, default: 16, step: 1, label: "Glow" },
    color:     { type: "color", min: 0, max: 16777215, default: 0x22d3ee, label: "Color" },
  },
  draw,
});

Determinism

Determinism is the one hard rule. It makes exports reproducible and previews faithful. Break it and your plugin fails the gate.

  • Derive everything from the frame. The only variation you may read is frame.time, the audio fields, and your knob config (plus particleSeed for particles).
  • No entropy or wall clock. Never call Math.random(), Date.now(), or new Date(). The gate scans your source for these.
  • Particles seed from the engine. Use frame.particleSeed for per-particle randomness — never a seed you generate.
  • Be alive. Output must change with time (a frame at 2s ≠ a frame at 7s) and, for audio kinds, with the audio. A static drawing is flagged as non-reactive.

Why it matters: a plugin that seeds from Math.random() renders a different field on every machine — so a distributed video export tears at the seams. That's exactly what this rule prevents.

The gate

When you upload a plugin, it runs through an automated gate before it can go live. It isn't adversarial — it checks the same guarantees this guide asks for.

Check What it verifies How you pass
Determinism No entropy or wall clock; identical inputs give identical output; the layer is alive. Follow the determinism rules.
Budget A frame stays within a render-cost budget, even with knobs at their limits. Clamp cost knobs; keep per-frame work bounded.
Render-safety No knob value can make a frame explode in cost or throw. Give every cost knob a max and enforce it in draw.

#1 tip: the most common false rejection is a cost knob without a code-side clamp. The gate probes your knobs at their declared maximums at once — if Math.min(CAP, …) guards each one, you pass.

Publish and enable

Publishing is one step: upload your .ts source file directly. The studio compiles it (with the SDK inlined) and runs it through the gate in the same request, then shows you the result. There is no separate build step and no hand-made bundle to prepare.

  1. Author — write one *.ts file that exports a single define<Kind> descriptor as its default export, importing only from @vocaler-ai/composer-sdk.
  2. Let the slug match your id. The slug a plugin is stored under must equal the descriptor's own id — e.g. id: "neon-starburst" publishes under the slug neon-starburst. When you pick your file, the studio reads the id and fills the slug in for you, so the simplest path is to leave it alone. If the slug and id disagree the upload is rejected with a message naming both, so you can fix either one.
  3. Upload — choose your .ts file in the plugin studio (.js / .tsx / .jsx are accepted too). It compiles and gates inline and is stored disabled with its gate result attached. If the source doesn't compile, the upload is rejected with the compiler error and nothing is stored — fix it and upload again.
  4. Gate — the automated gate checks determinism, budget, and render-safety and records the result. All six kinds are gated the same way.
  5. Enable — once it passes, enable it. It appears in the editor picker for its kind, previews live, and renders in every export — the same pixels in both.

Author in a real code editor. Editors that "smart-quote" straight quotes ("" ") break import and fail compilation with something like Expected ; but found {. Write and paste plain-text .ts from a code editor, not a notes or word processor app.

Already have a compiled bundle? A pre-compiled, self-contained ES module still uploads the same way — compiling on upload just means you rarely need to build one yourself.

Best practices

  • Cap before you loop. Compute clamped counts once, then draw. A const CAP in module scope documents the ceiling.
  • Batch canvas state. Set strokeStyle, shadowColor, and friends once outside the loop; wrap the whole draw in ctx.save() / ctx.restore() so you never leak state to the next layer.
  • Default to "normal". A knob's default is the value at which the plugin looks right. Absent config must render that default.
  • Honor the transform. Apply layout.scale / shiftX / shiftY so your plugin respects the user's size and position controls.
  • Make reactivity visible. Tie a length, alpha, or glow to energy or frequencyData so the layer breathes with the music.
  • Keep effects cheap. Effect and overlay passes touch the whole frame every frame; favor simple compositing over per-pixel work.

Troubleshooting

Symptom Likely cause Fix
Rejected: entropy detected Math.random / Date.now in source Derive from time + audio; seed particles from particleSeed.
Rejected: over budget An unclamped cost knob at its max Clamp with Math.min(CAP, …); lower the knob's max.
Rejected: not reactive Output doesn't change with time / audio Drive motion from time; tie a field to energy.
Throws at upload Bad id or default out of range id must be ^[a-z0-9-]{1,64}$; defaults finite and within [min,max].
Rejected: slug doesn't match the plugin's id Upload slug ≠ the descriptor id Publish under the exact id — the studio auto-fills the slug from your file, so re-pick it.
Won't compile: Expected ; but found { Smart quotes from a notes app broke import Re-author and paste as plain-text .ts from a code editor.
"The plugin gate is warming up — try again" The gate service was briefly cold or unreachable Nothing was stored; wait a few seconds and upload again.
A "0" slider snaps back Reading knobs with || Use ?? default everywhere.
Preview ≠ export Hidden non-determinism Remove cross-frame mutable state; recompute from the frame.

Reference

Factory signatures

Kind Factory Knob types
spectrum defineStyle(def) numeric
particle defineParticle(def) numeric + colorList
background defineBackground(def) numeric + colorList
lyric defineLyric(def) numeric + colorList
effect defineEffect(def) numeric + colorList
overlay defineOverlay(def) numeric + colorList

Validation rules

  • id matches ^[a-z0-9-]{1,64}$ (lowercase kebab-case, 1–64 chars).
  • Every numeric default is finite and within [min, max] when bounds are given.
  • A colorList default has 1–(max ?? 5) entries, each matching ^#[0-9a-fA-F]{6}$.
  • The descriptor is frozen after construction — id, knobs, and defaults can't drift at runtime.

Optional helpers. The SDK re-exports the engine's own math, seeded-random, and color helpers so a plugin built on them stays consistent with the built-ins. You never have to use them — you can draw entirely with frame.ctx and Math — but when you need an audio tap or a seeded stream, prefer the provided helper (for example sampleFrequency(frequencyData, ratio)) over rolling your own.