DEV Community

Cover image for ⚽🎙️ Gemini and ElevenLabs gave me a World Cup goal my team will never score
Vicente Junior
Vicente Junior Subscriber

Posted on

⚽🎙️ Gemini and ElevenLabs gave me a World Cup goal my team will never score

DEV Weekend Challenge: Passion Edition Submission

This is a submission for Weekend Challenge: Passion Edition


My passion is football. Not "I watch the final" football — the kind where the World Cup reorganizes your calendar for a month, where you know the backup left-back's injury history, where a group-stage result can make or ruin your week.

And this year, Brazil is out.

Knocked out by Haaland's Norway. For the first time in any World Cup, neither Brazil nor Germany made the last eight. The quarter-finals kicked off and I had nothing to do but watch other people's teams from my couch.

So I built Be the Commentator. Gemini writes the goal that never happened. ElevenLabs screams it back at me in my own cloned voice — in English, French, Portuguese, or Spanish. Then I send it to the group chat like it was real.

Two modes. ⚡ Epic, where you're the legendary commentator calling a goal. And 🛋 Couch, where you're a fan whose team went home early, narrating from the living room, roasting yourself the whole way.

Couch mode is the whole point. Grief, but funny.

What I Built

Be the Commentator hero

Four steps, about twenty seconds of user time:

1. LANGUAGE  →  EN / FR / PT / ES
                (this decides the script you'll read out loud)

2. VOICE     →  record ~1 min in the browser
                MediaRecorder → /api/clone → ElevenLabs IVC → voiceId

3. TEAM      →  the 8 real quarter-finalists + Brazil (marked ✖ OUT)

4. MOMENT    →  goal · save · comeback · red card · free kick
                penalty miss · nutmeg · elimination · full time
                + VIBE:  ⚡ Epic  or  🛋 Couch
                + 🌶 your own spin (optional)

   GENERATE  →  /api/script   Gemini writes the call, with audio tags
             →  /api/narrate  ElevenLabs speaks it in your voice
             →  play · tune the voice · download · share
Enter fullscreen mode Exit fullscreen mode

Real output. I picked France + Elimination + Couch, and typed three words into the spice box: "Chora Kilian" ("cry, Kylian"). Gemini took it from there, and my cloned voice delivered it:

FULL-TIME WHISTLE! [shouting] France is eliminated! [laughs] The dream of a second title is over for Les Bleus! Cry, Mbappé — the sixth one stays with us! [excited] Bye bye, France! The crowd is already singing: 'OLÉ, OLÉ, OLÉ! OFF YOU GO!'

Three words of input. The model found the joke, sharpened it, and handed it back with stage directions.

Demo

Code

🔗 github.com/vicente-r-junior/be-the-commentator

How I Built It

Architecture

┌────────────┐   /api/clone (audio)    ┌──────────────┐  voices.ivc.create    ┌────────────┐
│            │ ──────────────────────► │              │ ────────────────────► │            │
│  React     │   /api/script (json)    │   Express    │  generateContent      │   Gemini   │
│  (Vite)    │ ──────────────────────► │   keys live  │ ────────────────────► │            │
│            │   /api/narrate (json)   │   HERE only  │  textToSpeech.convert │ ElevenLabs │
│            │ ◄────────────────────── │              │ ◄──────────────────── │            │
└────────────┘        mp3 / json       └──────────────┘                       └────────────┘
Enter fullscreen mode Exit fullscreen mode

Node + Express backend, Vite + React frontend. The browser never sees an API key — it talks to my server, my server talks to the providers.

ElevenLabs: the voice is the product

Instant Voice Cloning turns a one-minute browser recording into a voiceId:

import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js';
const client = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });

const voice = await client.voices.ivc.create({
  name: `commentator-${Date.now()}`,
  files: [audioStream],            // 1–2 min, under 11MB
});
// → voice.voiceId
Enter fullscreen mode Exit fullscreen mode

eleven_v3 with audio tags is what makes it a commentator instead of a text reader:

const audio = await client.textToSpeech.convert(voiceId, {
  text: scriptWithTags,            // "[shouting] GOOOOOAL! [laughs] ..."
  modelId: 'eleven_v3',
  outputFormat: 'mp3_44100_128',
  voiceSettings: { stability, similarityBoost, style },  // user-controlled
});
Enter fullscreen mode Exit fullscreen mode

