<?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: nadai</title>
    <description>The latest articles on DEV Community by nadai (@nadai).</description>
    <link>https://dev.to/nadai</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%2F3935183%2Fc33c337b-5e5a-4052-ac05-3bc8b6c2f5d4.png</url>
      <title>DEV Community: nadai</title>
      <link>https://dev.to/nadai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nadai"/>
    <language>en</language>
    <item>
      <title>A million-tap linear-phase FIR in real time: 7 ms of plumbing, 650 ms of filter</title>
      <dc:creator>nadai</dc:creator>
      <pubDate>Fri, 16 Oct 2026 00:18:44 +0000</pubDate>
      <link>https://dev.to/nadai/a-million-tap-linear-phase-fir-in-real-time-7-ms-of-plumbing-650-ms-of-filter-17fa</link>
      <guid>https://dev.to/nadai/a-million-tap-linear-phase-fir-in-real-time-7-ms-of-plumbing-650-ms-of-filter-17fa</guid>
      <description>&lt;p&gt;I'm building Sonir Bench, a macOS app that captures everything your Mac plays, runs it through a DSP chain you design, and sends it to your DAC. The &lt;a href="https://dev.to/nadai/i-built-a-mac-app-that-routes-all-system-audio-through-a-dsp-chain-you-design-yourself-18co"&gt;first post in this series&lt;/a&gt; was the overview. This one goes deep on a single piece: long FIR convolution.&lt;/p&gt;

&lt;p&gt;Here's the result up front. On an M6 Mac mini, I ran a 998,545-tap linear-phase FIR at 768 kHz in f64 for almost eight hours. Zero dropouts: no underruns, no overruns, no missed deadlines.&lt;/p&gt;

&lt;p&gt;The round-trip latency was 657.43 ms. But 650.09 ms of that is the filter's own group delay. The machinery that moves the audio adds only 7.33 ms. It took me a few wrong turns to learn to keep those two numbers apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct convolution buys you about 1 ms
&lt;/h2&gt;

&lt;p&gt;Bench upsamples everything to 705.6 kHz (the 44.1 kHz family) or 768 kHz (the 48 kHz family) before processing. The upsampling filter itself is user-designed: you pick the transition band and tap count.&lt;/p&gt;

&lt;p&gt;The first thing I measured was direct convolution, one multiply-add per tap per sample. At 705.6 kHz stereo, one core tops out at 757 taps. That's about 1 ms of impulse response. Not enough for a steep resampling filter, let alone room correction.&lt;/p&gt;

&lt;p&gt;So the convolver is a uniformly partitioned FFT convolution (overlap-save):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Split the IR into K partitions of length B → FFT each to 2B points, keep them

Every B samples:
  FFT the latest 2B input samples → push onto the frequency-domain delay line (FDL)
  Σ_k FDL[p−k] · IR_k → inverse FFT → the second half (B samples) is valid output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's one hop for one channel, lightly trimmed (error handling removed, comments translated):&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;// 1. FFT the latest 2B samples (forward FFT clobbers its input, so copy first)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.time&lt;/span&gt;&lt;span class="nf"&gt;.iter_mut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&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;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;T&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_bus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;);&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;.fft&lt;/span&gt;&lt;span class="nf"&gt;.process_with_scratch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.spectrum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.fft_scratch&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="c1"&gt;// 2. Store it in the FDL&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;partitions&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fdl_pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bins&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;.fdl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.spectrum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Σ_k FDL[pos−k] · IR_k&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.acc&lt;/span&gt;&lt;span class="nf"&gt;.fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;zero&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;partitions&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;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fdl_pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;partitions&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;partitions&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;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;partitions&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bins&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nn"&gt;T&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;mul_add_mono&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.fdl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.ir_spectra&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bins&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Inverse FFT&lt;/span&gt;
&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.spectrum&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.acc&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;.ifft&lt;/span&gt;&lt;span class="nf"&gt;.process_with_scratch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.spectrum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.ifft_scratch&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="c1"&gt;// 5. Overlap-save: only the second B samples are valid. realfft doesn't normalize, hence 1/2B&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;b&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;f64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&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;out&lt;/span&gt;&lt;span class="nf"&gt;.iter_mut&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.time&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;..&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;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="nf"&gt;.to_bus&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;norm&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;The FFT is &lt;a href="https://crates.io/crates/realfft" rel="noopener noreferrer"&gt;realfft&lt;/a&gt;. The FFTs aren't where the time goes, though. Step 3 is: K complex multiply-accumulates over B + 1 bins, every hop. &lt;code&gt;mul_add_mono&lt;/code&gt; is hand-written NEON, and in stereo the IR spectrum is loaded once and applied to both channels. For a million taps the IR spectrum is about 17 MB of f64 complex numbers, and every hop reads all of it, so halving the reads is a direct speedup.&lt;/p&gt;

