DEV Community

Sho Naka
Sho Naka

Posted on Edited on

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

AI disclosure: 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.

I put an AI into a Google Meet call. It transcribed Japanese, generated a reply, and spoke it
out loud. Incremental service/API spend for this prototype: $0 — that excludes local compute
and infrastructure I already owned.

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. The latency was
real but a red herring — it didn't change with context. The model-capability problem wasn't
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 (source-available, self-hosted, Elastic License 2.0) 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 separate Cloud TTS project/service account/billing setup
Reasoning + voice LLM + TTS, two hops Gemini Live (speech-to-speech) one model, one hop

One naming note up front: the meeting-facing voice path above runs on the Gemini Live API
(speech-to-speech). The controlled A/B comparison later in this article — the one that isolates
context from everything else — uses a separate, non-voice call to gemini-2.5-flash with the
same prompt scaffold. "Same model" in that section refers only to the two sides of that specific
comparison, not to the voice path.

Attendee is 699 stars, last pushed 2026-08-07.
Google's Meet Media API existed only as a Developer Preview at the time of this build (August
2026) — gated enrollment, not a general-availability path — so Attendee instead drives a full
Chrome instance to interact with Meet like a human participant. That's the self-hosted route that
doesn't depend on preview enrollment, and it's 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 dedicated Cloud TTS setup left the critical path (the Gemini Live
API dependency itself remains).

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 — that part was real, and I started planning fixes for it. But then I
looked at what the bot was actually saying, and that turned out to be a second, separate
problem: whether the answer itself was any good. Fixing the ~4s connection-setup gap wasn't going
to touch that.

A separate controlled test

To isolate the context variable from voice plumbing (and from latency) entirely, I ran a
controlled, text-only comparison outside the live call: 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.

That's the split: the live loop's ~4s connection-setup latency and the quality of the answer are
two different layers, and they don't move together. Don't let a real latency number talk you into
diagnosing a context shortage as a model-capability shortage — they're separate problems with
separate fixes.

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

Gemini API's Unpaid Services terms are explicit: Google may use the content you send — prompts,
inputs, and outputs — to improve its products, and that content may be reviewed by human
raters. From the pricing page, on the Live API audio model row:

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

Google's own Gemini API terms also state that you should not send sensitive, confidential, or
personal information to the Unpaid Services. That rules out sending real meeting transcripts —
customer names included — to the free tier, masked or not. A masker doesn't change what tier
the data lands in.

So the deterministic masker described below is not a workaround that makes the free tier safe
for confidential data. It's defense in depth: a detection and redaction experiment for a
pipeline that, in production, would need to run against the paid tier (or an on-prem/VPC model)
for any meeting containing real customer information. Treat the masking numbers as a measurement
of how well a lookup-table approach catches spoken proper nouns — not as a justification for what
tier the underlying data was sent to.

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)