I recently built an experiment that I honestly can’t stop listening to. It’s a virtual, web-based Boombox. You tune the dial, the station changes, and the music crossfades in real-time.
But here is the kicker: The DJ is AI.
Every time you settle on a station, a dynamically generated voice (courtesy of Gemini text-to-speech) chimes in to introduce the track and the genre, totally context-aware. It feels like a ghost in the machine, and it was built using the Google Gen AI SDK, Lit, and the Lyria Real-Time model.
Here is how I built it.
The Tech Stack
- Vibe-coding Platform: Google AI Studio
- Framework: Lit (Web Components) + Vite
- Music Generation: Google's
lyria-realtime-expmodel - DJ Voice & Script: Gemini 2.5 Flash & Gemini TTS (Fenrir voice)
- Visuals: CSS for the radio, Gemini 2.5 Flash Image for the background.
1. The Infinite Music Stream 🎵
The core of the app is the LiveMusicHelper. It connects to the lyria-realtime-exp model. Unlike generating a static MP3 file, this establishes a session where we can steer the music in real-time by sending "Weighted Prompts."
When you turn the tuning knob on the UI, we aren't downloading a new song; we are telling the AI to shift its attention.
// from utils/LiveMusicHelper.ts
public readonly setWeightedPrompts = throttle(async (prompts: Map<string, Prompt>) => {
// Convert our UI map to an array for the API
const weightedPrompts = this.activePrompts.map((p) => {
return { text: p.text, weight: p.weight };
});
try {
// This is where the magic happens.
// We tell the model: "be 100% Bossa Nova" or "mix 50% Dubstep and 50% Jazz"
await this.session.setWeightedPrompts({
weightedPrompts,
});
} catch (e: any) {
console.error(e);
}
}, 200);
The visual knob component maps a rotation angle to an index in a prompt array. If you are on index 0, "Bossa Nova" gets a weight of 1.0.
2. The AI DJ (The "Secret Sauce") 🎙️
This is my favorite part. A radio isn't a radio without a DJ telling you what you're listening to. I created a RadioAnnouncer class to handle this.
It works in a two-step chain:
- Generate the Script: We ask Gemini to write a one-sentence intro.
- Generate the Audio: We pass that text to the TTS model.
Step 1: The Personality
We prompt Gemini 2.5 Flash to adopt a persona. Note the specific constraints: short, punchy, no quotes.
// from utils/RadioAnnouncer.ts
const scriptResponse = await this.ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: `You are a charismatic radio DJ. Write a single, short, punchy sentence to introduce the current song.
The station frequency is ${freq} FM.
The music genre is ${station}.
Do not use quotes. Just the spoken text.
Example: "You're locked in to 104.5, keeping it smooth with Bossa Nova."`,
});
Step 2: The Voice
We use the Fenrir voice from the prebuilt configurations.
const ttsResponse = await this.ai.models.generateContent({
model: 'gemini-2.5-flash-preview-tts',
contents: {
parts: [{ text: script }]
},
config: {
responseModalities: [Modality.AUDIO],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: 'Fenrir' }
}
}
}
});
The Logic: Debouncing
Because the user might scroll past 5 stations quickly to get to "Dubstep," we don't want the DJ to try and announce every single one. I used a debouncer so the generation only triggers once the user stops turning the knob for 800ms.
3. The Aesthetics 🎨
The UI is built using Lit. The boombox itself is a mix of CSS styling and SVG for the speakers and knobs.
The Speaker Pulse:
I used the Web Audio API to create an AudioAnalyser. We grab the current frequency data and map it to a CSS transform: scale() on the speaker cones.
/* from components/PromptDjMidi.ts */
.speaker-cone {
/* ... textures and gradients ... */
transition: transform 0.05s cubic-bezier(0.1, 0.7, 1.0, 0.1);
}
// In the render loop
const pulseScale = 1 + (this.audioLevel * 0.15);
const speakerStyle = styleMap({
transform: `scale(${pulseScale})`
});
The Background:
To really sell the vibe, I generate a background image on load.
// The prompt used for the background
text: 'A 90s-style girl\'s bedroom, dreamy, nostalgic, vaporwave aesthetic, anime posters on the wall, lava lamp, beaded curtains, photorealistic.'
4. Managing the "Spin" 😵💫
One of the hardest parts of this build was the math for the tuning knob. We need to convert mouse/touch movement into rotation, and then snap that rotation to specific "stations."
I implemented a circular capture logic:
- Calculate the angle between the center of the knob and the mouse cursor.
- Calculate
delta(change) from the start of the click. - Handle the 0/360 degree wrap-around logic so you can spin it endlessly.
// from components/PromptDjMidi.ts
private handlePointerMove(e: PointerEvent) {
// ... math to get angle ...
// Handle crossing the 0/360 boundary smoothly
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
this.rotation = (this.startRotation + delta + 360) % 360;
// Map rotation to station index
const index = Math.floor(((this.rotation + segmentSize/2) % 360) / segmentSize);
this.setStation(index);
}
TL;DR
This project was a blast to build because it combines the tactile feel of old-school interfaces with bleeding-edge generative AI.
The "Ghost DJ" effect really adds a layer of immersion that pure generative music apps usually lack. It gives the AI a voice—literally—and makes the infinite radio feel alive.
You can check out the full code here in AI Studio: https://aistudio.google.com/apps/drive/1L23eufECJSn0KPta3eAVo-PGdM4iXm-1
Let me know if you try building your own stations! I'm currently vibing to 94.0 "Shoegaze." 📻
Top comments (2)
This is such a perfect collision of nostalgia and sci‑fi—like if a 90s boombox got possessed by a very friendly ghost who secretly majored in DSP. Love how you treated stations as weighted prompts instead of tracks; the “steerable” infinite stream is genius. And the debounced AI DJ intros? That's the exact amount of chaos control a haunted radio needs.
Look awesome! Great project.