<?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: AbhishekBarali</title>
    <description>The latest articles on DEV Community by AbhishekBarali (@abhishekbarali).</description>
    <link>https://dev.to/abhishekbarali</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%2F4006020%2Fe36d8464-966f-4170-9229-418b2d2456f9.jpg</url>
      <title>DEV Community: AbhishekBarali</title>
      <link>https://dev.to/abhishekbarali</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhishekbarali"/>
    <language>en</language>
    <item>
      <title>How I Made a Local Voice Assistant Feel Instant</title>
      <dc:creator>AbhishekBarali</dc:creator>
      <pubDate>Fri, 07 Aug 2026 13:51:33 +0000</pubDate>
      <link>https://dev.to/abhishekbarali/how-i-made-a-local-voice-assistant-feel-instant-1o0p</link>
      <guid>https://dev.to/abhishekbarali/how-i-made-a-local-voice-assistant-feel-instant-1o0p</guid>
      <description>&lt;p&gt;I wanted one hotkey.&lt;/p&gt;

&lt;p&gt;Press it, talk, let the assistant look at my screen, and get an answer without opening six tabs or explaining what was already in front of me.&lt;/p&gt;

&lt;p&gt;Simple, right?&lt;/p&gt;

&lt;p&gt;In my head, the architecture looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;microphone → local AI magic → useful answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the code, it looked more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;global shortcut
    → audio recorder
    → voice activity detection
    → speech-to-text model
    → screen capture
    → image compression
    → local LLM server
    → streaming UI
    → text-to-speech
    → interruptible text-to-speech playback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The models were the easy part.&lt;/p&gt;

&lt;p&gt;Getting all of them to behave like one fast, calm assistant was where things became interesting.&lt;/p&gt;

&lt;p&gt;I built this while studying alone for exams. I kept bouncing between my work and a chatbot tab, copying context, asking a question, copying the answer back, and slowly losing the will to continue.&lt;/p&gt;

&lt;p&gt;I was also paying for dictation software because talking is faster than typing. The dictation worked, but it stopped at text. It could hear me. It could not help me.&lt;/p&gt;

&lt;p&gt;So I started building the thing I actually wanted: a voice layer over my desktop.&lt;/p&gt;

&lt;p&gt;SpeakoFlow began as a fork of &lt;a href="https://github.com/cjpais/Handy" rel="noopener noreferrer"&gt;Handy&lt;/a&gt;, which gave me a solid local dictation foundation. I did not reinvent that part and I want to be clear about the credit. I built the assistant, screen vision, spoken answers, translation, memory, and the orchestration that turns those pieces into one experience.&lt;/p&gt;

&lt;p&gt;Here are the problems that surprised me most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a fully local pipeline still felt slow
&lt;/h2&gt;

&lt;p&gt;My first mental model was completely sequential:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Record the user.&lt;/li&gt;
&lt;li&gt;Stop recording.&lt;/li&gt;
&lt;li&gt;Load the transcription model.&lt;/li&gt;
&lt;li&gt;Transcribe the audio.&lt;/li&gt;
&lt;li&gt;Capture the screen.&lt;/li&gt;
&lt;li&gt;Start the LLM.&lt;/li&gt;
&lt;li&gt;Generate an answer.&lt;/li&gt;
&lt;li&gt;Start text-to-speech.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing about that pipeline was technically wrong.&lt;/p&gt;

&lt;p&gt;It just felt terrible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://giphy.com/gifs/waiting-loud-polish-QZyBvNVaMbIZ9yadec" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0z44hm1c7dxu50grltjy.gif" alt="A skeleton sitting at a table and waiting for the pipeline to finish" width="220" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A fully local pipeline can still feel slow when every stage runs sequentially. The critical path matters more than the number of components running on-device.&lt;/p&gt;

&lt;p&gt;The biggest latency improvement did not come from switching models. It came from refusing to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hide cold starts inside the recording window
&lt;/h2&gt;

&lt;p&gt;A person is going to spend a few seconds speaking. That time is free latency budget.&lt;/p&gt;