Three things I learned the hard way:

1. stability is the emotion dial, and it's inverted. The defaults sounded like a corporate voicemail. High stability makes a voice consistent — and consistency is death for a commentator. Low stability is what lets the screaming through.

That knob turned out to be too important to hide, so I put it in the UI. But 0.4 means nothing to a user, so the presets speak human:

Tune the voice — stability presets, similarity and style sliders

Preset What it actually does
Creative Low stability. Wild, emotional, different every take.
Natural Balanced.
Robust High stability. Consistent, and boring for this use case.

Plus raw sliders for similarity and style, and a Re-generate narration button — so you can tune the voice and re-render the same script until it sounds right. Same words, different performance. That's the loop that makes it fun.

2. Audio tags only work on eleven_v3. On older models they get read out loud, literally. Your commentator will say the words "open bracket shouting close bracket." Ask me how I know.

3. Clones pile up until your account is full. Every ivc.create adds a voice to your ElevenLabs account. Test the flow twenty times and you're locked out mid-build. So the backend takes out its own trash before every clone:

const existing = await client.voices.search({ search: 'commentator-' });
for (const v of existing.voices ?? []) {
  if (v.name?.startsWith('commentator-')) await client.voices.delete(v.voiceId);
}
// only now create the new one
Enter fullscreen mode Exit fullscreen mode

Google Gemini: writing like a commentator, not a narrator

Gemini (gemini-2.5-flash via @google/genai) writes every script. The model isn't the hard part — the prompt is. It has to produce a human losing their mind on live TV, not an AI describing a sporting event.

import { GoogleGenAI } from '@google/genai';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const res = await ai.models.generateContent({
  model: 'gemini-2.5-flash',
  contents: prompt,                // team + moment + lang + vibe + user's spin
  config: { temperature: 1 },
});
return res.text.trim();
Enter fullscreen mode Exit fullscreen mode

The load-bearing constraints:

  • 2–4 sentences, under 700 characters. Commentary is short. Long is a documentary.
  • 3–5 audio tags, inline, right before the words they affect. Not decoration — stage direction.
  • Build tension, then explode at the climax of the MOMENT.
  • Output ONLY the script. No preamble, no quotes, no "Here's your commentary!"
  • Couch mode: the fan's team is eliminated. Reference the pain comedically, then still explode into a triumphant call. Funny and warm, never bitter.

That last one is the hardest thing I asked of the model, and it's the one it nailed. Comedy about losing — self-deprecating but joyful — is a narrow target.

And then there's the spice box.

Moment and vibe selection with the spice box where the user typed

A free-text field where the user throws in whatever they want — an inside joke, a player's name, a grudge — and Gemini folds it into the script. It's the difference between a generator and a collaborator. You bring the grudge, the model brings the timing.

"Chora Kilian""Cry, Mbappé — the sixth one stays with us!"

Three words in. A punchline out, with [laughs] placed exactly where it lands.

The two tools compound in the multilingual case: Gemini writes in the target language, and the same cloned voice delivers it. Same character, four languages. Your voice, speaking a language you might not even speak.

One quota gotcha worth knowing:

Model Free tier What happened
gemini-3.5-flash 20 requests/day Burned through it in one afternoon of testing
gemini-2.5-flash 20 requests/day, separate counter Switching models handed me a fresh quota instantly

Quota counters are per model. When 3.5 locked me out mid-build, dropping to 2.5 unblocked me immediately — same quality for this task, brand new budget. (I eventually enabled billing with a hard spending cap.)

The bug that wasn't in the code

The read-aloud script card shown while recording, with tips on energy, mic distance and length

My first clones sounded nothing like me. My wife confirmed it, brutally, in about four seconds.

I went looking for the bug. There wasn't one. The problem was the input: I'd recorded 29 seconds of flat, monotone reading. ElevenLabs wants 1–2 minutes of consistent, natural, energetic speech. The clone learns how you talk, not just how you sound. Feed it a bored voice, get a bored commentator.

So the recording screen now hands you a read-aloud script in your chosen language, engineered for exactly this: a story with rhythm, enough phonetic variety, and one controlled emotional peak. Culturally tuned, because commentators don't scream the same way everywhere:

The peak
🇧🇷 PT "GOOOOL! Que golaço, meu povo!"
🇬🇧 🇺🇸 EN "OH, WHAT A GOAL! Absolutely brilliant!"
🇫🇷 FR "BUUUT ! Quel but magnifique !"
🇪🇸 ES "¡GOOOL, GOOOL, GOOOL! ¡Qué golazo, señores!"

One peak — not sixty seconds of shouting. ElevenLabs explicitly warns that wild volume swings degrade the clone. Energy, not chaos.

Fix the input, and the clone fixes itself.

How this was actually built: agents on a short leash

I architected this. Agents wrote most of the code. The interesting part isn't that — it's the scaffolding that kept it from falling apart.

The repo has an AGENTS.md and a STATE.md. STATE.md is the single source of truth: sprints broken into small steps, each with a checkbox and — critically — a Verify line.

## Sprint 3 — Narration (ElevenLabs TTS)
- [x] S3.1 — lib/eleven.js: narrate({voiceId, text, lang}) using eleven_v3
      Verify: scripts/try-tts.js writes out.mp3, plays back.
- [x] S3.2 — POST /api/narrate streams mp3 back to client
      Verify: curl saves a playable mp3.
- [ ] S3.3 — chain sanity: script → narrate in one manual pass
      Verify: couch-mode PT clip sounds right.
Enter fullscreen mode Exit fullscreen mode

The rules were strict: one step at a time. Run the Verify line. Update STATE.md. Commit. Stop for review at the end of each sprint. Plus one more, which turned out to be the most valuable of all:

End every sprint review with a "How to test it yourself" — the exact copy-pasteable commands the human runs to check the work by hand. Don't just tell me it's verified.

That rule earned its keep immediately. An agent can curl a route and confirm a 200. It cannot open a browser and notice the design is wrong.

The best example: the agent built the language picker as step 4, after the recording step. Every test passed. Every route returned 200. And the flow was completely broken — because the language determines the script you read while recording. You'd record in English, then pick Portuguese, and the app would cheerfully hand you the wrong text. No assertion catches that. I caught it by clicking through the app like a user would.

I also hit a usage limit in Codex halfway through and handed the project off to Claude Code. The handoff was a two-line CLAUDE.md importing AGENTS.md — the new agent read STATE.md and knew exactly where we'd stopped, which sprint was open, and what the last verified step was. Total cost of context transfer: one commit.

The honest takeaway: agents are fast at the mechanical and genuinely bad at knowing when something is wrong but working. The leash isn't a better prompt. It's a state file, a verify step, and a human who actually opens the app.

What I'd do with more than a weekend

  • Professional Voice Cloning instead of Instant. IVC needs ~1 minute and is decent. PVC needs ~30 minutes of audio and is uncanny. For a product where the voice is the value, that's the upgrade that matters.
  • Real match data. The moments are archetypes today. Wire in a live feed and you could commentate an actual goal thirty seconds after it happens — your voice, your language, your group chat, before the highlights even post.
  • Ship it publicly. This runs locally right now. A hosted version needs a way to let people try it without a paid ElevenLabs key — probably a set of preset voices for the curious, cloning for those who bring their own.

Prize Categories

Best Use of ElevenLabs — the voice isn't a feature here, it's the product. Instant Voice Cloning makes you the commentator. eleven_v3's audio tags turn text-to-speech into an actual scream. And rather than hide the model's controls, I surfaced them: stability presets, similarity and style sliders, and a re-generate loop, so you can perform the same line ten different ways until one gives you chills. Plus automatic voice cleanup, so the app doesn't quietly fill up your account.

Best Use of Google AI — Gemini writes every single call. It has to hit the rhythm of live commentary, place audio tags where they land, absorb whatever the user throws into the spice box, work across four languages, and pull off the hardest ask in the whole project: be funny about losing, without being bitter.


Brazil isn't in the semi-finals.

But in my living room, on my phone, in my own voice — we never lost this Cup. ⚽🎙️

Top comments (1)

Collapse
 
toboreeee profile image
Laurina Ayarah

"The stability being inverted thing made me laugh, that's such a 'nobody tells you this until you've burned a weekend' kind of lesson. Also, your STATE.md + Verify line setup is smart, feels like the actual unlock for working with agents without losing control of the project