&lt;p&gt;Per-sample cost is roughly &lt;code&gt;12·N/B&lt;/code&gt; for N taps: linear in taps, divided by the partition length. Measured on an Apple M1, 705.6 kHz stereo, as a share of one core:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;taps \ B&lt;/th&gt;
&lt;th&gt;1,024&lt;/th&gt;
&lt;th&gt;4,096&lt;/th&gt;
&lt;th&gt;16,384&lt;/th&gt;
&lt;th&gt;65,536&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;65,537&lt;/td&gt;
&lt;td&gt;21.6%&lt;/td&gt;
&lt;td&gt;7.6%&lt;/td&gt;
&lt;td&gt;3.7%&lt;/td&gt;
&lt;td&gt;2.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300,001&lt;/td&gt;
&lt;td&gt;38.2%&lt;/td&gt;
&lt;td&gt;10.9%&lt;/td&gt;
&lt;td&gt;4.3%&lt;/td&gt;
&lt;td&gt;2.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,048,576&lt;/td&gt;
&lt;td&gt;154.5%&lt;/td&gt;
&lt;td&gt;33.9%&lt;/td&gt;
&lt;td&gt;9.5%&lt;/td&gt;
&lt;td&gt;4.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same million taps doesn't fit in a core at B = 1,024 and costs 4% at B = 65,536. B matters more than the tap count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partitioning without adding latency
&lt;/h2&gt;

&lt;p&gt;Textbooks say a partitioned convolver has a latency of B. That assumes the host's block size is smaller than B. Plugin hosts typically use 64 to 512 samples, so if you pick a large B for efficiency, you have to buffer input until you have B samples, and that buffering is the latency.&lt;/p&gt;

&lt;p&gt;If the host's block size is a multiple of B, there's nothing to buffer. You convolve the block you were handed and return a block of the same length.&lt;/p&gt;

&lt;p&gt;Bench's internal block length is the entry block length times the interpolation ratio. Take 256 frames at 48 kHz, upsample 16×, and you get 4,096 frames at 768 kHz. So B is set to the internal block length itself, and the convolver adds zero latency.&lt;/p&gt;

&lt;p&gt;B is the largest power of two that divides the block length:&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;partition_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&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;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;usize&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;if&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&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="c1"&gt;// Largest power of two dividing the block (= its lowest set bit).&lt;/span&gt;
    &lt;span class="c1"&gt;// If the block is a power of two, that's the block itself&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="nf"&gt;.wrapping_neg&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MIN_PARTITION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.then_some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;&lt;code&gt;block &amp;amp; block.wrapping_neg()&lt;/code&gt; keeps only the lowest set bit in two's complement: 4,096 gives 4,096, and 6,144 gives 2,048.&lt;/p&gt;

&lt;p&gt;A test pins this down. It builds the chain with a unit-impulse IR and checks the path latency is identical to an empty chain:&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="nd"&gt;#[test]&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;a_default_partition_convolver_adds_no_latency_to_the_chain&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;empty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nf"&gt;spec_48&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&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;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="nf"&gt;.report&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.latency_base_frames&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Unit impulse = pass-through IR. We only care about delay&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_PARTITION&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;ir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.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;conv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;FirConvolver&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_PARTITION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&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;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nf"&gt;spec_48&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;assert_eq!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="nf"&gt;.report&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.node_report&lt;/span&gt;&lt;span class="nf"&gt;.latency_frames&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nd"&gt;assert_eq!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="nf"&gt;.report&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.latency_base_frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"adding the convolver increased path latency"&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;Reporting zero latency and actually having zero latency are different claims, so a second test pushes a sine through the same chain and checks the output lines up with the input at the reported delay.&lt;/p&gt;

&lt;p&gt;The latency hasn't vanished, though. A bigger B means a bigger entry block, and the round trip grows with it. B is an exchange rate between latency and CPU, and Bench lets you pick it in the UI (4,096 to 65,536).&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: 1.37 seconds of wrong output after every swap
&lt;/h2&gt;

&lt;p&gt;Bench can swap the processing chain while audio is playing. You build two chains, A and B, and switch between them with a 5 to 10 ms crossfade to compare by ear.&lt;/p&gt;

&lt;p&gt;The first version started each new convolver from empty. A partitioned convolver keeps the spectra of the last K input blocks in its FDL, so starting empty means wrong output until the FDL fills. With a long IR that took up to 1.37 seconds. No 10 ms crossfade hides that.&lt;/p&gt;