&lt;p&gt;As soon as assistant recording begins, SpeakoFlow can start doing useful work in parallel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified version of the real flow&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;recording_started&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;initiate_transcription_model_load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;preload_vad&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;prewarm_local_llm&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;capture_screen_if_armed&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nf"&gt;stop_previous_spoken_answer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;show_listening_state&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While the user says, “Can you explain the error in this terminal?”, the app is already loading the local model and preparing the visual context.&lt;/p&gt;

&lt;p&gt;By the time speech-to-text finishes, much of the cold-start work may already be gone.&lt;/p&gt;

&lt;p&gt;The “Hey Flow” prewarm path is narrower. It runs only when Flow is enabled, a streaming transcription model produces live text, the activation phrase leads the committed transcript, and the selected assistant provider is the built-in engine. Batch transcription models emit no live text, and cloud providers have no local model to load.&lt;/p&gt;

&lt;p&gt;This avoids waking a multi-gigabyte LLM during ordinary dictation while still overlapping startup with genuine assistant requests.&lt;/p&gt;

&lt;p&gt;The lesson was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In a voice interface, the user’s speaking time is part of your latency budget.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The critical-path timeline
&lt;/h3&gt;

&lt;p&gt;I avoid quoting one universal latency number because cold starts vary by model, hardware, and accelerator. The more useful measurement is which work still blocks the first visible token.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Work started&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Assistant recording begins&lt;/td&gt;
&lt;td&gt;Start the transcription model load, preload VAD, stop old TTS, and prewarm the built-in LLM when selected unless its unload policy is Immediately&lt;/td&gt;
&lt;td&gt;Removes setup work from the post-recording path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;While the user speaks&lt;/td&gt;
&lt;td&gt;Continue model loading and optionally capture the screen immediately&lt;/td&gt;
&lt;td&gt;Overlaps cold starts with time the user already spends speaking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recording stops&lt;/td&gt;
&lt;td&gt;Finalize audio, transcribe, and capture on send if that timing is selected&lt;/td&gt;
&lt;td&gt;Blocks only on work that could not safely start earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation begins&lt;/td&gt;
&lt;td&gt;Stream SSE deltas from Rust to the React panel&lt;/td&gt;
&lt;td&gt;Shows the first text before the complete response exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation completes&lt;/td&gt;
&lt;td&gt;Sanitize a separate speech copy and start playback with a cancellation epoch&lt;/td&gt;
&lt;td&gt;Keeps formatted text on screen and prevents stale audio&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This does not make model inference free. It removes avoidable idle gaps between stages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Global hotkeys need a serialized state machine
&lt;/h2&gt;

&lt;p&gt;A normal button usually emits one event. A global shortcut can emit repeated press events, delayed releases, or no release event at all.&lt;/p&gt;

&lt;p&gt;A release event can arrive late. The user can press it again while transcription is still running. A hands-free recording can outlive the key that started it. A stale timeout can wake up and try to stop a completely different recording.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://giphy.com/gifs/community-troy-chaos-137TKgM3d2XQjK" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhkwqoq8ycxvd51os0rxq.gif" alt="Donald Glover walks into a room and discovers complete chaos" width="340" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I eventually routed recording through one coordinator with explicit states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle → Recording → Processing → Idle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coordinator owns start, stop, cancel, commit, and hands-free transitions. It rejects duplicate triggers and tags safety timers so an old timer cannot stop a newer recording.&lt;/p&gt;

&lt;p&gt;Serializing these transitions is the important part. Every lifecycle change goes through one coordinator, so duplicate key events cannot race the asynchronous transcription and paste pipeline.&lt;/p&gt;

&lt;p&gt;Voice software has very little room for ambiguity. If the app starts twice, ignores a sentence, or keeps recording after the user stops, confidence disappears immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screen vision is an image-budgeting problem
&lt;/h2&gt;

&lt;p&gt;Screen vision sounds easy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;screenshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;capture_screen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;send_to_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works beautifully until the screenshot hits a strict API gateway, a small local context window, or a multi-monitor setup.&lt;/p&gt;

&lt;p&gt;SpeakoFlow captures the monitor under the mouse cursor, with the primary monitor as a fallback. The cursor is usually the best clue about where the user is actually working.&lt;/p&gt;

