<?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: theinterviewcopilot</title>
    <description>The latest articles on DEV Community by theinterviewcopilot (@theinterviewcopilot).</description>
    <link>https://dev.to/theinterviewcopilot</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%2F4036917%2Fa84cb849-3c17-42b7-832a-5a32d1b225f7.png</url>
      <title>DEV Community: theinterviewcopilot</title>
      <link>https://dev.to/theinterviewcopilot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/theinterviewcopilot"/>
    <language>en</language>
    <item>
      <title>Shaving a second off a real-time speech-to-LLM pipeline in Electron</title>
      <dc:creator>theinterviewcopilot</dc:creator>
      <pubDate>Fri, 14 Aug 2026 23:18:07 +0000</pubDate>
      <link>https://dev.to/theinterviewcopilot/shaving-a-second-off-a-real-time-speech-to-llm-pipeline-in-electron-4c7h</link>
      <guid>https://dev.to/theinterviewcopilot/shaving-a-second-off-a-real-time-speech-to-llm-pipeline-in-electron-4c7h</guid>
      <description>&lt;p&gt;Every part of a speech→LLM pipeline is fast enough on its own. Put them in a row and you get three seconds, which is far too slow when a human is waiting for you to say something.&lt;/p&gt;

&lt;p&gt;I build a desktop overlay that listens to the other side of a video call, transcribes it, and streams an answer. The budget I care about is time from the speaker finishing a sentence to the first token on screen. Here is where that second and a half went, and what actually moved it.&lt;/p&gt;

&lt;p&gt;The naive pipeline&lt;/p&gt;

&lt;p&gt;mic/loopback → PCM → WebSocket STT → final transcript&lt;br&gt;
             → "is this a question?" classifier → LLM → stream&lt;br&gt;
Roughly 3.2s to first token. Four places to attack, and only two of them turned out to matter.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stop sending silence
The obvious win, and it is not about bandwidth — it is about the STT server's own endpointing. If you stream continuous audio, the server never sees a clean pause and delays its segment commit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So VAD runs client-side in an AudioWorklet, on the raw signal before normalization:&lt;/p&gt;

&lt;p&gt;this._vadThresh = 0.005;   // RMS: above → speech&lt;br&gt;
this._hangoverSec = 1.0;   // keep sending after level drops&lt;br&gt;
this._prerollMax = 14;     // ~600ms @48k of buffered silence&lt;br&gt;
Three parameters, and each one exists because of a specific failure:&lt;/p&gt;

&lt;p&gt;Threshold on raw audio. I normalized first, and normalization amplifies room noise into "speech". Detect on the raw signal, normalize afterwards.&lt;br&gt;
Hangover of 1.0s. Cutting the stream the instant RMS drops chops the tail off every sentence. It also has to exceed the server's own silence threshold (0.6s) with margin, or the server never gets the trailing silence it needs to commit the segment.&lt;br&gt;
Pre-roll of ~600ms. A quiet sentence onset sits below the VAD threshold. By the time you detect speech, the first syllable is gone. So keep a rolling buffer of the last 14 chunks and flush it when VAD opens.&lt;br&gt;
That last one is the difference between "what's a database index" and "at's a database index" — and the LLM answers the second one confidently and wrongly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Let the STT server do endpointing
ElevenLabs Scribe v2 Realtime supports commit_strategy: 'vad', so the server commits a segment after a pause instead of waiting for you to ask:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;commit_strategy: 'vad',&lt;br&gt;
vad_silence_threshold_secs: '0.6',&lt;br&gt;
Combined with client-side gating, the transcript arrives while the person is still drawing breath.&lt;/p&gt;

&lt;p&gt;One sharp edge: keyterm biasing caps at 50. Send 51 and the socket closes with code 1008 and a message you will not see unless you log close reasons. I lost an evening to that.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reconnects, and the "muted socket" bug
Long calls mean dropped sockets. Reconnecting is easy; reconnecting correctly is not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The failure mode: a reconnect fires, and while it is in flight the user switches language, which triggers another reconnect. Now two sockets exist. The older one still delivers events, the newer one is the real connection, and the transcript interleaves garbage from both. Or worse, the stale one wins and the live one is silently ignored — audio flows, nothing appears, no error anywhere.&lt;/p&gt;

