music video renderer web app deftoku.digi.rip
audio visualizer webapp
1

Configure Feed

Select the types of activity you want to include in your feed.

controller, visualizers, render: animated preview with fps-aware speed

- added animated preview for all three visualizers
- preview respects fps setting
- added number input next to slider for editing/seeing value
- moved preview logic for bars (the sine generator) from controller to
bars
- update render.ts draw signature to accept optional dt parameter

digi.rip (Jun 21, 2026, 12:53 AM EDT) 3a95b901 8e6688be

+212 -58
+1 -1
dev_server.ts
··· 114 114 115 115 startBuildWatch(); 116 116 startWatcher(); 117 - Deno.serve({ port: 8000, onListen: () => {} }, handler); 117 + Deno.serve({ port: 8000, hostname: "0.0.0.0", onListen: () => {} }, handler);
+71 -47
src/controller.ts
··· 10 10 } from "mediabunny"; 11 11 12 12 // visualizers 13 - import { draw as bars, params as barsParams } from "./visualizers/bars.ts"; 14 - import { draw as cdbottom } from "./visualizers/cd-bottom.ts"; 15 - import { draw as cdtop } from "./visualizers/cd-top.ts"; 13 + import { 14 + draw as bars, 15 + params as barsParams, 16 + preview as barsPreview, 17 + } from "./visualizers/bars.ts"; 18 + import { 19 + draw as cdbottom, 20 + params as cdbottomParams, 21 + preview as cdbottomPreview, 22 + } from "./visualizers/cd-bottom.ts"; 23 + import { 24 + draw as cdtop, 25 + params as cdtopParams, 26 + preview as cdtopPreview, 27 + } from "./visualizers/cd-top.ts"; 16 28 type VisualizerName = keyof typeof visualizers; 17 29 18 30 // visualizer opts registry, bundles draw & params ··· 24 36 freq: Uint8Array, 25 37 options: VisualizerOptions, 26 38 ) => void; 39 + preview: ( 40 + canvas: HTMLCanvasElement, 41 + freq: Uint8Array, 42 + getOptions: () => VisualizerOptions, 43 + fps: number, 44 + ) => () => void; 27 45 params: Array<{ 28 46 key: string; 29 47 type: string; 30 48 default: string | number; 31 49 min?: number; 32 50 max?: number; 51 + step?: number; 33 52 label: string; 34 53 }>; 35 54 }; 36 55 37 56 const visualizers: Record<string, VisualizerDef> = { 38 - bars: { draw: bars, params: barsParams }, 39 - cdbottom: { draw: cdbottom, params: [] }, 40 - cdtop: { draw: cdtop, params: [] }, 57 + bars: { draw: bars, params: barsParams, preview: barsPreview }, 58 + cdbottom: { 59 + draw: cdbottom, 60 + params: cdbottomParams, 61 + preview: cdbottomPreview, 62 + }, 63 + cdtop: { draw: cdtop, params: cdtopParams, preview: cdtopPreview }, 41 64 }; 42 65 43 66 const qualityMap: Record<string, Quality> = { ··· 47 70 "high": QUALITY_HIGH, 48 71 }; 49 72 50 - // quick cheap preview function 51 - function previewSine(size: number) { 52 - const freq = new Uint8Array(size); 53 - for (let i = 0; i < freq.length; i++) { 54 - const t = (i / size) * Math.PI * 2; 55 - freq[i] = Math.floor((Math.sin(t) * 0.5 + 0.5) * 255); 56 - } 57 - return freq; 58 - } 59 - 60 - const PREVIEW_FREQS = previewSine(1024); 73 + const EMPTY_FREQ = new Uint8Array(0); 61 74 62 75 function fitCanvas(canvas: HTMLCanvasElement) { 63 76 const container = canvas.parentElement!; ··· 84 97 85 98 const select = document.querySelector<HTMLSelectElement>("#vis-select")!; 86 99 100 + let stopPreview: (() => void) | null = null; 101 + 102 + function startPreview(name: string) { 103 + stopPreview?.(); 104 + const fps = Number(fpsSelect.value); 105 + stopPreview = visualizers[name].preview( 106 + canvas, 107 + EMPTY_FREQ, 108 + currentOptions, 109 + fps, 110 + ); 111 + } 112 + 87 113 function buildParamsUI(name: string) { 88 114 const container = document.querySelector("#vis-params")!; 89 115 container.innerHTML = ""; ··· 96 122 label.textContent = p.label; 97 123 98 124 if (p.type === "number") { 99 - const input = document.createElement("input"); 100 - input.type = "range"; 101 - input.id = `param-${p.key}`; 102 - input.min = String(p.min ?? 0); 103 - input.max = String(p.max ?? 100); 104 - input.value = String(p.default); 105 - input.addEventListener("input", () => preview(select.value)); 125 + const range = document.createElement("input"); 126 + range.type = "range"; 127 + range.id = `param-${p.key}`; 128 + range.min = String(p.min ?? 0); 129 + range.max = String(p.max ?? 100); 130 + if (p.step !== undefined) range.step = String(p.step); 131 + range.value = String(p.default); 132 + 133 + const number = document.createElement("input"); 134 + number.type = "number"; 135 + number.min = range.min; 136 + number.max = range.max; 137 + if (p.step !== undefined) number.step = range.step; 138 + number.value = range.value; 139 + number.addEventListener("change", () => { 140 + range.value = number.value; 141 + }); 142 + range.addEventListener("input", () => { 143 + number.value = range.value; 144 + }); 145 + 106 146 container.appendChild(label); 107 - container.appendChild(input); 147 + container.appendChild(range); 148 + container.appendChild(number); 108 149 } else if (p.type === "color") { 109 150 const input = document.createElement("input"); 110 151 input.type = "color"; 111 152 input.id = `param-${p.key}`; 112 153 input.value = String(p.default); 113 - input.addEventListener("input", () => preview(select.value)); 114 154 container.appendChild(label); 115 155 container.appendChild(input); 116 156 } ··· 138 178 canvas!.width = h * Number(ratioSelect!.value); 139 179 canvas!.height = h; 140 180 fitCanvas(canvas!); 141 - preview(select!.value); 181 + startPreview(select!.value); 142 182 } 143 183 144 - // change canvas height on res-select change, then fit canvas 145 184 resSelect.addEventListener("change", updateCanvas); 146 - 147 - // change canvas width on ratio-select change, then fit canvas 148 185 ratioSelect.addEventListener("change", updateCanvas); 149 - 150 - // generate preview 151 - const preview = (name: string) => { 152 - const ctx = canvas.getContext("2d")!; 153 - ctx.fillStyle = "black"; 154 - ctx.fillRect(0, 0, canvas.width, canvas.height); 155 - return visualizers[name as VisualizerName].draw( 156 - canvas, 157 - PREVIEW_FREQS, 158 - currentOptions(), 159 - ); 160 - }; 186 + fpsSelect.addEventListener("change", () => startPreview(select.value)); 161 187 162 188 type ShowSaveFilePicker = ( 163 189 options?: { ··· 178 204 179 205 const filename = "output.webm"; 180 206 181 - // try native "Save As" dialog 182 207 if ("showSaveFilePicker" in globalThis) { 183 208 let handle: FileSystemFileHandle; 184 209 try { ··· 191 216 ], 192 217 }); 193 218 } catch { 194 - return; // user cancelled 219 + return; 195 220 } 196 221 197 222 const fps = Number(fpsSelect.value); ··· 213 238 return; 214 239 } 215 240 216 - // fallback: auto-download 217 241 const fps = Number(fpsSelect.value); 218 242 const quality = qualityMap[qualitySelect.value]; 219 243 const def = visualizers[select.value as VisualizerName]; ··· 237 261 238 262 select.addEventListener("change", () => { 239 263 buildParamsUI(select.value); 240 - preview(select.value); 264 + startPreview(select.value); 241 265 }); 242 266 await document.fonts.ready; 243 267 buildParamsUI(select.value); 244 - preview(select.value); 268 + startPreview(select.value); 245 269 }; 246 270 247 271 if (document.readyState === "loading") {
+2 -1
src/render.ts
··· 17 17 canvas: HTMLCanvasElement, 18 18 freq: Uint8Array, 19 19 options: VisualizerOptions, 20 + dt?: number, 20 21 ) => void, 21 22 fps: number, 22 23 quality: Quality, ··· 45 46 await output.start(); 46 47 47 48 for (let i = 0; i < frames.length; i++) { 48 - draw(canvas, frames[i], options); 49 + draw(canvas, frames[i], options, 1 / fps); 49 50 await sourceVideo.add(i / fps, 1 / fps); 50 51 } 51 52 sourceVideo.close();
+37 -1
src/visualizers/bars.ts
··· 7 7 max: 64, 8 8 label: "amt", 9 9 }, 10 - { key: "barColor", type: "color", default: "#ffffff", label: "color" }, 10 + { key: "barColor", type: "color", default: "#ffffff", label: "fg" }, 11 + { key: "bgColor", type: "color", default: "#000000", label: "bg" }, 11 12 ]; 12 13 13 14 export function draw( ··· 18 19 const ctx = canvas.getContext("2d"); 19 20 if (!ctx) return; 20 21 22 + ctx.fillStyle = String(options.bgColor ?? "#000000"); 23 + ctx.fillRect(0, 0, canvas.width, canvas.height); 24 + 21 25 const barCount = Number(options.barCount ?? 16); 22 26 const barColor = String(options.barColor ?? "#ffffff"); 23 27 const barWidth = canvas.width / barCount; ··· 31 35 ctx.fillRect(i * barWidth, canvas.height - barHeight, barWidth, barHeight); 32 36 } 33 37 } 38 + 39 + export function preview( 40 + canvas: HTMLCanvasElement, 41 + _freq: Uint8Array, 42 + getOptions: () => Record<string, string | number>, 43 + fps: number, 44 + ) { 45 + let phase = 0; 46 + const fixedDt = 1 / fps; 47 + let nextFrame = performance.now() + fixedDt * 1000; 48 + let raf = 0; 49 + 50 + function loop(now: number) { 51 + if (now >= nextFrame) { 52 + nextFrame += fixedDt * 1000; 53 + if (nextFrame < now) nextFrame = now + fixedDt * 1000; 54 + phase += 3 * fixedDt; 55 + 56 + const size = 1024; 57 + const freq = new Uint8Array(size); 58 + for (let i = 0; i < size; i++) { 59 + const t = (i / size) * Math.PI * 2 + phase; 60 + freq[i] = Math.floor((Math.sin(t) * 0.5 + 0.5) * 255); 61 + } 62 + draw(canvas, freq, getOptions()); 63 + } 64 + raf = requestAnimationFrame(loop); 65 + } 66 + 67 + raf = requestAnimationFrame(loop); 68 + return () => cancelAnimationFrame(raf); 69 + }
+41 -4
src/visualizers/cd-bottom.ts
··· 1 - const ROTATION_SPEED = 0.2; 2 - 3 1 // disc dimensions 4 2 const OUTER_RADIUS_RATIO = 0.42; 5 3 const HUB_OUTER_RATIO = 0.28; ··· 8 6 const STREAK_SIZE_RATIO = 1.5; 9 7 const OUTER_EDGE_THICKNESS_RATIO = 0.012; 10 8 9 + export const params = [ 10 + { 11 + key: "speed", 12 + type: "number", 13 + default: 0.2, 14 + min: 0.1, 15 + max: 52, 16 + step: 0.1, 17 + label: "rps", 18 + }, 19 + ]; 20 + 11 21 let angle = 0; 12 22 13 23 export function draw( 14 24 canvas: HTMLCanvasElement, 15 25 _freq: Uint8Array, 16 - _options?: Record<string, string | number>, 26 + options: Record<string, string | number>, 27 + dt = 1, 17 28 ) { 18 29 const ctx = canvas.getContext("2d"); 19 30 if (!ctx) return; ··· 30 41 const hubInnerRadius = outerRadius * HUB_INNER_RATIO; 31 42 const holeRadius = outerRadius * HOLE_RADIUS_RATIO; 32 43 const streakSize = outerRadius * STREAK_SIZE_RATIO; 44 + const rotationSpeed = Number(options.speed) * dt; 33 45 34 46 // rotation 35 47 ctx.save(); ··· 194 206 ); 195 207 ctx.restore(); 196 208 197 - angle += ROTATION_SPEED; 209 + angle += rotationSpeed; 210 + } 211 + 212 + export function preview( 213 + canvas: HTMLCanvasElement, 214 + freq: Uint8Array, 215 + getOptions: () => Record<string, string | number>, 216 + fps: number, 217 + ) { 218 + angle = 0; 219 + const fixedDt = 1 / fps; 220 + let nextFrame = performance.now() + fixedDt * 1000; 221 + let raf = 0; 222 + 223 + function loop(now: number) { 224 + if (now >= nextFrame) { 225 + nextFrame += fixedDt * 1000; 226 + if (nextFrame < now) nextFrame = now + fixedDt * 1000; 227 + draw(canvas, freq, getOptions(), fixedDt); 228 + } 229 + raf = requestAnimationFrame(loop); 230 + } 231 + 232 + raf = requestAnimationFrame(loop); 233 + 234 + return () => cancelAnimationFrame(raf); 198 235 }
+41 -4
src/visualizers/cd-top.ts
··· 1 - const ROTATION_SPEED = 0.2; 2 - 3 1 // disc dimensions 4 2 const OUTER_RADIUS_RATIO = 0.42; 5 3 const HUB_OUTER_RATIO = 0.28; ··· 8 6 const STREAK_SIZE_RATIO = 1.5; 9 7 const OUTER_EDGE_THICKNESS_RATIO = 0.012; 10 8 9 + export const params = [ 10 + { 11 + key: "speed", 12 + type: "number", 13 + default: 0.2, 14 + min: 0.1, 15 + max: 52, 16 + step: 0.1, 17 + label: "rps", 18 + }, 19 + ]; 20 + 11 21 let angle = 0; 12 22 13 23 export function draw( 14 24 canvas: HTMLCanvasElement, 15 25 _freq: Uint8Array, 16 - _options?: Record<string, string | number>, 26 + options: Record<string, string | number>, 27 + dt = 1, 17 28 ) { 18 29 const ctx = canvas.getContext("2d"); 19 30 if (!ctx) return; ··· 30 41 const hubInnerRadius = outerRadius * HUB_INNER_RATIO; 31 42 const holeRadius = outerRadius * HOLE_RADIUS_RATIO; 32 43 const streakSize = outerRadius * STREAK_SIZE_RATIO; 44 + const rotationSpeed = Number(options.speed) * dt; 33 45 34 46 // rotation 35 47 ctx.save(); ··· 208 220 ); 209 221 ctx.restore(); 210 222 211 - angle += ROTATION_SPEED; 223 + angle += rotationSpeed; 224 + } 225 + 226 + export function preview( 227 + canvas: HTMLCanvasElement, 228 + freq: Uint8Array, 229 + getOptions: () => Record<string, string | number>, 230 + fps: number, 231 + ) { 232 + angle = 0; 233 + const fixedDt = 1 / fps; 234 + let nextFrame = performance.now() + fixedDt * 1000; 235 + let raf = 0; 236 + 237 + function loop(now: number) { 238 + if (now >= nextFrame) { 239 + nextFrame += fixedDt * 1000; 240 + if (nextFrame < now) nextFrame = now + fixedDt * 1000; 241 + draw(canvas, freq, getOptions(), fixedDt); 242 + } 243 + raf = requestAnimationFrame(loop); 244 + } 245 + 246 + raf = requestAnimationFrame(loop); 247 + 248 + return () => cancelAnimationFrame(raf); 212 249 }
+19
style.css
··· 179 179 vertical-align: middle; 180 180 } 181 181 182 + #vis-params input[type="number"] { 183 + width: 52px; 184 + padding: 2px 4px; 185 + background: rgba(255, 255, 255, 0.06); 186 + border: 1px solid rgba(255, 255, 255, 0.1); 187 + border-radius: 4px; 188 + color: #ddd; 189 + font-family: inherit; 190 + font-size: 0.75em; 191 + text-align: center; 192 + vertical-align: middle; 193 + appearance: textfield; 194 + } 195 + 196 + #vis-params input[type="number"]::-webkit-inner-spin-button, 197 + #vis-params input[type="number"]::-webkit-outer-spin-button { 198 + opacity: 1; 199 + } 200 + 182 201 #vis-params input[type="color"] { 183 202 width: 24px; 184 203 height: 24px;