&lt;p&gt;The fix came from noticing that the FDL holds spectra of the &lt;em&gt;input&lt;/em&gt;. It doesn't depend on the IR at all. Hand the old convolver's FDL to the new one and the new one is correct from its first block. That's &lt;code&gt;transfer_history&lt;/code&gt;:&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;transfer_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;Self&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;bool&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;self&lt;/span&gt;&lt;span class="py"&gt;.partition&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.partition&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;.channels&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.channels&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;.bins&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.bins&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;.precision&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.precision&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Input-side state carries over as is&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.history&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&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;previous&lt;/span&gt;&lt;span class="py"&gt;.history&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;.stage&lt;/span&gt;&lt;span class="nf"&gt;.copy_from_slice&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;previous&lt;/span&gt;&lt;span class="py"&gt;.stage&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;.fill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.fill&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Re-pack the FDL newest-first. K may differ, so positions are remapped&lt;/span&gt;
    &lt;span class="c1"&gt;// (a precision mismatch means a different FDL type; the guard above already bailed)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;geo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.geometry&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&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;previous_partitions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_pos&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;self&lt;/span&gt;&lt;span class="py"&gt;.channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.partitions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.fdl_pos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&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="n"&gt;next&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="n"&gt;old&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;self&lt;/span&gt;&lt;span class="py"&gt;.freq&lt;/span&gt;&lt;span class="nf"&gt;.as_mut&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="py"&gt;.freq&lt;/span&gt;&lt;span class="nf"&gt;.as_ref&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&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="nn"&gt;FreqDomain&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;F64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nn"&gt;FreqDomain&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;F64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="nf"&gt;.transfer_fdl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;geo&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;previous_partitions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_pos&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="nn"&gt;FreqDomain&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;F32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nn"&gt;FreqDomain&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;F32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="nf"&gt;.transfer_fdl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;geo&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;previous_partitions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous_pos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Don't carry the output side over. The old graph keeps emitting the same&lt;/span&gt;
    &lt;span class="c1"&gt;// blocks during the crossfade, so its pending output would play twice&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.pending&lt;/span&gt;&lt;span class="nf"&gt;.fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new tap count means a new K, but re-packing newest-first handles that. If K grows, the oldest slots stay zero and only the tail of the new IR is missing for a few hops. That's orders of magnitude better than starting from nothing, so I accept it.&lt;/p&gt;

&lt;p&gt;The output side is the other trap. During the crossfade the old chain is still producing the same blocks, so carrying its pending output over would play that audio twice. Only the input side transfers.&lt;/p&gt;

&lt;p&gt;The transfer only works when B, the channel count and the precision all match. That implies a rule: never derive B, or the choice of implementation, from anything a swap can change, such as the tap count. I went on to break that rule twice.&lt;/p&gt;

&lt;p&gt;One more thing: a longer crossfade isn't safer. Both chains run for its whole duration, which doubles the CPU load, and a longer fade means a longer stretch at double load. The crossfade is clamped to 5 to 10 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 2: 300k taps and a stuttering mess
&lt;/h2&gt;

&lt;p&gt;One day a 300k-tap resampling filter turned the output into a string of clicks. Design-time validation had said the filter was fine.&lt;/p&gt;

&lt;p&gt;The resampling stage turned out not to use the partitioned FFT convolver at all. It ran a direct polyphase filter. Polyphase saves you a factor of the interpolation ratio, 1/16 at 16×, and I'd assumed that made it cheap. Wrong comparison. Polyphase is still &lt;code&gt;O(N)&lt;/code&gt; per sample against &lt;code&gt;O(N/B)&lt;/code&gt; for the FFT path, and a constant factor of 1/16 can't beat a different complexity class. At 300k taps, stereo, 44.1 kHz in, it needed about 35 cores' worth of time, so it missed every single block.&lt;/p&gt;

&lt;p&gt;The fix was one convolution engine. The FIR node in the graph and the resampling stages now share the same partitioned convolver.&lt;/p&gt;

&lt;p&gt;My first version switched implementations by tap count: direct below a threshold, FFT above. A test then showed that a swap crossing the threshold changed the implementation, so &lt;code&gt;transfer_history&lt;/code&gt; refused and the audio dropped out, all because you nudged a design parameter. Since FFT was cheaper even at the default 1,345 taps, I deleted the switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: the limit wasn't the CPU
&lt;/h2&gt;

&lt;p&gt;The next investigation started from this: "On an M1 Air, 300k taps looks like the real-time ceiling. The compute estimate is two orders of magnitude below what the machine can do. Is something other than raw compute the bottleneck?"&lt;/p&gt;

&lt;p&gt;I went through thermal throttling, being scheduled on an E-core, allocation on the real-time thread, and memory bandwidth. None of them. The machine never got hot because the whole pipeline used one core out of eight.&lt;/p&gt;

&lt;p&gt;The cause was one line:&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;// How B was chosen (at the time)&lt;/span&gt;
&lt;span class="n"&gt;DEFAULT_PARTITION&lt;/span&gt;&lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However long the internal block, B never went above 4,096. That cap came from a 20 ms round-trip latency budget, and the reason for that budget had already gone away (next section). When the premise died, nobody went back to revisit what had been built on it.&lt;/p&gt;