&lt;p&gt;The fix is a generation counter:&lt;/p&gt;

&lt;p&gt;const myGen = ++this.gen;      // this connection&lt;br&gt;
// ...later, in every handler:&lt;br&gt;
if (myGen !== this.gen) return;  // a newer connection superseded us&lt;br&gt;
Every async continuation checks whether it is still the current generation. Anything from a superseded socket is dropped on the floor. It is four lines and it removed a whole class of "it just stops working after twenty minutes" reports.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deleting the classifier (the counterintuitive one)
The pipeline had a cheap model deciding "is this a question worth answering?" before spending a call on the expensive one. It cost ~200ms and it seemed obviously correct.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It was the single worst component in the system.&lt;/p&gt;

&lt;p&gt;Not because of latency — because of what it got wrong. Real conversation is full of follow-ups that are not questions in isolation:&lt;/p&gt;

&lt;p&gt;"What is SOLID?"&lt;br&gt;
— answer —&lt;br&gt;
"And the second letter?"&lt;/p&gt;

&lt;p&gt;"And the second letter?" scores as not-a-question. The classifier stayed silent exactly when context made the intent obvious. Precision was fine; the failures were catastrophic and clustered in the most valuable moments.&lt;/p&gt;

&lt;p&gt;I deleted it. The main model now sees every completed utterance plus recent context and decides for itself — it has the context the classifier never had. That removed 200ms and fixed the follow-up problem. Two wins from deleting code.&lt;/p&gt;

&lt;p&gt;What replaced it is much dumber and works better: a client-side completeness gate.&lt;/p&gt;

&lt;p&gt;const AUTO_SILENCE_MS = 900;    // no "?" — pause mid-sentence&lt;br&gt;
const AUTO_UTTEREND_MS = 120;   // ends with "?" — react almost immediately&lt;br&gt;
If the buffer ends in a question mark, the sentence is over — fire in 120ms. Otherwise wait 900ms in case they are just thinking. No model call, no network hop.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prompt caching, and why the TTL matters more than the hit rate
The system prompt carries a compressed CV card, so it is not small. Anthropic's prompt caching handles that — but the default TTL is 5 minutes, and interviews have pauses longer than 5 minutes. The interviewer talks, the candidate thinks, and the cache quietly expires. You then pay full price on exactly the questions that come after a long pause.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two blocks, both at a 1-hour TTL:&lt;/p&gt;

&lt;p&gt;const CACHE_1H = { type: "ephemeral", ttl: "1h" } as const;&lt;br&gt;
// block 1: static persona — shared across every session and user&lt;br&gt;
// block 2: session data (CV card, role) — stable for one interview&lt;br&gt;
Splitting static from per-session matters: block 1 is identical for everyone, so it is warm before the user's first question. Get greedy and interpolate anything variable into it — the answer language, say — and you shatter one cache entry into sixteen.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parallelise the gatekeeping
Auth, rate limit, quota check, body parse. All independent, all were sequential.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;const [, accessRes] = await Promise.all([&lt;br&gt;
  enforceRateLimit(db, userId, "ask", 15, 60),&lt;br&gt;
  assertAccess(db, userId, "ask"),&lt;br&gt;
  assertDailyCap(db, userId),&lt;br&gt;
  req.json().then((b) =&amp;gt; { body = b; }),&lt;br&gt;
]);&lt;br&gt;
~92ms, for free, with identical guarantees. Unglamorous and the best ratio of effort to result in the whole list.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick the model on measurements, not vibes
I assumed the frontier model was required. Measured across 10 technical questions:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;cost / 500 requests full answer&lt;br&gt;
Sonnet-class    $3.54   8.6s&lt;br&gt;
Haiku-class $1.03   5.2s&lt;br&gt;
3.4× cheaper and 3.4 seconds faster. The price is roughly one factual slip every 5–6 questions and slightly rougher prose. For a real-time assistant where a late answer is worth zero, that trade is not close.&lt;/p&gt;