&lt;p&gt;Then the image goes through a provider-specific compression ladder.&lt;/p&gt;

&lt;p&gt;The current code has different profiles for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Strict gateways such as Azure&lt;/li&gt;
&lt;li&gt;Local llama.cpp vision models&lt;/li&gt;
&lt;li&gt;Cloud models with larger payload limits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each profile tries combinations of image dimensions and JPEG quality until the base64 payload fits its target.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_dimension&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="py"&gt;.ladder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;jpeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resize_and_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_dimension&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;base64_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="py"&gt;.target_bytes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;as_data_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jpeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no rung fits the target, the encoder keeps the smallest attempt rather than failing the capture.&lt;/p&gt;

&lt;p&gt;The rough targets currently range from about 48 KB for strict gateways to 200 KB for local vision and 384 KB for more generous cloud providers.&lt;/p&gt;

&lt;p&gt;For local models, this is not only about transfer speed. A screenshot consumes vision tokens. Make it too large and it crowds the conversation out of the context window. Make it too small and the model cannot read the error message you wanted help with.&lt;/p&gt;

&lt;p&gt;That makes screen capture a constrained encoding problem, not a simple screenshot call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screen access and capture timing are separate decisions
&lt;/h2&gt;

&lt;p&gt;SpeakoFlow separates permission from timing. They are not the same setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Who decides whether a screen capture is allowed&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Off:&lt;/strong&gt; screen capture is disabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual:&lt;/strong&gt; the user explicitly arms screen sharing for the session, or attaches an image for a specific turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent decides:&lt;/strong&gt; the request starts without an image and the model receives a &lt;code&gt;capture_screen&lt;/code&gt; tool. It may call that tool only when the question genuinely depends on visible context, and it can call it at most once per message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. When a manually armed voice turn captures&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Immediate:&lt;/strong&gt; capture when recording begins, preserving what the user saw when they started the question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On send:&lt;/strong&gt; capture after transcription, using what is visible when the request is sent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Typed messages capture on send because they have no recording-start event. In Agent decides mode, capture happens when the model calls the tool, so the Immediate and On send setting does not control that path.&lt;/p&gt;

&lt;p&gt;Immediate capture runs in a background thread while the user speaks. The frame carries a generation token. If the user cancels, starts another turn, or changes screen permission, the token becomes invalid and the old frame cannot be attached to a later request.&lt;/p&gt;

&lt;p&gt;The agent-decided path adds a visible screenshot marker and compact thumbnail to the conversation when the tool succeeds. This preserves an audit trail even though the model chose when to look.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cancellation is not a button. It is a rule that every background task must understand.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Send the full screenshot once, persist only a thumbnail
&lt;/h2&gt;

&lt;p&gt;Keeping full-resolution screenshots in conversation history sounded convenient until I considered what it meant.&lt;/p&gt;

&lt;p&gt;Every later request could resend the same image. Context usage would grow. History files would become heavy. The app would retain more visual data than it needed.&lt;/p&gt;

&lt;p&gt;So the full image belongs to one model turn only.&lt;/p&gt;

&lt;p&gt;SpeakoFlow creates a smaller thumbnail for the visible chat history. The user can still see what was shared, but the original frame is not repeatedly sent back to the model.&lt;/p&gt;

&lt;p&gt;This preserves a visible audit trail while preventing repeated image payloads from consuming context and growing the persisted conversation history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treating llama.cpp as a managed local service
&lt;/h2&gt;

&lt;p&gt;The built-in assistant runs llama.cpp as a local loopback service.&lt;/p&gt;

&lt;p&gt;The application has to care for it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find or download a compatible engine.&lt;/li&gt;
&lt;li&gt;Pick the right build for the operating system.&lt;/li&gt;
&lt;li&gt;Start it on &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Load the selected GGUF model.&lt;/li&gt;
&lt;li&gt;Attach the vision projector when needed.&lt;/li&gt;
&lt;li&gt;Wait for the health check.&lt;/li&gt;
&lt;li&gt;Keep it alive during active requests.&lt;/li&gt;
&lt;li&gt;Unload it after the configured idle period.&lt;/li&gt;
&lt;li&gt;Make sure it dies when the app dies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two rapid requests also cannot be allowed to start two copies of the server. Startup is serialized, and model switches wait for the previous process to release the port.&lt;/p&gt;

