<?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: devs</title>
    <description>The latest articles on DEV Community by devs (@madnessbrains_i).</description>
    <link>https://dev.to/madnessbrains_i</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%2F4208612%2F8c1d2174-7f77-4a02-869b-d4d966403085.jpg</url>
      <title>DEV Community: devs</title>
      <link>https://dev.to/madnessbrains_i</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/madnessbrains_i"/>
    <language>en</language>
    <item>
      <title>Capturing the mic and the call as two separate tracks on Windows with cpal and WASAPI loopback</title>
      <dc:creator>devs</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:44:01 +0000</pubDate>
      <link>https://dev.to/madnessbrains_i/capturing-the-mic-and-the-call-as-two-separate-tracks-on-windows-with-cpal-and-wasapi-loopback-3do6</link>
      <guid>https://dev.to/madnessbrains_i/capturing-the-mic-and-the-call-as-two-separate-tracks-on-windows-with-cpal-and-wasapi-loopback-3do6</guid>
      <description>&lt;p&gt;I'm building a Windows app, Talkaside, that transcribes both sides of a call live. The microphone is one side. The other side is whatever the call app plays through the speakers or headphones. Those need to be two separate tracks, because each one gets its own speech-to-text session, its own language and its own speaker label.&lt;/p&gt;

&lt;p&gt;This post covers the capture layer, a Rust crate on top of &lt;code&gt;cpal&lt;/code&gt; 0.15. It covers how the system audio is tapped, how both tracks end up as mono 16 kHz, and the gating that stops the microphone from re-transcribing the speakers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two pipelines, not one mixer
&lt;/h2&gt;

&lt;p&gt;The obvious design is to mix everything into one stream and let the recognizer sort it out. That fails quickly: you lose who said what, and a recognizer tuned for one language sees two.&lt;/p&gt;

&lt;p&gt;So the engine starts one pipeline per direction, called &lt;code&gt;outgoing&lt;/code&gt; (the microphone) and &lt;code&gt;incoming&lt;/code&gt; (the call). Each pipeline owns its own capture stream, its own resampler, its own STT session and its own recording file. When both are requested the engine sets a &lt;code&gt;dual_channel_mode&lt;/code&gt; flag, which switches on the cross-track gating described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tapping system audio
&lt;/h2&gt;

&lt;p&gt;On Windows, &lt;code&gt;cpal&lt;/code&gt; enables WASAPI loopback when you build an &lt;em&gt;input&lt;/em&gt; stream on a &lt;em&gt;render&lt;/em&gt; endpoint. There is no separate loopback API to call. The relevant constructor in this repo:&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;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new_loopback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_device_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_output_device&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_device_name&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;default_cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;
        &lt;span class="nf"&gt;.default_output_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to get default output config for loopback capture"&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;default_cfg&lt;/span&gt;&lt;span class="nf"&gt;.channels&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;sample_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;default_cfg&lt;/span&gt;&lt;span class="nf"&gt;.sample_rate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="na"&gt;.0&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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;StreamConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;default_cfg&lt;/span&gt;&lt;span class="nf"&gt;.sample_rate&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;buffer_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;cpal&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;BufferSize&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// ... build_input_stream_{f32,i16,u16}(&amp;amp;device, &amp;amp;config, ...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two choices here are deliberate. The stream runs at the device's default config instead of a requested 16 kHz, which avoids negotiating a rate the device may not support. Resampling happens later, on our side. And the sample format is matched at runtime (&lt;code&gt;F32&lt;/code&gt;, &lt;code&gt;I16&lt;/code&gt;, &lt;code&gt;U16&lt;/code&gt;), with a separate callback for each.&lt;/p&gt;

&lt;p&gt;The UI stores "system output" as a sentinel device name, &lt;code&gt;__system_output_loopback__&lt;/code&gt;. One helper, &lt;code&gt;is_system_loopback_device&lt;/code&gt;, is the only branch; the rest of the pipeline is shared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding devices by name
&lt;/h2&gt;

&lt;p&gt;Windows device names are localized strings, and &lt;code&gt;cpal&lt;/code&gt; can expose more than one audio host. The lookup tries the default host first, then every other host &lt;code&gt;cpal&lt;/code&gt; reports. It matches exactly, then falls back to a normalized substring match: lowercase, punctuation to spaces, whitespace collapsed. A small table maps Russian Windows names (the words for microphone, speakers, headphones, stereo mix) to their English equivalents before comparing, so a setting saved on an English install still resolves on a Russian one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audio callback does almost nothing
&lt;/h2&gt;

