/showcases · Platform
Six interactive demos, one platform — the architecture
How AudioLab.tools ships six fully interactive audio analysis demos (MixLab, VoiceLab, HearLab, SignalLab, CueLab, SkillLab) from a single codebase, with shared Web Worker analysis, runtime-synthesised samples, and zero server-side audio processing.
Outcome
Six fully working interactive demos. Heavy analysis runs off the main thread. UI stays at 60 fps even on long files. Zero audio crosses the network.
When the masterplan called for six labs each with a “real working demo, not a screenshot”, the engineering question wasn’t how to build a demo. It was how to build six without the platform becoming an unmaintainable mess of overlapping audio pipelines, react components, and worker scripts.
This is the architecture we landed on. It’s opinionated, it’s working, and it solved the trade-offs the way we’d do it again.
The shape of the problem
Each lab has different requirements:
| Lab | Input | Heavy work | Output |
|---|---|---|---|
| MixLab Analyzer | uploaded audio | BS.1770-4 LUFS + spectrum | metrics + chart + feedback |
| VoiceLab QA | mic capture or upload | VAD + room echo + sibilance | metrics + timeline + feedback |
| HearLab Companion | live mic | Web Speech API captions + meter | captions + log |
| SignalLab Indexer | uploaded audio | classification + tags + regions | JSON + timeline |
| CueLab Monitor | none (simulation) | state machine + animations | routing graph + checklist |
| SkillLab Challenge | mic capture + synth target | spectral comparison scoring | score + facet breakdown |
The shared properties: each one needs WebAudio + React state + a result visualisation. The diverging properties: input source, analysis algorithm, output shape.
Layer 1: the static-first shell
Every page on the site is static-rendered Astro. The demos live inside React islands that hydrate only when needed:
---
import MixLabAnalyzer from '@/components/MixLabAnalyzer';
import VoiceLabQA from '@/components/VoiceLabQA';
// ... etc
---
{cluster.slug === 'mixlab' && <MixLabAnalyzer client:only="react" />}
{cluster.slug === 'voicelab' && <VoiceLabQA client:only="react" />}
{cluster.slug === 'hearlab' && <HearLabCompanion client:only="react" />}
client:only="react" tells Astro to skip server rendering entirely. The HTML loads with a placeholder, then the React island takes over once it’s downloaded. For audio demos that depend on browser-only APIs (WebAudio, MediaRecorder, Web Speech), this is the correct choice — there’s nothing meaningful to SSR.
The rest of the page — the hero, the use cases, the docs, the roadmap — is plain Astro markup. No hydration cost.
Layer 2: the unified analysis Web Worker
Three of the demos (MixLab, VoiceLab, SignalLab) do heavy off-thread analysis. The naive approach would be three separate worker files. We took the opposite approach: one worker, three analysis kinds.
// worker.ts
import { analyzeChannels } from '../lib/audio-analysis-core';
import { analyzeVoiceChannels } from '../lib/voice-analysis-core';
import { indexChannels } from '../lib/signal-analysis-core';
self.addEventListener('message', async (e) => {
const { id, kind, channels, sampleRate, fileName, ...extra } = e.data;
const onProgress = (pct: number) =>
self.postMessage({ type: 'progress', id, pct });
try {
let result;
if (kind === 'audio') result = await analyzeChannels(channels, sampleRate, fileName, onProgress);
if (kind === 'voice') result = await analyzeVoiceChannels(channels, sampleRate, fileName, onProgress);
if (kind === 'signal') result = await indexChannels(channels, sampleRate, { fileName, ...extra }, onProgress);
self.postMessage({ type: 'result', id, result });
} catch (err) {
self.postMessage({ type: 'error', id, message: String(err) });
}
});
Why one worker for three pipelines?
- One worker instance per session. No spin-up cost when the user switches between MixLab and VoiceLab in the same session.
- One bundle. Vite produces one worker bundle that includes the three analysis cores. Total cost: ~40 KB of gzipped worker JS.
- One client API. The component-side code can call
analyzeBufferOffThread,analyzeVoiceOffThread, orindexBufferOffThreadand get a clean Promise back. The fact that they share a worker is hidden.
The client wrapper handles the request/response correlation with monotonic ids:
async function dispatch<T>(kind, buffer, extras, fallback) {
const worker = getWorker();
if (!worker) return fallback();
const channels = copyChannels(buffer);
const id = `req-${nextId++}`;
return new Promise<T>((resolve, reject) => {
const onMessage = (e: MessageEvent) => {
const msg = e.data;
if (msg.id !== id) return;
if (msg.type === 'progress') extras.onProgress?.(msg.pct);
else if (msg.type === 'result') { worker.removeEventListener('message', onMessage); resolve(msg.result); }
else if (msg.type === 'error') { worker.removeEventListener('message', onMessage); reject(new Error(msg.message)); }
};
worker.addEventListener('message', onMessage);
worker.postMessage({ type: 'analyze', kind, id, channels, ...extras }, channels.map(c => c.buffer));
});
}
The channels.map(c => c.buffer) is the transfer list — Float32Array underlying ArrayBuffers get transferred to the worker, not copied. Zero serialisation cost.
Layer 3: runtime sample synthesis
The user-facing problem with audio analysers is they need audio. Most users don’t want to upload a track to see what an analyser does. They want to click “try sample” and see results immediately.
Our answer: runtime synthesis via OfflineAudioContext.
export async function synthesizeSample(id: SampleId): Promise<AudioBuffer> {
const sr = 48000;
const def = SAMPLES.find((s) => s.id === id);
const ctx = new OfflineAudioContext(2, Math.floor(sr * def.durationSec), sr);
const buffer = ctx.createBuffer(2, ctx.length, sr);
// Render kick + bass + lead + air directly into Float32Arrays
switch (id) {
case 'modern-master': renderModernMaster(buffer.getChannelData(0), buffer.getChannelData(1), sr); break;
case 'open-mix': renderOpenMix(...);
case 'boxy-room': renderBoxyRoom(...);
case 'voice-sample': renderVoiceSample(...);
}
return buffer;
}
Each sample is a small DSP routine that writes directly into the channel data:
function renderModernMaster(left: Float32Array, right: Float32Array, sr: number) {
// Kick on every 0.5s: 60 Hz body with downward sweep
renderInto(left, sr, (t) => {
const beat = t % 0.5;
const env = envelope(beat, 0.001, 0.18);
return Math.sin(2*Math.PI * (60 - 30*beat) * beat) * env;
}, 0.55);
// Bass + lead + air shimmer + master bus soft-clip…
// …
}
The trick is that these samples are real audio the analyser processes for real. Click “Modern master” and the analyser reports -7 LUFS with “Loud — likely over-limited” because the synthesised signal is actually loud and over-limited. There’s no faking; we just generated the audio that the analyser is genuinely analysing.
Zero binary asset cost. Deterministic output. Same engine the user would upload to.
Layer 4: per-cluster UI patterns
Each demo has its own React component. They share visual primitives (Metric, FeedbackCard, drop zone) but their interaction shapes differ enough that abstracting them harder would be premature.
- MixLabAnalyzer: dual drop zones (A/B reference compare), worker-backed analysis, spectrum + LUFS timeline + metrics + plain-language feedback.
- VoiceLabQA: live MediaRecorder + drop zone, voice-specific metrics, timeline with speech/silence overlay.
- HearLabCompanion: Web Speech API integration, live captions, environment meter, check-in log with JSON export.
- SignalLabIndexer: tabs (Overview / Timeline / Tags / JSON), worker-backed indexing, downloadable structured JSON.
- CueLabMonitor: pure state machine, no audio in or out. Routing graph drawn as SVG with animated route particles. Pre-show checklist with localStorage persistence.
- SkillLabChallenge: synthesised target audio (WebAudio oscillators + filters + envelopes), MediaRecorder capture, spectral comparison scoring.
Each one is ~300–500 lines of TypeScript React, sharing the platform’s design tokens via Tailwind.
What we deliberately didn’t do
- No global state manager. Each demo owns its own React state. The few cross-component bits (theme, command palette) use direct DOM APIs.
- No backend. Every demo runs entirely in the browser. No audio crosses the network. This was a hard constraint, not a performance optimisation.
- No premature abstraction. We have three analysers that do superficially similar things, but their algorithms diverge enough that a shared abstraction would obscure more than it clarified.
Performance
| Metric | Value |
|---|---|
| Time to interactive (homepage) | ~0.8 s on cable |
| MixLab Analyzer cold start | ~150 ms to drop zone visible |
| Worker analysis on 3-min track | ~600 ms |
| Main thread blocked during analysis | ~0 ms (it’s the worker’s job) |
| Largest contentful paint | hero image, ~0.5 s |
The whole site, including the demos, is hosted as static files. There’s no backend at all.
Outcome
Six labs. Six demos. One platform. Everything in the browser, nothing on a server. The architecture is opinionated, the choices are intentional, and the result is a site where every demo actually works — not a roadmap of demos that don’t.
The platform has shipped. Now we keep iterating.
Related
More build logs
-
Brand imagery pipeline — seven cinematic images, one design system
Generated 7 cinematic brand images (hero + 6 cluster-specific), optimised them from 5–9 MB PNGs into 50–400 KB WebP variants via Astro’s build pipeline, then integrated them across cluster cards, hero sections, and atmospheric brand beats.
-
Hero motion loop — from text prompt to 460 KB production asset
Built a 5-second subtle motion loop for the homepage hero using Higgsfield seedance 2.0 → ffmpeg → 252 KB WebM + 209 KB H.264 MP4 with image fallback, no audio track, autoplay-friendly, and reduced-motion-respecting.