DEV Community

Sho Naka
Sho Naka

Posted on

My AI Answered in 5.8 Seconds and Said Nothing Useful. I Almost Blamed the Model.

I put an AI into a Google Meet call. It transcribed Japanese, generated a reply, and spoke it
out loud. Total new spend: $0.

Then I asked it the one question I actually needed answered, and it said:
"I think there's still room for discussion. How about we set up a session to align our understanding?"

That is exactly what a person says when they don't know.

TL;DR: I had a latency problem and an "is this model smart enough" problem. Neither was real.
Same model, same question, 5.80s → 5.68s — 2,545 characters of context turned a deflection into
a claim you could argue with.

The stack, and what it replaced

I wanted an AI participant in a real meeting. Not a note-taker — something that answers when
someone demands specifics.

The obvious stack bills you three times: a hosted meeting-bot API, a speech-to-text vendor, and
a text-to-speech vendor. I replaced all three.

Layer Obvious choice What I used Why
Meeting bot Recall.ai, $0.50/hour Attendee (OSS, self-hosted) no per-hour billing
Speech-to-text Deepgram / AssemblyAI Google Meet's own captions the meeting already generates them
Text-to-speech Google Cloud TTS raw audio POST (below) no GCP project at all
Reasoning + voice LLM + TTS, two hops Gemini Live (speech-to-speech) one model, one hop

Attendee is 699 stars, last pushed 2026-08-07.
Google Meet exposes no bot API, so it drives a full Chrome instance — which is why setup hurt
before anything else did.

The setup tax, compressed

Two problems were routine. The image pins FROM --platform=linux/amd64 ubuntu:22.04, and my
machine is Apple Silicon, so colima with Rosetta:

colima start --vm-type=vz --vz-rosetta --cpu 6 --memory 12 --disk 60
docker run --rm --platform=linux/amd64 alpine:3.20 uname -m   # x86_64, 5.6s cold
Enter fullscreen mode Exit fullscreen mode

Then the build died at step 35 of 42 with the --chmod option requires BuildKit — colima's
docker CLI ships without the buildx plugin. brew install docker-buildx, point
~/.docker/config.json at /opt/homebrew/lib/docker/cli-plugins via cliPluginsExtraDirs,
rebuild. docker images then reported three images at 5.91 GB each.

The third problem is the one worth your time, because it produced no error at all:

/usr/bin/python: can't open file '/attendee/init_env.py': [Errno 2] No such file or directory
Enter fullscreen mode Exit fullscreen mode

The file existed on the host. dev.docker-compose.yaml mounted .:/attendee. Both true — and
the mount was still empty, because colima only mounts $HOME, and my checkout was under
/private/tmp/.... No warning, no failed-mount message, just an empty directory that looked
exactly like a correctly-mounted one.

Moving the checkout under $HOME fixed it. If a bind mount looks empty inside a colima
container, check the VM's mount list before you check anything else.

Making it speak without a TTS vendor

Attendee's documented endpoint for speaking is /speech, and the source told me it was a dead
end: "Check for Google TTS credentials. This is currently the only supported text-to-speech
provider."
That meant a GCP project, a service account, and a billing profile — for something
I'd already solved elsewhere.

But the URL list had another entry, and it doesn't care where the bytes came from:

subprocess.run(["ffmpeg", "-y", "-i", "out.wav",
                "-codec:a", "libmp3lame", "-b:a", "128k", "reply.mp3"], check=True)

requests.post(f"{API}/bots/{bot_id}/output_audio",
              headers={"Authorization": f"Token {key}"},
              json={"type": "audio/mp3",
                    "data": base64.b64encode(open("reply.mp3","rb").read()).decode()})
# 200
Enter fullscreen mode Exit fullscreen mode

MediaBlob.VALID_AUDIO_CONTENT_TYPES is [('audio/mp3', 'MP3 Audio')]. Generate audio
anywhere, convert, POST. The entire Google Cloud dependency left the critical path.

Why my "conversation" was a bot talking to itself

I ran a live loop: poll the transcript, generate a reply, speak it, repeat. Then I read the log.

→ [AI participant] ...how much time has passed, you don't know?
  ← replied in 3.82s
→ [AI participant] I don't know.
  ← replied in 7.07s
Enter fullscreen mode Exit fullscreen mode

The bot's own speech landed in Meet's caption stream, and the loop read it back as new input.

That alone is an obvious bug. The part I didn't expect was the collateral damage. I had a
cooldown so the bot wouldn't talk over people — and the self-loop kept that cooldown permanently
hot, so it started dropping the human's turns:

(skipped: cooldown 3.0s) [Human] I measured that one at about 7 seconds.
(skipped: cooldown 0.0s) [Human] so you'd need it to say "got it, thinking" immediately
Enter fullscreen mode Exit fullscreen mode

The human on the call said "this isn't connecting." They were right, and not for the reason
either of us assumed. The fix is one line — filter transcript rows by speaker_name.

The lesson is not the line. It's that a self-loop does not present as an error. It presents as
poor conversational quality
, and you will debug the wrong layer for as long as you trust your
impression of the conversation over the log.

The number that changed my mind

With the loop fixed, latency across seven consecutive turns looked like this:

6.16  3.82  7.07  6.44  5.31  4.84  8.77   (seconds)
Enter fullscreen mode Exit fullscreen mode

Median 6.16s. A single request outside the loop was 1.77s to first audio chunk (110 chunks,
5.32s of speech). The ~4s gap is connection setup, repeated every turn.

So I had a latency problem, and I started planning fixes for it. Then I looked at what the bot
was actually saying, and realized latency was the wrong problem.

I ran a controlled comparison. Same model (gemini-2.5-flash), same question, same prompt
scaffold. One variable: 2,545 characters of context appended to the system instruction —
a short digest of ongoing work, competitor pricing, and our own pricing.

The question was the kind anyone gets asked in a strategy meeting:
"What is our value again — what makes us different from these people?"

Latency Shape of the answer
No context 5.80s A deflection. "There's still room for discussion — how about we set up a session to align our understanding?"
With context 5.68s A position with specifics: named our pricing model, cited what the competitor comparison had shown, stated where we differ operationally.

Latency was identical. Within noise. The model was the same. The only thing that changed was
what it knew.

The first answer is what you say to buy time. The second can be argued with — someone can check
the pricing claim, disagree with the positioning, push back on the comparison. That is the
difference between an assistant and a stalling tactic.

Note the shape of the failure: ask a deep question, get a shallow answer. Not a wrong answer —
a smooth one, with nothing in it. That's the tell. It reads as "the model isn't good enough at
reasoning" when it's actually "the model has nothing to reason over."

The rule I extracted

Measure what your agent knows before you upgrade what it thinks with.

The reflex when an agent underperforms is to reach for a bigger model. That reflex has a cost —
more money, more latency, sometimes a migration — and in my case it would have bought nothing,
because the agent was reasoning from an empty context and doing that competently.

A cheap test: take one question your agent handled badly. Paste the context a human colleague
would have had in their head when answering it. Re-run with the same model. If the answer
transforms, your problem was never the model.

Which also means the build work isn't model selection. It's the layer that decides what your
agent knows at the moment it's asked — and that layer has its own problems, starting with what
you're allowed to put in it.

The free tier has a price, and it isn't money

From Gemini's pricing page, on the Live API audio model row:

Used to improve our products: Yes (free tier) / No (paid tier)

For meetings containing customer names, that's a real decision. So the context layer needs a
filter in front of it: a deterministic masker — no model judgment, just a lookup table.

Here's the part that surprised me. My hand-built table of 82 names, generated from canonical
company records, scored 1 replacement across 86 transcripts — and it was a false positive.

Speech transcripts don't contain canonical company names. They contain what people say, and
speech recognition mangles proper nouns. One person's surname never appeared in its correct form
in a 36,240-character transcript.

Switching to an existing alias registry — built for a different purpose, listing the terms people
actually use out loud — changed everything:

Table Replacements Files with ≥1 hit (of 86)
Hand-built, 82 canonical names 1 (false positive)
Registry strong terms 3,324 41 (47%)
strong + a dedicated mask field 5,071 58 (67%)

Measured across 1,728,094 characters. If you're redacting speech, build your table from spoken
forms, not from your database.

A note on running this in Japanese

I expected the language to be the hard part. It wasn't — not where I expected it.

What worked: Gemini Live took language_code: "ja-JP" and produced speech a native speaker
in the call judged natural on the first attempt. Google Meet's own captions
(google_meet_language: "ja-JP") transcribed conversational Japanese cleanly enough to feed
straight into the model. Neither needed tuning.

What broke: proper nouns, and only proper nouns — the same failure the masking numbers above
already show. If your pipeline keys on names (redaction, entity linking, CRM lookup), that's
where the work is, and it's a per-name problem you close by growing a table, not a model problem
you fix by upgrading.

One structural difference if you've only done this in English: Japanese has no spaces between
words, so substring matching has no natural boundaries. My masker replaced a fragment inside an
unrelated word on its first run, because two characters of a company name happened to sit inside
a common noun. In English a \b word boundary catches that for free. Here you need explicit
negative lookarounds per term, or a real tokenizer.

So: viable, and cheaper than I expected. Budget your effort for the name layer, not the speech
layer.

Try it yourself

You need about an hour and no paid accounts.

  1. brew install colima docker docker-compose docker-buildx, start colima with --vm-type=vz --vz-rosetta, and verify docker run --platform=linux/amd64 alpine uname -m prints x86_64 before building anything.
  2. Clone Attendee under $HOME, then follow its dev flow: docker compose -f dev.docker-compose.yaml build, generate .env via init_env.py, up, migrate, create an account at localhost:8000, and confirm it with the link printed to the server log. The API key comes from the UI afterward.
  3. Join a meeting with POST /api/v1/bots, then push any mp3 through /output_audio and confirm you hear it. Do this before writing any agent logic; it isolates the plumbing from your code.
  4. Now the real test: take one question your agent answered badly. Add the context a colleague would have had. Re-run with the same model.

If step 4 changes the answer, you know where your next week goes.

What did your version of step 4 tell you? I'd like to know whether the gap shows up outside my
setup.


This article was drafted with AI assistance. All measurements in it were produced on my own
machine, and responsibility for their accuracy rests with me.

Top comments (0)