&lt;p&gt;The callback runs on the driver's thread, so it only downmixes and hands off:&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;move&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;_info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;cpal&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;InputCallbackInfo&lt;/span&gt;&lt;span class="p"&gt;|&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;mono&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channels&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.sum&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;()&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;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AudioChunk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mono&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="nf"&gt;.try_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;note_dropped_chunk&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;dropped_chunks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nd"&gt;debug!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Capture channel full or disconnected: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&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;It uses &lt;code&gt;try_send&lt;/code&gt; into a bounded channel of 512 chunks. If the consumer stalls, the callback never blocks. It bumps an &lt;code&gt;AtomicU64&lt;/code&gt; and moves on. The consumer swaps that counter to zero on every chunk it receives and, when it was nonzero, emits a warning like &lt;code&gt;[incoming] capture queue dropped count=N&lt;/code&gt;. Without it, a drop shows up as missing words and nothing in the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resampling to 16 kHz
&lt;/h2&gt;

&lt;p&gt;Both streaming recognizers the app uses accept 16 kHz PCM, but devices run at 44.1 or 48 kHz. The engine keeps one &lt;code&gt;StreamingResampler&lt;/code&gt; per track. It is plain linear interpolation, chosen for being cheap and predictable on speech, with one rule: the position of each output sample is computed from integer counters, never from an accumulated float.&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;wanted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.received&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.to&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.from&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.emitted&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&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;wanted&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;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.emitted&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.from&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&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;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.to&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u64&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;fraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.to&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.to&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ... interpolate pending[local] and pending[local + 1]&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.emitted&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&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;Because the output count is a pure function of the input count, the result doesn't depend on how the driver happens to chunk the audio. A test feeds 512,000 samples through six rate pairs (48000 to 16000, 44100 to 16000, 8000 to 16000, 16000 to 16000, 24000 to 44100, 24000 to 48000) in chunks of 1, 127, 512 and 997 samples, and asserts the output is identical to converting the whole buffer at once. Another test pushes 1,000 chunks of 512 samples at 48 kHz and asserts the internal buffer never holds more than 3 samples and that exactly 170,666 samples came out.&lt;/p&gt;

&lt;p&gt;The trade-off: linear interpolation without a low-pass filter lets some aliasing through. For speech going into a recognizer that is a reasonable trade. For music it would not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping the microphone from transcribing the speakers
&lt;/h2&gt;

&lt;p&gt;Without headphones, the microphone hears the call. Then the same sentence is transcribed twice, once per track, and the second copy is wrong and late. In dual mode the engine gates the microphone while the incoming track is active:&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;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;audio_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&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;above_audio_gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OUTGOING_MIC_RMS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OUTGOING_MIC_PEAK&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="n"&gt;last_strong_outgoing_audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_strong_outgoing_audio&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;last_seen&lt;/span&gt;&lt;span class="nf"&gt;.elapsed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OUTGOING_MIC_HANGOVER_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&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;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;monotonic_wall_clock_ms&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;incoming_audio_active_until&lt;/span&gt;&lt;span class="nf"&gt;.load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Ordering&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SeqCst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&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;below_audio_gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OUTGOING_BLEED_RMS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OUTGOING_BLEED_PEAK&lt;/span&gt;&lt;span class="p"&gt;)&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;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;AudioDropReason&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;QuietOutgoing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;AudioDropReason&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;IncomingActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The incoming track marks itself active for 1,100 ms whenever a chunk crosses RMS 0.006 or peak 0.025. A microphone chunk passes through if it is clearly louder than bleed (RMS 0.010 or peak 0.035) and keeps passing for 650 ms afterwards, so the user can talk over the other person and the ends of their words aren't clipped. Anything quieter during incoming activity is dropped.&lt;/p&gt;

&lt;p&gt;These thresholds are hand-tuned constants, not learned values. They suit typical laptop microphones and speaker volumes. A loud speaker next to a sensitive microphone can still leak through, and a very quiet speaker can be gated away. This is gating, not echo cancellation.&lt;/p&gt;

&lt;p&gt;One more detail matters. Dropped chunks are not simply skipped: the engine calls &lt;code&gt;ensure_stt_keepalive&lt;/code&gt;, so the STT session is not left idle while the gate is closed. The recorder also sits before the gate, so the saved file keeps the full track. Gating only decides what the recognizer sees, and a muted track is recorded as zeros of the same length so the timeline stays aligned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing when a track went quiet
&lt;/h2&gt;

