<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Weng Dev Journal]]></title><description><![CDATA[Weng Dev Journal]]></description><link>https://wengjiyao.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Weng Dev Journal</title><link>https://wengjiyao.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 24 Sep 2026 16:28:14 GMT</lastBuildDate><atom:link href="https://wengjiyao.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Gemma 4 for Telephony: I Replaced Two AI Models With One in My Voice Phone Agent — Until I Switched to Chinese]]></title><description><![CDATA[Building a phone agent on a multimodal LLM: dropping faster-whisper and letting Gemma 4 hear the caller directly — a response-time and reply-accuracy benchmark across English, French, and Mandarin
My ]]></description><link>https://wengjiyao.hashnode.dev/gemma-4-for-telephony-i-replaced-two-ai-models-with-one-in-my-voice-phone-agent-until-i-switched-to-chinese</link><guid isPermaLink="true">https://wengjiyao.hashnode.dev/gemma-4-for-telephony-i-replaced-two-ai-models-with-one-in-my-voice-phone-agent-until-i-switched-to-chinese</guid><category><![CDATA[gemma4]]></category><category><![CDATA[Telephony]]></category><category><![CDATA[ipphone]]></category><category><![CDATA[local ai]]></category><category><![CDATA[RTX 3090]]></category><dc:creator><![CDATA[Jiyao Weng]]></dc:creator><pubDate>Sun, 14 Jun 2026 02:17:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a2e089682e89bd7b27c728d/bcdbcbe4-e1eb-4c4f-8dc5-4b3409f94540.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3>Building a phone agent on a multimodal LLM: dropping faster-whisper and letting Gemma 4 hear the caller directly — a response-time and reply-accuracy benchmark across English, French, and Mandarin</h3>
<p><em>My voice phone agent uses two models: one to hear the caller, one to think. Gemma 4 can do both at once — so I tried deleting the speech-to-text model entirely. Across English, French, and Mandarin, here's the head-to-head on response time and the thing that actually matters on a phone line: did it reply correctly. In English and French, one model beat two — faster <strong>and</strong> more accurate. In Chinese, it confidently told me a football field is 100 yards. Here's the data.</em></p>
<hr />
<h2>The cascade, and the temptation to collapse it</h2>
<p>My voice phone agent uses the same architecture as almost every spoken assistant: a <strong>cascade</strong> of specialist models.</p>
<pre><code>caller audio ─▶ [ faster-whisper ] ─▶ text ─▶ [ Gemma LLM ] ─▶ reply ─▶ [ TTS ] ─▶ audio
                  speech-to-text                 reasoning