&lt;p&gt;One tiny flag caused a surprisingly large improvement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--parallel 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;llama-server normally supports multiple generation slots. That is sensible for a shared server. SpeakoFlow is a single-user desktop app.&lt;/p&gt;

&lt;p&gt;Multiple slots divide the available context between concurrent requests. A screenshot may already consume a meaningful part of a small local model’s context window, so splitting the remainder can cause early truncation or KV-cache allocation failures.&lt;/p&gt;

&lt;p&gt;One slot gives the active desktop conversation the complete configured context. Rare overlapping requests queue instead of competing for fragmented cache space.&lt;/p&gt;

&lt;p&gt;The context failures came from a server default optimized for multi-user workloads, not from the model itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://giphy.com/gifs/stoner-sees-isopropyl-xSM46ernAUN3y" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Far1giu331jy3hfsrt6mc.gif" alt="Robert Redford gives a restrained nod of approval" width="245" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One API client talks to both local and cloud models
&lt;/h2&gt;

&lt;p&gt;The local engine exposes an OpenAI-compatible endpoint. The same Rust client can talk to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The built-in llama.cpp engine&lt;/li&gt;
&lt;li&gt;Ollama&lt;/li&gt;
&lt;li&gt;LM Studio&lt;/li&gt;
&lt;li&gt;OpenAI-compatible cloud services&lt;/li&gt;
&lt;li&gt;Other providers with small authentication adaptations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Responses arrive as SSE events. The Rust backend forwards text to the React assistant panel while generation is in progress, but coalesces deltas to at most one emit about every 40 ms. Each emit becomes an &lt;code&gt;evaluate_script&lt;/code&gt; call in the panel WebView, and that upstream path leaks memory per invocation, so batching preserves the streaming effect without issuing one WebView call per token. When the turn finishes, the authoritative conversation snapshot replaces the temporary streamed text.&lt;/p&gt;

&lt;p&gt;The assistant can also run a small, bounded tool loop. Depending on the user’s settings, the model can decide to search the web, get the current date, or capture the screen.&lt;/p&gt;

&lt;p&gt;The round cap prevents a model from repeatedly calling the same tool without producing a final response.&lt;/p&gt;

&lt;h2&gt;
  
  
  TTS interruption is a concurrency problem
&lt;/h2&gt;

&lt;p&gt;Text-to-speech sits after generation, but it has two separate responsibilities: normalize the text for speech and make playback cancellable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://giphy.com/gifs/lips-finger-on-n1qrZkwr4Fcj51XAQi" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frl27s1mumz4ah1va3jyn.gif" alt="A cat putting one paw to its mouth to ask for quiet" width="368" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The displayed answer may contain Markdown, code blocks, links, and emoji. SpeakoFlow keeps that original response in the panel while sending a sanitized copy to the speech engine.&lt;/p&gt;

&lt;p&gt;The local voice runs Kokoro in the assistant panel through WebGPU where available, with a WASM fallback for unsupported or failed GPU paths. Remote OpenAI-compatible, ElevenLabs, and Azure voices are optional.&lt;/p&gt;

&lt;p&gt;Interruption was the harder problem.&lt;/p&gt;

&lt;p&gt;If the user starts a new question, the previous answer must stop immediately. SpeakoFlow uses a monotonically increasing playback epoch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reply A starts with epoch 12
User begins a new recording
Current epoch becomes 13
Reply A notices that 12 is stale and stops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That same mechanism prevents a slow, old speech request from suddenly playing after the conversation has already moved on.&lt;/p&gt;

&lt;p&gt;Right now, speech begins after the complete text answer is ready. I considered speaking partial sentences as tokens arrive, but complete replies make cleanup, cancellation, and pronunciation more predictable.&lt;/p&gt;

&lt;p&gt;Sentence-level streaming is a possible future optimization, but the current design favors predictable cleanup and cancellation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local-first works better as explicit boundaries
&lt;/h2&gt;

