Voiceprint
Audio Waveform Background for React
A background that prints a voice as it speaks. Sound comes in at the middle as a stroke of dashes as tall as it is loud, then travels out to both edges, so a sentence spreads across the section like a pressed waveform. Each speaker prints in their own color, and in silence it settles back into a calm dot grid.
- Background
- AI
- Canvas
Uses clsx, tailwind-merge
<Voiceprint />The demo follows as you drag.
A scripted call: Marlow, a voice agent, books a same-day appointment for a patient with a chipped tooth.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Voiceprint } from "@/components/ui/voiceprint";
type Speaker = "agent" | "caller";
const CALL: { who: Speaker; text: string }[] = [
{ who: "agent", text: "Northside Dental, this is Marlow. How can I help?" },
{ who: "caller", text: "Hi, I chipped a tooth last night. Can someone see me today?" },
{ who: "agent", text: "Sorry to hear that. Dr. Osei has a 3:40 this afternoon. Shall I hold it for you?" },
{ who: "caller", text: "Yes please, 3:40 is perfect." },
{ who: "agent", text: "Booked. I'm texting you the address and a two-minute intake form now." },
{ who: "caller", text: "Great, thank you so much." },
];
// The call's clock, built once: every word gets a start and its syllables
// become loudness bumps, so the print and the transcript move together.
const SYLLABLE = 0.17; // s per syllable
const GAP = 0.06; // s between words
const COMMA = 0.28; // s after a comma
const STOP = 0.42; // s after a full stop or a question
const TURN = 0.75; // s between speakers
const LEAD = 0.6; // s of quiet before the first line
const REST = 1.8; // s of quiet before the call starts again
const LOUD = { agent: 0.62, caller: 0.5 }; // peak loudness of each voice
type Beat = { start: number; end: number; amp: number };
type Word = { text: string; start: number; end: number; beats: Beat[] };
type Line = { who: Speaker; start: number; end: number; words: Word[] };
function syllables(word: string) {
const letters = word.toLowerCase().replace(/[^a-z]/g, "");
if (!letters) return Math.max(1, word.replace(/\D/g, "").length); // "3:40" is three-forty
const groups = letters.replace(/e$/, "").match(/[aeiouy]+/g);
return Math.max(1, groups ? groups.length : 1);
}
function buildCall() {
let seed = 11;
const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
let t = LEAD;
const lines: Line[] = CALL.map(({ who, text }) => {
const start = t;
const words = text.split(" ").map((word) => {
const wordStart = t;
const beats: Beat[] = [];
for (let i = 0; i < syllables(word); i++) {
const length = SYLLABLE * (0.8 + 0.4 * random());
beats.push({ start: t, end: t + length, amp: LOUD[who] * (0.7 + 0.3 * random()) });
t += length;
}
const end = t;
t += GAP;
if (word.endsWith(",")) t += COMMA;
if (/[.?!]$/.test(word)) t += STOP;
return { text: word, start: wordStart, end, beats };
});
const end = t;
t += TURN;
return { who, start, end, words };
});
return { lines, duration: t + REST };
}
const TIMELINE = buildCall();
function levelAt(time: number) {
for (const line of TIMELINE.lines) {
if (time < line.start || time > line.end) continue;
for (const word of line.words) {
if (time < word.start || time > word.end) continue;
for (const beat of word.beats) {
if (time < beat.start || time > beat.end) continue;
const p = (time - beat.start) / (beat.end - beat.start);
// A rounded bump per syllable with a little flutter, over a floor so a word never drops to silence inside.
return Math.max(0.08, beat.amp * Math.pow(Math.sin(Math.PI * p), 0.7) * (0.92 + 0.08 * Math.sin(time * 53)));
}
}
}
return 0;
}
/** Which line the transcript shows, and how many of its words have been said. The next line takes over halfway through the pause before it. */
function cueAt(time: number) {
let line = 0;
TIMELINE.lines.forEach((l, i) => {
if (time >= l.start - TURN / 2) line = i;
});
const words = TIMELINE.lines[line].words.filter((w) => time >= w.start).length;
return { line, words, second: Math.max(0, Math.floor(time - LEAD)) };
}
const clock = (second: number) => `${String(Math.floor(second / 60)).padStart(2, "0")}:${String(second % 60).padStart(2, "0")}`;
type Mic =
| { status: "off" }
| { status: "asking" }
| { status: "on"; stream: MediaStream }
| { status: "blocked"; reason: string };
function MicIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-4">
<rect x="5.5" y="1.75" width="5" height="8" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function VoiceprintDemo() {
const start = React.useRef(0);
const alive = React.useRef(true);
const [cue, setCue] = React.useState({ line: 0, words: 0, second: 0 });
const [mic, setMic] = React.useState<Mic>({ status: "off" });
const callTime = React.useCallback(() => {
if (!start.current) return 0;
return ((performance.now() - start.current) / 1000) % TIMELINE.duration;
}, []);
// Read by the print every frame, so the call never re-renders the page at 60fps.
const level = React.useCallback(() => levelAt(callTime()), [callTime]);
React.useEffect(() => {
alive.current = true;
start.current = performance.now();
const timer = window.setInterval(() => {
const next = cueAt(callTime());
setCue((c) => (c.line === next.line && c.words === next.words && c.second === next.second ? c : next));
}, 50);
return () => {
alive.current = false;
window.clearInterval(timer);
};
}, [callTime]);
// Hang up the microphone when it's switched off or the demo goes away.
React.useEffect(() => {
if (mic.status !== "on") return;
const stream = mic.stream;
return () => stream.getTracks().forEach((track) => track.stop());
}, [mic]);
async function toggleMic() {
if (mic.status === "on") {
setMic({ status: "off" });
return;
}
if (mic.status === "asking") return;
if (!navigator.mediaDevices?.getUserMedia) {
setMic({ status: "blocked", reason: "No microphone here, so the demo call keeps playing." });
return;
}
setMic({ status: "asking" });
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
});
if (!alive.current) {
stream.getTracks().forEach((track) => track.stop());
return;
}
setMic({ status: "on", stream });
} catch {
if (alive.current) setMic({ status: "blocked", reason: "The microphone is blocked, so the demo call keeps playing." });
}
}
const talking = mic.status === "on";
const line = TIMELINE.lines[cue.line];
const agent = !talking && line.who === "agent";
return (
<Voiceprint
level={level}
source={talking ? mic.stream : null}
tone={agent ? "accent" : "ink"}
reach={0.6}
className="flex h-[var(--demo-height,36rem)] w-full flex-col items-center justify-between rounded-2xl border border-zinc-950/10 bg-[#fafafa] px-6 py-8 text-center text-zinc-950 sm:py-10 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-50"
>
<header className="flex flex-col items-center">
<h2 className="max-w-[16ch] text-4xl/[1.05] font-semibold tracking-[-0.03em] text-balance sm:text-[3.25rem]/[1.02]">
The front desk that picks up on the first ring
</h2>
</header>
<div className="flex w-full max-w-xl flex-col items-center">
<p className="sr-only">
A scripted call: Marlow, a voice agent, books a same-day appointment for a patient with a chipped tooth.
</p>
<div aria-hidden className="min-h-[5.5rem] w-full sm:min-h-[4.5rem]">
<p className="flex items-center justify-center gap-2 text-[11px] font-medium tracking-[0.08em] text-zinc-500 uppercase">
<span
className={cn(
"size-1.5 rounded-full",
agent ? "bg-[#2747ff] dark:bg-[#6b84ff]" : "bg-zinc-950 dark:bg-zinc-50",
)}
/>
{talking ? "You" : agent ? "Marlow" : "Patient"}
<span className="text-zinc-400 tabular-nums dark:text-zinc-600">
· {talking ? "Live mic" : `Northside Dental · ${clock(cue.second)}`}
</span>
</p>
<p className="mt-2 text-lg/7 font-medium text-balance">
{talking
? "Listening. Say a few words and watch them print."
: line.words.map((word, i) => (
<span
key={`${cue.line}-${i}`}
className={cn(
"transition-colors duration-150",
i < cue.words ? "text-zinc-950 dark:text-zinc-50" : "text-zinc-950/20 dark:text-zinc-50/20",
)}
>
{word.text}{" "}
</span>
))}
</p>
</div>
<div className="mt-5 flex items-center gap-1">
<button
type="button"
onClick={toggleMic}
aria-pressed={talking}
className="inline-flex h-11 items-center gap-2 rounded-full bg-zinc-950 px-5 text-sm font-medium text-white transition-transform active:scale-[0.97] dark:bg-white dark:text-zinc-950"
>
<MicIcon />
{talking ? "Stop" : mic.status === "asking" ? "Allow the mic…" : "Talk to it"}
</button>
<a
href="https://marlow.example/demo-line"
className="inline-flex h-11 items-center rounded-full px-4 text-sm font-medium text-zinc-600 transition-colors hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50"
>
Call the demo line
</a>
</div>
<p aria-live="polite" className="mt-2 min-h-4 text-xs text-zinc-500">
{mic.status === "blocked" ? mic.reason : ""}
</p>
</div>
</Voiceprint>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Voiceprint: A background that prints a voice as it speaks. Sound comes in at the middle as a stroke of dashes as tall as it is loud, then travels out to both edges, so a sentence spreads across the section like a pressed waveform. Each speaker prints in their own color, and in silence it settles back into a calm dot grid. This demo, Voice Agent Hero: A clinic's voice agent books a same-day appointment on a looping call, agent in blue and patient in ink, and Talk to it prints your own voice from the microphone. 1. Install it: npx shadcn@latest add @keyframeui/voiceprint-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/voiceprint-demo.json instead.) It adds the demo, components/voiceprint-demo.tsx, and the component, components/ui/voiceprint.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Voiceprint> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 90): How fast what was said travels out to the edges, in px per second. - spacing (default 10): Distance between dots, in px. - reach (default 0.9): How far the loudest sound reaches from the middle line, as a share of half the height. Lower it to keep a headline above the print clear. - attack (default 0.03): How quickly the level rises to a new sound, in seconds (a time constant). Longer softens the start of each stroke. - release (default 0.14): How quickly the level falls away after a sound, in seconds (a time constant). Longer smears syllables into one another; shorter prints each as its own stroke. - falloff (default 0.6): Share of its ink a sound has lost by the time it reaches the edge, from 0 (none) to 1 (all of it). 4. Don't change, in components/ui/voiceprint.tsx: - Every frame runs in `createPrint`, outside React: one rAF loop draws to a canvas and reads a function `level` each frame, and it sleeps once the last sound has left both edges, off screen, or in a hidden tab. - Nothing moves: each column shows the loudest history sample from as long ago as sound takes to reach it at `speed`, so sound travels out mirrored, and each sample keeps its tone. - Marks are batched into one stroke per tone and opacity step, and the resting grid is drawn once per size and color, then copied under every frame. - A `source` runs through a noise gate (a floor that drops fast and creeps up slowly, shut while it warms up), so tape hiss and room tone never print. - Reduced motion keeps the print still: a band around the middle takes the speaker's color by opacity alone, read from a `useSyncExternalStore` flag that's false while hydrating. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/voiceprint.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/voiceprint.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/voiceprintInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/voiceprint.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /** * Keep: * - Every frame runs in `createPrint`, outside React: one rAF loop draws to a canvas and reads a * function `level` each frame, and it sleeps once the last sound has left both edges, off * screen, or in a hidden tab. * - Nothing moves: each column shows the loudest history sample from as long ago as sound takes * to reach it at `speed`, so sound travels out mirrored, and each sample keeps its tone. * - Marks are batched into one stroke per tone and opacity step, and the resting grid is drawn * once per size and color, then copied under every frame. * - A `source` runs through a noise gate (a floor that drops fast and creeps up slowly, shut while * it warms up), so tape hiss and room tone never print. * - Reduced motion keeps the print still: a band around the middle takes the speaker's color by * opacity alone, read from a `useSyncExternalStore` flag that's false while hydrating. */ const TIMING = { /** What was said traveling out to both edges, in px/s (the `speed` prop). */ speed: 90, /** The level rising to a new sound, as a time constant in seconds (the `attack` prop). */ attack: 0.03, /** The level falling away after a sound, as a time constant in seconds (the `release` prop). */ release: 0.14, /** History samples per second; each column reads the past from these. */ sampleRate: 60, /** The noise floor dropping to a quieter moment, as a time constant in seconds. */ noiseFall: 0.08, /** The noise floor creeping up under steady sound, as a time constant in seconds. */ noiseRise: 3, /** How fast it rises while it learns the hiss, as a time constant in seconds. */ noiseLearn: 0.15, /** How long a new source learns its hiss, in seconds. */ learn: 1, /** The gate staying shut when a source starts, so the first hiss never prints, in seconds. */ warmup: 0.3, /** Between level checks while asleep, in ms. */ poll: 100, } as const; /* ───────────────────────────────────────────────────────── * VOICEPRINT STORYBOARD * * rest a calm grid of faint dots, one every `spacing` * px, fading out toward the edges * speak sound comes in at the middle column. Dots inside * its loudness envelope (above and below the middle * line, up to `reach` of half the height) stretch * into short vertical dashes in the speaker's color. * The level rises in 30ms and falls in 140ms, so a * syllable prints as one stroke * travel what was said moves outward to both edges at * `speed` px/s, mirrored, so a sentence spreads * across the surface like a pressed waveform. Sound * loses 60% of its ink on the way to the edge * turn `tone` switches speakers. Sound already printed * keeps the color it was spoken in, so a call reads * as two colors leaving the middle * listen a `source` runs through a noise gate: the floor * drops to any quieter moment in 80ms and creeps up * under steady sound over 3s (0.15s in the first * second, while it learns the hiss), and the gate is * shut for the first 0.3s, so tape hiss and room * tone never print * silence once the last sound has left both edges the field * is back at rest, and the loop sleeps until the * level rises again (checked every 100ms) * paused the print holds still where it is and the level * isn't read * sleep off screen or in a hidden tab nothing draws, and * the print picks up where it stopped * reduced nothing travels: the dots in a band around the * middle take on the speaker's color as loud as the * voice is, by opacity alone, and go back to rest in * silence * ───────────────────────────────────────────────────────── */ const VOICEPRINT = { dot: 1.6, // px, diameter of a resting dot and the width of a faint dash thicken: 0.45, // a dash at full ink is this much wider than a dot dash: 0.5, // a dash at full depth is this share of `spacing` long; shorter keeps the grid showing through rest: 0.18, // opacity of a resting dot soft: 1.4, // spacings over which a dot inside the envelope grows into a full dash history: 4096, // samples kept: 68s at 60 a second, enough for any width at slow speeds ageFade: 0.6, // share of its ink a sound has lost when it reaches the edge (`falloff`) edge: 0.14, // share of the width the field fades out over at each side edgeY: 0.06, // share of the height it fades out over at the top and bottom silent: 0.02, // a level under this counts as silence levels: 10, // opacity steps the marks are batched into gain: 1.8, // microphone and media loudness: sqrt(rms over the noise floor) × gain … floor: 0.04, // … minus this gate: [1.5, 2.3], // loudness over the noise floor, as a ratio, from gate shut to fully open fft: 1024, // analyser window, in samples band: [0.06, 0.3], // reduced: the lit band fades out this far from the middle, as shares of the width bandY: [0.08, 0.3], // and of the height bandInk: 0.9, // reduced: opacity of the band's dots at full loudness maxDpr: 2, } as const; type Tone = "accent" | "ink"; type Level = number | (() => number); type Options = { level: Level; tone: Tone; speed: number; spacing: number; reach: number; attack: number; release: number; falloff: number; paused: boolean; reduced: boolean; }; const clamp01 = (t: number) => Math.min(1, Math.max(0, t)); const smoothstep = (a: number, b: number, x: number) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); }; const REDUCED = "(prefers-reduced-motion: reduce)"; const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(REDUCED); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }; /** Reduced motion from the media query, false on the server and in the hydration render. */ function useReduced() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED).matches, () => false, ); } /* ── Listening ─────────────────────────────────────────── */ // A media element can feed only one source node, ever, and its sound then // plays through that node's context. So elements share one context that is // never closed (closing it would mute them), wired on the element's first // play so no context is made before a gesture. Streams get their own. type Ear = { analyser: AnalyserNode | null; // null until a media element first plays data: Float32Array<ArrayBuffer>; noise: number; // running estimate of the source's noise floor (rms), -1 before the first reading heard: number; // s of sound read since the source started idle: () => boolean; // a paused element: its silence says nothing about its noise release: () => void; }; let mediaContext: AudioContext | null = null; const mediaNodes = new WeakMap<HTMLMediaElement, MediaElementAudioSourceNode>(); function createAnalyser(context: AudioContext) { const analyser = context.createAnalyser(); analyser.fftSize = VOICEPRINT.fft; return analyser; } function listen(source: HTMLMediaElement | MediaStream): Ear | null { if (typeof window.AudioContext === "undefined") return null; const ear: Ear = { analyser: null, data: new Float32Array(VOICEPRINT.fft), noise: -1, heard: 0, idle: () => false, release: () => {}, }; try { if (source instanceof MediaStream) { if (source.getAudioTracks().length === 0) return null; const context = new AudioContext(); const node = context.createMediaStreamSource(source); const analyser = createAnalyser(context); // Not connected to the speakers: a microphone would echo. node.connect(analyser); void context.resume(); ear.analyser = analyser; ear.release = () => { node.disconnect(); void context.close(); }; return ear; } const element = source; let node: MediaElementAudioSourceNode | null = null; let analyser: AnalyserNode | null = null; const wire = () => { ear.noise = -1; ear.heard = 0; try { mediaContext ??= new AudioContext(); void mediaContext.resume(); if (analyser) return; node = mediaNodes.get(element) ?? null; if (!node) { node = mediaContext.createMediaElementSource(element); node.connect(mediaContext.destination); mediaNodes.set(element, node); } analyser = createAnalyser(mediaContext); node.connect(analyser); ear.analyser = analyser; } catch { // Already wired into another context: it reads as silence. } }; if (!element.paused) wire(); element.addEventListener("play", wire); ear.idle = () => element.paused; ear.release = () => { element.removeEventListener("play", wire); if (!node || !analyser) return; try { node.disconnect(analyser); } catch { // already disconnected } }; return ear; } catch { return null; } } function loudness(ear: Ear, dt: number) { const analyser = ear.analyser; if (!analyser || ear.idle()) return 0; analyser.getFloatTimeDomainData(ear.data); let sum = 0; for (let i = 0; i < ear.data.length; i++) sum += ear.data[i] * ear.data[i]; const rms = Math.sqrt(sum / ear.data.length); // A noise floor that drops at once to a quieter moment and creeps up under // steady sound, so tape hiss and room tone sit under it and read as silence. ear.heard += dt; if (ear.noise < 0) ear.noise = rms; else { const rise = ear.heard < TIMING.learn ? TIMING.noiseLearn : TIMING.noiseRise; const tau = rms < ear.noise ? TIMING.noiseFall : rise; ear.noise += (rms - ear.noise) * (1 - Math.exp(-dt / tau)); } if (ear.heard < TIMING.warmup) return 0; const open = smoothstep(VOICEPRINT.gate[0], VOICEPRINT.gate[1], rms / Math.max(ear.noise, 1e-4)); return clamp01(Math.sqrt(Math.max(0, rms - ear.noise)) * VOICEPRINT.gain - VOICEPRINT.floor) * open; } /* ── Print ─────────────────────────────────────────────── */ // Everything that runs per frame, outside React. function createPrint() { let root: HTMLElement | null = null; let canvas: HTMLCanvasElement | null = null; let ctx: CanvasRenderingContext2D | null = null; let probes: { ink: HTMLElement; accent: HTMLElement } | null = null; let colors: [string, string] = ["#18181b", "#2747ff"]; // by tone index: 0 ink, 1 accent let restLayer: HTMLCanvasElement | null = null; let options: Options = { level: 0, tone: "accent", speed: TIMING.speed, spacing: 10, reach: 0.9, attack: TIMING.attack, release: TIMING.release, falloff: VOICEPRINT.ageFade, paused: false, reduced: false, }; let ear: Ear | null = null; // The grid, rebuilt when the root changes size. let width = 0; let height = 0; let dpr = 1; let cols = 0; let rows = 0; let ox = 0; let oy = 0; let colAge = new Float32Array(0); // s back in time each column shows let colFade = new Float32Array(0); let rowFade = new Float32Array(0); let maxAge = 0; // s for a sound to leave the edge let slot = 0; // s of history one column covers // Marks to stroke this frame: x, y, length, batched by tone × opacity step. let marks: Float32Array[] = []; let markCount = new Int32Array(0); // History: one loudness and speaker per sample, newest at head - 1. const N = VOICEPRINT.history; const DT = 1 / TIMING.sampleRate; const levels = new Float32Array(N); const tones = new Uint8Array(N); let head = 0; let sampledAt = 0; let lastLoud = -Infinity; let smooth = 0; let lastT = 0; let raf = 0; let poll = 0; let visible = false; let hidden = false; const cleanups: (() => void)[] = []; function readLevel(dt: number) { if (ear) return loudness(ear, dt); const level = options.level; const value = typeof level === "function" ? level() : level; return Number.isFinite(value) ? clamp01(value) : 0; } function readColors() { if (!probes) return; colors = [getComputedStyle(probes.ink).color, getComputedStyle(probes.accent).color]; } function push(tone: number, alpha: number, x: number, y: number, length: number) { const step = Math.min(VOICEPRINT.levels - 1, Math.floor(alpha * VOICEPRINT.levels)); if (step < 0) return; const b = tone * VOICEPRINT.levels + step; const n = markCount[b]; const data = marks[b]; if (!data || n * 3 + 2 >= data.length) return; data[n * 3] = x; data[n * 3 + 1] = y; data[n * 3 + 2] = length; markCount[b] = n + 1; } function flush(target: CanvasRenderingContext2D, thicken: boolean) { target.lineCap = "round"; for (let tone = 0; tone < 2; tone++) { for (let step = 0; step < VOICEPRINT.levels; step++) { const b = tone * VOICEPRINT.levels + step; const n = markCount[b]; if (!n) continue; const data = marks[b]; const share = step / (VOICEPRINT.levels - 1); target.globalAlpha = (step + 1) / VOICEPRINT.levels; target.strokeStyle = colors[tone]; target.lineWidth = VOICEPRINT.dot * (thicken ? 1 + VOICEPRINT.thicken * share : 1); target.beginPath(); for (let i = 0; i < n; i++) { const x = data[i * 3]; const y = data[i * 3 + 1]; // A zero-length segment with round caps draws a dot. const half = data[i * 3 + 2] / 2 + 0.01; target.moveTo(x, y - half); target.lineTo(x, y + half); } target.stroke(); } } target.globalAlpha = 1; markCount.fill(0); } /** The resting grid, drawn once per size and color, then copied under every frame. */ function drawRest() { if (!canvas || !width) return; restLayer ??= document.createElement("canvas"); restLayer.width = canvas.width; restLayer.height = canvas.height; const rest = restLayer.getContext("2d"); if (!rest) return; rest.setTransform(dpr, 0, 0, dpr, 0, 0); rest.clearRect(0, 0, width, height); markCount.fill(0); const s = options.spacing; for (let c = 0; c < cols; c++) { if (colFade[c] < 0.02) continue; for (let r = 0; r < rows; r++) { push(0, VOICEPRINT.rest * colFade[c] * rowFade[r], ox + c * s, oy + r * s, 0); } } flush(rest, false); } function layout(force = false) { if (!root || !canvas) return; const w = root.clientWidth; const h = root.clientHeight; dpr = Math.min(window.devicePixelRatio || 1, VOICEPRINT.maxDpr); if (!force && w === width && h === height && canvas.width === Math.round(w * dpr)) return; width = w; height = h; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); const s = options.spacing; const speed = Math.max(1, options.speed); cols = Math.max(1, Math.floor(w / s)); rows = Math.max(1, Math.floor(h / s)); ox = (w - (cols - 1) * s) / 2; oy = (h - (rows - 1) * s) / 2; colAge = new Float32Array(cols); colFade = new Float32Array(cols); rowFade = new Float32Array(rows); for (let c = 0; c < cols; c++) { const x = ox + c * s; const xn = x / w; colAge[c] = Math.abs(x - w / 2) / speed; colFade[c] = smoothstep(0, VOICEPRINT.edge, xn) * smoothstep(0, VOICEPRINT.edge, 1 - xn); } for (let r = 0; r < rows; r++) { const yn = (oy + r * s) / h; rowFade[r] = smoothstep(0, VOICEPRINT.edgeY, yn) * smoothstep(0, VOICEPRINT.edgeY, 1 - yn); } slot = s / speed; maxAge = w / 2 / speed + slot; marks = Array.from({ length: 2 * VOICEPRINT.levels }, () => new Float32Array(cols * rows * 3)); markCount = new Int32Array(2 * VOICEPRINT.levels); drawRest(); } /** Dashes for every column, each showing the loudest moment of its slice of the past. */ function collectPrint() { const s = options.spacing; const mid = height / 2; const half = (height / 2) * options.reach; const soft = VOICEPRINT.soft * s; const oldest = head - N; for (let c = 0; c < cols; c++) { const fade = colFade[c]; if (fade < 0.02) continue; const age = colAge[c]; const from = Math.max(0, Math.floor((age - slot / 2) / DT)); const to = Math.ceil((age + slot / 2) / DT); let level = 0; let tone = 0; for (let k = from; k <= to; k++) { const i = head - 1 - k; if (i < 0 || i < oldest) break; const v = levels[i % N]; if (v > level) { level = v; tone = tones[i % N]; } } if (level < VOICEPRINT.silent) continue; const reach = level * half; if (reach < s * 0.3) continue; const ink = fade * (1 - options.falloff * Math.min(1, age / maxAge)); const x = ox + c * s; const r0 = Math.max(0, Math.ceil((mid - reach - soft - oy) / s)); const r1 = Math.min(rows - 1, Math.floor((mid + reach + soft - oy) / s)); for (let r = r0; r <= r1; r++) { const y = oy + r * s; const depth = smoothstep(-0.3 * s, soft, reach - Math.abs(y - mid)); if (depth < 0.03) continue; const alpha = (VOICEPRINT.rest + (1 - VOICEPRINT.rest) * depth) * ink * rowFade[r]; push(tone, alpha, x, y, depth * VOICEPRINT.dash * s); } } } /** Reduced: the band around the middle takes the speaker's color as loud as the voice is. */ function collectBand() { if (smooth < VOICEPRINT.silent) return; const s = options.spacing; const tone = options.tone === "accent" ? 1 : 0; const [x0, x1] = VOICEPRINT.band; const [y0, y1] = VOICEPRINT.bandY; for (let c = 0; c < cols; c++) { const x = ox + c * s; const bx = 1 - smoothstep(x0, x1, Math.abs(x / width - 0.5)); if (bx < 0.02) continue; for (let r = 0; r < rows; r++) { const y = oy + r * s; const k = smooth * bx * (1 - smoothstep(y0, y1, Math.abs(y / height - 0.5))); if (k < 0.02) continue; const alpha = (VOICEPRINT.rest + (VOICEPRINT.bandInk - VOICEPRINT.rest) * k) * colFade[c] * rowFade[r]; push(tone, alpha, x, y, 0); } } } function draw() { if (!ctx || !canvas || !width) return; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); if (restLayer) ctx.drawImage(restLayer, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (options.reduced) collectBand(); else collectPrint(); flush(ctx, !options.reduced); } function sampleTo(t: number) { // Coming back from sleep: carry on from here instead of replaying the gap. if (t - sampledAt > 0.25) sampledAt = t - DT; const tone = options.tone === "accent" ? 1 : 0; while (sampledAt + DT <= t) { const i = head % N; levels[i] = smooth; tones[i] = tone; if (smooth >= VOICEPRINT.silent) lastLoud = head; head++; sampledAt += DT; } } function quiet() { if (smooth >= VOICEPRINT.silent) return false; if (options.reduced) return true; return (head - 1 - lastLoud) * DT > maxAge + slot; } function frame(ms: number) { raf = 0; if (!ctx || !visible || hidden) return; const t = ms / 1000; const dt = Math.min(0.1, Math.max(0, t - lastT)); lastT = t; if (options.paused) { draw(); return; } const target = readLevel(dt); const tau = target > smooth ? options.attack : options.release; smooth += (target - smooth) * (1 - Math.exp(-dt / tau)); if (smooth < 1e-4) smooth = 0; if (!options.reduced) sampleTo(t); draw(); if (quiet()) { schedulePoll(); return; } raf = requestAnimationFrame(frame); } function schedulePoll() { window.clearTimeout(poll); poll = window.setTimeout(() => { poll = 0; if (!visible || hidden || options.paused) return; if (readLevel(TIMING.poll / 1000) >= VOICEPRINT.silent) wake(); else schedulePoll(); }, TIMING.poll); } function wake() { if (raf || !ctx || !visible || hidden) return; window.clearTimeout(poll); poll = 0; lastT = performance.now() / 1000; raf = requestAnimationFrame(frame); } function clearHistory() { levels.fill(0); head = 0; lastLoud = -Infinity; } return { connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; ink: HTMLElement; accent: HTMLElement }) { root = el.root; canvas = el.canvas; ctx = canvas.getContext("2d"); probes = { ink: el.ink, accent: el.accent }; readColors(); layout(true); draw(); const resize = new ResizeObserver(() => { layout(); draw(); }); resize.observe(root); const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) wake(); }); io.observe(root); const onVisibility = () => { hidden = document.hidden; if (!hidden) wake(); }; document.addEventListener("visibilitychange", onVisibility); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); const recolor = () => { readColors(); drawRest(); draw(); }; scheme.addEventListener("change", recolor); const themeAttrs = new MutationObserver(recolor); themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] }); cleanups.push( () => resize.disconnect(), () => io.disconnect(), () => document.removeEventListener("visibilitychange", onVisibility), () => scheme.removeEventListener("change", recolor), () => themeAttrs.disconnect(), ); return () => { cancelAnimationFrame(raf); raf = 0; window.clearTimeout(poll); poll = 0; cleanups.splice(0).forEach((fn) => fn()); ear?.release(); ear = null; root = canvas = null; ctx = null; probes = null; restLayer = null; width = height = 0; }; }, setOptions(next: Options) { const relayout = next.spacing !== options.spacing || next.speed !== options.speed; const switched = next.reduced !== options.reduced; options = next; if (switched) { clearHistory(); smooth = 0; } if (relayout) layout(true); draw(); wake(); }, setSource(source: HTMLMediaElement | MediaStream | null) { ear?.release(); ear = source ? listen(source) : null; wake(); }, }; } /* ── Voiceprint ────────────────────────────────────────── */ export interface VoiceprintProps extends React.HTMLAttributes<HTMLDivElement> { /** Loudness now, from 0 to 1. Pass a function to have it read every frame, so a live level never re-renders your component. */ level?: number | (() => number); /** An `<audio>` or `<video>` element, or a `MediaStream` (a microphone, a call), to listen to. While it's given, its loudness replaces `level`. A cross-origin file reads as silence unless it's served with CORS. */ source?: HTMLMediaElement | MediaStream | null; /** Who is speaking now: the accent color or the ink color. Sound already printed keeps the color it was spoken in. */ tone?: Tone; /** How fast what was said travels out to the edges, in px per second. */ speed?: number; /** Distance between dots, in px. */ spacing?: number; /** How far the loudest sound reaches from the middle line, as a share of half the height. */ reach?: number; /** Seconds the level takes to rise to a new sound (a time constant). Longer softens the start of each stroke. */ attack?: number; /** Seconds the level takes to fall away after a sound (a time constant). Longer smears syllables together. */ release?: number; /** Share of its ink a sound has lost by the time it reaches the edge, from 0 (none) to 1 (all of it). */ falloff?: number; /** Hold the print still where it is and stop reading the level. */ paused?: boolean; /** Classes that set the resting dots and the `ink` speaker's color (a `text-*` color). */ inkClassName?: string; /** Classes that set the `accent` speaker's color (a `text-*` color). */ accentClassName?: string; } export function Voiceprint({ level = 0, source = null, tone = "accent", speed = TIMING.speed, spacing = 10, reach = 0.9, attack = TIMING.attack, release = TIMING.release, falloff = VOICEPRINT.ageFade, paused = false, inkClassName, accentClassName, className, children, ...props }: VoiceprintProps) { const rootRef = React.useRef<HTMLDivElement>(null); const canvasRef = React.useRef<HTMLCanvasElement>(null); const inkRef = React.useRef<HTMLSpanElement>(null); const accentRef = React.useRef<HTMLSpanElement>(null); const [print] = React.useState(createPrint); const reduced = useReduced(); React.useLayoutEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const ink = inkRef.current; const accent = accentRef.current; if (!root || !canvas || !ink || !accent) return; return print.connect({ root, canvas, ink, accent }); }, [print]); React.useEffect(() => { print.setOptions({ level, tone, speed, spacing, reach, attack: Math.max(0.001, attack), release: Math.max(0.001, release), falloff: Math.min(1, Math.max(0, falloff)), paused, reduced, }); }, [print, level, tone, speed, spacing, reach, attack, release, falloff, paused, reduced]); React.useEffect(() => { print.setSource(source); return () => print.setSource(null); }, [print, source]); return ( <div ref={rootRef} className={cn("relative isolate overflow-hidden", className)} {...props}> <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 -z-10 size-full" /> {/* Probes the canvas reads its colors from. Defaults apply only when no class is given, so a `dark:` default can't win over yours. */} <span ref={inkRef} aria-hidden className={cn("hidden", inkClassName ?? "text-zinc-900 dark:text-zinc-100")} /> <span ref={accentRef} aria-hidden className={cn("hidden", accentClassName ?? "text-[#2747ff] dark:text-[#6b84ff]")} /> {children} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Voiceprint. Each one installs whole, with the component included.
Recording Player
A transcription player for NASA's Apollo 11 tapes, where the print listens to the audio element itself and the tape's hiss stays under the noise gate.
First step
Apollo 11 · 21 Jul 1969, 02:56 UTC
0:00 / 0:24
Neil Armstrong
I'm going to step off the LM now.
Transcribed by Quillmark · Audio: NASA, public domain
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Voiceprint } from "@/components/ui/voiceprint";
type Line = { start: number; end: number; text: string };
type Tape = { id: string; title: string; when: string; src: string; page: string; duration: number; lines: Line[] };
// Public-domain NASA recordings from Wikimedia Commons, which serves them with
// CORS headers. Without those the analyser would only ever hear silence.
const TAPES: Tape[] = [
{
id: "first-step",
title: "First step",
when: "Apollo 11 · 21 Jul 1969, 02:56 UTC",
src: "https://upload.wikimedia.org/wikipedia/commons/transcoded/d/dd/Armstrong_Small_Step.ogg/Armstrong_Small_Step.ogg.mp3",
page: "https://commons.wikimedia.org/wiki/File:Armstrong_Small_Step.ogg",
duration: 24.1,
lines: [
{ start: 3.6, end: 5.7, text: "I'm going to step off the LM now." },
{ start: 15.3, end: 17.8, text: "That's one small step for man," },
{ start: 20.6, end: 23.8, text: "one giant leap for mankind." },
],
},
{
id: "landing",
title: "Tranquility Base",
when: "Apollo 11 · 20 Jul 1969, 20:17 UTC",
src: "https://upload.wikimedia.org/wikipedia/commons/8/87/Apollo_11_Eagle_Has_Landed.mp3",
page: "https://commons.wikimedia.org/wiki/File:Apollo_11_Eagle_Has_Landed.mp3",
duration: 5.3,
lines: [
{ start: 1.0, end: 3.6, text: "Houston, Tranquility Base here." },
{ start: 3.7, end: 5.1, text: "The Eagle has landed." },
],
},
];
const clock = (s: number) => {
const whole = Math.max(0, Math.floor(s));
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};
function PlayIcon({ playing }: { playing: boolean }) {
return playing ? (
<svg viewBox="0 0 16 16" aria-hidden className="size-4" fill="currentColor">
<rect x="3.5" y="2.5" width="3" height="11" rx="1" />
<rect x="9.5" y="2.5" width="3" height="11" rx="1" />
</svg>
) : (
<svg viewBox="0 0 16 16" aria-hidden className="size-4 translate-x-px" fill="currentColor">
<path d="M4.5 2.9v10.2a.9.9 0 0 0 1.37.77l8.1-5.1a.9.9 0 0 0 0-1.54l-8.1-5.1A.9.9 0 0 0 4.5 2.9Z" />
</svg>
);
}
export function VoiceprintPlayerDemo() {
// The element in state for the print to listen to, and in a ref for the handlers that drive it.
const audioRef = React.useRef<HTMLAudioElement | null>(null);
const [audio, setAudio] = React.useState<HTMLAudioElement | null>(null);
const [tapeId, setTapeId] = React.useState(TAPES[0].id);
const [playing, setPlaying] = React.useState(false);
const [time, setTime] = React.useState(0);
const [duration, setDuration] = React.useState(TAPES[0].duration);
const [failed, setFailed] = React.useState(false);
const tape = TAPES.find((t) => t.id === tapeId) ?? TAPES[0];
// The playhead follows the tape every frame while it plays.
React.useEffect(() => {
if (!audio || !playing) return;
let raf = 0;
const tick = () => {
setTime(audio.currentTime);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [audio, playing]);
function toggle() {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) audio.play().catch(() => setFailed(true));
else audio.pause();
}
function pick(next: Tape) {
const audio = audioRef.current;
if (!audio || next.id === tape.id) return;
const resume = !audio.paused;
audio.src = next.src;
setTapeId(next.id);
setTime(0);
setDuration(next.duration);
setFailed(false);
if (resume) audio.play().catch(() => setFailed(true));
}
const progress = duration > 0 ? Math.min(1, time / duration) : 0;
const said = tape.lines.filter((l) => time >= l.start);
const latest = said.at(-1);
const live = latest !== undefined && time <= latest.end + 0.4;
const caption = latest ?? tape.lines[0];
return (
<Voiceprint
source={audio}
reach={0.5}
className="flex h-[var(--demo-height,36rem)] w-full flex-col items-center justify-between gap-6 rounded-2xl border border-zinc-950/10 bg-[#fafafa] px-4 py-8 text-zinc-950 sm:px-8 sm:py-10 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-50"
>
<h2 className="max-w-[17ch] text-center text-4xl/[1.05] font-semibold tracking-[-0.03em] text-balance sm:text-[3.25rem]/[1.02]">
Every word on the tape, pinned to its second
</h2>
<div className="w-full max-w-2xl rounded-2xl border border-zinc-950/10 bg-white p-4 shadow-sm sm:p-5 dark:border-white/10 dark:bg-zinc-950">
{/* Not a React-controlled src: switching tapes keeps the same element, which the print listens to. */}
<audio
ref={(el) => {
audioRef.current = el;
setAudio(el);
}}
src={TAPES[0].src}
crossOrigin="anonymous"
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => setTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => Number.isFinite(e.currentTarget.duration) && setDuration(e.currentTarget.duration)}
onError={() => setFailed(true)}
/>
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
className="grid size-11 shrink-0 place-items-center rounded-full bg-zinc-950 text-white transition-transform active:scale-[0.97] dark:bg-white dark:text-zinc-950"
>
<PlayIcon playing={playing} />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{tape.title}</p>
<p className="truncate text-xs text-zinc-500">{tape.when}</p>
</div>
<p className="shrink-0 text-xs text-zinc-500 tabular-nums">
<span className="text-zinc-950 dark:text-zinc-50">{clock(time)}</span> / {clock(duration)}
</p>
</div>
{/* The track marks where speech is, so the gaps on the tape are visible before you play it. */}
<div className="relative mt-3 h-6 rounded-full has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[#2747ff]">
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-zinc-950/10 dark:bg-white/10" />
{tape.lines.map((l) => (
<div
key={l.start}
className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-zinc-950/25 dark:bg-white/25"
style={{ left: `${(l.start / duration) * 100}%`, width: `${((l.end - l.start) / duration) * 100}%` }}
/>
))}
<div
className="absolute top-1/2 left-0 h-1 -translate-y-1/2 rounded-full bg-[#2747ff] dark:bg-[#6b84ff]"
style={{ width: `${progress * 100}%` }}
/>
<div
className="absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#2747ff] ring-2 ring-white dark:bg-[#6b84ff] dark:ring-zinc-950"
style={{ left: `${progress * 100}%` }}
/>
<input
type="range"
min={0}
max={duration}
step={0.1}
value={time}
onChange={(e) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = Number(e.target.value);
setTime(audio.currentTime);
}}
aria-label="Seek"
aria-valuetext={`${clock(time)} of ${clock(duration)}`}
className="absolute inset-0 w-full cursor-pointer opacity-0"
/>
</div>
<div className="mt-2 flex min-h-14 items-baseline gap-3">
<span className="w-9 shrink-0 text-xs text-zinc-500 tabular-nums">{clock(caption.start)}</span>
<div className="min-w-0">
<p className="text-[11px] font-medium tracking-[0.08em] text-zinc-500 uppercase">Neil Armstrong</p>
<p
className={cn(
"mt-0.5 text-lg/7 font-medium text-pretty transition-colors duration-200",
live ? "text-zinc-950 dark:text-zinc-50" : latest ? "text-zinc-950/45 dark:text-zinc-50/45" : "text-zinc-950/25 dark:text-zinc-50/25",
)}
>
{caption.text}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t border-zinc-950/10 pt-3 dark:border-white/10">
<div className="flex gap-1">
{TAPES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => pick(t)}
aria-pressed={t.id === tape.id}
className={cn(
"h-8 rounded-full px-3 text-xs font-medium transition-colors",
t.id === tape.id
? "bg-zinc-950/[0.06] text-zinc-950 dark:bg-white/10 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-950 dark:hover:text-zinc-50",
)}
>
{t.title} <span className="text-zinc-400 tabular-nums">{clock(t.duration)}</span>
</button>
))}
</div>
<p className="text-[11px] text-zinc-500">
{failed ? (
"The recording didn't load. Try again in a moment."
) : (
<>
Transcribed by{" "}
<a href="https://quillmark.example" className="font-medium text-zinc-950 hover:underline dark:text-zinc-50">
Quillmark
</a>{" "}
· Audio:{" "}
<a href={tape.page} className="hover:underline">
NASA, public domain
</a>
</>
)}
</p>
</div>
</div>
</Voiceprint>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Voiceprint: A background that prints a voice as it speaks. Sound comes in at the middle as a stroke of dashes as tall as it is loud, then travels out to both edges, so a sentence spreads across the section like a pressed waveform. Each speaker prints in their own color, and in silence it settles back into a calm dot grid. This demo, Recording Player: A transcription player for NASA's Apollo 11 tapes, where the print listens to the audio element itself and the tape's hiss stays under the noise gate. 1. Install it: npx shadcn@latest add @keyframeui/voiceprint-player-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/voiceprint-player-demo.json instead.) It adds the demo, components/voiceprint-player-demo.tsx, and the component, components/ui/voiceprint.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Voiceprint> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 90): How fast what was said travels out to the edges, in px per second. - spacing (default 10): Distance between dots, in px. - reach (default 0.9): How far the loudest sound reaches from the middle line, as a share of half the height. Lower it to keep a headline above the print clear. - attack (default 0.03): How quickly the level rises to a new sound, in seconds (a time constant). Longer softens the start of each stroke. - release (default 0.14): How quickly the level falls away after a sound, in seconds (a time constant). Longer smears syllables into one another; shorter prints each as its own stroke. - falloff (default 0.6): Share of its ink a sound has lost by the time it reaches the edge, from 0 (none) to 1 (all of it). 4. Don't change, in components/ui/voiceprint.tsx: - Every frame runs in `createPrint`, outside React: one rAF loop draws to a canvas and reads a function `level` each frame, and it sleeps once the last sound has left both edges, off screen, or in a hidden tab. - Nothing moves: each column shows the loudest history sample from as long ago as sound takes to reach it at `speed`, so sound travels out mirrored, and each sample keeps its tone. - Marks are batched into one stroke per tone and opacity step, and the resting grid is drawn once per size and color, then copied under every frame. - A `source` runs through a noise gate (a floor that drops fast and creeps up slowly, shut while it warms up), so tape hiss and room tone never print. - Reduced motion keeps the print still: a band around the middle takes the speaker's color by opacity alone, read from a `useSyncExternalStore` flag that's false while hydrating. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/voiceprint.md
Dubbing Preview
Each line plays in English in ink, then as an AI dub in orange on the same beats, so the same shape prints twice; the chips pick the language.
A looping preview: each line of a woodworking video plays in English, then as Mynah's Spanish dub in the same voice and rhythm.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Voiceprint } from "@/components/ui/voiceprint";
type Lang = "es" | "fr" | "de" | "ja" | "hi";
const LANGS: { id: Lang; label: string; code: string }[] = [
{ id: "es", label: "Spanish", code: "ES" },
{ id: "fr", label: "French", code: "FR" },
{ id: "de", label: "German", code: "DE" },
{ id: "ja", label: "Japanese", code: "JA" },
{ id: "hi", label: "Hindi", code: "HI" },
];
const SCRIPT: { en: string; dub: Record<Lang, string> }[] = [
{
en: "Welcome back to the workshop.",
dub: {
es: "Bienvenidos de nuevo al taller.",
fr: "Bon retour dans l'atelier.",
de: "Willkommen zurück in der Werkstatt.",
ja: "おかえりなさい、工房へようこそ。",
hi: "वर्कशॉप में फिर से स्वागत है।",
},
},
{
en: "Today we're turning one walnut board into a desk.",
dub: {
es: "Hoy convertimos una tabla de nogal en un escritorio.",
fr: "Aujourd'hui, on transforme une planche de noyer en bureau.",
de: "Heute machen wir aus einem Nussbaumbrett einen Schreibtisch.",
ja: "今日は一枚のウォルナット材からデスクを作ります。",
hi: "आज हम अखरोट की एक तख्ती से डेस्क बनाएंगे।",
},
},
{
en: "Measure twice, and let the saw do the work.",
dub: {
es: "Mide dos veces y deja que la sierra haga el trabajo.",
fr: "Mesurez deux fois, et laissez la scie travailler.",
de: "Zweimal messen, und dann die Säge arbeiten lassen.",
ja: "二度測って、あとはのこぎりに任せましょう。",
hi: "दो बार नापिए, और काम आरी को करने दीजिए।",
},
},
];
// The reel's clock, built once. Each line is spoken in English, then dubbed
// on exactly the same syllable beats: the dub keeps the speaker's timing,
// which the print shows as the same shape coming out a second time in orange.
const SYLLABLE = 0.18; // s per syllable
const GAP = 0.06; // s between words
const COMMA = 0.26; // s after a comma
const LEAD = 0.5; // s before the first line
const PAUSE = 0.9; // s between the original and its dub
const NEXT = 1.6; // s after a dub, before the next line
const LOUD = 0.58; // peak loudness of the voice
type Beat = { start: number; end: number; amp: number };
type Take = { start: number; end: number };
type Cue = { original: Take; dubbed: Take; beats: Beat[] };
function syllables(word: string) {
const letters = word.toLowerCase().replace(/[^a-z]/g, "");
const groups = letters.replace(/e$/, "").match(/[aeiouy]+/g);
return Math.max(1, groups ? groups.length : 1);
}
function buildReel() {
let seed = 5;
const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
let t = LEAD;
const cues: Cue[] = SCRIPT.map(({ en }) => {
const beats: Beat[] = [];
let at = 0;
en.split(" ").forEach((word, i, words) => {
for (let s = 0; s < syllables(word); s++) {
const length = SYLLABLE * (0.8 + 0.4 * random());
beats.push({ start: at, end: at + length, amp: LOUD * (0.7 + 0.3 * random()) });
at += length;
}
if (i < words.length - 1) at += GAP + (word.endsWith(",") ? COMMA : 0);
});
const original = { start: t, end: t + at };
const dubbed = { start: original.end + PAUSE, end: original.end + PAUSE + at };
t = dubbed.end + NEXT;
return { original, dubbed, beats };
});
return { cues, duration: t };
}
const REEL = buildReel();
/** A steady per-language wobble, so each dub has its own voice on the same beats. */
const wobble = (lang: Lang, i: number) => {
const x = Math.sin((LANGS.findIndex((l) => l.id === lang) + 1) * 12.9898 + i * 78.233) * 43758.5453;
return 0.88 + 0.24 * (x - Math.floor(x));
};
function levelAt(time: number, lang: Lang) {
for (const cue of REEL.cues) {
for (const take of [cue.original, cue.dubbed]) {
if (time < take.start || time > take.end) continue;
const local = time - take.start;
const dub = take === cue.dubbed;
for (let i = 0; i < cue.beats.length; i++) {
const beat = cue.beats[i];
if (local < beat.start || local > beat.end) continue;
const p = (local - beat.start) / (beat.end - beat.start);
const amp = beat.amp * (dub ? wobble(lang, i) : 1);
return amp * Math.pow(Math.sin(Math.PI * p), 0.7) * (0.92 + 0.08 * Math.sin(time * 53));
}
}
}
return 0;
}
/** The line on screen, whether the original or the dub has the floor, and how far through its take it is. */
function cueAt(time: number) {
let index = 0;
REEL.cues.forEach((c, i) => {
if (time >= c.original.start - 0.6) index = i;
});
const cue = REEL.cues[index];
const dub = time >= cue.original.end + PAUSE / 2;
const take = dub ? cue.dubbed : cue.original;
const share = Math.min(1, Math.max(0, (time - take.start) / (take.end - take.start)));
return { index, dub, share: Math.round(share * 40) / 40 };
}
/** Words, or characters for Japanese, so subtitles can light up as they're said. */
const tokens = (text: string, lang: Lang | "en") => (lang === "ja" ? Array.from(text) : text.split(/(?<= )/));
function Subtitle({
text,
lang,
shown,
state,
}: {
text: string;
lang: Lang | "en";
shown: number;
state: "speaking" | "said" | "waiting";
}) {
const parts = tokens(text, lang);
const lit = state === "speaking" ? Math.ceil(shown * parts.length) : state === "said" ? parts.length : 0;
return (
<p lang={lang} className="mt-1 text-lg/7 font-medium text-pretty">
{parts.map((part, i) => (
<span
key={i}
className={cn(
"transition-colors duration-150",
i < lit
? state === "said"
? "text-zinc-950/45 dark:text-zinc-50/45"
: "text-zinc-950 dark:text-zinc-50"
: "text-zinc-950/20 dark:text-zinc-50/20",
)}
>
{part}
</span>
))}
</p>
);
}
export function VoiceprintDubbingDemo() {
const start = React.useRef(0);
const [lang, setLang] = React.useState<Lang>("es");
const [cue, setCue] = React.useState({ index: 0, dub: false, share: 0 });
const reelTime = React.useCallback(() => {
if (!start.current) return 0;
return ((performance.now() - start.current) / 1000) % REEL.duration;
}, []);
const level = React.useCallback(() => levelAt(reelTime(), lang), [reelTime, lang]);
const seekTo = React.useCallback((time: number) => {
start.current = performance.now() - time * 1000;
}, []);
React.useEffect(() => {
start.current = performance.now();
const timer = window.setInterval(() => {
const next = cueAt(reelTime());
setCue((c) => (c.index === next.index && c.dub === next.dub && c.share === next.share ? c : next));
}, 50);
return () => window.clearInterval(timer);
}, [reelTime]);
/** A new language replays the line on screen from its original, so you hear the switch. */
function pick(next: Lang) {
if (next === lang) return;
setLang(next);
seekTo(REEL.cues[cue.index].original.start - 0.3);
}
const line = SCRIPT[cue.index];
const language = LANGS.find((l) => l.id === lang) ?? LANGS[0];
return (
<Voiceprint
level={level}
tone={cue.dub ? "accent" : "ink"}
reach={0.6}
accentClassName="text-[#ff4a17] dark:text-[#ff6a3d]"
className="flex h-[var(--demo-height,36rem)] w-full flex-col items-center justify-between gap-6 rounded-2xl border border-zinc-950/10 bg-[#fafafa] px-5 py-8 text-zinc-950 sm:px-8 sm:py-10 dark:border-white/10 dark:bg-[#09090b] dark:text-zinc-50"
>
<h2 className="max-w-[20ch] text-center text-4xl/[1.05] font-semibold tracking-[-0.03em] text-balance sm:text-[3.25rem]/[1.02]">
Your voice in 32 languages, on the same beat
</h2>
<div className="flex w-full max-w-2xl flex-col items-center">
<p className="sr-only">
A looping preview: each line of a woodworking video plays in English, then as Mynah's {language.label} dub
in the same voice and rhythm.
</p>
<div aria-hidden className="grid w-full gap-3 text-left sm:grid-cols-2 sm:gap-6">
<div>
<p className="flex items-center gap-2 text-[11px] font-medium tracking-[0.08em] text-zinc-500 uppercase">
<span className="size-1.5 rounded-full bg-zinc-950 dark:bg-zinc-50" />
EN · Theo, original
</p>
<Subtitle text={line.en} lang="en" shown={cue.share} state={cue.dub ? "said" : "speaking"} />
</div>
<div>
<p className="flex items-center gap-2 text-[11px] font-medium tracking-[0.08em] text-zinc-500 uppercase">
<span className="size-1.5 rounded-full bg-[#ff4a17] dark:bg-[#ff6a3d]" />
{language.code} · Theo's voice, by Mynah
</p>
<Subtitle text={line.dub[lang]} lang={lang} shown={cue.share} state={cue.dub ? "speaking" : "waiting"} />
</div>
</div>
<div role="group" aria-label="Dub language" className="mt-6 flex flex-wrap justify-center gap-1.5">
{LANGS.map((l) => (
<button
key={l.id}
type="button"
onClick={() => pick(l.id)}
aria-pressed={l.id === lang}
className={cn(
"h-9 rounded-full border px-3.5 text-sm font-medium transition-colors",
l.id === lang
? "border-[#ff4a17] bg-[#ff4a17] text-[#09090b] dark:border-[#ff6a3d] dark:bg-[#ff6a3d]"
: "border-zinc-950/10 bg-[#fafafa] text-zinc-600 hover:text-zinc-950 dark:border-white/10 dark:bg-[#09090b] dark:text-zinc-400 dark:hover:text-zinc-50",
)}
>
{l.label}
</button>
))}
</div>
</div>
</Voiceprint>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Voiceprint: A background that prints a voice as it speaks. Sound comes in at the middle as a stroke of dashes as tall as it is loud, then travels out to both edges, so a sentence spreads across the section like a pressed waveform. Each speaker prints in their own color, and in silence it settles back into a calm dot grid. This demo, Dubbing Preview: Each line plays in English in ink, then as an AI dub in orange on the same beats, so the same shape prints twice; the chips pick the language. 1. Install it: npx shadcn@latest add @keyframeui/voiceprint-dubbing-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/voiceprint-dubbing-demo.json instead.) It adds the demo, components/voiceprint-dubbing-demo.tsx, and the component, components/ui/voiceprint.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <Voiceprint> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 90): How fast what was said travels out to the edges, in px per second. - spacing (default 10): Distance between dots, in px. - reach (default 0.9): How far the loudest sound reaches from the middle line, as a share of half the height. Lower it to keep a headline above the print clear. - attack (default 0.03): How quickly the level rises to a new sound, in seconds (a time constant). Longer softens the start of each stroke. - release (default 0.14): How quickly the level falls away after a sound, in seconds (a time constant). Longer smears syllables into one another; shorter prints each as its own stroke. - falloff (default 0.6): Share of its ink a sound has lost by the time it reaches the edge, from 0 (none) to 1 (all of it). 4. Don't change, in components/ui/voiceprint.tsx: - Every frame runs in `createPrint`, outside React: one rAF loop draws to a canvas and reads a function `level` each frame, and it sleeps once the last sound has left both edges, off screen, or in a hidden tab. - Nothing moves: each column shows the loudest history sample from as long ago as sound takes to reach it at `speed`, so sound travels out mirrored, and each sample keeps its tone. - Marks are batched into one stroke per tone and opacity step, and the resting grid is drawn once per size and color, then copied under every frame. - A `source` runs through a noise gate (a floor that drops fast and creeps up slowly, shut while it warms up), so tape hiss and room tone never print. - Reduced motion keeps the print still: a band around the middle takes the speaker's color by opacity alone, read from a `useSyncExternalStore` flag that's false while hydrating. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/voiceprint.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | None | The section's content, drawn over the print. |
level | number | (() => number) | 0 | Loudness now, from 0 to 1. Pass a function to have it read every frame, so a live level never re-renders your component. |
source | HTMLMediaElement | MediaStream | null | null | An <audio> or <video> element, or a MediaStream (a microphone, a call), to listen to. While it's given, its loudness replaces level. A cross-origin file reads as silence unless it's served with CORS. |
tone | "accent" | "ink" | "accent" | Who is speaking now: the accent color or the ink color. Sound already printed keeps the color it was spoken in. |
speed | number | 90 | How fast what was said travels out to the edges, in px per second. |
spacing | number | 10 | Distance between dots, in px. |
reach | number | 0.9 | How far the loudest sound reaches from the middle line, as a share of half the height. Lower it to keep a headline above the print clear. |
attack | number | 0.03 | How quickly the level rises to a new sound, in seconds (a time constant). Longer softens the start of each stroke. |
release | number | 0.14 | How quickly the level falls away after a sound, in seconds (a time constant). Longer smears syllables into one another; shorter prints each as its own stroke. |
falloff | number | 0.6 | Share of its ink a sound has lost by the time it reaches the edge, from 0 (none) to 1 (all of it). |
paused | boolean | false | Hold the print still where it is and stop reading the level. |
inkClassName | string | "text-zinc-900 dark:text-zinc-100" | A text-* color for the resting dots and the ink speaker. The canvas reads it, so dark: variants work. |
accentClassName | string | "text-[#2747ff] dark:text-[#6b84ff]" | A text-* color for the accent speaker. |
className | string | None | Extra classes for the root. Give it a size; the print fills it. |
childrenType
React.ReactNodeThe section's content, drawn over the print.
levelType
number | (() => number)Default0Loudness now, from 0 to 1. Pass a function to have it read every frame, so a live level never re-renders your component.
sourceType
HTMLMediaElement | MediaStream | nullDefaultnullAn
<audio>or<video>element, or aMediaStream(a microphone, a call), to listen to. While it's given, its loudness replaceslevel. A cross-origin file reads as silence unless it's served with CORS.toneType
"accent" | "ink"Default"accent"Who is speaking now: the accent color or the ink color. Sound already printed keeps the color it was spoken in.
speedType
numberDefault90How fast what was said travels out to the edges, in px per second.
spacingType
numberDefault10Distance between dots, in px.
reachType
numberDefault0.9How far the loudest sound reaches from the middle line, as a share of half the height. Lower it to keep a headline above the print clear.
attackType
numberDefault0.03How quickly the level rises to a new sound, in seconds (a time constant). Longer softens the start of each stroke.
releaseType
numberDefault0.14How quickly the level falls away after a sound, in seconds (a time constant). Longer smears syllables into one another; shorter prints each as its own stroke.
falloffType
numberDefault0.6Share of its ink a sound has lost by the time it reaches the edge, from 0 (none) to 1 (all of it).
pausedType
booleanDefaultfalseHold the print still where it is and stop reading the level.
inkClassNameType
stringDefault"text-zinc-900 dark:text-zinc-100"A
text-*color for the resting dots and theinkspeaker. The canvas reads it, sodark:variants work.accentClassNameType
stringDefault"text-[#2747ff] dark:text-[#6b84ff]"A
text-*color for theaccentspeaker.classNameType
stringExtra classes for the root. Give it a size; the print fills it.