&lt;p&gt;The enforcement point matters too: the client asks for a model, but the server owns the allow-list. Updates ship manually, so a client-side default would mean every already-installed copy keeps requesting the expensive model forever.&lt;/p&gt;

&lt;p&gt;Where it landed&lt;br&gt;
~1.1s from end-of-sentence to first token, from ~3.2s. The breakdown of what got it there is not what I expected going in:&lt;/p&gt;

&lt;p&gt;deleting the classifier: −200ms and a correctness fix&lt;br&gt;
VAD gating + server-side endpointing: −1.2s&lt;br&gt;
parallel gatekeeping: −92ms&lt;br&gt;
cheaper, faster model: −3.4s on full answer&lt;br&gt;
The two biggest wins came from removing a component and from parameters in a 40-line audio worklet. Zero came from the LLM call itself, which is where I spent the first week.&lt;/p&gt;

&lt;p&gt;What I would do differently&lt;br&gt;
Log socket close codes from day one. The 1008 keyterm limit was invisible for a day because the close reason was never surfaced.&lt;/p&gt;

&lt;p&gt;Treat "it silently stopped" as the default failure. Every quiet path — a superseded socket, an expired cache, a swallowed catch — looks identical to "working" from the outside. The generation counter and an idle watchdog on the stream exist because both failed silently first.&lt;/p&gt;

&lt;p&gt;Measure before assuming the model is the bottleneck. It was 200ms of the 3.2s.&lt;/p&gt;

&lt;p&gt;The overlay is &lt;a href="https://theinterviewcopilot.com/" rel="noopener noreferrer"&gt;Interview Copilot&lt;/a&gt; — Electron on the client, Supabase Edge Functions on the server so the STT and model keys never reach the desktop app. Happy to go deeper on any part of the pipeline in the comments.&lt;/p&gt;

</description>
      <category>interview</category>
      <category>ai</category>
      <category>programming</category>
      <category>career</category>
    </item>
    <item>
      <title>I built Interview Copilot — a real-time desktop overlay that listens, thinks, and answers in under a second. Here's the full engineering story.</title>
      <dc:creator>theinterviewcopilot</dc:creator>
      <pubDate>Sun, 19 Jul 2026 18:56:55 +0000</pubDate>
      <link>https://dev.to/theinterviewcopilot/i-built-interview-copilot-a-real-time-desktop-overlay-that-listens-thinks-and-answers-in-under-43ob</link>
      <guid>https://dev.to/theinterviewcopilot/i-built-interview-copilot-a-real-time-desktop-overlay-that-listens-thinks-and-answers-in-under-43ob</guid>
      <description>&lt;p&gt;I spent the last few months building Interview Copilot — a desktop app (Electron) that listens to a live conversation, transcribes it in real time, figures out when a real question has been asked, and streams an answer onto the screen, hands-free. No buttons, no tab-switching — someone finishes a sentence and the answer is already appearing.&lt;/p&gt;

&lt;p&gt;It sounds simple. It is not. The whole product is one long fight against latency and against the messiness of real speech. This is the write-up I wish I'd had when I started. It's long — grab a coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Here's the full path a spoken sentence travels before an answer shows up:&lt;/p&gt;

&lt;p&gt;system audio (loopback capture)&lt;br&gt;
  → AudioWorklet: voice-activity detection + PCM normalization&lt;br&gt;
  → streaming speech-to-text over WebSocket (partial + final transcripts)&lt;br&gt;
  → a debounce / "is this a complete thought?" gate&lt;br&gt;
  → LLM request (SSE streaming) on a serverless edge function&lt;br&gt;
  → token-by-token markdown render on a transparent overlay&lt;/p&gt;

