Snazzie FM is a fictional radio station. The hosts aren’t real, the callers are invented, and every advertiser is fake. But the audio system underneath it is a real Web Audio API engine — a multi-track scheduler with loudness normalization, a pool-based ad rotation, and a timing system designed specifically to make AI-generated voices sound like they’re interrupting each other.
You can explore the audio timeline directly if you’d rather hear it than read about it.
The data model
Every episode is a JSON file. The file contains a slug, a title, a description, and an array of lines — one entry per spoken clip:
{
"speaker": "ronnie",
"text": "You're listening to The Truth Hour on Snazzie FM.",
"timestamp": 0.0,
"duration": 9.4,
"overlap": 0,
"audio": "/snazziefm/audio/the-truth-hour/0.flac"
}Each line maps to exactly one FLAC file. timestamp is when the clip starts playing relative to the episode start. duration is how long the clip runs. These values aren’t written by hand — they’re measured from the actual generated audio and written back into the JSON automatically.
overlap is the key field for making conversations sound natural. More on that below.
TTS generation
Every clip is generated locally with OmniVoice on a CUDA GPU. The Python script reads an episode’s lines array, sends each line’s text to the model with the speaker’s assigned voice, and writes the output to public/audio/<episode>/<index>.flac.
After generation, the script decodes each FLAC, measures its actual duration, and writes those timestamps back into the JSON. Every timestamp in the data is derived from real audio, not estimates. The timeline view is accurate because the data comes from the audio, not the other way around.
Terms and acronyms get CMU ARPAbet phoneme annotations in the source text before being sent to the model. The display layer strips those markers out; the voice model receives the phoneme sequence instead of the raw text. This is the same pipeline used for article narration on this site.
Speed is also a tunable parameter. Some voice clones come out slow and slightly slurred at their default rate — the pacing works for the reference material but drags on radio. The generation script applies a global speed multiplier (currently 1.15x) that tightens delivery across all voices and gives the station a sense of urgency without sounding rushed.
After generation, each clip goes through a validation pass. The script runs faster-whisper (tiny.en model) on the rendered audio and transcribes it back to text. That transcript is compared word-by-word against the original line. If any words are missing, the script prints a warning with the clip index, speaker, and the specific words that didn’t make it through. The validation catches mispronounced words the phoneme annotations didn’t fully fix, swallowed consonants at the end of clips, and the occasional model hallucination where the voice just skips a phrase. It doesn’t auto-retry — the warning is the signal to check that clip manually and decide whether it needs a new take.
The Web Audio API architecture
The audio engine uses the Web Audio API directly — no library wrapper. Everything goes through a single shared AudioContext, and every clip is an AudioBufferSourceNode connected to a shared analyser chain.
Every speaker’s clips flow through this same chain. There’s no separate routing per speaker at the Web Audio level — the multitrack separation you see in the timeline view is a rendering concept, not a separate audio graph. The scheduler places each clip at an absolute time on the shared AudioContext clock, and the browser handles mixing.
The clip scheduler
The scheduler is built around a single function: makeScheduler. It takes an AudioContext, a shared analyser node, and a start time t0 anchored to AudioContext.currentTime at the moment playback begins.
function makeScheduler(ctx: AudioContext, analyser: AudioNode, t0: number) {
let last: AudioBufferSourceNode | null = null;
let lastEnd = 0;
const schedule = (buf: AudioBuffer, at: number) => {
const s = ctx.createBufferSource();
s.buffer = buf;
connectNorm(ctx, s, buf, analyser); // normalization gain + connect
s.start(t0 + at); // absolute clock time
if (at + buf.duration >= lastEnd) { lastEnd = at + buf.duration; last = s; }
};
return { schedule, finish(onEnded) { ... } };
}When an episode plays, the engine loops through every line in the JSON and calls schedule(decodedBuffer, line.timestamp). Because AudioContext.currentTime is a high-resolution hardware clock, the browser can schedule dozens of clips in a single frame and play them back at exact millisecond precision — no polling, no setTimeout, no drift.
The last-to-finish clip gets an onended handler, which fires the transition to the next content block (ad break or next episode) once playback completes.
Overlapping clips: eliminating TTS silence
Every TTS clip has silence at the start and end. The voice model doesn’t produce audio that begins or ends cleanly at the first and last phoneme — there’s always a few hundred milliseconds of quiet on either side. If you simply stacked clips sequentially, conversations would feel sluggish and robotic.
The overlap field solves this. It’s a negative offset — typically between -0.1 and -0.35 — that tells the scheduler to start the next clip before the previous one fully ends:
{ "speaker": "barry", "text": "We always go there, Ronnie.", "overlap": -0.15, "timestamp": 8.57 }
{ "speaker": "ronnie", "text": "We go further there.", "overlap": -0.1, "timestamp": 9.731 }Here, Barry’s clip starts while Ronnie is still technically playing — cutting into the tail silence. Ronnie’s next line starts 1.16 seconds into Barry’s 2.32-second clip, cutting into Barry’s trailing silence. Both clips are playing simultaneously for a brief window. The Web Audio API mixes them at the destination — what you hear is two people talking with natural pacing rather than a queue of sequential audio files.
The overlap values are tuned per conversation. An interruption needs a tighter overlap. A pause for effect needs a small one or zero. This is the part that can’t be automated — every -0.15 is a judgment about when one character sounds natural cutting in on another.
Loudness normalization
Speech, ads, and music all record at different levels. Without compensation, volume would swing noticeably between every clip.
The engine measures each buffer’s integrated RMS once, caches the result in a WeakMap<AudioBuffer, number>, and derives a linear gain that brings the buffer to a target of −20 dBFS:
const TARGET_RMS_DB = -20;
const MAX_GAIN = 4; // +12 dB ceiling
const MIN_GAIN = 0.05; // −26 dB floor
function bufferGain(buf: AudioBuffer): number {
// measure RMS across all channels, stride 4 (cheap on long buffers)
// derive gain toward TARGET_RMS_DB, clamp to
}That gain gets applied via a GainNode between the source and the analyser. The result is that speech, a jingle, and a music track all play at roughly the same perceived level without any per-clip hand-tuning. The cache means the RMS is only measured once per buffer lifetime.
Ad scheduling
Between every episode, a commercial break fires. The break contains six ads, drawn from two pools: “Pro” spots (pre-produced, polished) and InstaAds (rougher, blunder-style spots). The scheduler alternates between the two types and enforces a hard rule: InstaAds never air back-to-back.
The Pro pool uses a shuffle bag — every spot plays before any repeats. The InstaAd pool groups spots by business (a business may have two takes), shuffles businesses, and keeps takes in order within each business. When a pool drains, it reshuffles — but the spots that aired in the current block get pushed to the back of the new cycle so the same ad can’t appear twice in one break.
Every third transition between episodes gets a music track before the ad block.
The music track is linked to the episode where possible — each episode JSON can specify a music slug, and the scheduler uses that track if it exists, otherwise picks randomly.
The music
Each music break is generated with Suno, and each track is written specifically for the episode it follows. The process starts with an LLM — given the episode’s script, it produces a Suno-compatible prompt: a genre, a style description, a mood, and optionally lyrics. Something like “melancholy indie folk, fingerpicked guitar, minor key, themes of quiet resignation” for an episode about someone who just accepted a bad outcome.
Suno takes that prompt and generates several candidates. Most get discarded immediately. The ones that survive aren’t necessarily the ones that match the prompt most literally — they’re the ones that feel right after a thirty-second listen. Sometimes a small tweak to the genre tag changes everything: swapping “indie folk” for “chamber folk” shifts the production from sparse to orchestrated, and suddenly it fits better. That iteration is manual. There’s no scoring function for whether a piece of music lands emotionally after a radio episode.
The result is that every music break in Snazzie FM is original, episode-specific, and can’t be reproduced — Suno’s output is non-deterministic, and the curation step throws away everything that doesn’t clear a bar that’s entirely subjective.
What makes it feel like radio
The engineering is mostly scheduling math and buffer management. What makes it feel like a real station is the overlap work — tightening the gaps until two AI voices sound like they’re genuinely reacting to each other rather than waiting their turn. That part is manual, conversation by conversation. The pipeline just faithfully executes whatever the JSON says.