<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lena Hoffmann</title>
    <description>The latest articles on DEV Community by Lena Hoffmann (@lenajhoffmann).</description>
    <link>https://dev.to/lenajhoffmann</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3977712%2Fc42f4c8f-8440-4fc5-add5-63bb5357ca63.jpeg</url>
      <title>DEV Community: Lena Hoffmann</title>
      <link>https://dev.to/lenajhoffmann</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lenajhoffmann"/>
    <language>en</language>
    <item>
      <title>Barge-In, VAD, and the Latency Budget: Engineering Realtime Voice</title>
      <dc:creator>Lena Hoffmann</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:09:14 +0000</pubDate>
      <link>https://dev.to/lenajhoffmann/barge-in-vad-and-the-latency-budget-engineering-realtime-voice-3i1b</link>
      <guid>https://dev.to/lenajhoffmann/barge-in-vad-and-the-latency-budget-engineering-realtime-voice-3i1b</guid>
      <description>&lt;p&gt;Realtime voice is the only modality where being right is not good enough — you also have to be right &lt;em&gt;now&lt;/em&gt;. A text model can take four seconds and nobody blinks. A voice agent that takes four seconds to start speaking sounds broken. I spent a chunk of last year wiring up bidirectional audio, and most of the hard problems had nothing to do with the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency budget is a sum, and you don't control most of it
&lt;/h2&gt;

&lt;p&gt;The number users feel is &lt;em&gt;time to first audible byte&lt;/em&gt; after they stop talking. That is not one number, it's a chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mic capture + encode    15-55 ms
network uplink RTT/2    20-150 ms   (mobile: worse, and variable)
VAD end-of-turn hold    200-700 ms  &amp;lt;-- you own this one
server queue + model    150-600 ms
first TTS chunk         50-300 ms
network downlink        20-150 ms
client jitter buffer    40-120 ms   &amp;lt;-- you own this one too
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A "fast" pipeline lands between several hundred milliseconds and over a second. Human conversational turn gaps are famously short — around 200 ms in the conversation-analysis literature — which is why even good voice agents feel slightly like a satellite call.&lt;/p&gt;

&lt;p&gt;The two terms you control are the end-of-turn hold and the jitter buffer, and both trade latency against correctness. Shrink the hold and you interrupt people mid-sentence. Shrink the jitter buffer and you get dropouts on flaky Wi-Fi. Instrument every segment before optimizing: almost every time I've been asked to "make the voice agent faster," the culprit was a fixed 500 ms silence timeout nobody had touched, not the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  VAD is a classifier, so it has a confusion matrix
&lt;/h2&gt;

&lt;p&gt;Voice activity detection sounds like a solved DSP problem until you deploy it. Energy-threshold VAD (frame RMS against an adaptive noise floor) is free and works fine in a quiet room. It falls apart with a TV, a fan, or a café. Neural VAD — Silero and WebRTC's are the two you'll encounter — is far more robust to non-speech noise, but costs a frame or two of lookahead and still can't distinguish &lt;em&gt;your&lt;/em&gt; speech from a nearby speaker's.&lt;/p&gt;

&lt;p&gt;The part people underestimate: VAD errors are asymmetric in cost.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;False negative&lt;/strong&gt; (you think the user is still talking): the agent sits there. Laggy, but recoverable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False positive&lt;/strong&gt; (you cut them off mid-thought): the agent barges in on a pause. Users hate this far more, and it poisons the transcript because you ship a truncated utterance to the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the hold timer should not be a constant. A pause after "so I was thinking, um—" is grammatically incomplete; a pause after "what's the weather tomorrow" is a complete request. If your streaming ASR emits partial hypotheses, use that text as a weak end-of-turn signal and shorten the hold when the transcript looks complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;end_of_turn_hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partial_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pitch_falling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;hold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;
    &lt;span class="c1"&gt;# Complete-looking utterance -&amp;gt; commit sooner.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;looks_syntactically_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partial_text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;hold&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;220&lt;/span&gt;
    &lt;span class="c1"&gt;# Falling terminal pitch is a turn-yielding cue in many languages.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pitch_falling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;hold&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;
    &lt;span class="c1"&gt;# Filled pauses mean "I'm not done" -&amp;gt; wait longer.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;partial_text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;um&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;so&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;like&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;hold&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;260&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing fancy, and worth more than a better model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Barge-in is a state machine problem, not an audio problem
&lt;/h2&gt;

&lt;p&gt;The moment your agent can be interrupted, you have concurrency. The user starts talking while TTS audio is still in flight, and three buffers now hold &lt;em&gt;stale&lt;/em&gt; audio: the server's generation queue, the network, and the client's playback buffer. Cancelling server-side does nothing if the client already has 800 ms queued — the agent keeps talking for almost a second after being interrupted, which reads as rude.&lt;/p&gt;