&lt;p&gt;Every arrow is a place where milliseconds and correctness go to die. The counterintuitive lesson up front: the LLM was NOT my bottleneck. The boring, fixed, client-side delays added up to more than the model's time-to-first-token.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Capturing audio you don't own
&lt;/h2&gt;

&lt;p&gt;I don't capture a microphone — I capture the &lt;em&gt;other side&lt;/em&gt; of the conversation, i.e. the system/loopback audio. In Electron that means getDisplayMedia with audio, piped into an AudioWorklet running on the audio thread. The worklet does two jobs: cheap energy-based voice-activity detection (so I'm not shipping silence over the wire) and PCM normalization/resampling into the exact format the STT expects.&lt;/p&gt;

&lt;p&gt;Doing this on the audio thread, not the main thread, matters more than it sounds. Any jank on the main thread while rendering an answer would show up as &lt;em&gt;dropped audio frames&lt;/em&gt; — and a dropped frame mid-question means a garbled transcript means a wrong answer. The worklet isolates the hot path.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Streaming transcription, and the real hard problem
&lt;/h2&gt;

&lt;p&gt;I use a streaming STT (currently ElevenLabs Scribe v2 Realtime) over a WebSocket. It emits two kinds of results: partials (updated live as you speak, constantly changing) and finals (committed after a pause). You can render partials to show "it's listening," but you can NEVER act on them — they mutate.&lt;/p&gt;

&lt;p&gt;The genuinely hard problem isn't transcription accuracy. It's this: &lt;strong&gt;when is a thought actually finished?&lt;/strong&gt; People don't speak in clean sentences. They pause. "Okay… so… let's talk about databases… how would you…" — each of those pauses can produce a separate final transcript. Fire on the first one and you confidently answer a fragment. Wait too long and the whole thing feels dead and useless.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. The "complete thought?" gate — my single biggest win
&lt;/h2&gt;

&lt;p&gt;What finally worked was a two-path debounce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the accumulated text ends in "?", the question is almost certainly complete → fire fast (~120ms).&lt;/li&gt;
&lt;li&gt;Otherwise the speaker might still be mid-thought → wait longer (~900ms) so a continuation can glue on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any new speech before the timer fires resets it. I also drop pure filler — "okay, thanks, got it," "let me grab a coffee" — so it never sticks to the next real question and never burns a model call on nothing.&lt;/p&gt;

&lt;p&gt;I tuned those two numbers DOWN over time (from 200/1100ms) and it shaved time off literally every answer. The only reason I could do that safely: I have an end-to-end test that replays nasty "paused speech" transcripts and asserts the questions don't get chopped into pieces. Without that test I'd have been tuning by vibes and shipping regressions.&lt;/p&gt;

&lt;p&gt;Design lesson: in a real-time UX, the fixed debounce you picked by feel is probably your single largest latency cost. Measure it, test it, then shrink it.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Answer on EVERYTHING, and never lose one
&lt;/h2&gt;

&lt;p&gt;Early on I had a separate "is this technical?" classifier gate. I ripped it out. It kept staying silent on legitimate follow-ups ("and the second letter?" after an acronym question). Now the system answers every completed, non-filler utterance and lets the main model itself decide how to respond. Fewer moving parts, fewer silent failures.&lt;/p&gt;

&lt;p&gt;Two invariants keep it sane under fire:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single "answering" guard so only one answer is ever in flight (kills duplicates from a hotkey + auto firing together).&lt;/li&gt;
&lt;li&gt;A queue: any question that arrives WHILE an answer is streaming gets queued and handled next, in order. Nothing is dropped, nothing is merged.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  5. First-paint render — a few lines, a real feel
&lt;/h2&gt;

&lt;p&gt;The answer streams as markdown, token by token. The trap: re-parsing the whole buffer on every token is O(n²) and janks on long answers, so I batched renders through requestAnimationFrame. Correct — but it made the &lt;em&gt;very first&lt;/em&gt; token wait up to a full frame (~16ms), which is exactly the moment the user is staring at an empty bubble waiting for any sign of life. Fix: render the first delta synchronously, batch the rest. Perceived latency is dominated by "time until &lt;em&gt;something&lt;/em&gt; appears," not total time.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Backend latency levers
&lt;/h2&gt;