</code></pre>
<p>Two models loaded, two GPU residents, two sequential hops before the caller hears anything. Then Gemma 4 shipped in Ollama with an <strong><code>audio</code> capability</strong> — it can take audio <em>directly</em> as input. Which raises the obvious question:</p>
<blockquote>
<p>Why transcribe at all? If the LLM can hear the caller, it can understand <strong>and</strong> answer in one step. One model, one hop.</p>
</blockquote>
<p>So I set up a clean head-to-head, <strong>holding the LLM constant</strong> so the only thing that changes is the input modality:</p>
<ul>
<li><strong>Pipeline A — the cascade:</strong> audio → faster-whisper → text → <strong>gemma4:12b</strong> → reply</li>
<li><strong>Pipeline B — multimodal:</strong> audio → <strong>gemma4:12b</strong> (audio in) → reply</li>
</ul>
<p>Same model doing the reasoning in both. I measured the two things that actually matter for a phone agent:</p>
<ol>
<li><strong>Reply correctness</strong> — did the agent <em>answer the question correctly</em>?</li>
<li><strong>Response time</strong> — how long until the reply is ready.</li>
</ol>
<p><strong>A note on what to measure, because for a telephony system it changes the conclusion.</strong> The instinct is to score the speech-to-text by Word Error Rate. But a caller never hears the transcript — they hear the <em>reply</em>. What matters on a phone line is whether the agent <strong>did the right thing</strong>: answered the question, booked the table, cancelled the reservation. A transcript that drops a filler word or writes "7:00" for "seven o'clock" is harmless if the reply is still correct. So <strong>reply correctness is the primary metric here; transcription WER is only a diagnostic</strong> I use later to explain <em>why</em> the replies succeed or fail. Judge the system by the thing the caller actually experiences.</p>
<h2>Methodology</h2>
<p>I wrote <strong>42 spoken caller turns</strong> (15 English, 15 French, 12 Mandarin) as natural speech via gTTS — factual questions, arithmetic, and task requests (bookings, cancellations) — each with a <strong>checkable answer key</strong> so correctness is objective, not vibes:</p>
<table>
<thead>
<tr>
<th>Spoken turn</th>
<th>Correct iff reply contains</th>
</tr>
</thead>
<tbody><tr>
<td>"What is the capital of France?"</td>
<td>paris</td>
</tr>
<tr>
<td>"What is fifteen plus twenty-seven?"</td>
<td>42 / forty-two</td>
</tr>
<tr>
<td>"I'd like to book a table for four at seven tonight."</td>
<td>(four <strong>and</strong> seven)</td>
</tr>
<tr>
<td>"法国的首都是哪里？"</td>
<td>巴黎</td>
</tr>
<tr>
<td>"二乘以八等于几？"</td>
<td>16 / 十六</td>
</tr>
</tbody></table>
<p>Grading is regex on the reply, case-insensitive, with Traditional→Simplified normalization for Chinese (faster-whisper and Gemma both emit Traditional characters; the keys are Simplified). Both pipelines used identical generation settings (<code>temperature=0</code>, reasoning disabled). Hardware: one 24 GB GPU, Ollama 0.30.7, gemma4:12b (Q4_K_M), faster-whisper <code>medium</code>.</p>
<p><strong>One setup gotcha worth saving you an hour:</strong> Ollama's <em>native</em> <code>/api/chat</code> silently ignores the <code>audios</code> field — you get "there's no audio attached." Audio only works through the <strong>OpenAI-compatible</strong> <code>/v1/chat/completions</code> endpoint as an <code>input_audio</code> content part (base64, 16 kHz mono).</p>
<hr />
<h2>English: the one-model dream, realized</h2>
<table>
<thead>
<tr>
<th>Pipeline</th>
<th>Reply accuracy</th>
<th>Median latency</th>
</tr>
</thead>
<tbody><tr>
<td>A — cascade (faster-whisper → gemma4)</td>
<td>93 %</td>
<td>0.81 s</td>
</tr>
<tr>
<td><strong>B — multimodal (gemma4 audio)</strong></td>
<td><strong>100 %</strong></td>
<td><strong>0.66 s</strong></td>
</tr>
</tbody></table>
<p>The multimodal model won on <strong>both</strong> axes. Every English question got a correct answer, and it did so <em>faster</em> than the cascade — because one model call beats two sequential ones (transcribe-then-reason). Side by side, they're indistinguishable in quality:</p>
<pre><code>"What is two times eight?"
  A: Two times eight is 16.          (0.79 s)
  B: Two times eight is sixteen.     (0.60 s)

"How many legs does a spider have?"
  A: A spider has eight legs.        (0.76 s)
  B: A spider has eight legs.        (0.61 s)
