Skip to content

/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.

June 4, 2026 14 min read
Astro React 19 Web Workers WebAudio API OfflineAudioContext TypeScript

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:

LabInputHeavy workOutput
MixLab Analyzeruploaded audioBS.1770-4 LUFS + spectrummetrics + chart + feedback
VoiceLab QAmic capture or uploadVAD + room echo + sibilancemetrics + timeline + feedback
HearLab Companionlive micWeb Speech API captions + metercaptions + log
SignalLab Indexeruploaded audioclassification + tags + regionsJSON + timeline
CueLab Monitornone (simulation)state machine + animationsrouting graph + checklist
SkillLab Challengemic capture + synth targetspectral comparison scoringscore + 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, or indexBufferOffThread and 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

MetricValue
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 painthero 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.