&lt;p&gt;Speech-to-text always runs on the user’s machine. The other stages are choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Built-in local LLM&lt;/li&gt;
&lt;li&gt;Ollama or LM Studio&lt;/li&gt;
&lt;li&gt;A cloud model with the user’s own key&lt;/li&gt;
&lt;li&gt;Local Kokoro text-to-speech&lt;/li&gt;
&lt;li&gt;A configured remote voice provider&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters on real hardware.&lt;/p&gt;

&lt;p&gt;A laptop user may want private local transcription but use a cloud LLM to protect battery life. A desktop user with a GPU may want the entire pipeline offline.&lt;/p&gt;

&lt;p&gt;Local-first works better when each boundary is explicit: transcription location, assistant provider, vision permission, memory storage, and speech engine can be configured independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source map for the pipeline
&lt;/h2&gt;

&lt;p&gt;The implementation is split by lifecycle responsibility rather than by model vendor:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source file&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/actions.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Starts and stops recording, initiates transcription model loading, prewarms the built-in LLM, and starts immediate vision capture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/transcription_coordinator.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Serializes recording, processing, cancel, commit, lock, and timeout transitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/managers/transcription.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Loads Whisper, transcribe.cpp, and ONNX engines including Parakeet, Moonshine, SenseVoice, Canary, and others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/screenshot.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Selects the monitor, applies provider-specific JPEG ladders, and creates persisted thumbnails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/assistant.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Builds turns, enforces screen authorization, runs the bounded tool loop, streams state, and stores conversation history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/managers/local_llm.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Installs, starts, health-checks, switches, and unloads the local llama.cpp server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/llm_client.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Normalizes provider requests and parses streamed SSE responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src-tauri/src/tts.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sanitizes speech text and cancels stale or interrupted playback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/assistant/useKokoroTts.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs local Kokoro speech with WebGPU preference and a WASM fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keeping these boundaries separate made failures easier to locate. A slow first response, stale screenshot, duplicate hotkey event, and late TTS playback each belong to a different lifecycle owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engineering rules I would reuse
&lt;/h2&gt;

&lt;p&gt;If I built another voice interface tomorrow, I would keep these rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hide cold starts inside actions the user is already taking.&lt;/strong&gt; Speaking time is useful time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make recording a state machine.&lt;/strong&gt; Hotkeys are not ordinary buttons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give every background task an identity.&lt;/strong&gt; Old work must be able to become invalid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat visual context as a budget.&lt;/strong&gt; Resolution, payload size, and vision tokens compete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send full images once.&lt;/strong&gt; Keep small thumbnails for history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make spoken output easy to interrupt.&lt;/strong&gt; The assistant should never fight the user for the floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimize the loop, not the benchmark.&lt;/strong&gt; A faster model cannot rescue a sequential pipeline.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The project is open source, so the modules and lifecycle boundaries above can be inspected directly:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/AbhishekBarali/SpeakoFlow" rel="noopener noreferrer"&gt;View SpeakoFlow on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I am curious how other developers would handle two choices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Would you start text-to-speech from partial sentences, or wait for the complete answer?&lt;/li&gt;
&lt;li&gt;For screen vision, would you capture when the user starts speaking or when they finish?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If there is interest, I can write a deeper follow-up about the screenshot compression ladder or managing llama.cpp as a desktop sidecar.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;P.S. I wrote this from my experience building SpeakoFlow, with AI helping me shape and polish the article. English is not my first language, so it was useful for organizing my ideas and improving clarity. The technical work, lessons, and opinions are my own, and I checked the details against the source code..&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I automated the boring part of game development with AI skills</title>
      <dc:creator>AbhishekBarali</dc:creator>
      <pubDate>Sun, 28 Jun 2026 04:43:47 +0000</pubDate>
      <link>https://dev.to/abhishekbarali/how-i-automated-the-boring-part-of-game-development-with-ai-skills-2koa</link>
      <guid>https://dev.to/abhishekbarali/how-i-automated-the-boring-part-of-game-development-with-ai-skills-2koa</guid>
      <description>&lt;p&gt;A few days ago I was adding a double jump to a Godot 4 project. My AI handed me this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;move_and_slide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;velocity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Vector2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. But that's the Godot 3 signature. In Godot 4 you set &lt;code&gt;velocity&lt;/code&gt; as a property and call &lt;code&gt;move_and_slide()&lt;/code&gt; with no arguments. The code didn't run.&lt;/p&gt;