</code></pre>
<p>(The single cascade "miss" wasn't an error — asked to book a table, it replied <em>"what's the name of the restaurant?"</em>, a sensible clarifying turn that just didn't contain the answer-key words. The multimodal model happened to confirm the booking outright.)</p>
<p>For an English voice agent, this is the result you hoped for: <strong>drop a whole model, get faster and at-least-as-accurate replies.</strong></p>
<hr />
<h2>French: not an English fluke</h2>
<p>To check whether this is an English-only trick, I ran the <strong>full 15-item set in French</strong>, both pipelines, same as English:</p>
<table>
<thead>
<tr>
<th>Pipeline</th>
<th>Reply accuracy</th>
<th>Median latency</th>
</tr>
</thead>
<tbody><tr>
<td>A — cascade (faster-whisper → gemma4)</td>
<td><strong>100 %</strong></td>
<td>0.89 s</td>
</tr>
<tr>
<td>B — multimodal (gemma4 audio)</td>
<td><strong>93 %</strong></td>
<td>0.71 s</td>
</tr>
</tbody></table>
<p>French behaves like English: the multimodal model understands the audio and answers correctly. Same near-indistinguishable quality:</p>
<pre><code>Quelle est la capitale de la France ?
  A: La capitale de la France est Paris.        B: La capitale de la France est Paris.   ✓
Quel est le contraire de chaud ?
  A: Le contraire de chaud est froid.           B: Le contraire de chaud est froid.       ✓
Combien de pattes a une araignée ?
  A: …elle possède huit pattes articulées.      B: Une araignée a généralement huit pattes. ✓
</code></pre>
<p>The single multimodal "miss" is revealing: asked <em>"combien font quinze plus vingt-sept ?"</em>, it answered <strong>"quarenta e dois"</strong> — the right answer (42), but <strong>in Portuguese</strong>. The audio was understood; the model just slipped languages. For a French phone line that's still a failure (the caller hears Portuguese), so it's scored wrong — but it's a language-control wobble, not deafness. Nothing like the Chinese collapse you're about to see. The single-model result clearly generalizes: gemma4's audio works on <strong>major European languages</strong>, not just English.</p>
<hr />
<h2>Chinese: the same model, off a cliff</h2>
<table>
<thead>
<tr>
<th>Pipeline</th>
<th>Reply accuracy</th>
<th>Median latency</th>
</tr>
</thead>
<tbody><tr>
<td>A — cascade (faster-whisper → gemma4)</td>
<td><strong>92 %</strong></td>
<td>0.83 s</td>
</tr>
<tr>
<td>B — multimodal (gemma4 audio)</td>
<td><strong>8 %</strong> †</td>
<td>0.67 s</td>
</tr>
</tbody></table>
<p>† The single "correct" is a fluke — asked water's boiling point, B answered <em>"a football field is 100 yards"</em>, which matched the "100" key. <strong>True accuracy is 0 / 12.</strong></p>
<p>Same LLM. Same questions. The <em>only</em> change is feeding audio instead of a transcript — and the agent goes from 92 % to effectively zero. The failure isn't subtle mistakes; it's the model answering <strong>completely unrelated questions</strong>, because it can't make out the Mandarin at all and confabulates:</p>
<pre><code>法国的首都是哪里？   (What's the capital of France?)
  A: 法國的首都是巴黎。                    ✓  (The capital of France is Paris.)
  B: 法語的字母數量是26個。               ✗  (The French alphabet has 26 letters.)

一只蜘蛛有几条腿？   (How many legs does a spider have?)
  A: 蜘蛛有八條腿。                        ✓  (A spider has eight legs.)
  B: The nearest subway station is just around the corner.   ✗

二乘以八等于几？     (What is two times eight?)
  A: 2乘以8等於16。                        ✓
  B: 法国一共有12个省。                    ✗  (France has 12 provinces.)
</code></pre>
<p>The cascade, meanwhile, handles Chinese almost perfectly — because faster-whisper transcribes the Mandarin correctly and <em>then</em> the very same Gemma answers it correctly.</p>
<hr />
<h2>Why: it's the ears, not the brain</h2>
<p>The reasoning is identical across both pipelines, so the collapse must be in the <strong>audio understanding</strong>. A separate transcription benchmark on Google's FLEURS read-speech set confirms it precisely:</p>
<p>For the WER/CER run I asked Gemma 4 to transcribe, nothing more — a single user turn (no system prompt), <code>temperature=0</code>, <code>reasoning_effort="none"</code>:</p>
<blockquote>
<p><em>Transcribe the speech in this audio exactly, word for word, in its original language. Output only the transcription text, nothing else.</em></p>
</blockquote>
<table>
<thead>
<tr>
<th></th>
<th>English (WER ↓)</th>
<th>Chinese (CER ↓)</th>
</tr>
</thead>
<tbody><tr>
<td>faster-whisper <code>medium</code></td>
<td>3.6 %</td>
<td>12.6 %</td>
</tr>
<tr>
<td>gemma4:12b (audio)</td>
<td>9.2 %</td>
<td><strong>76.6 %</strong></td>
</tr>
</tbody></table>
<p>(WER/CER are computed <em>after</em> normalization — Whisper's English text normalizer so "7:00" equals "seven o'clock", and Han-character-only comparison for Chinese — applied identically to both systems.)</p>
<p>Gemma 4's audio encoder hears <strong>English</strong> well enough (9.2 % word error — it even paraphrases sensibly) that the downstream answer comes out right. On <strong>Chinese</strong> it's at 76.6 % character error — it isn't transcribing, it's hallucinating — so the brain, however capable, is reasoning over noise. Garbage in, confident garbage out. The audio front-end isn't <em>English</em>-centric (French works just as well) — it's <strong>major-European-language-centric</strong>, with Mandarin coverage simply not there yet at this model size and quantization.</p>
<hr />
<h2>The latency nuance (and a landmine)</h2>
<p>The multimodal path's latency edge (0.66 s vs 0.81 s) is real but modest: collapsing two sequential calls into one saves a hop, and faster-whisper itself is cheap (~0.2–0.3 s). The bigger win is operational — <strong>one model to load, serve, and monitor</strong> instead of two.</p>
<p>One landmine, though: Gemma 4 is a <strong>reasoning model</strong>, and on the audio endpoint its thinking trace is <strong>hard to suppress reliably</strong> (<code>reasoning_effort</code>, <code>think:false</code>, and <code>enable_thinking</code> were all flaky in my tests). Most calls were snappy, but in separate runs a single transcription occasionally <strong>ballooned to ~40 seconds</strong> of internal monologue, sometimes leaking raw <code>&lt;channel|&gt;</code> control tokens. For a real-time phone line, an unpredictable 40 s stall is its own disqualifier until the control surface stabilizes.</p>
<hr />
<h2>How it plugs into a phone agent</h2>
<p>In a SIP/telephony stack the multimodal swap is small. Your phone bridge (e.g. pyVoIP over a VoIP provider) already buffers the caller's audio and uses voice-activity detection to decide when they've stopped talking. Today that buffer goes to a speech-to-text model and the text goes to an LLM. With Gemma 4 you collapse those two steps into one call:</p>
<pre><code>            ┌─ before ─────────────────────────────────────────────┐
 caller ▶ VAD ▶ buffer ▶ [faster-whisper] ▶ text ▶ [LLM] ▶ reply ▶ TTS ▶ caller
            └──────────────────────────────────────────────────────┘
            ┌─ after ──────────────────────────────────────────────┐
 caller ▶ VAD ▶ buffer ▶──────────▶ [Gemma 4 audio] ▶ reply ▶ TTS ▶ caller
            └──────────────────────────────────────────────────────┘
</code></pre>
<p>The turn handler becomes one function — resample the buffered turn to 16 kHz mono, attach it as an <code>input_audio</code> part, and keep the running conversation as ordinary text history:</p>
<pre><code class="language-python">def handle_turn(turn_wav_16k_mono, history):           # history = prior turns (text)
    audio_b64 = base64.b64encode(turn_wav_16k_mono).decode()
    messages = [{"role": "system", "content": AGENT_PROMPT}, *history,
                {"role": "user", "content": [
                    {"type": "input_audio",
                     "input_audio": {"data": audio_b64, "format": "wav"}}]}]
    r = requests.post("http://localhost:11434/v1/chat/completions", json={
        "model": "gemma4:12b", "messages": messages,
        "stream": True, "temperature": 0.0, "reasoning_effort": "none"})
    reply = stream_to_tts(r)                            # speak as tokens arrive
    history += [{"role": "user", "content": "(caller audio)"}, 
                {"role": "assistant", "content": reply}]
    return reply
</code></pre>
<p>Four things that matter in practice:</p>
<ul>
<li><strong>Use the OpenAI-compatible endpoint.</strong> The audio goes only through <code>/v1/chat/completions</code> as <code>input_audio</code>; the native <code>/api/chat</code> <code>audios</code> field is silently dropped.</li>
<li><strong>History stays text.</strong> Only the <em>current</em> turn is audio; past turns ride along as transcribed/assistant text, which keeps the prompt small and the model grounded.</li>
<li><strong>Stream into the TTS.</strong> Start synthesizing the reply on the first clause so the caller hears audio in well under a second — the same trick that makes the cascade feel fast.</li>
<li><strong>Guard the reasoning blowup.</strong> Cap the response (timeout / max tokens) and fall back to the cascade if a turn stalls, so one runaway thinking trace can't freeze the call.</li>
</ul>
<p>That's the whole change — for an English- or French-language line. For Chinese you keep faster-whisper in front, and Gemma 4 stays purely the text brain.</p>
<hr />
<h2>Verdict: one model, but mind the language</h2>
<table>
<thead>
<tr>
<th></th>
<th>English</th>
<th>French</th>
<th>Chinese</th>
</tr>
</thead>
<tbody><tr>
<td>Replace the cascade with one multimodal model?</td>
<td><strong>Yes</strong></td>
<td><strong>Yes</strong></td>
<td><strong>No</strong></td>
</tr>
<tr>
<td>Reply accuracy, multimodal</td>
<td>100 %</td>
<td>93 %</td>
<td>~0 %</td>
</tr>
<tr>
<td>Reply accuracy, cascade</td>
<td>93 %</td>
<td>100 %</td>
<td>92 %</td>
</tr>
<tr>
<td>Response time, multimodal vs cascade</td>
<td>0.66 s vs 0.80 s</td>
<td>0.71 s vs 0.89 s</td>
<td>0.67 s vs 0.83 s</td>
</tr>
<tr>
<td>Why</td>
<td>hears English well</td>
<td>hears French well</td>
<td>can't hear Mandarin</td>
</tr>
</tbody></table>
<p><em>(Reply correctness — what the caller actually experiences — is the column that decides this. Transcription WER/CER is the "Why," not the verdict.)</em></p>
<ul>
<li><strong>English- or French-first agent:</strong> the single multimodal model is a genuine win — faster, simpler, and at least as accurate on the metric that matters (correct replies). Just budget for the reasoning-mode latency tax.</li>
<li><strong>Mandarin (or any language the encoder can't hear) in the loop:</strong> keep the cascade. A dedicated ASR model isn't legacy baggage — it's the only reason the Chinese agent answers correctly at all. And note French works fine, so this is about <em>which</em> languages the audio encoder covers, not "English vs the rest."</li>
<li><strong>The metric that decides it is reply correctness, not WER</strong> — because a phone caller is served by the answer, not the transcript. Test the language(s) you'll actually deploy in, and grade the reply.</li>
<li><strong>My bilingual (Chinese/English) phone agent keeps faster-whisper + an LLM.</strong> The one-model future is clearly coming — for English and French it's already here — but it arrives one language at a time.</li>
</ul>
<p>The meta-lesson: a spec sheet that lists "audio input" is telling you the <em>plumbing</em> exists, not that the <em>quality</em> does — and a single model that aces a task in one language can score zero in another. Hold the reasoning constant, vary one thing, and measure the thing your users actually feel: did it answer, and how fast.</p>
<hr />
<h2>Reproduce it</h2>
<pre><code>e2e/items.json     # 27 spoken turns + answer keys (en + zh)
e2e/gen_audio.py   # gTTS -&gt; audio/&lt;id&gt;.mp3
e2e/stt.py         # cascade stage 1: faster-whisper -&gt; stt.tsv (+ latency)
e2e/run_e2e.py     # A (whisper text -&gt; gemma4) vs B (audio -&gt; gemma4); replies + latency + correctness
e2e/regrade.py     # Traditional-&gt;Simplified-aware scoring -&gt; summary tables
</code></pre>
<p>Plus a standalone transcription benchmark (<code>build_corpus.py</code> / <code>run_fw.py</code> / <code>run_gemma4.py</code> / <code>score.py</code>) on Google FLEURS for the WER/CER mechanism numbers. Hardware: one 24 GB GPU, Ollama 0.30.7. <em>If you run it on a larger Gemma 4 or other languages, I'd love to see the numbers.</em></p>
]]></content:encoded></item></channel></rss>