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.” š»