&lt;p&gt;This kept happening. Unity methods that don't exist. Bevy code written against a two-versions-old API. The AI isn't broken, it's just averaging over years of docs and old forum posts and picking the most common-looking code. Game engines move fast. The model doesn't know what version you're on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix doesn't work
&lt;/h2&gt;

&lt;p&gt;I tried pasting a big "here's how Godot works" doc into the chat. It kind of helps. Mostly doesn't. A giant doc is expensive, buries the part you need, and still doesn't tell the agent which 200 words out of 5,000 apply to "add a double jump."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually did
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/gamedev-skills/awesome-gamedev-agent-skills" rel="noopener noreferrer"&gt;awesome-gamedev-agent-skills&lt;/a&gt;. It's 66 small focused skills plus a router that picks the right ones for you. Free, open source (Apache 2.0), one install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add gamedev-skills/awesome-gamedev-agent-skills
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Each "skill" is a small file with a name and a one-line description. The agent only reads the full body when that skill is actually needed. So you can have all 66 installed and your context stays tiny.&lt;/p&gt;

&lt;p&gt;A router sits on top and does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Figures out your engine from the project files (a &lt;code&gt;project.godot&lt;/code&gt; means Godot, a &lt;code&gt;.uproject&lt;/code&gt; means Unreal, etc.)&lt;/li&gt;
&lt;li&gt;Reads your sentence for what you're trying to do&lt;/li&gt;
&lt;li&gt;Loads only the matching skills&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So "add a double jump to my Godot player" loads the Godot movement skill and the platformer skill. Nothing else. "Make an inventory for my Unity RPG" loads the ScriptableObjects skill, the RPG skill, and the save systems skill. Three small files instead of sixty-six.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it covers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What's in there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Engines (40)&lt;/td&gt;
&lt;td&gt;Godot, Unity, Unreal, Phaser, PixiJS, three.js, Bevy, pygame, LOVE, Roblox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disciplines (13)&lt;/td&gt;
&lt;td&gt;game AI, procedural gen, save systems, shaders, game-feel, camera, performance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Genres (9)&lt;/td&gt;
&lt;td&gt;platformer, roguelike, RPG, FPS, tower defense, card game, visual novel, survival, puzzle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflows (4)&lt;/td&gt;
&lt;td&gt;game jam, Steam publish, itch publish, fast prototyping&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The thing that actually matters: version pinning
&lt;/h2&gt;

&lt;p&gt;Every skill says which engine version it targets and sticks to it. Godot 4.x, Unity 6, Unreal 5.4+, PixiJS v8, and so on.&lt;/p&gt;

&lt;p&gt;This is boring but it's the whole point. It caught real bugs while I was writing the skills. A Godot ray query that used &lt;code&gt;exclude = [self]&lt;/code&gt; when the field wants an array of RIDs. A .NET setup that was right for Godot 4.3 and wrong by 4.5.&lt;/p&gt;

&lt;p&gt;Without pinning, you're just trusting the model to guess. With it, the code matches your version.&lt;/p&gt;

&lt;h2&gt;
  
  
  It works across agents
&lt;/h2&gt;

&lt;p&gt;Since skills are just markdown files, the same set works in Claude Code, Cursor, Codex, Gemini CLI, Kiro, and anything else that reads the format. One install, no lock-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add gamedev-skills/awesome-gamedev-agent-skills
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point your agent at a real project and give it a game dev request. Watch what the router loads before it writes anything. If it picks the wrong skill or the code is out of date for your engine version, that's exactly the bug report I want.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/gamedev-skills/awesome-gamedev-agent-skills" rel="noopener noreferrer"&gt;github.com/gamedev-skills/awesome-gamedev-agent-skills&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's early and the routing still has gaps on weird project setups. But it already writes a lot less broken engine code than a plain agent, which was the whole point.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gamedev</category>
      <category>automation</category>
      <category>godot</category>
    </item>
  </channel>
</rss>
