Building a browser music visualizer with Goose
A weekend-side-project: drop-zone upload, waveform/bars/circular modes, and letting Goose handle the boilerplate while I focused on the analyser edge cases.
- React
- Web Audio
- Goose
- TypeScript
Building a browser music visualizer with Goose
I wanted a local music visualizer without standing up a backend. Goose got me to a working React shell quickly; the real work was wiring Web Audio so three render modes share one analyser without leaking contexts.
#TL;DR
Goose (Block’s open agent for software tasks) helped me go from “I want a local music visualizer” to a working React + Vite + MUI app with Wavesurfer.js and three render modes — faster than hand-rolling every file. The interesting engineering was not the UI chrome; it was Web Audio graph wiring (one MediaElementSource per element, AudioContext resume on user gesture, cleanup on unmount).
#What shipped
| Piece | Role |
|---|---|
DropZone | Drag/drop or pick audio files → object URL |
useVisualizer | Wavesurfer instance + AnalyserNode + requestAnimationFrame loop |
EnhancedVisualizer | Play/pause, volume, mode menu (waveform / bars / circular) |
| MUI dark theme | Single-file gradient background, glassy panels |
Stack: Vite, React 18, TypeScript, wavesurfer.js v7, Material UI. No backend — pure client.
The audio graph is the product:
<audio> → MediaElementSource → AnalyserNode → (Wavesurfer | canvas FFT loop)Wavesurfer.js owns waveform rendering; Web Audio API owns frequency data for bar/circular modes. Mixing them on one AudioContext is standard, but lifecycle bugs dominate the debugging time.
#Why Goose here
Goose is strongest when the task has clear boundaries and lots of repetitive structure: component files, hook skeletons, MUI layout, icon imports. I described the target UX in plain language:
- Upload a local audio file
- Show waveform while playing
- Switch between bar spectrum and a circular FFT-style view
- Dark theme, minimal controls
Goose generated the initial component tree and hook API. I then iterated on the audio pipeline manually — agents are helpful for scaffolding, not for subtle browser audio rules.
Practical tips when pairing with Goose (or any coding agent):
- Pin libraries upfront — “Wavesurfer v7, no legacy plugins.”
- Ask for one vertical slice — file upload → decode → play → static waveform before fancy modes.
- Review lifecycle code —
useEffectcleanup is where visualizers leak GPU/CPU.
#Web Audio lessons (the real bugs)
#One MediaElementSource per <audio> element
The browser throws if you call createMediaElementSource twice on the same element. React strict mode and hot reload made this common. Pattern:
- Store
_mediaElementSourceon the media element - Reuse if already created
- Disconnect on teardown
#AudioContext suspension
Autoplay policies suspend contexts until a user gesture. Every play button needs:
if (audioContext.state === "suspended") {
await audioContext.resume();
}Without this, the waveform renders but the analyser flatlines.
#Mode switching
Three modes share one analyser:
- waveform — Wavesurfer’s built-in renderer
- bars / circular — custom canvas loop reading
getByteFrequencyData
Switching modes means canceling the previous requestAnimationFrame, detaching old canvases, and re-binding Wavesurfer without recreating the media element source.
#Scope boundaries
This repo is only the visualizer: upload, decode, play, three render modes. No backend, no graph database, no meeting-ingestion pipeline. That narrow scope is why Goose worked — the agent could own the component tree while I owned the audio graph.
#Takeaways
- Agents accelerate scaffolding, not audio graph correctness — budget time for browser APIs.
- Wavesurfer v7 + Web Audio is enough for a portfolio-grade demo without a backend.
- Explicit cleanup in hooks matters more than visual polish for “why did my laptop fan spin up?”
#Agent tooling note (2025–2026)
Goose is Block’s open-source agent for developer workflows (Block Open Source). It is not a model — it orchestrates tools against your repo. For bounded UI tasks (component tree, MUI shell, hook stubs), that is enough. For Web Audio, treat the agent output as a starting point and verify against MDN: Autoplay guide and browser DevTools performance panel.
#Analyser configuration (bars / circular modes)
Custom canvas modes share one AnalyserNode wired after Wavesurfer’s media element. The hook pins FFT and smoothing explicitly — defaults are not always what you want for bar charts:
| Parameter | Value | Effect |
|---|---|---|
fftSize | 1024 | Frequency bin resolution |
smoothingTimeConstant | 0.8 | Less jitter between frames |
minDecibels / maxDecibels | −90 / −10 | Normalizes quiet vs loud tracks |
analyserRef.current = audioContextRef.current.createAnalyser();
analyserRef.current.fftSize = 1024;
analyserRef.current.smoothingTimeConstant = 0.8;#The one-source-per-element rule
Browsers allow only one MediaElementSourceNode per <audio> element. The hook stores the node on the element as _mediaElementSource and reuses it when switching visual modes — recreating the source throws and leaves you with a silent analyser loop. Teardown disconnects analyser and source in cleanupAudioContext() before unmount.
#Autoplay policy in practice
AudioContext often starts suspended until a user gesture. The hook calls resume() before wiring nodes and logs failures instead of assuming play() alone is enough — the difference between “works on my machine after refresh” and “works in Safari on first click.”
#Closing thought
Visual modes are cheap; leaking AudioContext instances and fighting autoplay policy are what break demos. Budget time on teardown in useEffect, not on shader aesthetics.
#Reader field guide
When this stack fits: Client-only playback and visualization; no server-side transcoding; you want Wavesurfer for waveform plus custom canvas modes on the same AudioContext.
Operational checklist
- Create one
MediaElementSourceper<audio>element — stash on the element and reuse across mode switches - Call
audioContext.resume()on every user-initiated play — suspended contexts render flat analysers - Cancel
requestAnimationFramewhen switching waveform ↔ bars ↔ circular - Disconnect analyser and source in hook teardown before unmount
- Pin Wavesurfer v7 in agent prompts so scaffolding does not pull legacy plugin APIs
- Profile with DevTools Performance if the fan spins up after hot reload
#Related reading
#On this site
| Post | Why |
|---|---|
| An Astro + Bun landing template experiment | Another client-only side project with a clear deploy boundary |
| Building a collaborative editor with CRDTs | Real-time in the browser, but state sync instead of a playback analyser loop |
| Building a Gemini AI backend with SSE | When your “stream” is tokens over SSE, not analyser frames |
#References (curated)
Wavesurfer and Web Audio docs disagree on who owns the element graph—read both before you let an agent scaffold the hook.
| Reference | Notes |
|---|---|
| Wavesurfer.js docs | v7 API surface—pin it in prompts so scaffolding does not pull legacy plugins. |
| Web Audio API | AudioContext lifecycle, AnalyserNode, and why resume() follows user gesture. |
| MediaElementAudioSourceNode | One source per <audio>—the leak that survives hot reload. |
| MDN: Server-sent events | Contrast with rAF-driven visualizers—different backpressure story. |