&lt;p&gt;I made B follow the internal block length and removed the cap. That opened the hole next to it. If B is longer than the block, the convolver can't complete a hop and fills the output with silence, without any error. Nothing in the path looks wrong, and silence plays. B is now derived from the block length the device actually delivers, and blocks that couldn't be filled are counted and shown in the UI.&lt;/p&gt;

&lt;p&gt;To count them, &lt;code&gt;process&lt;/code&gt; now returns the number of frames it actually wrote:&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="cd"&gt;/// Returns the number of frames that received convolution output. The rest is filled with silence.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&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;f64&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;f64&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;usize&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Accumulate input; run one hop every time B samples are buffered&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0usize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;frames&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;take&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;.fill&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frames&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// ... (copy into stage)&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.fill&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;take&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;take&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;self&lt;/span&gt;&lt;span class="py"&gt;.fill&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.hop&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;.fill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="c1"&gt;// 2. Emit output. Silence if there isn't enough&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="nf"&gt;.min&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;.available&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... (copy from pending to output, zero-fill the rest)&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.available&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;emit&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the block length equals B, each call runs exactly one hop and returns what it was given. That's the whole zero-latency trick from earlier. When B is longer than the block, calls before B samples have accumulated run no hop and &lt;code&gt;emit&lt;/code&gt; is 0. The output buffer still has the right length, so nobody notices unless they check the return value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency was the filter
&lt;/h2&gt;

&lt;p&gt;This is the part I most wanted to write about.&lt;/p&gt;

&lt;p&gt;Bench's round-trip latency target is 20 ms. Browser video and games go through it too, so lip sync puts the ceiling around there. Originally, designs that exceeded it couldn't be saved.&lt;/p&gt;

&lt;p&gt;But a linear-phase FIR with N taps always has a group delay of &lt;code&gt;(N−1)/2&lt;/code&gt; samples. At 300k taps that's 212 ms of group delay alone, ten times the budget. Making the convolver add zero latency didn't matter when the filter itself carried an order of magnitude more.&lt;/p&gt;

&lt;p&gt;The UI makes it obvious. Here's an upsampler designed at 500k taps, built for 48 kHz input. The latency panel (labels translated):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Segment&lt;/th&gt;
&lt;th&gt;ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;5.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IPC&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upsampler&lt;/td&gt;
&lt;td&gt;354.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node chain&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decimator&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DAC&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;361.94&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 768 kHz the same transition band needs 8.8% more taps than at 705.6 kHz, so the upsampler was built with 544,673 taps. Its 354.60 ms is (544,673 − 1) / 2 = 272,336 samples at 768 kHz. Everything else (input 5.33, IPC 1.00, DAC 1.00) adds up to 7.33 ms. The DAC was fed 768 kHz directly, so there's no decimator and it shows 0.00 ms.&lt;/p&gt;

&lt;p&gt;The odd tap count is deliberate. 272,336 is exactly 17,021 × 16, a multiple of the interpolation ratio. Otherwise the delay would be a fractional number of samples at the input rate, and the latency couldn't be reported as whole frames. For linear phase, the tap count gets bumped up slightly to make it line up:&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="cd"&gt;/// Align a linear-phase tap count to the conversion ratio: grow it until the group&lt;/span&gt;
&lt;span class="cd"&gt;/// delay `(N−1)/2` is a whole number of frames at the lower rate&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;align_taps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FirPhase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&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;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;)&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;match&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;FirPhase&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Linear&lt;/span&gt; &lt;span class="k"&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;aligned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;requested&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.div_ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;factor&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;taps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aligned&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&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;span class="n"&gt;taps&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_TAPS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.then&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;taps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;try_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aligned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nn"&gt;FirPhase&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Minimum&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&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;requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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;The extra taps aren't zero padding. They go inside the window and the filter is redesigned with them, because if you're paying the CPU anyway, it should buy filter performance. Minimum-phase filters don't have a &lt;code&gt;(N−1)/2&lt;/code&gt; group delay, so there's nothing to align.&lt;/p&gt;

&lt;p&gt;Now roughly double the taps: 96 kHz input, a 998,545-tap upsampler, and an 18-node chain behind it. The upsampler alone is 650.09 ms (499,272 samples), for a total of 657.43 ms. Input, IPC and DAC are still 7.33 ms. At 96 kHz the interpolation ratio is 8×, so the entry block is 512 frames, which is still 5.33 ms. The 18 nodes add 0.00 ms.&lt;/p&gt;

&lt;p&gt;Double the taps and the filter's delay roughly doubles. The plumbing stays where it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop refusing latency
&lt;/h2&gt;