&lt;p&gt;A dead device and a quiet room look the same: all zeros. The pipeline logs input level over the first 10 seconds, then watches a rolling 60-second window and warns once if the track stays under the activity gate, resetting when signal returns. A separate idle-only probe opens both devices for 3 to 5 seconds, refuses to run if both selections resolve to the same device, and reports aggregate RMS and peak without keeping any PCM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Loopback is bound to one output device, resolved when the pipeline starts. If that device disappears, the pipeline reports an audio-device-lost event instead of silently following another one.&lt;/li&gt;
&lt;li&gt;Per-application capture isn't used: the loopback includes everything that device plays, including notification sounds.&lt;/li&gt;
&lt;li&gt;Linear resampling and fixed gates are simple on purpose. They would be the first things to replace if a user's setup needs better rejection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Unit tests for all of this live next to the code in &lt;code&gt;capture.rs&lt;/code&gt;, &lt;code&gt;resample.rs&lt;/code&gt;, &lt;code&gt;probe.rs&lt;/code&gt; and &lt;code&gt;engine.rs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://talkaside.com/" rel="noopener noreferrer"&gt;Talkaside&lt;/a&gt;, a Windows app for job interviews and calls in another language.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance from this project's code and logs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>windows</category>
      <category>audio</category>
      <category>performance</category>
    </item>
    <item>
      <title>Where the time goes in a live AI call assistant: my code takes 150 ms, the model takes the rest</title>
      <dc:creator>devs</dc:creator>
      <pubDate>Mon, 26 Oct 2026 11:50:43 +0000</pubDate>
      <link>https://dev.to/madnessbrains_i/where-the-time-goes-in-a-live-ai-call-assistant-my-code-takes-150-ms-the-model-takes-the-rest-2cc4</link>
      <guid>https://dev.to/madnessbrains_i/where-the-time-goes-in-a-live-ai-call-assistant-my-code-takes-150-ms-the-model-takes-the-rest-2cc4</guid>
      <description>&lt;p&gt;I'm building a Windows app that listens to both sides of a call, transcribes it and drafts a short answer while the other person is still talking. People use it for job interviews and for calls in a second language. The first thing I wanted to fix was speed: the answer often arrived after the moment had passed.&lt;/p&gt;

&lt;p&gt;Before touching the code, I logged every stage of every answer. This post is what 2,314 completed answers from my own logs say about where the seconds go.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Speech recognition streams partial text, then a final transcript for each utterance.&lt;/li&gt;
&lt;li&gt;The client waits briefly for the speaker to finish, then sends a request.&lt;/li&gt;
&lt;li&gt;The server builds the prompt: the last 30 messages of the conversation (capped at 12,000 characters), plus the user's CV and the job description.&lt;/li&gt;
&lt;li&gt;The provider streams tokens back, and the server relays them to the UI over Server-Sent Events.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every answer ends with one log line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[SSE quick] done via groq final_to_request=5ms prepare=78ms ttft=1188ms total=1329ms chunks=62
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;final_to_request&lt;/code&gt;: from the final transcript to the request leaving the client&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prepare&lt;/code&gt;: server work before the provider call (prompt assembly, context trimming)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ttft&lt;/code&gt;: time to the first token from the provider&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;total&lt;/code&gt;: time to the last token&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two answers per question
&lt;/h2&gt;