&lt;p&gt;The model runs behind serverless edge functions. Three things mattered most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prompt caching with a 1-hour TTL. The system prompt + context block are large and static across a session; caching them means repeat calls skip re-processing a big prefix — lower cost AND lower time-to-first-token. Gotcha: the default 5-minute cache TTL kept expiring during natural pauses, so the 4th question would mysteriously go slow again. Bumping to 1h fixed it.&lt;/li&gt;
&lt;li&gt;SSE streaming end to end, so the first token paints the instant the model emits it.&lt;/li&gt;
&lt;li&gt;Keep bookkeeping off the critical path — usage/analytics logging happens &lt;em&gt;after&lt;/em&gt; the stream closes, never before it starts.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  7. Resilience — the part nobody demos but everybody hits
&lt;/h2&gt;

&lt;p&gt;A long-lived WebSocket WILL drop mid-session. What saved me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reconnect with backoff, then a slow background retry that never fully gives up (the early version died after N attempts and left the user dead in the water).&lt;/li&gt;
&lt;li&gt;Buffer audio during the reconnect gap (~6s) and flush it in order, so a network blip doesn't eat a sentence.&lt;/li&gt;
&lt;li&gt;Flush any uncommitted partial transcript as a final on unexpected close.&lt;/li&gt;
&lt;li&gt;A generation counter so a stale socket's late "close" event can't clobber the state of the fresh connection. That bug took me an embarrassingly long time to find.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  8. The little things that make it feel real
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tech-term correction: English terms spoken inside another language get mangled by STT ("майкросервіси" → microservices). A post-processing layer plus keyterm biasing fixes the common ones.&lt;/li&gt;
&lt;li&gt;Multilingual: 7 UI languages, and the answer language is independent of the recognition language.&lt;/li&gt;
&lt;li&gt;It's a transparent, always-on-top overlay that stays out of your way and is excluded from screen capture at the OS level.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Instrument time-to-first-token BEFORE optimizing. My gut said "the model is slow." It was the fastest part.&lt;/li&gt;
&lt;li&gt;In a real-time pipeline, fixed client-side delays (debounce, render frames) can dominate the variable network cost.&lt;/li&gt;
&lt;li&gt;Prompt caching is a latency tool, not just a cost tool.&lt;/li&gt;
&lt;li&gt;Write the replay/E2E test before tuning the magic numbers, or you regress silently.&lt;/li&gt;
&lt;li&gt;Perceived latency ≠ total latency. Optimize the moment the first pixel changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: Electron, AudioWorklet, WebSocket streaming STT, serverless edge functions, SSE, vanilla JS on the renderer (zero framework overhead on the hot path).&lt;/p&gt;

&lt;p&gt;It's called Interview Copilot. Happy to go deep on any piece — the VAD worklet, the reconnect/generation-counter logic, the caching setup, or how the "complete thought" gate is tuned. And I'm genuinely curious: for a speech-triggered UI, where would you spend the next 100ms of your latency budget?&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://theinterviewcopilot.com/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftheinterviewcopilot.com%2Fog-image.jpg" height="420" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://theinterviewcopilot.com/" rel="noopener noreferrer" class="c-link"&gt;
            Interview Copilot — Real-Time AI Co-Pilot for Any Interview
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            A real-time AI copilot for any interview, technical or not. It hears the interviewer and answers live — invisible on Zoom, Meet and Teams screen share.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftheinterviewcopilot.com%2Ffavicon.svg" width="64" height="64"&gt;
          theinterviewcopilot.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;a href="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%2Fklbnaa0veiknt7jrk4bp.png" class="article-body-image-wrapper"&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%2Fklbnaa0veiknt7jrk4bp.png" alt=" " width="800" height="716"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>interview</category>
      <category>career</category>
    </item>
  </channel>
</rss>