&lt;p&gt;Linear-phase group delay is a pure delay, constant across the band. It doesn't change the sound by a single bit. What it costs you is lip sync, and transport response (when you pause or skip, the delay's worth of audio is still in the pipe). Both are costs the user is entitled to decide to pay.&lt;/p&gt;

&lt;p&gt;So Bench no longer refuses designs over the latency budget. It shows the current round-trip latency at all times instead. The bar isn't clipped at 20 ms; it extends to the actual value and keeps a line at the 20 ms mark. The condition is that nobody uses it without knowing, and the decision only stands as long as that display does.&lt;/p&gt;

&lt;p&gt;CPU got the same treatment. For a while there was a gate that refused designs predicted to overload the CPU. But the prediction moves by 1.75× with thermal state alone, so passing the gate never guaranteed real-time performance. A gate that says yes reads as "this will be fine," which is exactly the wrong message. The gate became a forecast: the expected load at design time, then the measured load and dropout counts once it's playing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two CPU numbers
&lt;/h2&gt;

&lt;p&gt;The status bar shows two numbers: "CPU load 46% (peak 77%)" and "CPU 46%".&lt;/p&gt;

&lt;p&gt;There used to be one: callback wall-clock time divided by the block deadline. Then I noticed that changing the entry block from 256 to 512 frames, same design, made it jump from 7.0% to 22.2%. Still zero dropouts, and the forecast barely moved. The jump was a step, not a slope, so my theory was that longer callbacks were spanning scheduler quanta and the measurement was counting time spent preempted.&lt;/p&gt;

&lt;p&gt;To test that, I measured the thread's own CPU time separately. Rust's standard library has no API for thread CPU time, so this is one line of &lt;code&gt;unsafe&lt;/code&gt;:&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="cd"&gt;/// CPU time consumed so far by the calling thread.&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;thread_cpu_time&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;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Duration&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="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;timespec&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tv_sec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tv_nsec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="c1"&gt;// SAFETY: `ts` is a valid `timespec` and `clock_gettime` only writes to it.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nn"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;clock_gettime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;CLOCK_THREAD_CPUTIME_ID&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;raw&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ts&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A call costs about 154 ns, twice per block. Against the 5.8 ms deadline of 256 frames at 44.1 kHz that's 0.005%, so measuring doesn't disturb what's being measured.&lt;/p&gt;

&lt;p&gt;The result: wall clock and CPU time agreed to within 0.1 percentage points at every B. The jump wasn't preemption. The work really had grown, and my explanation was the thing that was wrong. I still haven't tracked down why the forecast misses it.&lt;/p&gt;

&lt;p&gt;Splitting them was still worth it. I can only say the two agree because I measured them separately. With one number I'd never have gotten past "probably preemption." Both are shown now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU load&lt;/strong&gt; (wall clock ÷ deadline): are we making the deadline? Includes time spent waiting. At 100%, audio drops out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU&lt;/strong&gt; (thread CPU time ÷ deadline): pure compute. Used to check the forecast.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Over the eight-hour run, peak load was 77%, so even the worst block left about 20% headroom.&lt;/p&gt;

&lt;p&gt;One caveat: this run had a single resampling stage. Output stayed at 768 kHz, so there was no stage bringing the rate back down. With a DAC that tops out at 192 kHz, a second filter runs on the way out, and you pay its CPU too.&lt;/p&gt;

&lt;p&gt;These numbers are for f64 convolution. Bench also has a mode that runs only the frequency-domain part in f32, which is about 2× faster for the same design with error around −141 dBFS. You can't switch it while playing, though: the FDL type changes, so &lt;code&gt;transfer_history&lt;/code&gt; can't carry state across.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Direct convolution at 705.6 kHz stereo caps out at 757 taps per core. Partitioned FFT convolution gets a million taps into real time.&lt;/li&gt;
&lt;li&gt;If the internal block length equals the partition length B, the convolver adds zero samples of latency. The price is that a bigger B means a bigger entry block.&lt;/li&gt;
&lt;li&gt;Never derive B or the implementation from anything a swap can change, like tap count. The FDL can't transfer, and a small design tweak becomes a dropout.&lt;/li&gt;
&lt;li&gt;Almost all of the round-trip delay is the filter's group delay, not the plumbing: 650 ms for a million-tap linear-phase filter versus 7 ms to move the audio.&lt;/li&gt;
&lt;li&gt;Neither latency nor CPU load is refused anymore. Both are shown: latency as the sum of each segment's reported value, CPU as a measurement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Not done yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The latency figures are the sum of what each segment reports. The group delay is exact by construction, but nothing here was measured with a loopback recording. The 1.00 ms IPC figure is an estimate, and since no audio actually crosses a process boundary, it's a segment with nothing behind it.&lt;/li&gt;
&lt;li&gt;Convolution runs on one core. This stage is limited by memory traffic more than arithmetic, so more cores wouldn't scale linearly. I did the f32 frequency domain first (2.05×) and left parallelism for later.&lt;/li&gt;
&lt;li&gt;Low latency and very long filters together would need non-uniform partitioning. Not implemented.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Sonir Bench is free during the beta. It runs on Apple Silicon Macs with macOS 13 or later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sonir.app" rel="noopener noreferrer"&gt;https://sonir.app&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Sonir Bench: &lt;a href="https://sonir.app" rel="noopener noreferrer"&gt;https://sonir.app&lt;/a&gt;&lt;br&gt;
Author: &lt;a href="https://nadai.dev" rel="noopener noreferrer"&gt;https://nadai.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>audio</category>
      <category>dsp</category>
      <category>performance</category>
    </item>
    <item>
      <title>I built a Mac app that routes all system audio through a DSP chain you design yourself</title>
      <dc:creator>nadai</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:47:33 +0000</pubDate>
      <link>https://dev.to/nadai/i-built-a-mac-app-that-routes-all-system-audio-through-a-dsp-chain-you-design-yourself-18co</link>
      <guid>https://dev.to/nadai/i-built-a-mac-app-that-routes-all-system-audio-through-a-dsp-chain-you-design-yourself-18co</guid>
      <description>&lt;p&gt;Sonir Bench is a macOS app that takes every sound your Mac plays, runs it through signal processing you design, and sends the result to your DAC. The beta went live in October 2026.&lt;/p&gt;