&lt;p&gt;Cancellation has to be client-first, server-second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="nf"&gt;user_speech_detected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;SPEAKING&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BARGE_IN_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;playback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;# local, ~0 ms, do this FIRST
&lt;/span&gt;    &lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cancel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current_turn&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LISTENING&lt;/span&gt;
    &lt;span class="c1"&gt;# Truncate history to what the user ACTUALLY heard.
&lt;/span&gt;    &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncate_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;playback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;played_ms&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one everyone forgets. If you log the full generated response but the user only heard the first eight words, your history is now a lie, and the model will confidently reference things it "said" that nobody heard. Track played-milliseconds and truncate to match.&lt;/p&gt;

&lt;p&gt;Barge-in also needs a &lt;em&gt;higher&lt;/em&gt; confidence threshold than normal turn detection, because the biggest source of false triggers is your own output. Without echo cancellation the agent hears itself through the speaker and interrupts itself in a loop. On the web, &lt;code&gt;getUserMedia&lt;/code&gt; with &lt;code&gt;echoCancellation: true&lt;/code&gt; covers the common case; on speakerphone in a hard-surfaced room browser AEC struggles, and you want half-duplex gating as a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transport: prefer WebRTC once you leave the lab
&lt;/h2&gt;

&lt;p&gt;Sending PCM over a WebSocket is easy and works right up until packet loss. TCP head-of-line blocking means one lost segment stalls everything queued behind it, and your tuned jitter buffer eats a 300 ms spike. WebRTC gives you an SRTP/UDP path with real jitter buffering, loss concealment, and AEC free from the browser stack, at the cost of signaling complexity. My heuristic: WebSocket + Opus is fine for prototypes; anything shipping to mobile networks belongs on WebRTC. Either way send Opus at 16 kHz mono, not raw PCM at 48 kHz.&lt;/p&gt;

&lt;p&gt;When I wanted a reference for how these pieces behave alongside other modalities in one session, the &lt;a href="https://geminiomni-ai.com" rel="noopener noreferrer"&gt;GeminiOmni studio&lt;/a&gt; was a useful system to poke at, because it surfaces the ugly interaction fast: the moment a voice turn triggers a slow non-voice tool call, your latency budget is blown and you need a filler-speech strategy or users assume it crashed.&lt;/p&gt;

&lt;p&gt;One page for my past self: log per-segment timestamps from day one, make the hold adaptive before touching anything else, truncate history to what was played, and test on a phone, on cellular, in a room with a fan running. These systems are all beautiful on a wired desktop with a headset, and that is not where your users are.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>audio</category>
    </item>
    <item>
      <title>Holding Many Modalities in One Session: A Parts-Based API Structure</title>
      <dc:creator>Lena Hoffmann</dc:creator>
      <pubDate>Fri, 24 Jul 2026 19:05:07 +0000</pubDate>
      <link>https://dev.to/lenajhoffmann/holding-many-modalities-in-one-session-a-parts-based-api-structure-327b</link>
      <guid>https://dev.to/lenajhoffmann/holding-many-modalities-in-one-session-a-parts-based-api-structure-327b</guid>
      <description>&lt;p&gt;Multimodal APIs are usually documented one modality at a time — here is image input, here is audio, here is streaming text. What the docs rarely cover is how to hold them in one coherent session, which is what an actual application needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parts, not endpoints
&lt;/h2&gt;

&lt;p&gt;The mental shift that made this tractable: stop thinking in endpoints, start thinking in &lt;strong&gt;typed parts&lt;/strong&gt;. A request is an ordered list where each element declares its own type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once input is a part list, interleaving stops being a special case. "Here are two images, a question about them, and an audio clip for tone reference" is just a four-element array — not three API calls you have to correlate afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inline versus reference, and why it bites
&lt;/h2&gt;

&lt;p&gt;Small assets go inline as base64. Large ones must be uploaded first and passed by URI. The threshold is provider-specific and &lt;strong&gt;the failure mode is bad&lt;/strong&gt;: exceed it and you get an opaque 400, not a helpful "too large, upload it instead."&lt;/p&gt;

&lt;p&gt;Worth building a size check into your part constructor so it routes automatically rather than discovering the limit in production.&lt;/p&gt;

&lt;p&gt;Also: base64 inflates payloads by roughly a third. A "small enough to inline" image is meaningfully smaller than you assume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming breaks the tidy model
&lt;/h2&gt;

&lt;p&gt;Text streams token by token. Images arrive whole. Audio may stream as chunks. So your response handler cannot assume a uniform shape — it needs to be a state machine over part-typed events, accumulating text deltas while treating an image part as atomic.&lt;/p&gt;

&lt;p&gt;The bug I hit repeatedly: treating every stream event as appendable text, which quietly corrupts binary parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context accumulation is the real cost
&lt;/h2&gt;

&lt;p&gt;Multi-turn multimodal sessions grow fast. Every image stays in context and keeps costing tokens on every subsequent turn.&lt;/p&gt;