&lt;p&gt;Each question fires two requests in parallel. The quick slot returns a sentence or two you can say straight away. The detail slot returns a fuller answer to read while you're already talking. The slots can use different providers, so the fast answer never waits for the thorough one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Medians for every slot and provider with at least 50 answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slot / provider&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;prepare&lt;/th&gt;
&lt;th&gt;first token&lt;/th&gt;
&lt;th&gt;total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;quick / Groq&lt;/td&gt;
&lt;td&gt;638&lt;/td&gt;
&lt;td&gt;234 ms&lt;/td&gt;
&lt;td&gt;813 ms&lt;/td&gt;
&lt;td&gt;937 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;quick / Groq, second key&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;203 ms&lt;/td&gt;
&lt;td&gt;1,351 ms&lt;/td&gt;
&lt;td&gt;1,382 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;quick / Cloudflare Workers AI&lt;/td&gt;
&lt;td&gt;131&lt;/td&gt;
&lt;td&gt;203 ms&lt;/td&gt;
&lt;td&gt;1,109 ms&lt;/td&gt;
&lt;td&gt;1,468 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;quick / Gemini API&lt;/td&gt;
&lt;td&gt;341&lt;/td&gt;
&lt;td&gt;94 ms&lt;/td&gt;
&lt;td&gt;3,063 ms&lt;/td&gt;
&lt;td&gt;3,282 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;quick / Claude Code CLI&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;148 ms&lt;/td&gt;
&lt;td&gt;5,047 ms&lt;/td&gt;
&lt;td&gt;7,250 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;detail / Gemini API&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;125 ms&lt;/td&gt;
&lt;td&gt;4,054 ms&lt;/td&gt;
&lt;td&gt;5,117 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;detail / Claude Code CLI&lt;/td&gt;
&lt;td&gt;185&lt;/td&gt;
&lt;td&gt;109 ms&lt;/td&gt;
&lt;td&gt;4,203 ms&lt;/td&gt;
&lt;td&gt;7,641 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;detail / Codex CLI&lt;/td&gt;
&lt;td&gt;706&lt;/td&gt;
&lt;td&gt;172 ms&lt;/td&gt;
&lt;td&gt;7,062 ms&lt;/td&gt;
&lt;td&gt;10,188 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across all 2,314 answers, &lt;code&gt;prepare&lt;/code&gt; has a median of 157 ms and a 90th percentile of 609 ms. Time to first token ranges from 0.8 s to 7 s depending on the provider.&lt;/p&gt;

&lt;p&gt;So the part I was about to optimise is roughly 150 ms. Moving the quick slot from Gemini to Groq saves more than two seconds on every answer, and that's a settings change, not a code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the CLI rows are slow
&lt;/h2&gt;

&lt;p&gt;The Codex and Claude rows go through the vendors' command-line tools, which lets people use a subscription they already pay for instead of an API key. Each call starts a new CLI session, and most of the wait is on the vendor's side: in a separate test, keeping a Codex process warm barely beat starting a cold one. These work for the detail slot. They're too slow for the quick one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1.2 seconds I added on purpose
&lt;/h2&gt;

&lt;p&gt;In the earliest logs, &lt;code&gt;final_to_request&lt;/code&gt; was 4 to 6 ms. In most recent rows it's about 1.2 s, and that delay is deliberate.&lt;/p&gt;

&lt;p&gt;Speech recognisers sometimes split one sentence into two finals. Deepgram once sent "Can you tell me a little about your experience?" and then, 329 ms later, "about your experience?" as a separate final. The app drew a second bubble and cancelled the answer it had just started.&lt;/p&gt;

&lt;p&gt;There are two fixes in place now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A tail check: a new final from the same speaker that is the last three or more words of the previous one, arriving within 2.5 s, gets dropped.&lt;/li&gt;
&lt;li&gt;A 1.2 s debounce after each final before asking the model, so a question split by a pause becomes one request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That costs 1.2 s on every answer in exchange for no duplicate or cancelled answers. I haven't settled whether that's the right trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skipping the long answer for small talk
&lt;/h2&gt;

&lt;p&gt;"Thanks, nice to meet you" doesn't need a 100-word answer. The detail request is skipped when an utterance has no question mark, is 12 words or fewer, opens with a greeting or thanks, and contains no question word. That skips a slow request nobody would read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speech recognition comes on top
&lt;/h2&gt;

&lt;p&gt;All of the numbers above start at the final transcript. Recognition adds its own delay before that, about a second with streaming cloud STT in my tests. With Groq in the quick slot, that puts the first words of an answer around three seconds after the speaker stops: one second of recognition, 1.2 s of debounce, a fifth of a second of prep and under a second to the first token. Without the debounce it would be closer to two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Log every stage before optimising. Without a &lt;code&gt;ttft&lt;/code&gt; field I'd have spent a week on a part that takes 150 ms.&lt;/li&gt;
&lt;li&gt;Put the fastest provider in the slot people wait for, and let the slow, thorough one run in parallel.&lt;/li&gt;
&lt;li&gt;Clean up recogniser output before it reaches the model. A split utterance costs a cancelled request and a second answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual caveats apply. These are medians from one person's usage on one connection. The models behind each provider changed during the period, and prompt size depends on how much CV and job text is loaded.&lt;/p&gt;

&lt;p&gt;The app is &lt;a href="https://talkaside.com/" rel="noopener noreferrer"&gt;Talkaside&lt;/a&gt;, a Windows interview assistant and live call translator.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance. The numbers come straight from the app's logs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>python</category>
    </item>
  </channel>
</rss>