&lt;p&gt;This post is the overview: what the pieces are and why each one is shaped the way it is. Later posts in this series will dive deep into individual components: million-tap FIR convolution in real time, writing a virtual audio device, keeping the audio thread allocation-free, and so on.&lt;/p&gt;

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

&lt;p&gt;Bench installs a virtual audio device. Pick "Sonir Bench" as your output and anything that plays audio (Spotify, Apple Music, Qobuz, a browser tab, a game) lands in Bench. Bench upsamples it to 705.6 kHz or 768 kHz, runs your processing chain, converts it to a rate your DAC accepts, and plays it.&lt;/p&gt;

&lt;p&gt;There are three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Virtual audio device&lt;/strong&gt;: an AudioServerPlugIn written in C, running inside &lt;code&gt;coreaudiod&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daemon&lt;/strong&gt;: a headless Rust process that owns all audio processing and device I/O&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI&lt;/strong&gt;: Tauri 2 + React 19, in a separate process&lt;/li&gt;
&lt;/ul&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%2Fi2elgconpyvk0h3l3pew.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%2Fi2elgconpyvk0h3l3pew.png" alt="Overview" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The UI talks to the daemon over a local WebSocket (JSON-RPC for control, binary frames for telemetry). If the UI crashes, the audio keeps going.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build it
&lt;/h2&gt;

&lt;p&gt;High-end playback software tends to ship dozens of carefully tuned filters, but you pick from a menu; you can't design your own. Filter design tools exist too, but they stop at a file. You export, load it into a player, restart playback, and by then your memory of the previous sound has faded.&lt;/p&gt;

&lt;p&gt;Plenty of people can write a windowed sinc. What they can't easily do is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Line up the impulse response, step response and group delay of the filters they designed&lt;/li&gt;
&lt;li&gt;Switch between them instantly on their own system&lt;/li&gt;
&lt;li&gt;Tie what they hear back to something they can measure&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bench exists to close that loop: design, look, listen A/B, measure. It doesn't sell "good sound". You decide what good sounds like; Bench is the workbench.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entry point: a virtual device of my own
&lt;/h2&gt;

&lt;p&gt;During early development I captured audio through an existing open-source virtual device. Because it is GPLv3, I couldn't bundle it with a commercial app, and the shipping build uses my own AudioServerPlugIn instead.&lt;/p&gt;

&lt;p&gt;Owning the device turned out to matter for more than licensing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I choose the advertised sample rates.&lt;/strong&gt; It declares both the 44.1 kHz and 48 kHz families up to 192 kHz, so the sending app can play at its native rate and macOS doesn't slip a sample-rate converter in front of Bench&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It has no volume control.&lt;/strong&gt; The device is always at unity gain, so the system volume slider has no path to the captured signal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's no audio IPC.&lt;/strong&gt; The plug-in loops its output back to its input stream, and the daemon opens it like any other input device. One audio path, not two&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building it surfaced a class of bugs I now look for everywhere: the screen looks fine while the sound is quietly altered.&lt;/p&gt;

&lt;p&gt;One example: if you open a stream at a rate different from the device's current nominal rate, macOS inserts a resampler and says nothing. No error. The daemon now reads the nominal rate directly before opening, and CI fails any code that opens a stream at a requested rate instead of the device's nominal rate.&lt;/p&gt;