&lt;p&gt;Two things that helped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Summarise old turns&lt;/strong&gt; — after N turns, replace early image parts with a text description of what they were&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit pinning&lt;/strong&gt; — let the user mark which assets stay in context, and drop the rest&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without either, a ten-turn session with images becomes surprisingly expensive, and the cost is invisible until the bill arrives.&lt;/p&gt;

&lt;p&gt;I put this architecture into practice at &lt;a href="https://geminiomni-ai.com" rel="noopener noreferrer"&gt;GeminiOmni&lt;/a&gt; — image editing, video generation, and live chat sharing one session model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveat
&lt;/h2&gt;

&lt;p&gt;Part-list APIs are converging across providers but are not identical. This structure ports with modest adapter work — it is not free.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What I Learned Building a Multimodal AI Studio Solo on Gemini + Veo</title>
      <dc:creator>Lena Hoffmann</dc:creator>
      <pubDate>Wed, 10 Jun 2026 12:44:08 +0000</pubDate>
      <link>https://dev.to/lenajhoffmann/what-i-learned-building-a-multimodal-ai-studio-solo-on-gemini-veo-474h</link>
      <guid>https://dev.to/lenajhoffmann/what-i-learned-building-a-multimodal-ai-studio-solo-on-gemini-veo-474h</guid>
      <description>&lt;p&gt;I spent a weekend wiring Google's Gemini and Veo APIs into a single app just to feel where the edges of multimodal AI actually are. It turned into a small studio I now use daily, and along the way I learned more about these models from &lt;em&gt;plumbing&lt;/em&gt; them than from any paper. Here's the honest technical debrief.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three pipelines, three completely different problems
&lt;/h2&gt;

&lt;p&gt;I wanted one prompt box that could do video, image editing, and document Q&amp;amp;A. Naively I assumed they'd share most of the stack. They don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Image-to-video: the enemy is time, not pixels
&lt;/h3&gt;

&lt;p&gt;Generating one good frame is solved. Video is about &lt;strong&gt;temporal coherence&lt;/strong&gt; — frame 13 must agree with frame 12 or you get flicker and identity drift. Modern video models treat the clip as one object in space and time (latent diffusion over a width x height x time volume, with spatiotemporal attention) rather than 120 independent images. Conditioning on a reference image as the first frame is what makes image-to-video feel controlled: you've handed the model a strong anchor and asked it to extrapolate motion, not invent a world.&lt;/p&gt;

&lt;p&gt;The surprise: native &lt;strong&gt;audio sync&lt;/strong&gt; (Veo 3.1 generating clip + soundtrack jointly) does more for perceived realism than another notch of resolution. A door slam landing on the exact frame the door shuts is uncanny in a good way.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Instruction-based image editing: preservation is the hard part
&lt;/h3&gt;

&lt;p&gt;Generating is unconstrained; editing must change one thing and preserve everything else. Condition the diffusion model on &lt;strong&gt;both&lt;/strong&gt; the instruction and the source image's latents, cross-attend the instruction to steer only the referenced region, and bias hard toward preserving unedited latents. Push that preservation too soft and the subject's face quietly morphs across edits — the classic 'character consistency' failure that makes or breaks storytelling use-cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. PDF chat: it's retrieval, not a long context
&lt;/h3&gt;

&lt;p&gt;The naive 'paste the whole PDF' approach dies on long files (models get &lt;em&gt;lost in the middle&lt;/em&gt;) and costs you the full document every turn. The version that works is a tiny RAG pipeline: chunk with overlap that respects structure, embed chunks into a vector index, retrieve the few nearest passages per question, and &lt;strong&gt;ground&lt;/strong&gt; the answer in only those passages with a citation. Half the real work is just parsing hostile PDFs (multi-column, scanned, tables) into clean ordered text before any model sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was genuinely hard solo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost control.&lt;/strong&gt; Every modality has a different price curve. I collapsed everything to one credit balance and route to the cheapest model that clears a quality bar per task. Hard-coding model names at call sites is a trap; put them behind one config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency UX.&lt;/strong&gt; Video takes seconds-to-minutes. The product is mostly about making waiting feel intentional — optimistic UI, job queues, auto-refunding failed jobs so a timeout never costs a user a credit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Glue &amp;gt; models.&lt;/strong&gt; The models are an API call. The studio is chunkers, parsers, queues, a credit ledger, and a lot of error handling. That's the actual product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you want to understand these models, stop reading and wire three of them into one app. The cheapest experiment is still the same one I ran: feed a model a single image and watch what it does with time. The result of mine, if you want to poke at it, lives at &lt;a href="https://geminiomni-ai.com" rel="noopener noreferrer"&gt;geminiomni-ai.com&lt;/a&gt; — but the real value was the debugging, not the demo.&lt;/p&gt;

&lt;p&gt;Happy to compare notes if you're building in this space.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