&lt;p&gt;Another issue: macOS treats input from a virtual device as microphone input. Without microphone permission, Core Audio doesn't return an error; it returns digital silence. Bench reads the permission state and alerts you in the UI, instead of silently staying muted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring whether the input is untouched
&lt;/h2&gt;

&lt;p&gt;"Lossless" on the streaming side doesn't mean the app hands over the bits unchanged. When I measured it, Spotify with lossless enabled was not bit-exact at Bench's input. Qobuz, measured the same day, was bit-exact at both CD quality and hi-res.&lt;/p&gt;

&lt;p&gt;So Bench doesn't claim "bit-perfect". It measures what arrives and reports it, in two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input verification.&lt;/strong&gt; You play a reference file in your player, and Bench compares what arrives bit for bit. The file has a sync chirp, a ramp that walks through all 65,536 16-bit codes, a pseudo-random sequence, and a closing chirp in a different band. A match proves there's no resampling, no volume change and no mixing in the path. A mismatch is classified: a constant gain points to app-level volume attenuation, while stretching points to resampling. Right now this runs from the CLI only; it isn't in the UI yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous monitoring.&lt;/strong&gt; The input callback inspects each sample as it passes. Integer PCM passed through untouched sits on a fixed grid of values. A volume change makes the grid one step finer; a filter or resampler knocks samples off any valid grid. Bench tallies this per one-second window.&lt;/p&gt;

&lt;p&gt;The trap here was silence. Silence sits on every grid, so a naive check reports "bit-perfect" while nothing is playing. Bench gives no verdict until it has enough non-zero samples.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxijiz6m3r5gqda7q8ieu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxijiz6m3r5gqda7q8ieu.webp" alt="Entry transparency monitor" width="800" height="317"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Monitor page. On the left, the always-on entry check reports a 16-bit source arriving untouched.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why process at 705.6 / 768 kHz
&lt;/h2&gt;

&lt;p&gt;The high internal rate isn't there for the spec sheet. It's there so that naive nonlinear code just works.&lt;/p&gt;

&lt;p&gt;Run a signal through &lt;code&gt;tanh&lt;/code&gt; and you get harmonics that keep going up. At 48 kHz, anything above 24 kHz folds back into the audible band as aliasing. That's why plug-ins for music production oversample 8 to 32 times internally, and why they need anti-aliasing filters to do it.&lt;/p&gt;

&lt;p&gt;At 705.6 kHz the Nyquist frequency is 352.8 kHz, so a 1 kHz tone can produce up to its 352nd harmonic before anything folds back. No oversampling filter is needed, so there's no phase shift or ringing from one either. One line of &lt;code&gt;tanh&lt;/code&gt; is fine here.&lt;/p&gt;

&lt;p&gt;The internal rate is decoupled from the output rate. A DAC that tops out at 192 kHz (or 176.4 kHz for the 44.1 kHz family) still gets the benefit: processing happens at 768 kHz or 705.6 kHz respectively, and the signal is decimated once at the very end.&lt;/p&gt;

&lt;p&gt;Math is f64 throughout. The rounding floor is about -145 dB for f32 and about -320 dB for f64; the best DACs have a noise floor around -130 dB. I'm not going to market precision beyond that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Long FIR filters in real time
&lt;/h2&gt;

&lt;p&gt;In Bench you also design the sample-rate conversion filters yourself: cutoff, transition width and tap count. Whatever you design runs live.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffds2p8zslpxwcqtfgu26.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffds2p8zslpxwcqtfgu26.webp" alt="Resampling filter designer" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The resampling filter designer. Each design is verified per input rate: taps, group delay, stopband rejection.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Direct convolution at 705.6 kHz stereo topped out at 757 taps per core, about 1 ms of impulse response. Not enough for resampling or room correction. Bench uses uniformly partitioned FFT convolution (overlap-save) instead. On an Apple M1, a single 1,048,576-tap convolution takes about 36 to 50 percent of one core.&lt;/p&gt;

&lt;p&gt;Partitioned convolution normally adds latency equal to the partition length B. Bench sets its internal block size to exactly B, so each incoming block is convolved and returned as the same block. The convolution adds zero latency, and adding taps doesn't add delay.&lt;/p&gt;

&lt;p&gt;The block size does set round-trip latency, though. With B = 4,096 the round trip is 8.33 ms on the 48 kHz family and 9.71 ms on the 44.1 kHz family. The current default is B = 16,384 to lower the CPU cost of the resampling filters, which works out to about 24 to 27 ms. You choose, and the current latency is always on screen.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F334m0op09u45hthch6wm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F334m0op09u45hthch6wm.webp" alt="Round-trip latency breakdown" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Round-trip latency breakdown, here with a partition length of 8,192. The node chain adds 0.00 ms.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Check before it plays
&lt;/h2&gt;

&lt;p&gt;A chain you build yourself can hurt your ears or your speakers. It's easy to write a filter that blows up, a stage that outputs DC, or a design that boosts ultrasonic content. So every chain goes through three checks before it makes a sound:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;validate&lt;/strong&gt;: shape, ranges, finite coefficients, peak gain, and no boost above 25 kHz&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;simulate&lt;/strong&gt;: only for chains with nonlinear stages. Bench runs a sine, a log sweep and band-limited noise through the chain and checks for NaN, peaks, DC and ultrasonic energy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;arm&lt;/strong&gt;: any chain with nonlinear stages must have a final limiter and a soft start&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any step fails, that chain doesn't play; Bench falls back to passthrough.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5b61m1qbie9vu4eah020.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5b61m1qbie9vu4eah020.webp" alt="Graph editor with pre-flight checks" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The graph editor. Before saving, the chain is checked on both the 44.1 kHz and 48 kHz families.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Linear chains skip &lt;code&gt;simulate&lt;/code&gt; because a linear filter can't create frequencies that aren't in its input. How much ultrasonic energy it can produce follows from its magnitude response, computed once from the coefficients. Nonlinear stages do create new frequencies, so you have to run a signal through them to know.&lt;/p&gt;

&lt;p&gt;Chains are JSON, and the spec is public at &lt;a href="https://sonir.app/spec/" rel="noopener noreferrer"&gt;sonir.app/spec&lt;/a&gt;, so you can have an AI write one. There's deliberately no path for an AI to drive the daemon, though. You hand the JSON to Bench yourself, and if validation fails, you paste the result back to the AI. A conversation can't change what's coming out of your speakers without you in the loop.&lt;/p&gt;
&lt;h2&gt;
  
  
  A/B listening
&lt;/h2&gt;

&lt;p&gt;You keep two chains loaded, A and B, and switch between them. The switch is a 5 to 10 ms crossfade, with no dropouts.&lt;/p&gt;

&lt;p&gt;Louder sounds better in a comparison, so loudness matching is on by default, and the applied correction in dB is always shown. The correction is computed from the chains' coefficients, not by measuring the music. Measuring the music would give a different correction for every track, and the comparison wouldn't be repeatable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wl331axq0wage3sbwzt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wl331axq0wage3sbwzt.webp" alt="A/B page" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The A/B page. The loudness correction currently applied (-0.54 dB) is shown at the top right.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The line I won't cross: captured audio never goes to a file
&lt;/h2&gt;

&lt;p&gt;Audio captured through the virtual device is never written to a file, in any form. Otherwise Bench would be a stream ripper.&lt;/p&gt;

&lt;p&gt;That's enforced by types, not by a setting or a warning. Every audio buffer carries its origin as a type parameter:&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;// Two origins: captured from the virtual device, or not&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nb"&gt;Owned&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Captured&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Only Owned audio can be written to a file&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;FileSink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Sample&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sample&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Owned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Sample&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;-&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="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;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;There is no conversion from &lt;code&gt;Captured&lt;/code&gt; to &lt;code&gt;Owned&lt;/code&gt;. Mixing two signals yields &lt;code&gt;Captured&lt;/code&gt; if either side was captured. "You can't write captured audio to a file" is tested as a &lt;code&gt;compile_fail&lt;/code&gt; doctest: code that tries it must not compile.&lt;/p&gt;

&lt;p&gt;Types only protect what's inside the process. The WebSocket to the UI has no field that could carry a waveform at all; it carries levels, spectra and other numbers. Phase spectra and impulse responses are left out too, since an inverse FFT would turn them back into audio.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn't done yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No built-in player for local files. Today Bench processes what other apps play&lt;/li&gt;
&lt;li&gt;Input verification isn't in the UI yet (CLI only)&lt;/li&gt;
&lt;li&gt;With default settings, round-trip latency is above my 20 ms target&lt;/li&gt;
&lt;li&gt;macOS on Apple Silicon only&lt;/li&gt;
&lt;li&gt;A companion phone app (Sonir Remote) measures your room for correction, but the full measure-and-send round trip hasn't been verified on real hardware yet&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Sonir Bench is free during the beta. It runs on Apple Silicon Macs with macOS 13 or later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sonir.app" rel="noopener noreferrer"&gt;https://sonir.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next in the series: &lt;a href="https://dev.to/nadai/a-million-tap-linear-phase-fir-in-real-time-7-ms-of-plumbing-650-ms-of-filter-17fa"&gt;A million-tap linear-phase FIR in real time: 7 ms of plumbing, 650 ms of filter&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Sonir Bench: &lt;a href="https://sonir.app" rel="noopener noreferrer"&gt;https://sonir.app&lt;/a&gt;&lt;br&gt;
Author: &lt;a href="https://nadai.dev" rel="noopener noreferrer"&gt;https://nadai.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>audio</category>
      <category>macos</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
