<?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: Lluis Estape</title>
    <description>The latest articles on DEV Community by Lluis Estape (@lluisestape).</description>
    <link>https://dev.to/lluisestape</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%2F4035316%2F4545328b-9213-4cd3-be02-676b5ba2f240.png</url>
      <title>DEV Community: Lluis Estape</title>
      <link>https://dev.to/lluisestape</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lluisestape"/>
    <language>en</language>
    <item>
      <title>Your LFO runs at the buffer size: auditing the control rate of my own synth</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:33:28 +0000</pubDate>
      <link>https://dev.to/lluisestape/your-lfo-runs-at-the-buffer-size-auditing-the-control-rate-of-my-own-synth-5cd0</link>
      <guid>https://dev.to/lluisestape/your-lfo-runs-at-the-buffer-size-auditing-the-control-rate-of-my-own-synth-5cd0</guid>
      <description>&lt;p&gt;&lt;em&gt;A follow-up audit of SYNTH/1, my JUCE wavetable synth. The oscillator has been measured, rewritten and measured again. Everything that modulates it had never been measured once. I pointed a meter at five of those paths and all five were wrong, one of them by a factor of thirteen.&lt;/em&gt;&lt;/p&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%2F5cwo6in9ss9uxvbrkh2e.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%2F5cwo6in9ss9uxvbrkh2e.png" alt="SYNTH/1's main tab: wavetable visualiser, filter, ADSR" width="800" height="423"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.toPASTE-URL-OF-POST-02-HERE"&gt;the plugin post&lt;/a&gt; I wrote about finding that my oscillator had been aliasing over most of the keyboard for a year, and about the rewrite that fixed it: mip-mapped tables, cubic Hermite interpolation, a double phase accumulator. The number at the end of that story was 96 dB of alias rejection at A4 and 139 dB at C8, measured by compiling the shipping header against a JUCE stub.&lt;/p&gt;

&lt;p&gt;That is the audio-rate half of the synth. It runs once per sample and I now have a meter pointed at it.&lt;/p&gt;

&lt;p&gt;The other half runs once per &lt;code&gt;processBlock&lt;/code&gt;, and I had never pointed anything at it at all. The LFO, the portamento, the unison summing, the phase warp: all of it control-rate code that I wrote by feel, listened to, and shipped. This post is what happened when I finally measured it.&lt;/p&gt;

&lt;p&gt;All five are fixed now, and the last section has the after numbers. I am writing them up as they were found, because the interesting part of each one is not the patch, it is why I could not hear it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I should have written down first
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;processBlock&lt;/code&gt; gets called with whatever buffer size the host feels like. 32 samples in a low-latency live rig, 2048 in a mixing session, and it can change while the plugin is loaded.&lt;/p&gt;

&lt;p&gt;So anything you advance once per block is &lt;strong&gt;a signal sampled at &lt;code&gt;fs/N&lt;/code&gt;&lt;/strong&gt;, with its own Nyquist at &lt;code&gt;fs/2N&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;buffer size&lt;/th&gt;
&lt;th&gt;control rate at 44.1 kHz&lt;/th&gt;
&lt;th&gt;control Nyquist&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;344 Hz&lt;/td&gt;
&lt;td&gt;172 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;86.1 Hz&lt;/td&gt;
&lt;td&gt;43.1 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;43.1 Hz&lt;/td&gt;
&lt;td&gt;21.5 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2048&lt;/td&gt;
&lt;td&gt;21.5 Hz&lt;/td&gt;
&lt;td&gt;10.8 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;td&gt;10.8 Hz&lt;/td&gt;
&lt;td&gt;5.4 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is two lines of arithmetic and it is the whole post. A modulation source that can be set faster than the control Nyquist does not run fast: it folds, exactly like an oscillator above half the sample rate, for exactly the same reason.&lt;/p&gt;

&lt;p&gt;My LFO goes to 20 Hz.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The LFO is sampled at the buffer size
&lt;/h2&gt;

&lt;p&gt;Here is the entire LFO, verbatim from &lt;code&gt;PluginProcessor::processBlock&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;lfoRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lfoRateParam&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;lfoPhase&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lfoRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentSR&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lfoPhase&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;lfoPhase&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;lfoVal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;sin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lfoPhase&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MathConstants&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;::&lt;/span&gt;&lt;span class="n"&gt;twoPi&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;lfoVisBuf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lfoVal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;cutoffMod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lfoVal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;lfoCutoffDepthParam&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;4000.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;pitchMod&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lfoVal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;lfoPitchDepthParam&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;sin()&lt;/code&gt; per block. The phase advances by &lt;code&gt;rate * N / fs&lt;/code&gt; each time, which is correct bookkeeping: over a second the LFO completes exactly &lt;code&gt;rate&lt;/code&gt; cycles of phase. What it does not do is produce &lt;code&gt;rate&lt;/code&gt; Hz of modulation, because the sine is only &lt;em&gt;evaluated&lt;/em&gt; &lt;code&gt;fs/N&lt;/code&gt; times a second.&lt;/p&gt;

&lt;p&gt;The condition is the standard one. The modulation is what the knob says while&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate &amp;lt; fs / (2N)        =&amp;gt;        N &amp;lt; fs / (2 * rate)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and folds above it. At &lt;code&gt;rate = 20 Hz&lt;/code&gt; and 44.1 kHz that threshold is &lt;strong&gt;N = 1102 samples&lt;/strong&gt;. A 1024-sample buffer is fine. The next power of two is not.&lt;/p&gt;

&lt;p&gt;Measured, by replicating those four lines in float32 and taking the FFT of the block-rate sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  set | N=32    | N=64    | N=128   | N=256   | N=512   | N=1024  | N=2048  | N=4096
-------------------------------------------------------------------------------------
  1.0 |    1.00 |    1.00 |    1.00 |    1.00 |    1.00 |    1.00 |    1.00 |    1.00
  5.0 |    5.00 |    5.00 |    5.00 |    5.00 |    5.01 |    5.01 |    5.01 |    5.01
 20.0 |   20.00 |   20.00 |   20.00 |   20.00 |   20.03 |   20.03 |    1.50 |    1.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8a84h9ehrjdg1dgvvjqz.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%2F8a84h9ehrjdg1dgvvjqz.png" alt="Left: modulation frequency produced versus host buffer size, per-block against the fixed 32-sample chunk. Right: the 20 Hz setting at N=2048, against the chunked version and against the sine the knob is promising" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At a 2048-sample buffer, the 20 Hz setting produces 1.5 Hz.&lt;/strong&gt; Not a rough 20 Hz, not a steppy 20 Hz. A different, slower, unrelated frequency: &lt;code&gt;21.5 - 20 = 1.5&lt;/code&gt;, the fold, right where the arithmetic says it will be.&lt;/p&gt;

&lt;p&gt;Three things make this worse than it first looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rate knob becomes host-dependent.&lt;/strong&gt; The same project, the same preset, a different buffer size, and the vibrato is a slow wobble. Nothing in the UI hints at it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smoothing hides steps, not folds.&lt;/strong&gt; The cutoff modulation goes through a 20 ms smoother (&lt;code&gt;smoothCutoffMod.reset (sampleRate, 0.02)&lt;/code&gt;), so the staircase gets rounded off on its way to the filter and never sounds like zipper noise. It sounds smooth. It is smoothly at the wrong frequency. A lowpass after the sampler cannot undo aliasing any more than a lowpass after your converter can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LFO scope is drawing the bug.&lt;/strong&gt; &lt;code&gt;lfoVisBuf.write (lfoVal)&lt;/code&gt; is fed from the same per-block value, so the on-screen scope in the MOD tab shows the folded waveform faithfully. The evidence was on the front panel the whole time and I read it as "the scope redraws at 30 Hz, of course it looks chunky".&lt;/p&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%2Fpkmxcm6oh84s5p975u1l.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%2Fpkmxcm6oh84s5p975u1l.png" alt="The MOD tab. That scope has been telling the truth all along" width="799" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more sharp edge in those four lines: &lt;code&gt;if (lfoPhase &amp;gt;= 1.0f) lfoPhase -= 1.0f;&lt;/code&gt; subtracts &lt;strong&gt;once&lt;/strong&gt;. Past &lt;code&gt;N = fs / rate&lt;/code&gt; (2205 samples at 20 Hz) the per-block increment exceeds 1.0 and one subtraction no longer brings the phase back into range, so it grows without bound instead of wrapping. The output is still a sine of a folded frequency, so it does not announce itself, but in float32 an unbounded accumulator quietly coarsens over a long session.&lt;/p&gt;

&lt;p&gt;The fix is not subtle: advance the LFO on a fixed sub-block so its rate stops being a function of the host. &lt;code&gt;juce::Synthesiser::renderNextBlock&lt;/code&gt; takes MIDI positions as absolute offsets into the buffer, so the same &lt;code&gt;MidiBuffer&lt;/code&gt; can be handed to each chunk with only &lt;code&gt;startSample&lt;/code&gt; moving, and the voices never notice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;offset&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="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;numSamples&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;kControlBlockSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&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;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jmin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kControlBlockSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;numSamples&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;lfoPhase&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;phaseStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;lfoPhase&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;floor&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lfoPhase&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// not a single conditional subtract&lt;/span&gt;

    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;lfoVal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;sin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lfoPhase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;twoPi&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;synth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getNumVoices&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;auto&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;dynamic_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SynthVoice&lt;/span&gt;&lt;span class="o"&gt;*&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;synth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getVoice&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;setLFOMod&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lfoVal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cutoffDepth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;4000.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lfoVal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pitchDepth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;synth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;renderNextBlock&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kControlBlockSize = 32&lt;/code&gt; puts the control rate at 1378 Hz and the modulation ceiling at 689 Hz, two decades above anything the rate knob can ask for, at any buffer size. The green trace in the figure above is that version: 20.00 Hz whatever the host does. &lt;code&gt;std::floor&lt;/code&gt; replaces the conditional subtract for the same reason: it wraps a phase no matter how large the increment gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Portamento is quantised to the host buffer
&lt;/h2&gt;

&lt;p&gt;Same failure, different knob. From &lt;code&gt;SynthVoice::renderNextBlock&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Block-rate glide + pitch LFO&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;glideHz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smoothedFreqHz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numSamples&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;pMod&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smoothPitchMod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numSamples&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;modBase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;glideHz&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;pow&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pMod&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="n"&gt;f&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;smoothedFreqHz&lt;/code&gt; is a &lt;code&gt;juce::SmoothedValue&amp;lt;float, ValueSmoothingTypes::Multiplicative&amp;gt;&lt;/code&gt;, which is the right choice: pitch is logarithmic, so a glide should be a constant ratio per sample and not a constant number of hertz. &lt;code&gt;skip(n)&lt;/code&gt; advances the smoother &lt;code&gt;n&lt;/code&gt; steps and returns where it landed, so the &lt;em&gt;ramp rate&lt;/em&gt; is exactly right.&lt;/p&gt;

&lt;p&gt;It is read once per block. The oscillator frequency is therefore piecewise constant over each buffer, and a portamento is a staircase whose step height is set by the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cents per step = 1200 * N / (glideTime * fs)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a one-octave glide in 100 ms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;buffer size&lt;/th&gt;
&lt;th&gt;pitch updates&lt;/th&gt;
&lt;th&gt;cents per step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;68.9&lt;/td&gt;
&lt;td&gt;17.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;34.5&lt;/td&gt;
&lt;td&gt;34.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;17.2&lt;/td&gt;
&lt;td&gt;69.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;td&gt;139.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;td&gt;278.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj7jz6u2t8lk4if0n4wk3.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%2Fj7jz6u2t8lk4if0n4wk3.png" alt="A one-octave, 100 ms portamento: per-block at N=1024 and N=256, against the 32-sample control chunk" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 512 samples a fast glide moves in &lt;strong&gt;139-cent steps&lt;/strong&gt;, larger than a semitone. At 1024 it is 279 cents and four steps: that is not a portamento, that is an arpeggio. And again the knob behaves differently depending on a setting in a different application.&lt;/p&gt;

&lt;p&gt;Unlike the LFO this one has a real cost attached, which is why I suspect past-me did it on purpose and then forgot. Per-sample pitch means calling &lt;code&gt;setFrequency&lt;/code&gt; per sample on up to eight unison oscillators per voice, and &lt;code&gt;setFrequency&lt;/code&gt; does a &lt;code&gt;log2&lt;/code&gt; for the mip index. The honest middle is a sub-block, which is the same sub-block the LFO now needs: because &lt;code&gt;renderNextBlock&lt;/code&gt; is called per 32-sample chunk, &lt;code&gt;skip(numSamples)&lt;/code&gt; is handed 32 instead of the host's buffer, and the fix arrives for free. &lt;strong&gt;8.7 cents per step, at every buffer size.&lt;/strong&gt; That is the green trace above, and 1/32 of the cost of doing it per sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cutoff is smooth, pitch is not, and the expensive one is the one I did not need
&lt;/h2&gt;

&lt;p&gt;The two modulation destinations inside the voice are handled differently, three lines apart. Pitch, above, is &lt;code&gt;skip(numSamples)&lt;/code&gt;: one value per block. Cutoff is inside the per-sample loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;numSamples&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;cMod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smoothCutoffMod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getNextValue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adsr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getNextSample&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;svFilter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setCutoffFrequency&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jlimit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;20000.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cMod&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;&lt;code&gt;getNextValue()&lt;/code&gt; per sample. Smooth, correct, and the most expensive line in the voice, because of what JUCE does behind that setter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;StateVariableTPTFilter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SampleType&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;::&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SampleType&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;tan&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MathConstants&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;::&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cutoffFrequency&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;R2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SampleType&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&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="n"&gt;resonance&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="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SampleType&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&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="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;R2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;g&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;A &lt;code&gt;std::tan&lt;/code&gt; and two divisions, per sample, per voice. Sixteen voices at 44.1 kHz is &lt;strong&gt;705,600 tangents a second&lt;/strong&gt;, and the loop calls it unconditionally: with the LFO cutoff depth at zero, &lt;code&gt;cMod&lt;/code&gt; is a constant and every one of those recomputes the same three coefficients.&lt;/p&gt;

&lt;p&gt;So the voice pays per-sample cost for the modulation path that also happens to be easy to make cheap, and takes the block-rate shortcut on the path where per-sample actually costs something. I would defend block-rate pitch to a reviewer. I cannot defend recomputing a filter coefficient 705,600 times a second to apply a modulation of zero.&lt;/p&gt;

&lt;p&gt;The fix is a cached comparison, and it costs nothing when the modulation &lt;em&gt;is&lt;/em&gt; moving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jlimit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxCutoffHz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cMod&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;abs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastCutoffHz&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;lastCutoffHz&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.0e-4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;svFilter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setCutoffFrequency&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;lastCutoffHz&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&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 threshold is relative, so it is 0.0002 of a semitone at any cutoff rather than a fixed number of hertz that would be inaudible at 8 kHz and a staircase at 40 Hz. With the LFO depth at zero the smoother converges and the tangents stop entirely.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;maxCutoffHz&lt;/code&gt; is the other half of that line, and a bug of its own: see the small print below.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two of the four warp modes are one warp mode
&lt;/h2&gt;

&lt;p&gt;The oscillator's &lt;code&gt;applyWarp&lt;/code&gt; offers None, Sync, Bend and PWM. Here are the last two, verbatim and adjacent in the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// Bend - non-linear phase redistribution&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;warpAmount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;phase&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&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="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&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="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&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="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// PWM&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;warpAmount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pw&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="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&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="n"&gt;pw&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;Same constant, same breakpoint, same two branches, same arithmetic. Rename &lt;code&gt;pw&lt;/code&gt; to &lt;code&gt;k&lt;/code&gt; and the two cases are character for character identical, which is a fact about the source and needs no measurement to settle. Rendering both modes through the oscillator settles it anyway: the two buffers came out bit-identical, maximum sample difference &lt;code&gt;0.0&lt;/code&gt;. (After the rewrite below they differ by up to 1.63, which is the version of that check that passes.)&lt;/p&gt;

&lt;p&gt;The dropdown offers four modes and the DSP implements three. Nobody reported it, including me, because the one thing a phase-distortion mode reliably does is sound different from the mode before it, and Bend already does.&lt;/p&gt;

&lt;p&gt;The mode that was missing is the interesting one. Pulse-width modulation on a wavetable is not a phase remap at all and cannot be written as one, which is exactly how it ended up as a renamed copy of its neighbour. It is the difference of two reads of the same table a duty cycle apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;   &lt;span class="n"&gt;isPWM&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;warpMode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;pulseW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;warpAmount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isPWM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;p2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentPhase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pulseW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;p2&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;floor&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readAt&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentPhase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;readAt&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p2&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readAt&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;applyWarp&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentPhase&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;That needed the single table read to be pulled out into a &lt;code&gt;readAt()&lt;/code&gt; lambda (morph blend, mip blend and all), which the non-PWM path now calls once and PWM calls twice. The &lt;code&gt;0.5&lt;/code&gt; keeps it inside unity: two unit sawtooths a distance &lt;code&gt;w&lt;/code&gt; apart differ by at most &lt;code&gt;2 - 2w&lt;/code&gt;, which reaches 1.9 at the narrow end of the range.&lt;/p&gt;

&lt;p&gt;Then the part I did not expect. &lt;code&gt;x(p) - x(p + w)&lt;/code&gt; is a sum of the harmonics &lt;strong&gt;already in the table&lt;/strong&gt;, with modified amplitudes and phases and nothing new above them. Sync and Bend re-create the slope discontinuities the band-limited tables exist to avoid, so they alias badly. The two-read PWM cannot. Rendered through the shipping oscillator at Amount = 0.75 and measured the same way as the oscillator sweep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alias-to-signal ratio (dB, lower is better)
  f0 (Hz) |     none |     sync |     bend |      pwm
----------------------------------------------------
    109.7 |    -91.3 |    -18.4 |    -34.9 |    -92.0
    440.1 |   -121.6 |    -12.4 |    -28.1 |   -122.5
   1320.3 |   -142.3 |     -6.2 |    -22.9 |   -142.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PWM matches the unwarped oscillator to a fraction of a dB.&lt;/strong&gt; It is the only warp mode in the plugin that does not throw away the mip-mapping the oscillator rewrite paid for.&lt;/p&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%2Fbvnoxj4fomdfjopr8o3t.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%2Fbvnoxj4fomdfjopr8o3t.png" alt="Left: the phase remaps that are left. Right: two cycles at 440 Hz, Bend against the new PWM, rendered by the plugin's own oscillator" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One footnote from setting up that measurement, which I ran at Amount = 0.75 rather than the obvious 0.5. Bend's breakpoint is &lt;code&gt;k = 0.05 + amount * 0.9&lt;/code&gt;, so at Amount = 0.5 you get &lt;code&gt;k = 0.5&lt;/code&gt;, both branches collapse to &lt;code&gt;phase&lt;/code&gt;, and the map is the identity. &lt;strong&gt;The Bend knob does nothing at its own midpoint.&lt;/strong&gt; I found that by measuring Bend and getting the unwarped column back, digit for digit.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Unison at zero detune is a +9 dB knob
&lt;/h2&gt;

&lt;p&gt;The unison summing, from the same render loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;normGain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;sqrt&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numU&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;1/sqrt(N)&lt;/code&gt; is the right normalisation for &lt;em&gt;incoherent&lt;/em&gt; sources. Sum N uncorrelated signals and the power adds, so the amplitude goes as &lt;code&gt;sqrt(N)&lt;/code&gt; and dividing it out keeps the level constant. That is what a detuned unison stack becomes after a few tens of milliseconds.&lt;/p&gt;

&lt;p&gt;It is not what it is at note-on, because &lt;code&gt;startNote&lt;/code&gt; does this to all eight oscillators:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;u&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="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;kMaxUnisonVoices&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;unisonOscs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;setFrequency&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseFreqHz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;unisonOscs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// phase = 0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every unison oscillator starts at phase zero, so for the first cycles they are one signal copied N times. Coherent sum, amplitude N, scaled by &lt;code&gt;1/sqrt(N)&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;unison voices&lt;/th&gt;
&lt;th&gt;coherent gain&lt;/th&gt;
&lt;th&gt;dB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.414&lt;/td&gt;
&lt;td&gt;+3.01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2.000&lt;/td&gt;
&lt;td&gt;+6.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;2.828&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+9.03&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With detune above zero they drift apart and the level settles where the normalisation intends. With &lt;strong&gt;detune at exactly zero they never drift&lt;/strong&gt;, so the voice count stops being a texture control and becomes a +9 dB gain knob that also triples your chance of clipping the FX chain downstream.&lt;/p&gt;

&lt;p&gt;The transient version of this is the more musical bug: at any detune setting, the attack of every note is up to 9 dB hotter than its body. I have been hearing that as "the unison has a nice punch to it".&lt;/p&gt;

&lt;p&gt;The fix is one line, and the obvious version of it is wrong. Staggering the phases evenly, &lt;code&gt;u / numU&lt;/code&gt;, looks like the tidy deterministic choice and is the worst of the three: summing N copies of one waveform at phases &lt;code&gt;k/N&lt;/code&gt; cancels every harmonic that is not a multiple of N, so an 8-voice unison at zero detune would come out three octaves up, on 8*f0, with seven eighths of its spectrum deleted. Even spacing is a comb filter wearing a normalisation's clothes.&lt;/p&gt;

&lt;p&gt;Random phases are the fix, because random phases are the assumption &lt;code&gt;1/sqrt(N)&lt;/code&gt; was already making:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Oscillator 0 keeps phase 0 so unison = 1 is bit-identical to before.&lt;/span&gt;
&lt;span class="n"&gt;unisonOscs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;resetPhase&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nextDouble&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator is seeded from the voice's construction index, so a fresh instance still renders a given note sequence identically twice, which is what you want for an offline bounce. Measured on a 50-harmonic sawtooth over 500 phase draws, as level relative to a single oscillator:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;unison voices&lt;/th&gt;
&lt;th&gt;phase 0 (before)&lt;/th&gt;
&lt;th&gt;random phase, RMS&lt;/th&gt;
&lt;th&gt;random, peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;+3.01&lt;/td&gt;
&lt;td&gt;-0.32&lt;/td&gt;
&lt;td&gt;-0.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;+6.02&lt;/td&gt;
&lt;td&gt;-0.27&lt;/td&gt;
&lt;td&gt;+0.65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+9.03&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;-0.31&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.68&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The +9 dB is gone from both the steady state and the attack. What is left is a few tenths of a dB of draw-to-draw variation, which is the phasing that makes an analog unison sound like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small print
&lt;/h2&gt;

&lt;p&gt;Three more things I measured or re-read, kept here because a list of only the dramatic findings is a dishonest list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The saturator is used outside its documented range.&lt;/strong&gt; The per-voice drive stage is &lt;code&gt;juce::dsp::FastMathApproximations::tanh&lt;/code&gt;, whose header says "You are advised to use input values only between -5 and +5". The &lt;code&gt;drive&lt;/code&gt; parameter goes to 10, so the argument does too. Measured against &lt;code&gt;std::tanh&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;x&lt;/th&gt;
&lt;th&gt;fast tanh&lt;/th&gt;
&lt;th&gt;true tanh&lt;/th&gt;
&lt;th&gt;dB over unity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;1.00001&lt;/td&gt;
&lt;td&gt;0.99991&lt;/td&gt;
&lt;td&gt;0.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10.0&lt;/td&gt;
&lt;td&gt;1.00917&lt;/td&gt;
&lt;td&gt;1.00000&lt;/td&gt;
&lt;td&gt;+0.079&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20.0&lt;/td&gt;
&lt;td&gt;1.13348&lt;/td&gt;
&lt;td&gt;1.00000&lt;/td&gt;
&lt;td&gt;+1.088&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Padé approximant stays monotonic and does not blow up; it just stops saturating and creeps above unity, first crossing 1.0 at x = 4.972. At the maximum drive the "soft clipper" overshoots its ceiling by 0.08 dB. That is inaudible and it is still wrong, because the one property I wanted from that function is a hard ceiling at 1.&lt;/p&gt;

&lt;p&gt;This is the one I left alone, on purpose. Swapping in &lt;code&gt;std::tanh&lt;/code&gt; fixes 0.08 dB and changes the saturation curve of every preset anyone has saved; clamping the argument at ±5 turns a soft knee into a hard corner at high drive. Neither is worth it for 0.08 dB. Measured, judged, documented, unchanged: that is a legitimate outcome for a finding and I would rather write it down than quietly "fix" the sound of the plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cutoff clamp assumed 44.1 kHz.&lt;/strong&gt; &lt;code&gt;juce::jlimit (20.0f, 20000.0f, ...)&lt;/code&gt; is a fixed ceiling, but the TPT filter needs &lt;code&gt;cutoff &amp;lt; fs/2&lt;/code&gt;. Below a 40 kHz sample rate the clamp let through a cutoff above Nyquist, &lt;code&gt;std::tan(pi * fc / fs)&lt;/code&gt; went negative, and JUCE's own &lt;code&gt;jassert&lt;/code&gt; fires in a debug build. It never bit me because I have never run the plugin at 32 kHz. The ceiling is now &lt;code&gt;jmin (20000.0, 0.49 * sampleRate)&lt;/code&gt;, computed in &lt;code&gt;prepareToPlay&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The oscillator takes a &lt;code&gt;double&lt;/code&gt; and the voice hands it a &lt;code&gt;float&lt;/code&gt;.&lt;/strong&gt; The header argues, at length and correctly, that &lt;code&gt;setFrequency&lt;/code&gt; should take a &lt;code&gt;double&lt;/code&gt; because a float32 Hz value leaves the tone slightly non-periodic and shows up as leakage skirts at -86 dBc. Then the caller computes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;freq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jlimit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;20000.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;modBase&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;pow&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detuneSt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;unisonOscs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;setFrequency&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The precision was thrown away one line above the call that had been widened to preserve it. The measurement harness passes an exact double, so the rig I built to keep me honest was the one place in the codebase where the argument held. The whole glide and detune path is in double now: a &lt;code&gt;double&lt;/code&gt; and an &lt;code&gt;std::pow&lt;/code&gt; in double, free, and it makes the header comment true.&lt;/p&gt;

&lt;p&gt;And a documentation one, since docs ship too: the repo README still advertised "Four band-limited wavetables (64 harmonics each)", which describes the oscillator I deleted a year ago. &lt;code&gt;CLAUDE.md&lt;/code&gt; described the current one. Two files, two different synths, and the one users read was the wrong one. Also fixed, and it is the change I am least proud of needing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five, before and after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LFO at 20 Hz, N = 2048&lt;/td&gt;
&lt;td&gt;1.50 Hz&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;20.00 Hz&lt;/strong&gt;, at every buffer size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Octave portamento in 100 ms&lt;/td&gt;
&lt;td&gt;139 cents/step at N = 512, 279 at N = 1024&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;8.7 cents/step&lt;/strong&gt;, at every buffer size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;std::tan&lt;/code&gt; per second, 16 voices, LFO depth 0&lt;/td&gt;
&lt;td&gt;705,600&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PWM&lt;/td&gt;
&lt;td&gt;bit-identical to Bend&lt;/td&gt;
&lt;td&gt;a real pulse, and the only warp mode that does not alias (-92.0 dB against -91.3 unwarped)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unison 8 at zero detune&lt;/td&gt;
&lt;td&gt;+9.03 dB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;-0.31 dB&lt;/strong&gt; RMS, +0.68 dB peak&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything still builds clean in Release x64, and the oscillator sweep from the previous post comes back digit for digit (-121.6 dB at A4, -148.2 at C8), which is the regression test that mattered: the control-rate work moved nothing in the audio-rate path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing this
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python analysis/measure_modulation.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The warp table needs the C++ harness first, because it renders through the real oscillator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cl /std:c++17 /O2 /EHsc /I analysis&lt;span class="se"&gt;\j&lt;/span&gt;uce_stub /I Source analysis&lt;span class="se"&gt;\m&lt;/span&gt;easure_warp.cpp /Fe:analysis&lt;span class="se"&gt;\m&lt;/span&gt;easure_warp.exe
analysis&lt;span class="se"&gt;\m&lt;/span&gt;easure_warp.exe analysis/data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest caveat about method, because it differs from the oscillator work. &lt;code&gt;plot_oscillator.py&lt;/code&gt; and &lt;code&gt;measure_warp.cpp&lt;/code&gt; measure the &lt;strong&gt;shipping header&lt;/strong&gt;, compiled as-is against a minimal JUCE stub: those numbers come from the code that runs in the product. The LFO, glide and unison paths live in &lt;code&gt;SynthVoice.h&lt;/code&gt; and &lt;code&gt;PluginProcessor.cpp&lt;/code&gt;, tangled up with APVTS and &lt;code&gt;juce::Synthesiser&lt;/code&gt;, so &lt;code&gt;measure_modulation.py&lt;/code&gt; &lt;strong&gt;replicates them rather than compiling them&lt;/strong&gt;, four to six lines at a time, quoted beside the original above.&lt;/p&gt;

&lt;p&gt;That is a weaker claim and worth stating plainly. The warp findings do not depend on it at all, being rendered audio. Nor does the identity of the two modes, which is a textual fact about the source, or the &lt;code&gt;sqrt(N)&lt;/code&gt; unison gain, which is arithmetic on one line of code. The LFO fold and the glide staircase are arithmetic too (&lt;code&gt;N = fs/2f&lt;/code&gt; and &lt;code&gt;1200N/(t*fs)&lt;/code&gt;); the replication only draws them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write down the control rate the same way you write down Nyquist.&lt;/strong&gt; &lt;code&gt;fs/2N&lt;/code&gt; is the ceiling on every modulation source in the plugin, and N belongs to the host, not to you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A parameter range that the implementation cannot honour is a bug in the range.&lt;/strong&gt; A 20 Hz LFO in a block-rate modulator is a knob that lies above about a third of its travel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smoothing is not sampling.&lt;/strong&gt; A 20 ms smoother turns a staircase into a ramp and leaves an alias exactly where it was. It made this bug &lt;em&gt;harder&lt;/em&gt; to hear, not smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalisation laws have assumptions in their names.&lt;/strong&gt; &lt;code&gt;1/sqrt(N)&lt;/code&gt; is the incoherent law. If you reset every source to phase zero, you have just guaranteed the coherent case for the first cycles, and forever at zero detune.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two adjacent switch cases are worth diffing.&lt;/strong&gt; Not reading, diffing. I have read that function a dozen times and my eyes matched &lt;code&gt;k&lt;/code&gt; against &lt;code&gt;pw&lt;/code&gt; and moved on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the parts you feel confident about.&lt;/strong&gt; I measured the oscillator because I already suspected it. The LFO I never suspected, and it is the worst one on the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The front panel was showing it.&lt;/strong&gt; The LFO scope had been drawing the folded waveform since the day I wrote it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tidy fix can be the worst one.&lt;/strong&gt; Evenly spaced unison phases look more principled than random ones and delete most of the spectrum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving something unfixed is a result too.&lt;/strong&gt; The saturator overshoots by 0.08 dB and I am not touching it, because the alternative is silently changing how every saved preset sounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SYNTH/1 is MIT licensed and on &lt;a href="https://github.com/lluisestape-upc/Synth1.0" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;code&gt;analysis/&lt;/code&gt; folder included: &lt;code&gt;measure_oscillator.cpp&lt;/code&gt; and &lt;code&gt;plot_oscillator.py&lt;/code&gt; for the audio-rate story, &lt;code&gt;measure_warp.cpp&lt;/code&gt; and &lt;code&gt;measure_modulation.py&lt;/code&gt; for this one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an audio DSP student at UPC. I build &lt;a href="https://esp-plugin-store.vercel.app/" rel="noopener noreferrer"&gt;VST plugins&lt;/a&gt; and instruments you play with your hands.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>audio</category>
      <category>dsp</category>
      <category>showdev</category>
    </item>
    <item>
      <title>My first open source contribution was a bug nobody could hear</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Sat, 12 Sep 2026 14:39:32 +0000</pubDate>
      <link>https://dev.to/lluisestape/my-first-open-source-contribution-was-a-bug-nobody-could-hear-3i02</link>
      <guid>https://dev.to/lluisestape/my-first-open-source-contribution-was-a-bug-nobody-could-hear-3i02</guid>
      <description>&lt;p&gt;I am an audio DSP student, and for a long time "contribute to open source" sat on my list next to "learn to swim properly". Something I would clearly do at some point, with no first step attached to it.&lt;/p&gt;

&lt;p&gt;This is what the first step turned out to be: a bug in &lt;a href="https://github.com/surge-synthesizer/surge" rel="noopener noreferrer"&gt;Surge XT&lt;/a&gt;, a free synthesizer, where notes started up to 15 milliseconds late and no one had noticed because you cannot hear a delay you have nothing to compare it against.&lt;/p&gt;

&lt;p&gt;The pull request is in review as I write this. What follows is the part I would have wanted to read before starting, including the four times I was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking something
&lt;/h2&gt;

&lt;p&gt;I did not go looking for a project. I went looking for an issue I could hold in my head.&lt;/p&gt;

&lt;p&gt;The one I picked was titled &lt;em&gt;BLIT oscillators are a bit late&lt;/em&gt;. It had three properties I would now look for on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A symptom stated as a measurement&lt;/strong&gt;, not as a feeling. "Late" is checkable. "Sounds weird" is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small surface.&lt;/strong&gt; Three files, one function each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No product decision inside it.&lt;/strong&gt; Nobody had to agree on what the feature should be. There was a right answer and the code was not producing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues that are open for a while are not always hard. Sometimes they are open because they are boring, or because they sit in a corner of the codebase the maintainers rarely touch. That is a good place for a newcomer to be useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading until the bug fits in one sentence
&lt;/h2&gt;

&lt;p&gt;Surge has an oscillator setting called retrigger. With it on, every note starts at the same point in the waveform. With it off, notes are supposed to start at a random point, so stacked voices do not phase-lock into an artificial-sounding block.&lt;/p&gt;

&lt;p&gt;The code did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;drand&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;note_to_pitch_inv_tuningctr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detune&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;oscstate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I stared at that for a while before the problem clicked, and the click was a vocabulary problem. &lt;code&gt;oscstate&lt;/code&gt; is not phase. It is the &lt;strong&gt;remaining&lt;/strong&gt; phase space before the next impulse fires. The oscillator counts it down and emits when it reaches zero.&lt;/p&gt;

&lt;p&gt;So with the output buffers freshly cleared and the level tracking at zero, there is nothing to emit until that countdown finishes. The voice is not starting at a random phase. It is silent, and then it starts at phase zero.&lt;/p&gt;

&lt;p&gt;That is the whole bug in one sentence: &lt;strong&gt;it was a random delay, not a random start phase.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I could not have written the fix before I could write that sentence. Every hour I spent reading instead of typing paid for itself twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring before touching anything
&lt;/h2&gt;

&lt;p&gt;I wrote a test that initialised each oscillator 300 times and counted samples until the first non-zero output. Before any fix, in oversampled samples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Oscillator&lt;/th&gt;
&lt;th&gt;MIDI 24&lt;/th&gt;
&lt;th&gt;MIDI 60&lt;/th&gt;
&lt;th&gt;MIDI 96&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Classic&lt;/td&gt;
&lt;td&gt;665.6&lt;/td&gt;
&lt;td&gt;82.8&lt;/td&gt;
&lt;td&gt;10.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wavetable&lt;/td&gt;
&lt;td&gt;1263.1&lt;/td&gt;
&lt;td&gt;167.5&lt;/td&gt;
&lt;td&gt;21.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S&amp;amp;H Noise&lt;/td&gt;
&lt;td&gt;655.4&lt;/td&gt;
&lt;td&gt;81.5&lt;/td&gt;
&lt;td&gt;9.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It halves per octave, which is what a fixed fraction of a cycle should do. At MIDI 24 that is 7.5 ms off the front of the attack on average, and around 15 ms at worst. On a bass note with a fast attack, that is the difference between a note that lands and a note that arrives.&lt;/p&gt;

&lt;p&gt;Those numbers did more for the pull request than the patch did. They turned "this feels off" into something a maintainer could check in thirty seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three oscillators, three different fixes
&lt;/h2&gt;

&lt;p&gt;The temptation was to write one patch and apply it three times. That was wrong, and working out why was the interesting part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classic&lt;/strong&gt; builds its waveform as a four segment cycle, tracking the current level and a DC slope across each segment. To start mid-cycle you have to replay that bookkeeping up to the segment you are landing in, otherwise the level is wrong and the waveform is wrong from there on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wavetable&lt;/strong&gt; emits pure steps with no DC ramp. So it starts at a random index and lets the first impulse go through the existing sinc convolution. The opening step comes out band limited for free. This one ended up being the cleanest of the three because I did less to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S&amp;amp;H Noise&lt;/strong&gt; holds a random value by construction. There is no level to reconstruct. It draws one and starts partway through the segment holding it.&lt;/p&gt;

&lt;p&gt;Same bug, three shapes, because the thing being reconstructed is different in each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four times I was wrong
&lt;/h2&gt;

&lt;p&gt;This is the section I would actually read.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. I ran a subset of the tests
&lt;/h3&gt;

&lt;p&gt;I ran the &lt;code&gt;[dsp]&lt;/code&gt; tag. 856,979 assertions, all green. I felt good.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;[osc]&lt;/code&gt; tag was red. There was a serious bug in my S&amp;amp;H change: I had copied a pattern from the Classic oscillator that used a &lt;code&gt;first_run&lt;/code&gt; flag, but in S&amp;amp;H that flag was set once in the constructor and never cleared, because until then nothing read it. My new code ran on every audio block, forever.&lt;/p&gt;

&lt;p&gt;Run the whole suite. A tag is a filter you chose while holding an assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. I argued with the evidence
&lt;/h3&gt;

&lt;p&gt;When those tests failed, I reasoned that they could not be my fault: my change only affected the first block, and the failing tests measured steady-state frequency. A transient cannot shift a steady-state measurement.&lt;/p&gt;

&lt;p&gt;That reasoning was airtight and the conclusion was false. I reverted my S&amp;amp;H change alone, the tests passed, and my elegant argument was worth nothing. The flag was never cleared, so there was no transient. It was every block.&lt;/p&gt;

&lt;p&gt;When a measurement disagrees with your reasoning, the measurement is not the thing that needs explaining away.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. I tested a stale binary
&lt;/h3&gt;

&lt;p&gt;Twice. The build command failed with &lt;code&gt;cmake: command not found&lt;/code&gt;, the test runner ran anyway using the previous binary, and I spent time interpreting results from code that no longer existed.&lt;/p&gt;

&lt;p&gt;Now I read the first line of build output before I read the last line of test output.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. My test harness never reached the code
&lt;/h3&gt;

&lt;p&gt;This one is my favourite, because it bit me three times before I recognised the pattern.&lt;/p&gt;

&lt;p&gt;In Surge, an oscillator reads its parameters from a scene data block, not from the parameter objects you set. If you set the parameter and forget to push it across, the oscillator runs on zeros, and zeros are usually a valid configuration that produces plausible-looking output.&lt;/p&gt;

&lt;p&gt;So I had a measurement showing my sync fix did nothing (the parameter never arrived), and later a correlation test reporting a beautiful 0.9995 on a waveform that was degenerate because two of its four segments had collapsed to zero length.&lt;/p&gt;

&lt;p&gt;A test harness is code. It has bugs. When a result surprises you, suspect the harness before you suspect the thing you are measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review was the best part
&lt;/h2&gt;

&lt;p&gt;I opened the pull request expecting either silence or a nitpick about brace style. Instead the maintainer wrote several paragraphs and found things I had missed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The wavetable oscillator recomputes its mipmap level only at the start of a cycle. Starting mid-cycle skipped it, so the first block ran 1137 convolutions where the steady state needs 49. Not audible, but a CPU spike on every note-on.&lt;/li&gt;
&lt;li&gt;My replay loop used &lt;code&gt;&amp;gt;&lt;/code&gt; where it needed &lt;code&gt;&amp;gt;=&lt;/code&gt;, so a random draw of exactly 1.0 would fall out of the loop with the wrong state. Probability about one in sixteen million, which is roughly once per hour of dense playing.&lt;/li&gt;
&lt;li&gt;The seeding computed the oscillator period without the sync parameter, while the running code includes it. A synced voice started at the wrong rate. At an extreme setting, the first block ran at 48% of the correct rate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One of his suggestions I did not follow literally, and saying so was fine. He proposed guarding a whole block on an extra condition. Tracing it, that block also increments a sample counter, which would have fired dozens of times in the first block. I split the guard instead and explained why in the reply. He was fine with it. The suggestion was pointing at a real bug, and the specific line was the fastest way to describe where it lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I almost shipped without checking
&lt;/h2&gt;

&lt;p&gt;The maintainer asked for a test proving that retrigger-off output is the retrigger-on output shifted in time. I wrote it. It passed. I was pleased with it.&lt;/p&gt;

&lt;p&gt;Then I broke the oscillator on purpose to watch the test catch it.&lt;/p&gt;

&lt;p&gt;It did not. It passed on broken code.&lt;/p&gt;

&lt;p&gt;The reason is that the Classic oscillator resets its level absolutely at the start of each cycle, so a seeding error erases itself within one cycle, and my comparison window started after that. The test was measuring "does it eventually produce the right waveform", which is a real property, and not the one I had claimed.&lt;/p&gt;

&lt;p&gt;A test you have never seen fail is a decoration. Break the code and watch it go red before you believe it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pick an issue whose symptom is a number.&lt;/li&gt;
&lt;li&gt;Read until you can state the bug in one sentence. If you cannot, you are not ready to fix it.&lt;/li&gt;
&lt;li&gt;Measure first. The before-and-after table is what gets your pull request read.&lt;/li&gt;
&lt;li&gt;Run everything, not the subset that matches your mental model.&lt;/li&gt;
&lt;li&gt;Suspect your harness.&lt;/li&gt;
&lt;li&gt;Break your own test on purpose.&lt;/li&gt;
&lt;li&gt;Write down why you did not follow a suggestion, rather than quietly not following it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The patch itself is maybe forty lines. Everything else was working out what those forty lines needed to be, and then finding out which parts of my confidence were unearned.&lt;/p&gt;

&lt;p&gt;The pull request is &lt;a href="https://github.com/surge-synthesizer/surge/pull/8543" rel="noopener noreferrer"&gt;surge-synthesizer/surge#8543&lt;/a&gt; if you want to read the full thread. The review is more instructive than the diff.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>cpp</category>
      <category>audio</category>
      <category>dsp</category>
    </item>
    <item>
      <title>I replaced a GNN's message function with a 4-qubit quantum circuit</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Wed, 09 Sep 2026 06:37:44 +0000</pubDate>
      <link>https://dev.to/lluisestape/i-replaced-a-gnns-message-function-with-a-4-qubit-quantum-circuit-24ik</link>
      <guid>https://dev.to/lluisestape/i-replaced-a-gnns-message-function-with-a-4-qubit-quantum-circuit-24ik</guid>
      <description>&lt;p&gt;&lt;em&gt;A build log on QTGNN v2, what happens when the message function in a graph attention network is a variational quantum circuit instead of a matrix multiply.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not investment advice.&lt;/strong&gt; This is a machine learning experiment. Nothing here is a trading system, I make no performance claims, and the limitations section at the end is the most important part of the post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The idea in one sentence
&lt;/h2&gt;

&lt;p&gt;A graph neural network passes messages along edges. Normally that message function is a learned linear map. I made it a &lt;strong&gt;4-qubit variational quantum circuit&lt;/strong&gt; and pointed the whole thing at a graph whose nodes are stocks and whose edges are rolling price correlations.&lt;/p&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%2Fguko7etxs39jgk9o9j5d.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%2Fguko7etxs39jgk9o9j5d.png" alt="QTGNN v2 pipeline: raw data → FinBERT sentiment → GRU temporal encoder → quantum GAT convolution → next-day price prediction" width="800" height="282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a graph at all
&lt;/h2&gt;

&lt;p&gt;Most price prediction models treat each ticker in isolation: feed in AAPL's history, predict AAPL's next move. That throws away the thing every trader knows: assets move together. NVDA and META are not independent draws, and neither are JPM and GS.&lt;/p&gt;

&lt;p&gt;That relational structure is exactly what GNNs are for. So the 10 tickers become nodes in a fully-connected directed graph, and the edge weight &lt;code&gt;w_ij&lt;/code&gt; is the &lt;strong&gt;Pearson correlation over a rolling 30-day window&lt;/strong&gt; of normalised closing prices.&lt;/p&gt;

&lt;p&gt;The rolling window is what makes this interesting rather than decorative: the graph is &lt;strong&gt;dynamic&lt;/strong&gt;. Connectivity changes at every timestep, so the model sees the market's structure shift between regimes rather than assuming one fixed correlation matrix for two years of history.&lt;/p&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%2Fsgyyj1nj0uhziooozb8z.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%2Fsgyyj1nj0uhziooozb8z.png" alt="The stock correlation graph, 10 nodes across 5 sectors, edge weight is the rolling 30-day correlation" width="800" height="794"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The universe is 10 S&amp;amp;P 500 names across 5 sectors: AAPL, MSFT, GOOGL, NVDA and META in tech, JPM and GS in finance, JNJ in health, XOM in energy, AMZN in consumer. Roughly 500 trading days of OHLCV from &lt;code&gt;yfinance&lt;/code&gt;. Price and volume are Min-Max normalised &lt;strong&gt;separately per ticker&lt;/strong&gt;, because volume's raw magnitude would otherwise dominate price entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each node knows
&lt;/h2&gt;

&lt;p&gt;Every node carries a 31-dimensional feature vector at every timestep, assembled from four sources:&lt;/p&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%2Ffyp3uwa66zgash07iipm.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%2Ffyp3uwa66zgash07iipm.png" alt="Node feature vector: 10 days of price, 10 days of volume, 3-dim sentiment, 5-dim sector one-hot, 3 macro features" width="800" height="168"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temporal (GRU).&lt;/strong&gt; A Gated Recurrent Unit reads the last &lt;code&gt;SEQ_LEN = 10&lt;/code&gt; trading days of price and volume jointly as a 2-D sequence. Its final hidden state &lt;code&gt;h ∈ ℝ³²&lt;/code&gt; is the temporal summary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sentiment (FinBERT).&lt;/strong&gt; Recent headlines per ticker are pulled from Yahoo Finance and passed through &lt;code&gt;ProsusAI/finbert&lt;/code&gt;, a BERT fine-tuned on financial text. It emits a 3-vector of (positive, negative, neutral) probabilities, averaged across headlines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Macro.&lt;/strong&gt; Three series aligned to the same trading calendar, each with an actual economic reason to be there:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symbol&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Why it belongs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;^VIX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CBOE Volatility Index&lt;/td&gt;
&lt;td&gt;implied volatility, the market's fear gauge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;^TNX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10-year Treasury yield&lt;/td&gt;
&lt;td&gt;the risk-free rate; high yields pull capital out of equities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SPDR Gold Shares&lt;/td&gt;
&lt;td&gt;safe-haven demand, rises in risk-off regimes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Sector.&lt;/strong&gt; A 5-dimensional one-hot.&lt;/p&gt;

&lt;p&gt;Concatenated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;z_i = [ h_GRU ‖ sentiment_i ‖ sector_i ‖ macro_t ]  ∈ ℝ⁴³
        32          3            5          3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the two different numbers. The &lt;strong&gt;raw&lt;/strong&gt; node vector is 31-dimensional, because the price and volume histories enter as 10 days each. After the GRU has folded those 20 numbers into a 32-dimensional hidden state, the embedding the graph actually passes around is 43-dimensional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quantum part
&lt;/h2&gt;

&lt;p&gt;Here is the actual novelty. In a standard graph attention network, the message from node &lt;em&gt;j&lt;/em&gt; to node &lt;em&gt;i&lt;/em&gt; is a learned linear transform of &lt;em&gt;j&lt;/em&gt;'s embedding. In QTGNN it is a circuit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;m_{j→i} = QCircuit( tanh(W·z_j)·π ,  θ ,  corr_ij )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node embedding is squashed with &lt;code&gt;tanh&lt;/code&gt; and scaled by π, which maps it into a rotation angle: the thing a qubit can actually consume. The correlation &lt;code&gt;corr_ij&lt;/code&gt; is injected as its own rotation, so the edge weight is not a scalar multiplier bolted on afterwards; it participates in the state evolution.&lt;/p&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%2Fpqftycrhztgm2wb69ce5.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%2Fpqftycrhztgm2wb69ce5.png" alt="The circuit: 4 wires, data re-uploading blocks in blue, trainable HEA layers in green, CNOT entanglers, Pauli-Z measurements" width="799" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two ideas are stacked here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data re-uploading&lt;/strong&gt; (&lt;code&gt;N_REUP = 4&lt;/code&gt;). Pérez-Salinas et al. (2020) showed that repeating the data encoding &lt;em&gt;interleaved with trainable layers&lt;/em&gt; lets a quantum model represent arbitrary Fourier series of its input, making it a universal function approximator on the device. Encode once and you are stuck with a very limited function class; encode four times and the expressivity opens up. It is the quantum analogue of depth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Hardware-Efficient Ansatz&lt;/strong&gt; (&lt;code&gt;N_Q_LAYERS = 3&lt;/code&gt;). The trainable part: alternating single-qubit &lt;code&gt;RY&lt;/code&gt; and &lt;code&gt;RZ&lt;/code&gt; rotations with nearest-neighbour CNOT entanglers. "Hardware-efficient" means it is built from gates real devices actually implement natively, rather than a mathematically elegant ansatz that compiles into a thousand physical operations.&lt;/p&gt;

&lt;p&gt;Measuring &lt;code&gt;⟨Z₀⟩…⟨Z₃⟩ ∈ [−1,1]&lt;/code&gt; gives a 4-dimensional message vector. From there it is ordinary GAT machinery: a learnable linear layer produces attention coefficients &lt;code&gt;α_ij&lt;/code&gt;, softmax-normalised per destination node; aggregated messages combine with the node's own embedding through a residual connection and LayerNorm. A second QGAT layer refines, and a linear readout maps each node's 4-D quantum output to one scalar, the predicted next-day normalised price delta.&lt;/p&gt;

&lt;p&gt;The whole thing trains end-to-end. PennyLane differentiates through the circuit, so the quantum weights &lt;code&gt;θ&lt;/code&gt; sit in the same backward pass as the GRU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two training details that are not optional
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never shuffle a financial time series.&lt;/strong&gt; Shuffling before splitting leaks the future into the training set, and the model will happily report a beautiful validation number that means nothing. QTGNN uses &lt;strong&gt;walk-forward validation&lt;/strong&gt;: the training window grows sequentially and each validation window immediately follows its training period, 5 folds over 500 days, validation fixed at 30 days.&lt;/p&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%2Fvk49kjfptynp08yh9p4y.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%2Fvk49kjfptynp08yh9p4y.png" alt="Walk-forward validation: training windows grow with each fold, validation windows follow immediately" width="800" height="262"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two learning rates, and the quantum one is 10× smaller.&lt;/strong&gt; Adam runs with classical weights at &lt;code&gt;1e-2&lt;/code&gt; and quantum circuit weights at &lt;code&gt;1e-3&lt;/code&gt;. This is a hedge against &lt;strong&gt;barren plateaus&lt;/strong&gt;: the phenomenon where gradients in a variational circuit vanish exponentially with qubit count and depth. On a plateau, a learning rate tuned for the classical half will push the circuit parameters around on essentially no gradient signal, which is worse than not moving. A smaller step is the cheap mitigation. (The real ones, local cost functions, smart initialisation and shallower ansätze, are future work.)&lt;/p&gt;

&lt;p&gt;Everything else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;EPOCHS&lt;/code&gt; / &lt;code&gt;PATIENCE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;60 / 10 (early stopping)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BATCH_SIZE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8 graphs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SEQ_LEN&lt;/code&gt; / &lt;code&gt;GRU_HIDDEN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;10 days / 32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;N_WIRES&lt;/code&gt; / &lt;code&gt;N_Q_LAYERS&lt;/code&gt; / &lt;code&gt;N_REUP&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4 / 3 / 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CORR_WIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;N_FOLDS&lt;/code&gt; / &lt;code&gt;VAL_SIZE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;5 / 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Loss is MSE on the next-day normalised price delta.&lt;/p&gt;

&lt;p&gt;There is a second metric printed each epoch, and it is worth stopping on because I had it wrong. My report calls it an annualised Sharpe ratio, &lt;code&gt;S = (μ_e/σ_e)·√252&lt;/code&gt;, where &lt;code&gt;μ_e&lt;/code&gt; and &lt;code&gt;σ_e&lt;/code&gt; are the mean and standard deviation of the per-sample &lt;strong&gt;prediction errors&lt;/strong&gt;. That is not a Sharpe ratio. A Sharpe ratio is mean excess &lt;em&gt;return&lt;/em&gt; over the standard deviation of returns; mean error over standard deviation of error is a different quantity, and it points the wrong way: a model with a large, consistent bias scores wonderfully on it, because a big &lt;code&gt;μ_e&lt;/code&gt; over a small &lt;code&gt;σ_e&lt;/code&gt; is exactly what a confidently wrong model produces.&lt;/p&gt;

&lt;p&gt;So it is not a trading metric, it is closer to an inverse coefficient of variation of the residuals, and the report's thresholds ("above 1.0 is acceptable in live trading") do not transfer to it at all. Leaving it labelled Sharpe would have been the single most misleading thing in the project, which is a good argument for writing the post: I only caught it because I had to explain the formula in prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 50-qubit wall
&lt;/h2&gt;

&lt;p&gt;Everything above runs on PennyLane's &lt;code&gt;default.qubit&lt;/code&gt;: a &lt;strong&gt;simulator&lt;/strong&gt;, on my CPU. This is worth being precise about, because "quantum machine learning" invites the wrong assumption.&lt;/p&gt;

&lt;p&gt;An N-qubit state lives in ℂ^(2^N). For 4 qubits that is a 16-dimensional complex vector, and every gate is a dense matrix multiply against it. Perfectly cheap.&lt;/p&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%2Fxmkmczyjin4i1gsyu8ko.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%2Fxmkmczyjin4i1gsyu8ko.png" alt="State vector size grows as 2^N, around 50 qubits the memory required exceeds all the RAM on Earth" width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That exponential is the entire story of the field. At ~50 qubits the state vector no longer fits in any machine that exists, which is simultaneously why simulation stops and why real quantum hardware is interesting. At 4 qubits I am nowhere near that line, which means &lt;strong&gt;I get none of the theorised advantage&lt;/strong&gt;. What I have is a circuit that is a differentiable, entangling, non-linear function with an unusual inductive bias, that happens to be cheap enough to simulate exactly.&lt;/p&gt;

&lt;p&gt;That is a legitimate thing to study. It is not a speedup, and anyone telling you their 4-qubit simulated model beats a classical baseline &lt;em&gt;because of quantum&lt;/em&gt; should be asked which baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I cannot claim
&lt;/h2&gt;

&lt;p&gt;The limitations are the honest half of this project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simulated hardware.&lt;/strong&gt; &lt;code&gt;default.qubit&lt;/code&gt; on CPU. Any real advantage requires QPU execution or at minimum GPU simulation via &lt;code&gt;lightning.gpu&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 tickers is not a portfolio.&lt;/strong&gt; Real inference needs sparse graph construction and mini-batch graph sampling to reach S&amp;amp;P 500 scale. A fully-connected graph is O(N²) messages, and each message is a circuit evaluation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentiment is computed once at startup.&lt;/strong&gt; FinBERT scores are static for the whole run. A production system would re-score daily off a live feed, as it stands, the sentiment feature is closer to a constant than a signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No trading metric at all, let alone transaction costs.&lt;/strong&gt; Nothing here measures profit. There is no back-test, no position sizing, and therefore no spreads, market impact or fees, all of which eat exactly the kind of small edge a next-day model would produce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-step only.&lt;/strong&gt; Multi-horizon forecasting would need an autoregressive decoder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next steps, in the order I would do them: GPU simulation, a live news feed, 50+ tickers with sparse attention, and a real back-test with position sizing and costs, so that there is finally a number that means profit rather than error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually took away
&lt;/h2&gt;

&lt;p&gt;The interesting question in hybrid QML is not "is it faster", at this scale it obviously is not. It is &lt;strong&gt;where in the architecture you put the circuit.&lt;/strong&gt; Most hybrid models bolt a variational circuit on as a classifier head, which makes it a small non-linear layer with an expensive gradient. Putting it in the &lt;em&gt;message function&lt;/em&gt; means it runs once per edge, sees a pair of nodes and their relationship, and its output has to mean something relational. That is a much more specific role, and it makes the resulting model easier to reason about than "and then some qubits."&lt;/p&gt;

&lt;p&gt;The rest of what I learned was not quantum at all. It was that walk-forward validation is non-negotiable, that a single learning rate across a hybrid model is a silent failure mode, and that a static feature dressed up as a dynamic one will quietly do nothing while you tune everything around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt; Pérez-Salinas et al., &lt;em&gt;Data re-uploading for a universal quantum classifier&lt;/em&gt; (Quantum 4, 226, 2020) · Bergholm et al., &lt;em&gt;PennyLane&lt;/em&gt; (arXiv:1811.04968) · Veličković et al., &lt;em&gt;Graph Attention Networks&lt;/em&gt; (ICLR 2018) · Yang et al., &lt;em&gt;FinBERT&lt;/em&gt; (arXiv:2006.08097) · Cerezo et al., &lt;em&gt;Variational quantum algorithms&lt;/em&gt; (Nature Reviews Physics 3, 2021) · Orús et al., &lt;em&gt;Quantum computing for finance&lt;/em&gt; (Reviews in Physics 4, 2019).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a UPC student who mostly builds audio DSP and real-time systems. QTGNN was the project where I wanted to find out what a variational circuit actually feels like to train, and the answer was "slow, and unusually sensitive to your learning rate."&lt;/em&gt;&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>quantum</category>
      <category>datascience</category>
    </item>
    <item>
      <title>I built a drone Ground Control Station in Electron, using LiDAR for autonomous navigation</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Tue, 08 Sep 2026 07:16:43 +0000</pubDate>
      <link>https://dev.to/lluisestape/i-built-a-drone-ground-control-station-in-electron-using-lidar-for-autonomous-navigation-2phn</link>
      <guid>https://dev.to/lluisestape/i-built-a-drone-ground-control-station-in-electron-using-lidar-for-autonomous-navigation-2phn</guid>
      <description>&lt;p&gt;&lt;em&gt;A build log on the ground station for an autonomous warehouse inventory drone, ROS2 on the aircraft, Electron on the laptop, and nothing in between but a WebSocket.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The drone is a hexacopter that flies through a warehouse on its own, builds a 3D map as it goes, reads the barcodes off the shelves, and hands you a geolocated inventory. It runs &lt;strong&gt;ArduCopter on a Pixhawk&lt;/strong&gt; for flight and &lt;strong&gt;ROS2 Jazzy on a Raspberry Pi 5&lt;/strong&gt; for everything else: a Unitree 4D LiDAR feeding Point-LIO SLAM, two Pi cameras, and barcode detection.&lt;/p&gt;

&lt;p&gt;That is the aircraft. The other half of the problem is the laptop.&lt;/p&gt;

&lt;p&gt;Whoever is standing in the warehouse holding that laptop needs to see the telemetry, arm the drone, watch the map build, and read the barcode log. And critically: &lt;strong&gt;they should not need ROS installed to do it.&lt;/strong&gt; A ground station that requires a full ROS2 desktop install on every machine that wants to look at the drone is a ground station that only I can run.&lt;/p&gt;

&lt;p&gt;So the constraint I set was: the GCS is an ordinary desktop app. Download an installer, double-click, connect. No ROS on the client, ever.&lt;/p&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%2Fmguclfq5cw026rrbrlyu.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%2Fmguclfq5cw026rrbrlyu.png" alt="The GCS dashboard: arm/disarm and flight-mode commands, live camera, hexacopter thrust ring, and the nav map with the flown path in blue against the planned waypoints" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture, and the one decision that shapes it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─ Raspberry Pi 5 (ROS2 Jazzy) ──────────┐      ┌─ laptop (no ROS) ──────┐
│  MAVROS ──► /mavros/*                  │      │                        │
│  Point-LIO ──► /scan, /map, pose       │ WS   │  Electron              │
│  camera_publisher.py ──► CompressedImage├─────►│   └ renderer           │
│  barcode_detector.py ──► /barcode/*    │ 9090 │      roslib.js         │
│  brain_node.py ──► /brain/planned_path │      │      canvas 2D         │
│      └─► /mavros/setpoint_position/local      └────────────────────────┘
│  gcs_control.py ──► /gcs/cmd, /gcs/status
│  rosbridge_server ─────────────────────┘
└────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, as the team drew it, the ROS2 node graph on the aircraft side, with the LiDAR feeding SLAM, SLAM feeding both the brain and MAVROS, and MAVROS driving the Pixhawk:&lt;/p&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%2Fx3dc2epl7xgsjcqcohm4.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%2Fx3dc2epl7xgsjcqcohm4.png" alt="ROS2 node graph: LiDAR Publisher → SLAM → MAVROS → PixHawk, with the Brain Node between SLAM and MAVROS, and an Image/Position Publisher going out to an external server" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;rosbridge is the whole trick.&lt;/strong&gt; It exposes every ROS2 topic as JSON over a WebSocket on port 9090. Once that is running, a browser is a first-class ROS client, &lt;code&gt;roslibjs&lt;/code&gt; subscribes to &lt;code&gt;/mavros/battery&lt;/code&gt; exactly the way a Python node would, and the message arrives as a plain JavaScript object.&lt;/p&gt;

&lt;p&gt;Which means the renderer needs no Node APIs at all. The Electron window runs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;webPreferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preload.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;contextIsolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;nodeIntegration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;and the preload file is &lt;em&gt;empty&lt;/em&gt;. Nothing is bridged from main to renderer, because nothing needs to be. My &lt;code&gt;preload.js&lt;/code&gt; is three lines of comment explaining why it is otherwise blank.&lt;/p&gt;

&lt;p&gt;One packaging gotcha worth writing down: &lt;code&gt;roslib&lt;/code&gt; is a dependency in &lt;code&gt;package.json&lt;/code&gt;, but the renderer loads a &lt;strong&gt;bundled copy&lt;/strong&gt; from &lt;code&gt;renderer/lib/roslib.js&lt;/code&gt;, not from &lt;code&gt;node_modules&lt;/code&gt;. After &lt;code&gt;electron-builder&lt;/code&gt; packages the app, &lt;code&gt;node_modules&lt;/code&gt; is inside an asar archive that a plain &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; in the renderer cannot reach. Copying the library into the renderer folder is unglamorous and it is what makes the built installer work.&lt;/p&gt;

&lt;h3&gt;
  
  
  No framework, no bundler
&lt;/h3&gt;

&lt;p&gt;The whole UI is one HTML file with inline CSS, one 1,700-line &lt;code&gt;app.js&lt;/code&gt;, and the Canvas 2D API. No React, no build step, no transpile. &lt;code&gt;npm start&lt;/code&gt; runs the actual shipping code.&lt;/p&gt;

&lt;p&gt;I want to defend that, because it is not laziness. Almost every widget here is a &lt;em&gt;drawing&lt;/em&gt;: an artificial horizon, a gauge needle, a thrust ring, a point cloud, an occupancy grid. React's value is reconciling a DOM tree against state, but there is no DOM tree to reconcile when the answer is "repaint this canvas at 60 fps from the latest telemetry." A framework would sit between the ROS callback and &lt;code&gt;ctx.fillRect&lt;/code&gt; and do nothing but add a frame of latency and a build directory.&lt;/p&gt;

&lt;p&gt;So the pattern is deliberately dumb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;S&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="c1"&gt;// one shared state object&lt;/span&gt;

&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mavros/battery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sensor_msgs/BatteryState&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batPct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;percentage&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;     &lt;span class="c1"&gt;// callbacks only ever write to S&lt;/span&gt;
  &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batV&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;voltage&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;frame&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                 &lt;span class="c1"&gt;// rAF loop only ever reads from S&lt;/span&gt;
  &lt;span class="nf"&gt;drawHorizon&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;drawGauges&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;drawNavMap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&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;Sixteen subscriptions write into &lt;code&gt;S&lt;/code&gt;. One &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop reads it. Nothing else in the app coordinates anything. Rendering is decoupled from message arrival for free, a topic publishing at 200 Hz and one publishing at 1 Hz both just leave their latest value in &lt;code&gt;S&lt;/code&gt;, and the frame loop picks up whatever is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telemetry: 16 topics, one table
&lt;/h2&gt;

&lt;p&gt;The MAVROS side is where a GCS earns its name. The full subscription set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Feeds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/vfr_hud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mavros_msgs/VFR_HUD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;speed, climb rate, heading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/altitude&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mavros_msgs/Altitude&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;altitude bar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/battery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sensor_msgs/BatteryState&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;battery % and voltage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/imu/data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sensor_msgs/Imu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;attitude → artificial horizon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/local_position/velocity_body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;geometry_msgs/TwistStamped&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;body-frame velocity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/global_position/global&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sensor_msgs/NavSatFix&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GPS position + trail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/gpsstatus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mavros_msgs/GPSRAW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fix type, satellite count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mavros_msgs/State&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;armed state, flight mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/mavros/rc/out&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mavros_msgs/RCOut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;motor PWM, channels 1–6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/scan&lt;/code&gt;, &lt;code&gt;/map&lt;/code&gt;, &lt;code&gt;/slam_toolbox/pose&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;LiDAR + SLAM&lt;/td&gt;
&lt;td&gt;the SLAM tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/barcode/detection&lt;/code&gt;, &lt;code&gt;/detection/volume&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;std_msgs/String&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;inventory log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/camera/{forward,down}/image_raw/compressed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CompressedImage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the video panels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The IMU one is the only piece of real maths on the client. MAVROS publishes attitude as a quaternion; the horizon needs Euler angles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnks41duoutuyoct8qlkp.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%2Fnks41duoutuyoct8qlkp.png" alt="The navigation tab: artificial horizon, compass, and the full flight-data readout with roll/pitch/yaw, body velocities and GPS fix state" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the motor ring is a small lesson in reading someone else's convention. &lt;code&gt;/mavros/rc/out&lt;/code&gt; gives you PWM in microseconds, 1000–2000, per channel. Turning that into a percentage is trivial; the part that took reading the docs is that &lt;strong&gt;motor numbering is not spatial&lt;/strong&gt;. M1 through M6 on a hexacopter sit at specific angular positions with specific rotation directions, and if you lay them out in the order the array arrives, you get a diagram that looks plausible and is wrong. The positions in &lt;code&gt;drawHexDiagram()&lt;/code&gt; match the PX4/QGroundControl actuator layout, so a pilot who has used QGC reads it correctly on the first glance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to get video, because one was not enough
&lt;/h2&gt;

&lt;p&gt;Camera streaming is where I ended up with a genuinely dual path, and both paths earn their keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path one, MJPEG through a plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;.&lt;/strong&gt; A Flask server on the Pi serves &lt;code&gt;multipart/x-mixed-replace&lt;/code&gt; on port 8080 and the dashboard points an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; at it. Chromium's own decoder does all the work; the JS cost is zero. On connect the app derives the URL from the rosbridge host automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;connectMJPEG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`http://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:8080/cam1`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// falls back to a test pattern, retries every 3 s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the stream is not up, the canvas underneath draws SMPTE colour bars and "NO SIGNAL". It is a small thing that makes the app feel finished rather than broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path two, &lt;code&gt;sensor_msgs/CompressedImage&lt;/code&gt; over rosbridge.&lt;/strong&gt; rosbridge base64-encodes the JPEG bytes into the JSON message. Decoding that in the renderer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;H&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;createImageBitmap&lt;/code&gt; is the important call; it decodes off the main thread and hands back something &lt;code&gt;drawImage&lt;/code&gt; takes directly, so a 30 fps stream does not stall the frame loop.&lt;/p&gt;

&lt;p&gt;This path is slower than the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; one, and I keep it because it can do things the fast path cannot: it carries the &lt;em&gt;annotated&lt;/em&gt; feed from &lt;code&gt;barcode_detector.py&lt;/code&gt; with detection ROIs already drawn on it, and because the frames land as pixels in my canvas I can rotate and channel-swap them. Both of which I needed. The forward camera is mounted upside down (&lt;code&gt;rotation: 180&lt;/code&gt;) and one of the Pi cameras publishes with red and blue swapped, which is a one-line fix in the draw call and a much bigger one on the Pi.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scan is worthless without a position
&lt;/h2&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%2F45ew7we15y9x5ch2uvnk.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%2F45ew7we15y9x5ch2uvnk.png" alt="The image-processing tab: the annotated feed with a decoded barcode boxed in green and the SLAM pose stamped into the frame, the inventory table below it, and the database insert log beside it" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That screenshot is the whole product in one frame, and it is worth reading carefully. The green box is &lt;code&gt;barcode_detector.py&lt;/code&gt;'s ROI. &lt;code&gt;P003GUA&lt;/code&gt; is the decoded code. The overlay in the top-left is the &lt;strong&gt;SLAM pose at the moment of the scan&lt;/strong&gt; (&lt;code&gt;x:1600.77 y:-2482.32 z:0.00&lt;/code&gt;) burned into the frame by the detector, not added by the GCS. The table underneath pairs each code with that position, and the log on the right shows the row landing in the database.&lt;/p&gt;

&lt;p&gt;That is what "geolocated inventory" actually means in practice: a barcode is worthless unless you know &lt;em&gt;where&lt;/em&gt; it was, and the only thing that knows where the drone was is SLAM. Tagging the detection at the moment of detection (on the Pi, in the same process that read the code) is what keeps the two from drifting apart. Export to CSV/Excel is one button.&lt;/p&gt;

&lt;h2&gt;
  
  
  800,000 points per scan, drawn with fillRect
&lt;/h2&gt;

&lt;p&gt;The SLAM tab is the one that surprises people. Point-LIO publishes roughly 800,000 points per scan from the Unitree LiDAR, and the viewer renders them coloured by height.&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%2Ff7lramynv4ypdxfhtqdw.gif" 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%2Ff7lramynv4ypdxfhtqdw.gif" alt="The SLAM tab: Point-LIO cloud coloured by height, with pose, frame names and start/pause/save-map controls" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things make this survivable on a laptop:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Draw only when visible.&lt;/strong&gt; &lt;code&gt;drawSLAM()&lt;/code&gt; is called from the frame loop only when &lt;code&gt;S.activeView === 'slam'&lt;/code&gt;. On the dashboard tab, the SLAM subscriptions still run and still update &lt;code&gt;S&lt;/code&gt;, but nothing paints. That one guard is the difference between a smooth app and a hot one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The occupancy grid is cells, not pixels.&lt;/strong&gt; &lt;code&gt;nav_msgs/OccupancyGrid&lt;/code&gt; arrives as a flat array with a resolution in metres per cell, so each cell is one &lt;code&gt;fillRect&lt;/code&gt; at the grid's own scale, not a per-pixel &lt;code&gt;ImageData&lt;/code&gt; write. The LiDAR overlay draws on top of it in the SLAM pose frame at 60 px/m.&lt;/p&gt;

&lt;p&gt;Colouring by height is what turns the cloud into a room. Shelving units come out as vertical bands, the floor is a plane, and you can see the aisle. It is the same data either way; the ramp is what makes it legible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The brain node: what actually flies the mission
&lt;/h2&gt;

&lt;p&gt;Go back to the dashboard screenshot for a second. There is an orange dashed line running through it, labelled &lt;code&gt;origin&lt;/code&gt; and &lt;code&gt;wp-1&lt;/code&gt;, and a badge in the corner reading &lt;strong&gt;&lt;code&gt;WP 1/5: ORIGIN&lt;/code&gt;&lt;/strong&gt;. Neither of those is telemetry. They come from a node I have not introduced yet, and it is the one that makes the word "autonomous" mean anything.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;brain_node.py&lt;/code&gt; is the mission planner. It is about 175 lines and it does four things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Waypoints live in SQLite, not in the code.&lt;/strong&gt; Two tables (&lt;code&gt;missions&lt;/code&gt; and &lt;code&gt;waypoints&lt;/code&gt;) in &lt;code&gt;~/brain_data.db&lt;/code&gt;, with an &lt;code&gt;active&lt;/code&gt; flag picking which mission runs. A brand-new database seeds itself with a 5 m square at 5 m altitude so the node has something to do on first launch, and you edit the DB to match the real environment.&lt;/p&gt;

&lt;p&gt;Putting the route in a database rather than a constant is the difference between "re-flash the Pi to change the route" and "run one &lt;code&gt;UPDATE&lt;/code&gt;". For a warehouse, where the route &lt;em&gt;is&lt;/em&gt; the shelf layout, that matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Position comes from SLAM, not GPS.&lt;/strong&gt; This is the design decision the whole node hangs on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Odometry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/Odometry&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_on_odom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;/Odometry&lt;/code&gt; is Point-LIO's output. Look at any screenshot in this post and the top-right corner says &lt;strong&gt;NO GPS&lt;/strong&gt;: because there is no GPS &lt;em&gt;indoors&lt;/em&gt;. A warehouse is exactly the environment where the usual position source does not exist, so the LiDAR SLAM odometry &lt;strong&gt;is&lt;/strong&gt; the position source. The node does subscribe to &lt;code&gt;/mavros/global_position/global&lt;/code&gt;, but only to keep the fix around; the control loop never reads it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A 10 Hz loop that is deliberately boring.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_tick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;wp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waypoints&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wp_idx&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;dist2d&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slam_x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slam_y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;'&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;ARRIVE_DIST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wp_idx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;                       &lt;span class="c1"&gt;# arrived, advance
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PoseStamped&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                          &lt;span class="c1"&gt;# otherwise, keep asking
&lt;/span&gt;    &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&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="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;z&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pub_sp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;# → /mavros/setpoint_position/local
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire autonomy loop: &lt;em&gt;am I close enough to the current waypoint? If yes, target the next one. If no, publish this one as a setpoint, again.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The restraint is the point. The brain does not compute trajectories, ramp velocities or tune anything; it publishes a &lt;strong&gt;position setpoint&lt;/strong&gt; and lets ArduCopter's own controller work out how to get there. Trying to out-fly the flight controller from a Python node at 10 Hz over ROS is how you get a drone that fights itself. The division of labour is: ArduCopter flies, the brain decides where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. It publishes its own plan for the GCS to draw.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pub_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mission&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mission_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wp_index&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wp_idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;waypoints&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waypoints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;slam_pos&lt;/span&gt;&lt;span class="sh"&gt;'&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;One JSON string on &lt;code&gt;/brain/planned_path&lt;/code&gt; at 1 Hz. The renderer subscribes to it, draws the waypoints as the orange dashed route, and turns &lt;code&gt;wp_index&lt;/code&gt; into that &lt;code&gt;WP 1/5&lt;/code&gt; badge, green and reading "Mission complete" when &lt;code&gt;done&lt;/code&gt; flips.&lt;/p&gt;

&lt;p&gt;That last part is why the GCS map has &lt;em&gt;two&lt;/em&gt; lines. The blue one is where the drone actually went, from SLAM. The orange dashed one is where the brain intends to go. Drawing both, from two independent sources, means the gap between them is visible in real time, and that gap is the only honest measure of whether the autonomy is working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One honest gap.&lt;/strong&gt; &lt;code&gt;ARRIVE_DIST&lt;/code&gt; is &lt;strong&gt;1.5 m&lt;/strong&gt;: the conservative value that flew, and considerably looser than the waypoint radius the simulations below suggest is achievable. Tightening it, and pushing a denser waypoint list into the missions table to match, is the obvious next step and it has not been flown yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before any of it flew: Monte Carlo
&lt;/h2&gt;

&lt;p&gt;You cannot iterate on a warehouse mission by flying it. Battery is ten minutes, the room has to be free, and a bad waypoint list is a drone in a shelf.&lt;/p&gt;

&lt;p&gt;So the route was validated in simulation first: a physics-based flight simulator plus a Monte Carlo runner that flies the same mission over and over with noise injected into it. Two separate noise sources, because they are physically different things, LiDAR noise corrupts what the drone &lt;em&gt;perceives&lt;/em&gt;, motor noise corrupts what it &lt;em&gt;achieves&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The simulated stack mirrors the real one node for node, with the aircraft replaced by an integrator:&lt;/p&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%2Fiogivg7sil22mn8ej7qg.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%2Fiogivg7sil22mn8ej7qg.png" alt="Simulation architecture: LiDAR Node publishes /drone/pose, Brain Node reads mission.json and publishes the next destination, MAVROS Node computes a proportional velocity on /drone/cmd_vel, and the Pixhawk/Drone node applies it and adds noise, closing the loop back to /drone/pose" width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things worth flagging about that diagram, because they are differences rather than details. It is &lt;strong&gt;ROS 2 Humble&lt;/strong&gt;, while the aircraft runs Jazzy, the simulation predates the flight stack. And in simulation the controller is ours: a proportional law &lt;code&gt;v = K_p × (target − position)&lt;/code&gt; publishing velocities on &lt;code&gt;/drone/cmd_vel&lt;/code&gt;. On the real drone, that job belongs to ArduCopter and the brain only publishes a position setpoint. The simulation had to model a controller precisely because it did not have one.&lt;/p&gt;

&lt;p&gt;It also explains a bit of archaeology in the GCS: the first version of the renderer subscribed to &lt;code&gt;/drone/pose&lt;/code&gt; as a &lt;code&gt;Float32MultiArray&lt;/code&gt; and to &lt;code&gt;/drone/cmd_vel&lt;/code&gt;: the simulation's topics, not MAVROS's. The ground station was built against the simulated drone weeks before there was a real one to point it at.&lt;/p&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%2F8t8nbueiunhpg093ejci.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%2F8t8nbueiunhpg093ejci.png" alt="Monte Carlo runs over the warehouse aisle with three shelving units, in 3D" width="800" height="640"&gt;&lt;/a&gt;&lt;/p&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%2F4uzp4fgotjec0i9vtmpo.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%2F4uzp4fgotjec0i9vtmpo.png" alt="30 runs of the single-shelf route, showing the trajectory bundle against the ideal path and its milestones" width="800" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ideal route is the dashed line, the milestones are the red dots, and the blue bundle is every simulated run. Where the bundle stays tight, the route survives realistic sensor noise; where it fans out, it does not. That is a question worth answering with &lt;code&gt;numpy&lt;/code&gt; in an afternoon rather than with a drone and a shelf.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature I did not plan: finding the drone
&lt;/h2&gt;

&lt;p&gt;Here is the thing nobody warns you about. In a lab, the Pi is at &lt;code&gt;192.168.1.42&lt;/code&gt; and you type it once. In a warehouse (on a hotspot, on a different subnet, on someone else's phone tethering) the Pi is at some address you do not know, and the person holding the laptop is not going to SSH in and run &lt;code&gt;ip addr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the connection popover has a &lt;strong&gt;Scan&lt;/strong&gt; button that finds it. In a browser sandbox, with no Node APIs.&lt;/p&gt;

&lt;p&gt;First, discover the local subnet. There is no API for "what is my IP" in a renderer, but WebRTC leaks it in ICE candidates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLocalIP&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;iceServers&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="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDataChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOffer&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="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onicecandidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;candidate&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{1,3}(?:\.[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{1,3}){3})&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&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="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;(This is the same behaviour browsers spent years trying to suppress for fingerprinting reasons. Inside a desktop app talking to hardware on the same LAN, it is exactly the right tool.)&lt;/p&gt;

&lt;p&gt;Then probe the /24 for anything with port 9090 open, in batches, with a short timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;   &lt;span class="c1"&gt;// parallel probes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOUT&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;  &lt;span class="c1"&gt;// ms per probe&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;start&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="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;254&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&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="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;BATCH&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="mi"&gt;254&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;probeWS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ws://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;subnet&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TOUT&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="nx"&gt;ok&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;onFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;subnet&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tasks&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;A failed WebSocket connection is a fast, cheap probe, you never complete a handshake, you just see whether the socket errors before the timeout. 254 addresses in seven batches of 40 at 400 ms each is about three seconds, and the found hosts show up as clickable rows. The URL then lives in &lt;code&gt;localStorage&lt;/code&gt;, so it is remembered next launch.&lt;/p&gt;

&lt;p&gt;Three seconds of clever hack, and the app stopped needing me standing next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlling the Pi from the dashboard
&lt;/h2&gt;

&lt;p&gt;The last piece closes the loop. Early on, every demo started with me SSHing into the Pi to launch SLAM, then the camera, then MAVROS, in the right order, in separate terminals. That is fine for a developer and impossible for a demo.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;gcs_control.py&lt;/code&gt; runs permanently under systemd next to rosbridge, and it is a process supervisor driven by a ROS topic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Subscribes:  /gcs/cmd     {"action":"start"|"stop","service":"slam"|"camera"|"mavros"|"brain"}
# Publishes:   /gcs/status  {"slam":"running","camera":"stopped",...}  @ 1 Hz
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service is a command line it knows how to spawn, with stdout going to &lt;code&gt;~/gcs_logs/&amp;lt;service&amp;gt;.log&lt;/code&gt;. The dashboard gets a SERVICES panel with a green dot and a STOP button per service, and the operator never opens a terminal.&lt;/p&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%2F057w3w2984x1sudcrnh0.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%2F057w3w2984x1sudcrnh0.png" alt="rosbridge under systemd on the Pi, with gcs_control starting SLAM, both cameras, MJPEG, MAVROS, the mission brain and the barcode detector" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One deployment note that cost me an evening: &lt;strong&gt;&lt;code&gt;ros-jazzy-rosbridge-suite&lt;/code&gt; is not available via apt on the Pi.&lt;/strong&gt; I built rosbridge from source at Release 2.3.0 into &lt;code&gt;~/rosbridge_ws&lt;/code&gt;. If you are on Jazzy and &lt;code&gt;apt install&lt;/code&gt; comes up empty, that is the reason and building it is the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you if you are about to build one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;rosbridge turns "needs ROS" into "needs a browser."&lt;/strong&gt; For a client that only reads telemetry and calls a few services, this is a much better deal than a native ROS client, and it is the single decision that made the rest of the app simple.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A framework is not free, and canvas widgets do not need one.&lt;/strong&gt; If most of your UI is drawn rather than laid out, the reconciliation you are paying for has nothing to reconcile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard the expensive draw on visibility.&lt;/strong&gt; The 800k-point cloud is only unaffordable when you paint it on a tab nobody is looking at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery is a feature, not a nicety.&lt;/strong&gt; The subnet scanner took an afternoon and removed the last reason anyone needed me in the room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume the built app is a different app.&lt;/strong&gt; &lt;code&gt;nodeIntegration: false&lt;/code&gt; plus an asar archive breaks assumptions that hold perfectly in &lt;code&gt;npm start&lt;/code&gt;. Package early, package often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulate the mission before you fly it.&lt;/strong&gt; A Monte Carlo runner and two separate noise sources cost an afternoon; the alternative is finding out with a drone and a shelf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the flight controller fly.&lt;/strong&gt; The autonomy node publishes a position setpoint at 10 Hz and gets out of the way. ArduCopter has spent a decade learning to hold a position; a Python node over ROS will not beat it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draw the plan and the reality as two separate lines.&lt;/strong&gt; They come from independent sources (the brain's waypoints and SLAM odometry), so the gap between them is the one number that tells you whether any of this is working.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is WP2 of a 14-student project for the industry client EDISA: the ground station, the mission planner and the drone-control stack. The airframe, the SLAM tuning and the barcode/volumetry pipeline are other people's work packages, and this post only covers mine.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an audio DSP and software engineering student at UPC. Most of what I build is &lt;a href="https://esp-plugin-store.vercel.app/" rel="noopener noreferrer"&gt;audio plugins&lt;/a&gt;: this was a very enjoyable detour. More at &lt;a href="https://github.com/lluisestape-upc" rel="noopener noreferrer"&gt;github.com/lluisestape-upc&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/JXmmZV7UnU8" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>robotics</category>
      <category>electron</category>
    </item>
    <item>
      <title>Designing a 5-band parametric EQ from the biquad up, in MATLAB</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Mon, 07 Sep 2026 05:27:38 +0000</pubDate>
      <link>https://dev.to/lluisestape/designing-a-5-band-parametric-eq-from-the-biquad-up-in-matlab-367n</link>
      <guid>https://dev.to/lluisestape/designing-a-5-band-parametric-eq-from-the-biquad-up-in-matlab-367n</guid>
      <description>&lt;p&gt;&lt;em&gt;A build log on a 5-band parametric equalizer written entirely in MATLAB: the filter design in detail, the pole-zero reading that tells you what a filter does before you plot it, and an honest audit of where my implementation diverges from its own UI.&lt;/em&gt;&lt;/p&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%2F8j8tj0jnb6sic5uaz1mv.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%2F8j8tj0jnb6sic5uaz1mv.png" alt="The EQ mid-playback: a -15 dB notch at 99 Hz, a +13.8 dB bell at 517 Hz, a Q = 10 cut at 1 kHz and a low-pass at 4.7 kHz, over the live spectrum fill" width="799" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five cascaded biquads, RBJ cookbook coefficients, a live response curve, a rolling spectrogram and a pole-zero view. One &lt;code&gt;classdef&lt;/code&gt;, no App Designer, no toolboxes beyond base MATLAB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design: twenty lines that contain a bilinear transform
&lt;/h2&gt;

&lt;p&gt;Here is the entire filter design, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="k"&gt;function&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="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;biquadCoeffs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;wc&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="nb"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Freqs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;wc&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wc&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nb"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wc&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="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Qs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;cosw&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tipos&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s1"&gt;'Peaking'&lt;/span&gt;
            &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Gains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;40&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="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;A&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;A&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nb"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s1"&gt;'High-Pass'&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="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;)/&lt;/span&gt;&lt;span class="mi"&gt;2&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="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;)/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nb"&gt;alpha&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nb"&gt;alpha&lt;/span&gt;  &lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s1"&gt;'Low-Pass'&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="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;)/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;)/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nb"&gt;alpha&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cosw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nb"&gt;alpha&lt;/span&gt;   &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost everything interesting about this project is compressed into those coefficients, so it is worth unpacking properly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the bilinear transform went
&lt;/h3&gt;

&lt;p&gt;These are digital filters, but nobody designs a biquad directly in the z-domain. You start from an analog prototype, in the peaking case&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        s² + (A·ω0/Q)·s + ω0²
H(s) = ------------------------
        s² + (ω0/(A·Q))·s + ω0²
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and map the s-plane onto the z-plane with the bilinear transform, &lt;code&gt;s = (2/T)·(1−z⁻¹)/(1+z⁻¹)&lt;/code&gt;. That map is not linear in frequency. It squeezes the entire infinite analog axis into the finite interval &lt;code&gt;[0, π]&lt;/code&gt;, according to&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ω_digital = 2·arctan(ω_analog·T / 2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which is why you prewarp: you deliberately design the analog prototype at the distorted frequency that maps back onto the digital frequency you actually wanted. Prewarping at &lt;code&gt;ω0&lt;/code&gt; is what makes the center frequency come out exact.&lt;/p&gt;

&lt;p&gt;And that is the reason &lt;code&gt;cos(wc)&lt;/code&gt; and &lt;code&gt;sin(wc)&lt;/code&gt; appear in the code at all. If you carry the algebra through with prewarping at &lt;code&gt;ω0&lt;/code&gt;, every &lt;code&gt;tan(ω0/2)&lt;/code&gt; collapses into trigonometric functions of &lt;code&gt;ω0&lt;/code&gt; itself, and the coefficients come out in the closed form above. The trig is not a shortcut or an approximation. It is the bilinear transform, already solved.&lt;/p&gt;

&lt;p&gt;Which also tells you what the design does &lt;em&gt;not&lt;/em&gt; promise. Prewarping is exact at one frequency and only one. Everything else is warped, and the warping compresses hardest near Nyquist. That is measurable, and I measured it further down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alpha, and what Q means here
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;α = sin(ω0) / (2Q)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;α&lt;/code&gt; is the only place the bandwidth enters. For the low-pass and high-pass this is the classical resonance Q: &lt;code&gt;Q = 1/√2 ≈ 0.707&lt;/code&gt; gives the maximally flat Butterworth response, higher Q gives a resonant peak at the corner.&lt;/p&gt;

&lt;p&gt;One caveat worth stating precisely, because it is a common misreading: for the &lt;strong&gt;peaking&lt;/strong&gt; filter, RBJ defines the bandwidth between the &lt;em&gt;half-gain&lt;/em&gt; points (half the boost in dB), not the conventional −3 dB points. A 12 dB bell at Q = 2 has its stated bandwidth measured at 6 dB. So Q numbers are not directly comparable between a bell and a shelf, and they are not comparable between EQs unless both tell you which convention they use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why 10^(G/40) and not 10^(G/20)
&lt;/h3&gt;

&lt;p&gt;The obvious way to turn decibels into a linear gain is &lt;code&gt;10^(G/20)&lt;/code&gt;. The code uses &lt;strong&gt;forty&lt;/strong&gt;, and the reason is a small piece of algebra that is worth doing once.&lt;/p&gt;

&lt;p&gt;Evaluate the peaking filter on the unit circle at the center frequency, &lt;code&gt;z = e^{jω0}&lt;/code&gt;, and factor &lt;code&gt;e^{−jω0}&lt;/code&gt; out of the numerator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N(ω0) = e^{−jω0} · [ (1+αA)·e^{jω0} − 2cos ω0 + (1−αA)·e^{−jω0} ]
      = e^{−jω0} · [ 2cos ω0 − 2cos ω0 + j·2αA·sin ω0 ]
      = e^{−jω0} · j·2αA·sin ω0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cosine terms annihilate and only the imaginary part survives. The denominator is the same computation with &lt;code&gt;α/A&lt;/code&gt; in place of &lt;code&gt;αA&lt;/code&gt;, so almost everything cancels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;H(ω0) = (2αA·sin ω0) / (2(α/A)·sin ω0) = A²
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter delivers &lt;code&gt;A²&lt;/code&gt;, not &lt;code&gt;A&lt;/code&gt;, because &lt;code&gt;A&lt;/code&gt; multiplies the numerator &lt;em&gt;and&lt;/em&gt; divides the denominator. So to get &lt;code&gt;G&lt;/code&gt; decibels of boost you need &lt;code&gt;A² = 10^(G/20)&lt;/code&gt;, hence &lt;code&gt;A = 10^(G/40)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Verified numerically on the shipping coefficients, which is the only reason I trust the derivation rather than my memory of it:&lt;/p&gt;

&lt;p&gt;| Slider | &lt;code&gt;20·log10|H(ω0)|&lt;/code&gt; |&lt;br&gt;
|---|---|&lt;br&gt;
| +6 dB | +6.0000 |&lt;br&gt;
| −9 dB | −9.0000 |&lt;br&gt;
| +12 dB | +12.0000 |&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading the poles and zeros
&lt;/h2&gt;

&lt;p&gt;This is the part I would want a student to take away, because it turns filter design from coefficient soup into something you can predict on paper.&lt;/p&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%2F9cb8052ek1681h9bam9q.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%2F9cb8052ek1681h9bam9q.png" alt="Pole-zero diagram of the full cascade, all five biquads convolved into one transfer function" width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The zeros are the filter's identity
&lt;/h3&gt;

&lt;p&gt;Look at the numerators, not the denominators.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High-pass&lt;/strong&gt;: &lt;code&gt;b = (1+cos ω0)/2 · [1, −2, 1]&lt;/code&gt;. The polynomial &lt;code&gt;1 − 2z⁻¹ + z⁻²&lt;/code&gt; factors as &lt;code&gt;(1 − z⁻¹)²&lt;/code&gt;, a &lt;strong&gt;double zero at z = +1&lt;/strong&gt;. On the unit circle &lt;code&gt;z = 1&lt;/code&gt; is DC, so the response is forced to exactly zero at 0 Hz. Not attenuated. Zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-pass&lt;/strong&gt;: &lt;code&gt;b = (1−cos ω0)/2 · [1, 2, 1]&lt;/code&gt;, which is &lt;code&gt;(1 + z⁻¹)²&lt;/code&gt;, a &lt;strong&gt;double zero at z = −1&lt;/strong&gt;, and &lt;code&gt;z = −1&lt;/code&gt; is Nyquist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are second-order nulls, which is where the 12 dB/octave rolloff comes from: each zero contributes 6 dB/octave.&lt;/p&gt;

&lt;p&gt;The denominators of both are identical. A low-pass and a high-pass at the same frequency and Q have exactly the same poles and differ only in where they put their zeros. That is the whole distinction.&lt;/p&gt;
&lt;h3&gt;
  
  
  The pole radius has a closed form
&lt;/h3&gt;

&lt;p&gt;For the LP/HP denominator &lt;code&gt;a = [1+α, −2cos ω0, 1−α]&lt;/code&gt;, normalise by &lt;code&gt;a(1)&lt;/code&gt; and use the fact that for a monic quadratic the product of the roots is the constant term. The poles are a complex conjugate pair, so their product is &lt;code&gt;|p|²&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;|p|² = (1 − α)/(1 + α)        ⟹        r = sqrt( (1−α)/(1+α) )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two consequences fall straight out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The filter is unconditionally stable.&lt;/strong&gt; &lt;code&gt;α &amp;gt; 0&lt;/code&gt; for any positive Q, so &lt;code&gt;(1−α)/(1+α) &amp;lt; 1&lt;/code&gt;, so &lt;code&gt;r &amp;lt; 1&lt;/code&gt; always. There is no Q, no frequency and no sample rate at which this design puts a pole outside the unit circle. That is a property of the form, not of your parameter clamping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q is pole radius.&lt;/strong&gt; As &lt;code&gt;Q → ∞&lt;/code&gt;, &lt;code&gt;α → 0&lt;/code&gt; and &lt;code&gt;r → 1&lt;/code&gt;: the pole walks toward the unit circle and the response sharpens into a resonance. Checked against &lt;code&gt;roots()&lt;/code&gt; on the actual coefficients at 1 kHz:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Q&lt;/th&gt;
&lt;th&gt;α&lt;/th&gt;
&lt;th&gt;&lt;code&gt;sqrt((1−α)/(1+α))&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;abs(roots(a))&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;0.14199&lt;/td&gt;
&lt;td&gt;0.866788&lt;/td&gt;
&lt;td&gt;0.866788&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.7071&lt;/td&gt;
&lt;td&gt;0.10041&lt;/td&gt;
&lt;td&gt;0.904163&lt;/td&gt;
&lt;td&gt;0.904163&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;0.03550&lt;/td&gt;
&lt;td&gt;0.965110&lt;/td&gt;
&lt;td&gt;0.965110&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10.0&lt;/td&gt;
&lt;td&gt;0.00710&lt;/td&gt;
&lt;td&gt;0.992925&lt;/td&gt;
&lt;td&gt;0.992925&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six decimal places. The formula is not an approximation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Boost sharpens the poles, cut sharpens the zeros
&lt;/h3&gt;

&lt;p&gt;The peaking filter is where this gets elegant. Its numerator carries &lt;code&gt;αA&lt;/code&gt; and its denominator &lt;code&gt;α/A&lt;/code&gt;, so by the same argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;r_pole = sqrt( (1 − α/A)/(1 + α/A) )        r_zero = sqrt( (1 − αA)/(1 + αA) )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 1 kHz, Q = 2:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gain&lt;/th&gt;
&lt;th&gt;pole radius&lt;/th&gt;
&lt;th&gt;zero radius&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;+12 dB&lt;/td&gt;
&lt;td&gt;0.982364&lt;/td&gt;
&lt;td&gt;0.931511&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;−12 dB&lt;/td&gt;
&lt;td&gt;0.931511&lt;/td&gt;
&lt;td&gt;0.982364&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They swap. Exactly. A boost pulls the poles toward the unit circle to build the peak; a cut pulls the &lt;em&gt;zeros&lt;/em&gt; toward the circle to dig the notch, and leaves the poles further in.&lt;/p&gt;

&lt;p&gt;And that symmetry is not a coincidence of these numbers. Substituting &lt;code&gt;A → 1/A&lt;/code&gt; maps the numerator coefficients onto the denominator coefficients and vice versa, so&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;H_{+G}(z) · H_{−G}(z) = 1        exactly, at every frequency
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RBJ peaking filter is its own inverse. I checked it on 512 log-spaced points: the largest deviation of the product from unity was &lt;strong&gt;4.6 × 10⁻¹⁶&lt;/strong&gt;, which is floating point noise and nothing else. Boost 9 dB at 1 kHz with Q = 2, then cut 9 dB at 1 kHz with Q = 2, and you have mathematically reconstructed the input signal. Not approximately undone it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cascade
&lt;/h2&gt;

&lt;p&gt;Five biquads in series. Two consequences, one for the audio and one for the picture.&lt;/p&gt;

&lt;p&gt;For the audio, &lt;code&gt;applyEQ&lt;/code&gt; runs them sequentially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&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;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NumBands&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="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;biquadCoeffs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;filter&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="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The explicit &lt;code&gt;a(1)&lt;/code&gt; normalisation is redundant, since &lt;code&gt;filter&lt;/code&gt; normalises internally, but it makes the difference equation the code is implementing unambiguous. And it is worth knowing that MATLAB's &lt;code&gt;filter&lt;/code&gt; is a &lt;em&gt;transposed&lt;/em&gt; direct form II, which has better floating-point behaviour than the plain DF-II you would write by hand.)&lt;/p&gt;

&lt;p&gt;For the picture, cascading LTI systems multiplies transfer functions, so &lt;code&gt;updateEQ&lt;/code&gt; evaluates the product directly on the unit circle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;logspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fs&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;j&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="nb"&gt;pi&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fs&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="nb"&gt;ones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;i&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;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NumBands&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="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;biquadCoeffs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&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="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="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="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.*&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;.^-&lt;/span&gt;&lt;span class="mi"&gt;1&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.*&lt;/span&gt;&lt;span class="n"&gt;z&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="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="k"&gt;...&lt;/span&gt;
             &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&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="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.*&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;.^-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.*&lt;/span&gt;&lt;span class="n"&gt;z&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="k"&gt;end&lt;/span&gt;
&lt;span class="n"&gt;magdB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nb"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;eps&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;z = exp(1j·2πf/Fs)&lt;/code&gt; is sampling the DTFT: walking the unit circle and reading &lt;code&gt;H(z)&lt;/code&gt; at each stop. Points are &lt;strong&gt;log-spaced&lt;/strong&gt;, because the display is logarithmic in frequency and linear spacing would put hundreds of points in the top octave where nothing changes and a handful below 200 Hz where everything does.&lt;/p&gt;

&lt;p&gt;The important structural property is that both loops call the same &lt;code&gt;biquadCoeffs&lt;/code&gt;. The curve cannot drift away from the audio, because there is exactly one place the coefficients exist. The tempting alternative, drawing an idealised bell shape and separately writing the DSP, is how EQ displays end up lying to their users.&lt;/p&gt;

&lt;p&gt;The POLES button does the third version of the same idea. Cascading in the coefficient domain is polynomial multiplication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="n"&gt;b_tot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b_tot&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="n"&gt;a_tot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;conv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a_tot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five biquads convolve into a single 10th-order transfer function with 11 coefficients each side, and &lt;code&gt;zplane(b_tot, a_tot)&lt;/code&gt; draws the composite. So the plot above is not five overlaid biquads, it is the one filter the audio actually passes through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places the implementation does not match its own UI
&lt;/h2&gt;

&lt;p&gt;Deriving the maths carefully is also how you find out where your code stops obeying it. All three of these came out of writing this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The frequency slider lies below 35 Hz.&lt;/strong&gt; That clamp on line 3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="n"&gt;wc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;pi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wc&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is there to keep &lt;code&gt;cos(ω0)&lt;/code&gt; away from the degenerate values at DC and Nyquist. Reasonable. But solve it for frequency at 44.1 kHz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;f_min = 0.005 · 44100 / 2π = 35.09 Hz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slider goes down to 20 Hz, and 20 Hz maps to &lt;code&gt;ω = 0.00285&lt;/code&gt;, well under the clamp. So &lt;strong&gt;every band set between 20 and 35 Hz produces an identical filter at 35.09 Hz&lt;/strong&gt;, while the label happily reads 20 Hz. The upper clamp is harmless by comparison: &lt;code&gt;π − 0.01&lt;/code&gt; corresponds to 21,980 Hz, above the 20 kHz the UI allows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The warping is visible at the top of the band, as predicted.&lt;/strong&gt; A 12 dB bell at Q = 2, measured between its half-gain points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;f0&lt;/th&gt;
&lt;th&gt;half-gain band&lt;/th&gt;
&lt;th&gt;below f0&lt;/th&gt;
&lt;th&gt;above f0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 kHz&lt;/td&gt;
&lt;td&gt;781 Hz to 1279 Hz&lt;/td&gt;
&lt;td&gt;219 Hz&lt;/td&gt;
&lt;td&gt;279 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 kHz&lt;/td&gt;
&lt;td&gt;14579 Hz to 17212 Hz&lt;/td&gt;
&lt;td&gt;1421 Hz&lt;/td&gt;
&lt;td&gt;1212 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 1 kHz the bell is almost perfectly log-symmetric: &lt;code&gt;f0²/f_lo = 1280 Hz&lt;/code&gt; against a measured 1279 Hz. At 16 kHz log symmetry would put the upper edge at 17,561 Hz and it actually lands at 17,212 Hz. The upper skirt has been pulled in by 350 Hz, squashed against Nyquist. This is the bilinear transform doing exactly what the theory says it does, and it is not a bug; it is the cost of the design, and now it has a number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The spectrum behind the curve shares an axis but not a scale.&lt;/strong&gt; The overlay is computed like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="n"&gt;dB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dB&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;% normalise to own peak&lt;/span&gt;
&lt;span class="n"&gt;dB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dB&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;% compress and floor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;0.35&lt;/code&gt; is a display factor with no acoustic meaning. So the FFT fill sitting behind the response curve is on the same dB axis as the curve but is neither calibrated nor absolute; you cannot read a level off it. It is there to show spectral shape. You can watch the floor doing its work in the first screenshot: the blue fill flattens along the bottom of the plot at exactly −25 dB, and that flat line is the clamp, not the signal. The spectrogram gets the honest version, normalised to peak with a −60 dB floor and no compression.&lt;/p&gt;

&lt;p&gt;There is a fourth issue in the same function that is subtler. The FFT is 8192 points, giving a bin spacing of &lt;code&gt;Fs/N = 5.38 Hz&lt;/code&gt;, and it is resampled onto a 300-point log-spaced display grid with &lt;code&gt;interp1(..., 'linear')&lt;/code&gt; in &lt;strong&gt;linear magnitude&lt;/strong&gt;. That resampling is doing two opposite and equally wrong things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Between 20 and 100 Hz there are about &lt;strong&gt;69 display points drawn from only 14 FFT bins&lt;/strong&gt;, so the bottom of the display is interpolation, not measurement.&lt;/li&gt;
&lt;li&gt;Between 10 and 20 kHz there are about &lt;strong&gt;30 display points point-sampling 1857 bins&lt;/strong&gt;, so 98% of the bins are simply never read. A narrow peak that lands between two sampled bins does not appear at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is standard and I have not done it: bin the FFT into the display bands and take the max or the RMS per band, instead of point-sampling. Point-sampling a spectrum is how analysers under-report high-frequency content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rendering strategy, which came out of a performance bug
&lt;/h2&gt;

&lt;p&gt;The first version redrew the response the obvious way: &lt;code&gt;cla&lt;/code&gt;, then re-plot. Every parameter change destroyed every graphics object and built new ones, and dragging a band was visibly laggy.&lt;/p&gt;

&lt;p&gt;Every graphics object is now created &lt;strong&gt;once&lt;/strong&gt; in &lt;code&gt;initPlot&lt;/code&gt;, and afterwards only ever updated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HEQCurve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'YData'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;magdB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;% not plot(...) again&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HSpecFill&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YData'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;specdB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HBandDot&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s1"&gt;'XData'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YData'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating a MATLAB graphics object is expensive; updating one's &lt;code&gt;YData&lt;/code&gt; is cheap. Same lesson as the DOM, and as &lt;code&gt;ctx.fillRect&lt;/code&gt; versus rebuilding a canvas: the drawing was never the problem, the allocation was.&lt;/p&gt;

&lt;p&gt;The spectrogram uses the buffer form of the same trick, a &lt;strong&gt;300 × 100 circular buffer&lt;/strong&gt; with a head index, refreshed into a single &lt;code&gt;imagesc&lt;/code&gt; handle. New columns overwrite the oldest, nothing is reallocated and nothing scrolls.&lt;/p&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%2Fh9cil5ew0ec30yt7u8zs.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%2Fh9cil5ew0ec30yt7u8zs.png" alt="The Spectrogram tab: a rolling waterfall fed by the same 80 ms FFT as the overlay, 20 Hz to 20 kHz on a log axis, 60 dB of range" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is also an offline path: &lt;code&gt;computeFullSpectrogram()&lt;/code&gt; runs a proper STFT over the whole file and opens it as a 3-D surface. That one is allowed to be slow, because you asked for it explicitly and it renders once.&lt;/p&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%2Fuyk0x05fgy12va61uoad.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%2Fuyk0x05fgy12va61uoad.png" alt="The offline 3-D spectrogram: a full STFT of the 188-second track, power in dB as both height and colour" width="800" height="649"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three timers, and the constraint they work around
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;80 ms, spectrum.&lt;/strong&gt; An 8192-point Hann-windowed FFT of the current playback window, feeding both the overlay and one new spectrogram column. One transform, two consumers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;50 ms, debounce poll.&lt;/strong&gt; Fires &lt;code&gt;restartPlayback()&lt;/code&gt; 150 ms after the &lt;em&gt;last&lt;/em&gt; parameter change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10-second chunks, playback.&lt;/strong&gt; &lt;code&gt;startChunk()&lt;/code&gt; processes and plays ten seconds, chaining the next from the &lt;code&gt;audioplayer&lt;/code&gt; object's &lt;code&gt;StopFcn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The last two are one mechanism, and they exist because of a hard limit: MATLAB's &lt;code&gt;audioplayer&lt;/code&gt; plays a buffer you hand it, and gives you no way to swap coefficients mid-buffer the way a real-time audio callback would. Changing the EQ therefore means re-filtering from the current position and restarting the player.&lt;/p&gt;

&lt;p&gt;Chunking makes that affordable. Re-filtering a five-minute file on every knob movement is unusable; re-filtering ten seconds is instant. The 150 ms debounce stops a knob &lt;em&gt;drag&lt;/em&gt;, which emits dozens of callbacks a second, from queuing dozens of restarts. Drag freely, and the audio catches up 150 ms after you let go.&lt;/p&gt;

&lt;p&gt;It is scaffolding around a missing feature, but naming the constraint honestly is what produced the right structure: &lt;strong&gt;the buffer you can afford to recompute sets the latency you can offer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The mouse maths
&lt;/h2&gt;

&lt;p&gt;Grab a band directly on the response plot and move it. &lt;code&gt;onAxesButtonDown&lt;/code&gt; picks the nearest band, &lt;code&gt;onMouseMove&lt;/code&gt; maps cursor position to frequency and gain, &lt;code&gt;onMouseUp&lt;/code&gt; clears it.&lt;/p&gt;

&lt;p&gt;Nearest is measured in &lt;strong&gt;normalised log-frequency&lt;/strong&gt;, not in pixels or hertz:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight matlab"&gt;&lt;code&gt;&lt;span class="n"&gt;logDist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Freqs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clickF&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="nb"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;minD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logDist&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;minD&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The denominator is 3 decades, so the 0.2 threshold is 0.6 decades, a factor of 3.98, almost exactly &lt;strong&gt;two octaves&lt;/strong&gt;. You can grab a band from up to two octaves away and no further.&lt;/p&gt;

&lt;p&gt;The drag mapping is logarithmic in X and linear in dB in Y, so a centimetre of mouse travel near 100 Hz changes the frequency far less than the same centimetre near 10 kHz, which is what makes it feel right. The scroll wheel adjusts Q &lt;strong&gt;multiplicatively&lt;/strong&gt;, ×1.2 or ÷1.2 per tick, clamped to &lt;code&gt;[0.1, 10]&lt;/code&gt;. Additive steps would crawl at high Q and leap at low Q; a constant ratio feels identical everywhere in the range.&lt;/p&gt;

&lt;p&gt;Every gesture ends by calling &lt;code&gt;updateEQ()&lt;/code&gt;, &lt;code&gt;scheduleRestart()&lt;/code&gt; and &lt;code&gt;markCustomPreset()&lt;/code&gt;, the last of which flips the preset dropdown to "Custom" the moment you touch anything, so the label never lies about what you are hearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Presets
&lt;/h2&gt;

&lt;p&gt;Six built-ins plus user presets saved as &lt;code&gt;.mat&lt;/code&gt; files into a &lt;code&gt;presets/&lt;/code&gt; folder created on first launch.&lt;/p&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%2Fptlx4o1lccp3nx7r93av.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%2Fptlx4o1lccp3nx7r93av.png" alt="The Rock preset" width="800" height="524"&gt;&lt;/a&gt;&lt;/p&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%2Fzq5ihdkkvhzrsahref38.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%2Fzq5ihdkkvhzrsahref38.png" alt="The Vocal preset" width="800" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each preset is a struct of &lt;code&gt;Freqs&lt;/code&gt;, &lt;code&gt;Gains&lt;/code&gt;, &lt;code&gt;Qs&lt;/code&gt; and &lt;code&gt;Tipos&lt;/code&gt;. &lt;code&gt;savePreset()&lt;/code&gt; validates the name against the built-ins, sanitises it for the filesystem, warns on overwrite and rebuilds the dropdown. Unremarkable, and it is what makes the thing usable rather than a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MATLAB was good at, and what it was not
&lt;/h2&gt;

&lt;p&gt;I write most of my audio code in C++ with JUCE, so the comparison is worth making.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The maths is the code.&lt;/strong&gt; &lt;code&gt;filter(b,a,x)&lt;/code&gt;, &lt;code&gt;conv&lt;/code&gt;, &lt;code&gt;roots&lt;/code&gt;, &lt;code&gt;zplane&lt;/code&gt;: the gap between the equation on the page and the line in the editor is nearly zero. Verifying the pole-radius formula against &lt;code&gt;roots()&lt;/code&gt; took one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inspection is free.&lt;/strong&gt; Being able to stop, print the actual coefficient vectors, convolve the cascade by hand and check &lt;code&gt;H(+G)·H(−G) = 1&lt;/code&gt; in the command window is a different debugging experience from attaching a debugger to a plugin host. Every verified number in this post came out of that.&lt;/p&gt;

&lt;p&gt;And what it was worse at is the thing the timers work around: &lt;strong&gt;there is no real-time audio callback.&lt;/strong&gt; The chunking, the debouncing, the restarting are all scaffolding around that one absence. In JUCE the same feature is a coefficient update inside &lt;code&gt;processBlock&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;Which is the honest summary. MATLAB is where I would design and verify a filter. It is not where I would ship one.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The trig in the RBJ coefficients is a solved bilinear transform.&lt;/strong&gt; &lt;code&gt;cos(ω0)&lt;/code&gt; and &lt;code&gt;sin(ω0)&lt;/code&gt; are what prewarping collapses into. Knowing that tells you the center frequency is exact and everything else is warped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;A = 10^(G/40)&lt;/code&gt; because the peaking filter delivers &lt;code&gt;A²&lt;/code&gt;.&lt;/strong&gt; The gain multiplies the numerator and divides the denominator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;r = sqrt((1−α)/(1+α))&lt;/code&gt; is the pole radius, exactly.&lt;/strong&gt; It proves the form is unconditionally stable and it makes Q concrete: Q is how close the pole gets to the unit circle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the zeros.&lt;/strong&gt; &lt;code&gt;z = +1&lt;/code&gt; is DC, &lt;code&gt;z = −1&lt;/code&gt; is Nyquist, and a double zero is 12 dB/octave. You can predict a filter's shape before plotting anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive your own constraints and then check them numerically.&lt;/strong&gt; The 35 Hz clamp had been in the code for months and I found it by solving the clamp for frequency, not by listening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create graphics objects once, &lt;code&gt;set()&lt;/code&gt; them forever.&lt;/strong&gt; &lt;code&gt;cla&lt;/code&gt; in a live update path is the performance bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source is on &lt;a href="https://github.com/lluisestape-upc/MATLAB-Parametric-EQ" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an audio DSP student at UPC. Most of my work is C++/JUCE (&lt;a href="https://esp-plugin-store.vercel.app/" rel="noopener noreferrer"&gt;six VST3 plugins here&lt;/a&gt;), but this one started in MATLAB and stayed there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>matlab</category>
      <category>audio</category>
      <category>dsp</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Amparo: applying for food aid without reading a single word</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Sun, 06 Sep 2026 00:44:08 +0000</pubDate>
      <link>https://dev.to/lluisestape/amparo-applying-for-food-aid-without-reading-a-single-word-2mfi</link>
      <guid>https://dev.to/lluisestape/amparo-applying-for-food-aid-without-reading-a-single-word-2mfi</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Every year an enormous amount of aid money goes unclaimed. Not because it runs out, and not because nobody needs it, but because of something much stupider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The form is the wall.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you are blind, if you never learned to read well, or if you arrived last month and don't yet speak the language the form is printed in, the help you are entitled to is sitting behind a document you cannot fill in. You need a neighbour, a caseworker, or a volunteer to sit down with you. So you wait. Or you never apply at all.&lt;/p&gt;

&lt;p&gt;I wanted to see if the wall could just be removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amparo&lt;/strong&gt; completes a real aid application &lt;strong&gt;entirely by talking&lt;/strong&gt;. No reading. No typing. No form. It asks a few simple questions out loud, you answer in your own words in whatever language you speak, it reads back what it understood so you can catch mistakes, and it hands you a finished PDF to take to your local food bank.&lt;/p&gt;

&lt;p&gt;The part I care most about is that it accepts answers the way people actually give them. Nobody says "household size: four". They say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We're me, my mum and two little kids."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Amparo works out that there are &lt;strong&gt;4 people in the household, 2 of them children&lt;/strong&gt;, and moves on. It does the paperwork thinking so the person doesn't have to.&lt;/p&gt;

&lt;p&gt;You can also correct it at any time, about any field, however long ago you answered. Say &lt;em&gt;"no, I said three, not four"&lt;/em&gt; and it fixes that value and carries on. That mattered more than I expected. A voice interface without a correction path is a trap, because you cannot see what it wrote down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/VABNAjKT-58" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&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%2Fb1eyxx0ovq6puxqwd2wf.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%2Fb1eyxx0ovq6puxqwd2wf.png" alt="Main page" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&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%2Fn03wtnqc0zl79153eiau.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%2Fn03wtnqc0zl79153eiau.png" alt="Talking to Amparo" width="800" height="396"&gt;&lt;/a&gt;&lt;/p&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%2Fu8s9dwwo6rtcn3n2qff5.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%2Fu8s9dwwo6rtcn3n2qff5.png" alt="Pdf form created with the info" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The moment worth watching: one messy spoken sentence, and three fields fill themselves in on the right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/lluisestape-upc" rel="noopener noreferrer"&gt;
        lluisestape-upc
      &lt;/a&gt; / &lt;a href="https://github.com/lluisestape-upc/Amparo" rel="noopener noreferrer"&gt;
        Amparo
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Apply for aid entirely by talking, in your own language. Voice-first accessibility tool built with Gemini + ElevenLabs.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Amparo&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Apply for aid entirely by talking, in your own language.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Billions in aid go unclaimed every year, and one of the reasons is painfully
simple: the form itself is the wall. If you are blind, if you don't read well
or if you don't speak the language the form is written in, the help you are
entitled to is out of reach.&lt;/p&gt;
&lt;p&gt;Amparo removes the form. It asks you a few simple questions out loud, you
answer in your own words in whatever language you speak, and it fills in a real
aid application for you, then hands you the completed PDF.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/weekend-2026-09-03" rel="nofollow"&gt;DEV Weekend Challenge: Generosity Edition&lt;/a&gt;.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;    you speak  ──►  Web Speech API  ──►  Gemini (the brain)  ──►  filled form
        ▲                                       │
        └────────  ElevenLabs (the voice)  ◄────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gemini&lt;/strong&gt; reads the blank form, asks about one field at a time in simple
kind…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/lluisestape-upc/Amparo" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. &lt;code&gt;form_schema.py&lt;/code&gt; defines the one form being filled. Swap the fields there and the whole app targets a different aid program without another line changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Two models, each doing what it is genuinely good at, behind a Flask backend and a single accessible HTML page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Gemini is the brain.&lt;/strong&gt; It is not transcribing. It is conducting an interview. It reads the blank form, decides what to ask next one field at a time, and pulls structured values out of unstructured human speech. Every turn it returns strict JSON: the complete state of the form, what to say next, and which language the person is speaking.&lt;/p&gt;

&lt;p&gt;Returning the &lt;em&gt;whole&lt;/em&gt; form state rather than a diff is the decision that made corrections work. My first version returned only the fields learned that turn, and corrections silently failed: the model kept its earlier answer. Sending back the full state each turn removed the ambiguity entirely, because a corrected value simply replaces the old one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ElevenLabs is the voice.&lt;/strong&gt; &lt;code&gt;eleven_multilingual_v2&lt;/code&gt; speaks every question in a warm human voice, in whatever language Gemini detected. The opening greeting is spoken in several languages at once, so there is no language menu to read before you can begin. You just answer in yours, and everything follows from there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The decisions I would defend
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It reads back every value it records.&lt;/strong&gt; The person cannot see the screen, so a value that is not spoken aloud is a value they cannot check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors are spoken, never silent text.&lt;/strong&gt; An accessibility tool that reports its failures in writing has failed twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the browser cannot understand you, Gemini listens instead.&lt;/strong&gt; Browser speech recognition is weakest on accented and hesitant speech, precisely the people this is for, which is an awkward gap for an accessibility product. So every answer is recorded alongside it, and when it gives up, the audio goes to Gemini, which transcribes it directly and answers in the same call. Recordings are re-encoded to 16 kHz mono WAV in the browser rather than trusting container support for whatever the browser happens to record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It tells you where to take it, and it does not guess.&lt;/strong&gt; Once the form is complete, the applicant's own address finds the nearest real food bank, which is spoken aloud and printed on the form. Those places come from OpenStreetMap, never from the model. I could have asked Gemini for nearby food banks and it would happily have answered, but sending someone who is already struggling to an address a model invented is worse than telling them nothing. When nothing is found, nothing is claimed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is kept.&lt;/strong&gt; Answers include income and home address. They are deleted the moment the PDF is downloaded, and abandoned sessions expire within the hour. A tool that asks vulnerable people for their address should be able to say exactly how long it keeps it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You never lose your place.&lt;/strong&gt; Answers survive a reload or a dropped connection, and it welcomes you back where you left off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interface itself translates into five languages and flips to right-to-left for Arabic, because an app that speaks your language while its buttons don't is only half accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Google AI.&lt;/strong&gt; Gemini runs the entire conversation: choosing questions, extracting structured data from natural speech, detecting the language, applying corrections to any earlier field, and transcribing the audio itself when the browser's recognition fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of ElevenLabs.&lt;/strong&gt; Every word the app speaks, multilingual, including the opening greeting that lets someone choose their language without reading anything at all.&lt;/p&gt;




&lt;p&gt;Next would be a hosted version, so it can be tried rather than watched, and more forms. The food bank intake is one wall of many, and the interesting thing about this approach is that the wall was never really about food.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>a11y</category>
      <category>ai</category>
    </item>
    <item>
      <title>How I built my own set of audio plugins with JUCE</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Sat, 05 Sep 2026 12:51:47 +0000</pubDate>
      <link>https://dev.to/lluisestape/how-i-built-my-own-set-of-audio-plugins-with-juce-4i2b</link>
      <guid>https://dev.to/lluisestape/how-i-built-my-own-set-of-audio-plugins-with-juce-4i2b</guid>
      <description>&lt;p&gt;&lt;em&gt;A build log on ESP, six VST3 plugins written in C++ with JUCE 8 and shipped through a store I built myself. What the framework does for you, where it stops, and the one measurement that changed how I work.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The line
&lt;/h2&gt;

&lt;p&gt;Six plugins, all JUCE 8, all VST3 plus standalone, all GPL v3, all downloadable from &lt;a href="https://esp-plugin-store.vercel.app/" rel="noopener noreferrer"&gt;esp-plugin-store.vercel.app&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Basic Oscilator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;three oscillators on &lt;code&gt;juce::dsp&lt;/code&gt;, the first thing I ever built, kept honestly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VERTEX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;dynamic range compressor with a live transfer curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ESP-L1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;brick-wall limiter with pre and post spectrum overlay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MEGACRUSHER&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;distortion, saturation and bit-crusher, three algorithms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SPECTRUM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;real-time analyser, 2048-point FFT, spectrogram and 3D waterfall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SYNTH/1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16-voice wavetable synth, unison, step sequencer, FX rack, interactive EQ&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is in the order I wrote them, and the order matters more than any single plugin. Each one starts roughly where the previous one ran out of framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;juce::dsp&lt;/code&gt; actually hands you
&lt;/h2&gt;

&lt;p&gt;Basic Oscilator is three oscillators, three LFOs, a bit-crusher and a master gain. Almost all of it is the &lt;code&gt;juce::dsp&lt;/code&gt; module doing the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;dsp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ProcessSpec&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maximumBlockSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;samplesPerBlock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sampleRate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;numChannels&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;getTotalNumOutputChannels&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;oscillators&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;prepare&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;lfos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;prepare&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;lfos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;initialise&lt;/span&gt; &lt;span class="p"&gt;([](&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;sin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&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="n"&gt;masterGain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prepare&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole contract of the module. Prepare everything with one &lt;code&gt;ProcessSpec&lt;/code&gt;, wrap your buffer in an &lt;code&gt;AudioBlock&lt;/code&gt;, hand it to a processor as a context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;dsp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;AudioBlock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tempBuffer&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;oscillators&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;dsp&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ProcessContextReplacing&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;&lt;code&gt;juce::dsp::Oscillator&lt;/code&gt; takes its waveform as a lambda, so the three waves are three one-liners:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;case&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;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialise&lt;/span&gt; &lt;span class="p"&gt;([](&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;sin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;                       &lt;span class="c1"&gt;// sine&lt;/span&gt;
&lt;span class="k"&gt;case&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;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialise&lt;/span&gt; &lt;span class="p"&gt;([](&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;juce&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;MathConstants&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;::&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// saw&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initialise&lt;/span&gt; &lt;span class="p"&gt;([](&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;x&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="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&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="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;            &lt;span class="c1"&gt;// square&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about that code, and I am leaving both in the repo rather than quietly fixing them.&lt;/p&gt;

&lt;p&gt;The first is a bug I can name precisely: &lt;code&gt;tempBuffer.setSize(...)&lt;/code&gt; is called inside &lt;code&gt;processBlock&lt;/code&gt;. That is a heap allocation on the audio thread, once per block, which is the one thing you are not allowed to do. It never caused an audible dropout on my machine at a 512-sample buffer, which is exactly why beginners keep it. The rule is not "avoid allocations because they are slow", it is "avoid them because their worst case is unbounded and your deadline is not".&lt;/p&gt;

&lt;p&gt;The second is not a bug, it is the framework being honest about its scope. &lt;code&gt;x / pi&lt;/code&gt; is a mathematically perfect sawtooth and a spectrally terrible one: it has infinite harmonics, and every one above Nyquist folds back into the audible band. &lt;code&gt;juce::dsp::Oscillator&lt;/code&gt; does not band-limit and does not claim to. Everything I built afterwards is, one way or another, me finding out what that costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things every plugin inherited
&lt;/h2&gt;

&lt;p&gt;Before the DSP gets interesting, the skeleton. All six share it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AudioProcessorValueTreeState&lt;/code&gt; for every parameter.&lt;/strong&gt; Automation, host state, preset save and load all arrive free, and the UI never writes a parameter directly. Even an interactive gesture goes through the tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;apvts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getParameter&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;setValueNotifyingHost&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;convertTo0to1&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is why dragging a band on SYNTH/1's EQ is automation-recordable without one extra line of code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hard split between the two classes.&lt;/strong&gt; The processor owns state and touches audio. The editor owns pixels and touches neither. Where they meet, they meet through atomics or a FIFO.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing on the audio thread blocks, and the UI does the expensive work.&lt;/strong&gt; In SPECTRUM and ESP-L1 the audio thread's entire visualisation job is pushing samples into a lock-free FIFO. The editor pulls from it on a timer, runs the FFT, computes the meter ballistics, and draws. On the message thread a slow frame costs a dropped frame. In &lt;code&gt;processBlock&lt;/code&gt; it costs a dropped buffer, and a dropped buffer is a click.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compressor and limiter: the same envelope, two different opinions
&lt;/h2&gt;

&lt;p&gt;VERTEX is a compressor: gain, threshold from -60 to 0 dB, ratio 1 to 10, attack from 0.1 to 100 ms, release from 10 to 500 ms. The detector is &lt;code&gt;juce::dsp::Compressor&amp;lt;float&amp;gt;&lt;/code&gt;, and I am not going to pretend otherwise, because it is a good demonstration of where the framework's line actually sits. It gives you a correct compressor. It gives you nothing at all for showing the user what that compressor is doing, and that turned out to be most of the work.&lt;/p&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%2Fe3ve7wzff30f8k106zdw.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%2Fe3ve7wzff30f8k106zdw.png" alt="VERTEX: the transfer curve redraws live as you move threshold and ratio" width="755" height="707"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The transfer curve is drawn from the same threshold and ratio the audio path uses, so the picture cannot drift from the sound. That sounds obvious. It is worth stating because the tempting shortcut, drawing a nice curve and separately writing the maths, is how visualisers end up lying.&lt;/p&gt;

&lt;p&gt;ESP-L1 is where I stopped using the built-in and wrote the detector by hand, because a limiter is a compressor with one parameter deleted and I wanted to feel which one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="n"&gt;releaseCoeff&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;exp&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="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;releaseMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;envelopeState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;envelopeState&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;releaseCoeff&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelopeState&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;gainReduction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;envelopeState&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 attack time. Attack is instantaneous, because a brick-wall limiter that takes 5 ms to react is a limiter that lets 5 ms of overshoot through, and the entire promise of the thing is that nothing gets past. Release stays smooth and exponential, because that is what stops the gain reduction from pumping.&lt;/p&gt;

&lt;p&gt;One coefficient, one line, and the character of the processor is decided.&lt;/p&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%2Fot8u6c3os7ars2bfydv8.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%2Fot8u6c3os7ars2bfydv8.png" alt="ESP-L1: pre and post limiting spectra overlaid, with gain-reduction metering" width="800" height="470"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ESP-L1 runs &lt;strong&gt;two independent FFT pipelines&lt;/strong&gt;, pre and post limiting, drawn overlaid so you can see what the limiter actually took. Two 2048-point transforms at 30 Hz would be an unpleasant thing to put anywhere near &lt;code&gt;processBlock&lt;/code&gt;. Where they are, on the message thread behind a FIFO, they cost nothing that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  MEGACRUSHER: three ways to be wrong on purpose
&lt;/h2&gt;

&lt;p&gt;Distortion is the one place where "mathematically incorrect" is the product.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SOFT: tanh(drive * x)              smooth, compressive, harmonics come in gradually
HARD: clamp(drive * x, -1, +1)     brick wall, sharp corners, very bright
FOLD: mirror x back into range     reflections, wildly inharmonic at high drive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drive spans 1x to 40x and changes character rather than just level: &lt;code&gt;tanh&lt;/code&gt; at 40x is nearly a hard clip, so SOFT and HARD converge at the top of the range and diverge completely at the bottom. FOLD never converges with anything.&lt;/p&gt;

&lt;p&gt;Then a one-pole tilt filter for tone, a bit-depth reducer from 16 down to 2 bits (&lt;code&gt;std::round(x * res) / res&lt;/code&gt;, the same three-line trick as in Basic Oscilator, promoted to a feature), and a dry/wet mix so all of it can be run in parallel.&lt;/p&gt;

&lt;p&gt;All three shapers alias, of course. That is not the same admission as the synth: here the harmonics are the point, the drive is extreme, and the folding sits under a wall of intentional distortion. The honest version is that oversampling the saturator is on the list and the plugin ships without it.&lt;/p&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%2Fxoc0v9uzu0liev1h2rsx.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%2Fxoc0v9uzu0liev1h2rsx.png" alt="MEGACRUSHER: live saturation curve, ember particles that react to the drive level" width="753" height="756"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SPECTRUM: one transform, four views
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;juce::dsp::FFT&lt;/code&gt; with &lt;code&gt;fftOrder = 11&lt;/code&gt;, so 2048 points, Hann-windowed. The audio thread fills a FIFO; the editor's timer windows it, transforms it, and maps bins to a 512-point display array with &lt;strong&gt;logarithmic&lt;/strong&gt; frequency scaling from 20 Hz to 20 kHz, because linear spacing puts half the pixels in the top octave where nothing interesting happens.&lt;/p&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%2Fw7fixvog7zc3u7ytl8du.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%2Fw7fixvog7zc3u7ytl8du.png" alt="SPECTRUM: Bezier-smoothed frequency response with analog-style VU metering" width="799" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two display details are doing most of the work in that screenshot. The curve is smoothed with a 5-point moving average and drawn as quadratic Beziers rather than line segments, and the display array uses peak-hold with a slow per-frame decay, so transients stay visible long enough to read instead of flashing for a single frame.&lt;/p&gt;

&lt;p&gt;The other views cost almost nothing, because the transform has already happened and only the drawing changes:&lt;/p&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%2Fy0jthooeq7otvtc03hj6.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%2Fy0jthooeq7otvtc03hj6.png" alt="SPECTRUM's 3D waterfall: each FFT frame becomes a line receding into the past" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&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%2Fv8e5v47kzk7tfni46yfd.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%2Fv8e5v47kzk7tfni46yfd.png" alt="The spectrogram view of the same signal" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the payoff of the FIFO pattern. Once the expensive thing lives on the UI side, adding a new way to look at it is a rendering problem and never a real-time one.&lt;/p&gt;

&lt;h2&gt;
  
  
  SYNTH/1: where the framework runs out
&lt;/h2&gt;

&lt;p&gt;Sixteen voices of &lt;code&gt;juce::Synthesiser&lt;/code&gt;, each voice holding up to 8 unison oscillators, one &lt;code&gt;juce::ADSR&lt;/code&gt;, and one stereo &lt;code&gt;juce::dsp::StateVariableTPTFilter&lt;/code&gt;. Per voice the chain is: unison oscillators with phase warp, constant-power panning, &lt;code&gt;tanh&lt;/code&gt; drive, TPT filter, then ADSR times velocity.&lt;/p&gt;

&lt;p&gt;At the processor level: LFO, voices, master gain, chorus, phaser, tempo-synced delay, 2x oversampled saturation, 3-band EQ, then reverb.&lt;/p&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%2F386ogcay7psrx4gbkxil.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%2F386ogcay7psrx4gbkxil.png" alt="The MOD tab: LFO scope, rate and depth, the unison engine and voice mode" width="799" height="365"&gt;&lt;/a&gt;&lt;/p&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%2Fue3iqpahz55oqk3peyj1.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%2Fue3iqpahz55oqk3peyj1.png" alt="The FX rack: chorus, phaser, tempo-synced delay, 2x oversampled saturation, reverb" width="800" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three details from that chain that took real time to get right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Glide uses a multiplicative smoother.&lt;/strong&gt; &lt;code&gt;juce::SmoothedValue&amp;lt;float, ValueSmoothingTypes::Multiplicative&amp;gt;&lt;/code&gt; on the frequency, not a linear ramp, because pitch is perceived logarithmically. A linear glide from 100 Hz to 200 Hz spends most of its time sounding like it has nearly arrived, then lurches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mono and Legato modes need a note stack, not a note.&lt;/strong&gt; Releasing a key has to fall back to whichever key is still held, so the processor keeps a stack of held notes and preprocesses MIDI before the synthesiser sees it. Legato changes pitch without retriggering the ADSR, which is a separate code path, not a flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sequencer is entirely lock-free.&lt;/strong&gt; Notes, velocities, active steps, the play flag and the current step are all &lt;code&gt;std::atomic&lt;/code&gt;, and serialised into the plugin state as a &lt;code&gt;Sequencer&lt;/code&gt; child &lt;code&gt;ValueTree&lt;/code&gt;. The step grid redraws from a 30 Hz timer that reads those atomics and never locks anything.&lt;/p&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%2Fg6mow88w4ubjbhojvron.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%2Fg6mow88w4ubjbhojvron.png" alt="The step sequencer, mirrored from lock-free atomics at 30 Hz" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&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%2F17vl2rh9q4qvnsg08287.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%2F17vl2rh9q4qvnsg08287.png" alt="The interactive EQ tab, drag the bands over a live spectrum" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And then there is the oscillator, which was wrong for a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: 64 harmonics is a promise you can only keep below F4
&lt;/h2&gt;

&lt;p&gt;SYNTH/1's oscillator started as a wavetable doing the obvious thing: build one table per waveform holding a fixed 64 harmonics, and read it at every pitch. Better than &lt;code&gt;x / pi&lt;/code&gt;. Not correct.&lt;/p&gt;

&lt;p&gt;Write down when it is actually alias-free. A table holding harmonics up to &lt;code&gt;64 * f0&lt;/code&gt; stays under Nyquist only while:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;64 * f0 &amp;lt; fs/2      =&amp;gt;      f0 &amp;lt; fs/128
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 44.1 kHz that is &lt;strong&gt;f0 &amp;lt; 344.5 Hz&lt;/strong&gt;, roughly F4. Above that note the upper partials stored in the table exceed Nyquist and fold back as inharmonic tones. Folded partials also move the &lt;em&gt;wrong way&lt;/em&gt;: play up the keyboard and they come down to meet you.&lt;/p&gt;

&lt;p&gt;More than half the MIDI range sits above F4. The oscillator was aliasing over most of its useful span, and I had shipped it.&lt;/p&gt;

&lt;p&gt;Why did I not hear it? Because a saw with some filtering and reverb on it sounds like a saw, and because I had never compared it against anything correct. "It sounds fine" is not a measurement, it is the absence of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rig: measure the header, not the plugin
&lt;/h2&gt;

&lt;p&gt;This is the part I would recommend to anyone doing DSP, above any specific fix.&lt;/p&gt;

&lt;p&gt;I did not measure the oscillator inside the plugin. I built &lt;code&gt;analysis/&lt;/code&gt;: a folder that compiles &lt;code&gt;WavetableOscillator.h&lt;/code&gt;, &lt;strong&gt;the exact shipping header, unmodified&lt;/strong&gt;, against a minimal JUCE stub, renders sustained tones across the keyboard, and dumps raw float32 buffers. A Python script turns those buffers into alias-to-signal figures.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;analysis/
  measure_oscillator.cpp   # renders tones, writes .f32 buffers
  juce_stub/JuceHeader.h   # just enough JUCE to compile the header standalone
  plot_oscillator.py       # FFT, harmonic mask, alias-to-signal ratio
  data/                    # legacy_82.f32, mip_82.f32, one pair per pitch
  figures/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The method per rendered tone: FFT it, mask out the bins belonging to true harmonics of &lt;code&gt;f0&lt;/code&gt;, and sum what is left. That leftover &lt;em&gt;is&lt;/em&gt; the aliasing, and its ratio to the signal is a single number per pitch. Sweep the pitch and you get a curve.&lt;/p&gt;

&lt;p&gt;Nothing here needs a DAW, a plugin host, or ears. It runs in a terminal, it produces a number, and the number is either better than last time or it is not.&lt;/p&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%2Fsyfdtxjtwukkozp08d4b.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%2Fsyfdtxjtwukkozp08d4b.png" alt="Sawtooth alias-to-signal across the keyboard, before and after" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is the bug, drawn. The red curve is the old oscillator: flat at about -82 dB while the 64-harmonic promise holds, then a cliff at exactly &lt;code&gt;fs/128 = 344 Hz&lt;/code&gt; straight up to -26 dB and worse. By the top of the keyboard the aliasing is &lt;strong&gt;-10 dB&lt;/strong&gt;. That is not a subtle artefact, that is a tenth of the output being wrong.&lt;/p&gt;

&lt;p&gt;The spectra make it visceral:&lt;/p&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%2Fz41jtq6dihxdkmndokj7.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%2Fz41jtq6dihxdkmndokj7.png" alt="Spectra at A4 and at 2 kHz, dotted lines are true harmonics, everything between them is aliasing" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 2 kHz the old version has more energy sitting &lt;em&gt;between&lt;/em&gt; the harmonics than on some of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: mip-mapping, and taking the budget from the top of the band
&lt;/h2&gt;

&lt;p&gt;The standard answer is mip-mapping: a bank of tables, each holding only the harmonics that are safe in its pitch range. The detail that matters is which end of the range you compute the budget for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;H(L) = floor( (fs/2) / (f_base * 2^((L+1)/M)) )      M = bands per octave
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;L+1&lt;/code&gt;. The budget comes from the &lt;strong&gt;top&lt;/strong&gt; of the band, not the bottom, so every fundamental inside the band is safe rather than just the lowest one. Take it from the bottom and you have rebuilt the original bug with a smaller blast radius per band.&lt;/p&gt;

&lt;p&gt;That safety costs brightness, and the cost is exactly why &lt;code&gt;kMipsPerOctave&lt;/code&gt; exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At A4 the ideal harmonic budget is &lt;code&gt;floor(22050/440) = 50&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A full-octave bank would only allow &lt;strong&gt;34&lt;/strong&gt;, throwing away a third of the spectrum to stay safe at the top of the band.&lt;/li&gt;
&lt;li&gt;Half-octave bands (&lt;code&gt;kMipsPerOctave = 2&lt;/code&gt;) recover &lt;strong&gt;48 of the ideal 50&lt;/strong&gt;, for twice the tables and a build that still takes tens of milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two more changes came with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous level selection.&lt;/strong&gt; The fractional band position crossfades between adjacent mip levels. A hard switch changes the harmonic count by a step, and that step is plainly audible during a glide or a slow pitch LFO: the timbre flickers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cubic Hermite instead of linear interpolation.&lt;/strong&gt; Linear interpolation of a 2048-point table is a triangular kernel whose frequency response leaks badly into the stopband. It acts as a gentle lowpass plus a noise floor that rises with playback rate. Four-point Catmull-Rom costs a handful of extra multiply-adds and drops that floor substantially.&lt;/p&gt;

&lt;p&gt;The blue curve above is the result. &lt;strong&gt;96 dB better at A4. 139 dB better at C8.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I was wrong twice more
&lt;/h2&gt;

&lt;p&gt;This is the bit I enjoyed, and the reason the measurement rig paid for itself several times over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A 2048-point table can &lt;em&gt;represent&lt;/em&gt; 1024 harmonics. No practical interpolator can &lt;em&gt;read&lt;/em&gt; them.&lt;/strong&gt; Reading a table at a fractional rate produces images at &lt;code&gt;k*(N*f0) +/- n*f0&lt;/code&gt;, and it is the interpolation kernel that has to suppress them. Content near the table's own Nyquist has two or three samples per cycle, where the kernel has essentially no stopband left, so those images come back as a noise floor.&lt;/p&gt;

&lt;p&gt;Measured: at 55 Hz, an &lt;em&gt;uncapped&lt;/em&gt; budget of 389 harmonics put images at &lt;strong&gt;-75.5 dBc&lt;/strong&gt;, worse than the 64-harmonic version it replaced despite being far more correct spectrally. I could identify them beyond doubt because they sat exactly at &lt;code&gt;bin(N*f0) - H*bin(f0)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Capping the budget at &lt;code&gt;kTableSize/8&lt;/code&gt; (eight table samples per cycle of the topmost harmonic) moves that floor to -77.3 dBc. Honest accounting: &lt;strong&gt;that is 1.8 dB, not a fix.&lt;/strong&gt; The real remedy is longer tables at the low mips, because the image floor is set by table length against harmonic count, not by the cap. I kept the cap because it makes the invariant explicit and costs nothing audible: it only binds below &lt;code&gt;fs/2/256 = 86 Hz&lt;/code&gt;, where the harmonics it discards are already under -48 dB.&lt;/p&gt;

&lt;p&gt;And it shows up in the sweep as a real regression: below 86 Hz the new oscillator is &lt;strong&gt;4 dB worse&lt;/strong&gt; than the old one. That is in the header comment, in the report, and now in this post. A rewrite that is better by 139 dB at the top and worse by 4 dB at the bottom is still a rewrite you ship, but the 4 dB does not get to quietly disappear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The phase accumulator is &lt;code&gt;double&lt;/code&gt;, and &lt;code&gt;setFrequency&lt;/code&gt; takes a &lt;code&gt;double&lt;/code&gt;.&lt;/strong&gt; Accumulating &lt;code&gt;f0/fs&lt;/code&gt; in float32 lets rounding error random-walk, smearing each harmonic into its neighbours. With a double accumulator, energy within +/-2 bins of the harmonic grid sits at -164 dBc. That is two double operations per sample against four Hermite evaluations already in the loop, so it is free. Similarly, a float32 Hz value lands within about 1e-4 Hz of the target: inaudible as pitch, but it leaves the tone very slightly non-periodic, and a rectangular-window spectrum shows leakage skirts at -86 dBc. The caller already holds the precise value, so there is nothing to gain by throwing it away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And one thing that is still broken.&lt;/strong&gt; &lt;code&gt;applyWarp()&lt;/code&gt; (the Sync, Bend and PWM phase-warp modes) runs &lt;em&gt;before&lt;/em&gt; the table read and still aliases. It is phase distortion, which generates content the mip level was never chosen for. Fixing it properly needs oversampling. It is documented in the header as a known limitation rather than quietly left for someone to discover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The store
&lt;/h2&gt;

&lt;p&gt;The plugins are distributed from a store I built rather than a Gumroad page: a self-contained React 18 SPA with no build step, React, ReactDOM and Babel Standalone from a CDN, and the whole application in one &lt;code&gt;index.html&lt;/code&gt;. Each plugin gets a seeded generative waveform animation, a screenshot carousel, and a download modal with OS and format selection. Vercel deploys it on push.&lt;/p&gt;

&lt;p&gt;It is an unusual choice and I will defend exactly one thing about it: &lt;strong&gt;no build step means the deployed artifact and the file I edit are the same file.&lt;/strong&gt; For a five-page site with no dependencies beyond React, the build tooling would have been more code than the site.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;juce::dsp&lt;/code&gt; is scaffolding, not a synthesiser.&lt;/strong&gt; &lt;code&gt;ProcessSpec&lt;/code&gt;, &lt;code&gt;AudioBlock&lt;/code&gt;, &lt;code&gt;ProcessorChain&lt;/code&gt; and the FFT save you weeks. The band-limiting, the detector characters and the interpolation are yours to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the audio thread boring.&lt;/strong&gt; Atomics, FIFOs, APVTS, and every expensive thing on the UI side. My first plugin allocates in &lt;code&gt;processBlock&lt;/code&gt; and every one after it does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"It sounds fine" is the absence of a measurement.&lt;/strong&gt; My oscillator aliased over half the keyboard for a year and sounded fine the whole time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the offline rig and point it at the shipping header.&lt;/strong&gt; A JUCE stub and a &lt;code&gt;.cpp&lt;/code&gt; that renders tones is an afternoon of work, and it turns "I think this is better" into "96 dB at A4".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish the regression.&lt;/strong&gt; The 4 dB I lost below 86 Hz is in the header comment and in this post. A fix you only report the good half of is a fix you will re-break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive the constraint, do not guess it.&lt;/strong&gt; &lt;code&gt;f0 &amp;lt; fs/128&lt;/code&gt; is two lines of algebra that would have caught this on day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is GPL v3 and on &lt;a href="https://github.com/lluisestape-upc" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://github.com/lluisestape-upc/Synth1.0" rel="noopener noreferrer"&gt;SYNTH/1&lt;/a&gt; included, &lt;code&gt;analysis/&lt;/code&gt; folder and all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an audio DSP student at UPC. If you build wavetable oscillators, go and measure yours. I would like to hear what you find.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpp</category>
      <category>audio</category>
      <category>showdev</category>
      <category>dsp</category>
    </item>
    <item>
      <title>Creating sound with gestural harmony: AethericGeometry</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Fri, 04 Sep 2026 10:10:45 +0000</pubDate>
      <link>https://dev.to/lluisestape/creating-sound-with-gestural-harmony-aethericgeometry-2dlh</link>
      <guid>https://dev.to/lluisestape/creating-sound-with-gestural-harmony-aethericgeometry-2dlh</guid>
      <description>&lt;p&gt;&lt;em&gt;A build log on Aetheric Geometry, a musical instrument you play with your bare hands in front of a webcam. The first half is how hand geometry becomes harmony. The second half is the seven things that were wrong with it while it sounded perfectly good.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Point a webcam at yourself. MediaPipe finds 21 landmarks per hand, and the &lt;em&gt;geometry&lt;/em&gt; of your hands becomes the sound.&lt;/p&gt;

&lt;p&gt;Pinch both hands to start a thread between your index fingers. Bring them together, or make a pyramid, and the thread becomes a polygon whose vertices are your fingertips. That polygon is the instrument.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webcam frame → flip → MediaPipe → assign_hands()
  → waveform selector (5-frame debounce)
  → gesture detection (pinch, kiss, pyramid, cross, prayer, open palm)
  → state machine (IDLE / THREAD / POLYGON)
  → compute_poly_sound() → SynthEngine.set_params()
  → OpenCV render
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pure Python. No JUCE and no external synth: &lt;code&gt;dsp.py&lt;/code&gt; implements PolyBLEP oscillators, a TPT filter and a Schroeder reverb from scratch, and &lt;code&gt;sounddevice&lt;/code&gt; plays them. It ships as a PyInstaller executable and has a pytest suite that needs no camera, no microphone and no audio device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands to geometry to sound
&lt;/h2&gt;

&lt;p&gt;The whole mapping is one function, &lt;code&gt;compute_poly_sound()&lt;/code&gt;, and it is deliberately small:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Geometry&lt;/th&gt;
&lt;th&gt;Musical parameter&lt;/th&gt;
&lt;th&gt;Why that axis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;centroid X&lt;/td&gt;
&lt;td&gt;pitch of the root&lt;/td&gt;
&lt;td&gt;left to right is the obvious pitch axis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;centroid Y&lt;/td&gt;
&lt;td&gt;filter cutoff&lt;/td&gt;
&lt;td&gt;raising your hands opens the sound&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;number of fingertips&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;number of voices in the chord&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;you hold up the chord you want&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;polygon area&lt;/td&gt;
&lt;td&gt;reverb wet, from 6 vertices&lt;/td&gt;
&lt;td&gt;a bigger shape is a bigger room&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;aspect ratio&lt;/td&gt;
&lt;td&gt;tremolo depth, from 8 vertices&lt;/td&gt;
&lt;td&gt;stretching wide makes it pulse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;extended finger count&lt;/td&gt;
&lt;td&gt;waveform&lt;/td&gt;
&lt;td&gt;with a 5-frame debounce&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the one the instrument is built around. Every extended fingertip is both a polygon vertex and a chord voice, so &lt;strong&gt;the shape you make and the chord you hear are the same object counted two ways.&lt;/strong&gt; Open one more finger and the polygon gains a corner at the same instant the chord gains a note.&lt;/p&gt;

&lt;p&gt;Two of the mappings are deliberately locked behind complexity. Reverb needs 6 vertices, tremolo needs 8. You cannot reach the full parameter space with a lazy gesture, which turns "more fingers" into a progression rather than a switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ratios are &lt;code&gt;Fraction&lt;/code&gt;, not &lt;code&gt;float&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The chord is built on exact just-intonation ratios above a movable root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CHORD_RATIOS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&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="n"&gt;UNISON&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAJOR_SECOND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAJOR_THIRD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PERFECT_FOURTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;PERFECT_FIFTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MAJOR_SIXTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HARMONIC_SEVENTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OCTAVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and they are &lt;code&gt;fractions.Fraction&lt;/code&gt;, not decimals. An earlier version stored &lt;code&gt;1.333&lt;/code&gt; for the perfect fourth and &lt;code&gt;1.667&lt;/code&gt; for the major sixth, which looks harmless. It is not, and the reason is the whole point of just intonation.&lt;/p&gt;

&lt;p&gt;Against the true &lt;code&gt;4/3&lt;/code&gt;, storing &lt;code&gt;1.333&lt;/code&gt; is an error of&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1200 · log2(1.333 / (4/3)) ≈ -0.43 cents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which is inaudible as pitch. Nobody hears 0.43 cents. But just intonation does not exist to make intervals &lt;em&gt;sound in tune&lt;/em&gt;, it exists to make partials of different voices land on &lt;strong&gt;exactly&lt;/strong&gt; the same frequency so the beating between them disappears. And beating is a difference of frequencies, which is a far more sensitive measurement than pitch.&lt;/p&gt;

&lt;p&gt;Work it out. A fourth above 220 Hz is 293.333... Hz exact and 293.26 Hz rounded. The third partial of the root sits at 660 Hz. The second partial of the fourth sits at 586.67 Hz exact and 586.52 Hz rounded. Against the root's harmonic series those two disagree by roughly &lt;strong&gt;0.15 Hz&lt;/strong&gt;, which is a slow wow with a period of about seven seconds across a sustained chord.&lt;/p&gt;

&lt;p&gt;That is not a tuning error you hear as pitch. It is a tuning error you hear as movement, and it is precisely the artefact just intonation was chosen to remove. Storing exact fractions costs nothing, and the only rounding left in the system is one float conversion per partial at the final multiply.&lt;/p&gt;

&lt;p&gt;Here is what those exact ratios are, measured against the equal-tempered intervals they replace:&lt;/p&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%2Fspz90c4ikrvrw75g2h53.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%2Fspz90c4ikrvrw75g2h53.png" alt="The eight chord ratios and their deviation from the nearest 12-TET interval, in cents" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fifth is 1.96 cents sharp of tempered and the fourth 1.96 flat, which is why those two barely matter on a piano. The major third is &lt;strong&gt;13.69 cents flat&lt;/strong&gt;, the sixth 15.64 flat, and the harmonic seventh is &lt;strong&gt;31.17 cents flat&lt;/strong&gt;, nearly a third of a semitone. That last one is not a badly tuned minor seventh, it is a different interval: the one that appears naturally as the 7th partial, and that 12-TET has no room for.&lt;/p&gt;

&lt;p&gt;The voicings are ordered on purpose too. Voices enter as fifth, then major third, then octave, then ninth, then harmonic seventh, then fourth, then sixth: &lt;strong&gt;simplest ratios first&lt;/strong&gt;, because the simpler the ratio, the sooner its partials coincide with the root's and the more strongly the two voices fuse into one sound rather than two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compromise, stated plainly
&lt;/h2&gt;

&lt;p&gt;The instrument is justly tuned &lt;strong&gt;within&lt;/strong&gt; a chord and equal-tempered &lt;strong&gt;between&lt;/strong&gt; chords: the ratios are exact, but the root they sit on is quantised to a pentatonic subset of 12-TET.&lt;/p&gt;

&lt;p&gt;That is a real compromise and it is worth saying out loud rather than hiding. A fully just system has no single answer for what happens when the root moves, because stacking exact fifths never closes the octave. Twelve of them overshoot by the Pythagorean comma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(3/2)¹² / 2⁷ ≈ 1.0136        about 23.5 cents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Somebody has to absorb those 23.5 cents. Choosing a fixed ratio set over a tempered root sidesteps comma drift entirely, at the cost of transposition purity. For an instrument you wave your hands at, that is the right trade.&lt;/p&gt;

&lt;p&gt;Two more details in the pitch path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pitch map is exponential, not linear.&lt;/strong&gt; &lt;code&gt;f = f_lo · (f_hi/f_lo)^t&lt;/code&gt; over 110 Hz to 880 Hz, so a centimetre of hand travel is the same number of cents everywhere in the range. A linear map would make the bottom of the gesture range musically cramped and the top sparse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The root is quantised to A minor pentatonic&lt;/strong&gt; before the ratios are applied. Five notes per octave means that with your hand anywhere in the frame, the root is one of a set with no bad intervals in it. The instrument cannot be played out of key, which for a gesture instrument with the precision of a human arm is not a limitation, it is what makes it playable.&lt;/p&gt;

&lt;h2&gt;
  
  
  And all of that sounded fine while being full of bugs
&lt;/h2&gt;

&lt;p&gt;Here is the turn.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dsp.py&lt;/code&gt;, &lt;code&gt;tuning.py&lt;/code&gt; and &lt;code&gt;knob.py&lt;/code&gt; are &lt;strong&gt;free of camera, audio and MediaPipe imports&lt;/strong&gt;. The entire numeric core is importable with nothing plugged in.&lt;/p&gt;

&lt;p&gt;That sounds like ordinary hygiene. In practice it is what let me write &lt;code&gt;analysis/measure_dsp.py&lt;/code&gt;, a script that renders buffers from the real DSP primitives and FFTs them. Every number below comes out of it, and I can regenerate all of it with one command and no hardware.&lt;/p&gt;

&lt;p&gt;Without that separation, checking "is my sawtooth aliasing" means opening the app, playing a note and listening. With it, it means a number. Here is what the numbers found.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 1: the phase accumulator was one sample short, every block
&lt;/h3&gt;

&lt;p&gt;My favourite, because it is so easy to write and so hard to hear.&lt;/p&gt;

&lt;p&gt;The oscillator renders a block, then writes the final phase back for the next block. I wrote back the phase &lt;em&gt;at&lt;/em&gt; the last sample, not the phase &lt;em&gt;after&lt;/em&gt; the last sample. One sample short, every block, forever.&lt;/p&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%2Frybdg0r0g4g5ebapnd19.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%2Frybdg0r0g4g5ebapnd19.png" alt="The full spectrum of what should be a pure 440 Hz sine, and the sideband detail" width="799" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The left panel should be a single spike at 440 Hz. Instead the block period stamps a whole harmonic series onto it, because a periodic discontinuity at the block rate &lt;em&gt;is&lt;/em&gt; a modulator. The right panel shows where they land: sidebands at exactly &lt;code&gt;fs/N&lt;/code&gt; = &lt;strong&gt;±86.1 Hz&lt;/strong&gt; around the carrier, at −40 dB.&lt;/p&gt;

&lt;p&gt;And the tuning error it causes: &lt;strong&gt;3.385 cents flat.&lt;/strong&gt; Not enough to sound out of tune on its own. Enough to be wrong against anything else, and note the irony: I had just spent all that care getting the ratios exact to fractions of a cent, on top of an oscillator that was 3.4 cents off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n_samples&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;inc&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="c1"&gt;# not (n_samples - 1)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One character of arithmetic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: naive saw and square, aliasing at −12 dB
&lt;/h3&gt;

&lt;p&gt;A naive digital sawtooth (&lt;code&gt;phase * 2 - 1&lt;/code&gt;) has a discontinuity per cycle, and a discontinuity has infinite bandwidth. Everything above Nyquist folds back.&lt;/p&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%2Fq6at43fy8v501g3qz7d0.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%2Fq6at43fy8v501g3qz7d0.png" alt="Alias-to-signal ratio across the pitch range, naive versus PolyBLEP" width="799" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 2 kHz the naive saw sits at about &lt;strong&gt;−12 dB&lt;/strong&gt; alias-to-signal. A twelfth of the output is inharmonic garbage that moves the wrong way as you play up, which on an instrument built around exact ratios is an unusually expensive thing to get wrong.&lt;/p&gt;

&lt;p&gt;PolyBLEP corrects the samples immediately around each discontinuity with a polynomial approximation of a band-limited step, and the triangle is generated by integrating the BLEP square. Across the range that buys roughly &lt;strong&gt;16 dB&lt;/strong&gt; of alias rejection.&lt;/p&gt;

&lt;p&gt;It does not eliminate aliasing; nothing cheap does. It moves it from "obviously wrong" to "below what you will notice", for a handful of operations per sample.&lt;/p&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%2F95uzjmykcajvqbpgbwrz.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%2F95uzjmykcajvqbpgbwrz.png" alt="The same thing as spectra: the harmonic series before and after" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 3: four comb filters sharing one feedback gain
&lt;/h3&gt;

&lt;p&gt;The reverb is a classic Schroeder: four damped comb filters in parallel into two allpass diffusers. Combs need different delay lengths; that is the point, it decorrelates them.&lt;/p&gt;

&lt;p&gt;I gave all four the &lt;strong&gt;same feedback gain &lt;code&gt;g&lt;/code&gt;&lt;/strong&gt;, which seems reasonable until you write down what T60 depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T60 = -3 · D / (fs · log10(g))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;D&lt;/code&gt; is in there. The same &lt;code&gt;g&lt;/code&gt; across four different delay lengths means &lt;strong&gt;four different decay times&lt;/strong&gt;: measured, 1.12 s to 1.28 s, a 13.7 % spread. The tail does not decay, it &lt;em&gt;stages&lt;/em&gt;. The short comb dies first, then the next, and the character of the reverb changes as it fades.&lt;/p&gt;

&lt;p&gt;The fix inverts the relationship: take one T60 target and solve each comb's own gain from its own delay length.&lt;/p&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%2Fj4nfsnfghodxo7bomkxr.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%2Fj4nfsnfghodxo7bomkxr.png" alt="Schroeder backward-integrated decay: one shared gain, versus a gain solved per comb from a single T60 target" width="799" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Right panel: all four combs now cross −60 dB together, at the T60 you asked for, damped or not. Left panel is what I had.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bugs 4 to 7, briefly
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;No allpass diffusion at all.&lt;/strong&gt; Four combs with no diffusers are not a reverb, they are four echo trains, and on a percussive attack you hear them as four distinct repeats rather than a wash. Two allpass stages after the combs smear the echo density without touching the magnitude response. It is the difference between a delay bank and a room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every envelope time changed with the sample rate.&lt;/strong&gt; The envelope stored raw one-pole coefficients, and &lt;code&gt;0.999&lt;/code&gt; means one thing at 44.1 kHz and something else at 48 kHz, so attack and release changed depending on which audio device you plugged in. Store the time constant in seconds and derive the coefficient at the current rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;coeff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&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="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tau_seconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every MIDI note was an octave flat.&lt;/strong&gt; MIDI note 0 is C−1, not C0, so &lt;code&gt;12·log2(f/C0)&lt;/code&gt; is not a MIDI note number. Everything was &lt;em&gt;consistently&lt;/em&gt; an octave off, which is why it sounded internally correct and only broke when the MIDI drove something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The triangle overshot by 85 % on every note.&lt;/strong&gt; The triangle comes from integrating a BLEP square, integrators need an initial condition, and mine started from zero. That is only right if the note begins at exactly the phase where the triangle is zero. Start anywhere else and the integrator spends its first moments walking to where it should have started: measured, &lt;strong&gt;85 % overshoot for about 16 ms&lt;/strong&gt; on every note onset. A click on every note, which I had mentally filed as "attack character".&lt;/p&gt;

&lt;h3&gt;
  
  
  The seven, in one table
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defect&lt;/th&gt;
&lt;th&gt;Measured effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phase written back one sample short&lt;/td&gt;
&lt;td&gt;−3.385 cents, sidebands at ±86.1 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Naive saw and square&lt;/td&gt;
&lt;td&gt;−12 dB alias-to-signal at 2 kHz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One feedback gain shared by four combs&lt;/td&gt;
&lt;td&gt;T60 spread 1.12 s to 1.28 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No allpass diffusion&lt;/td&gt;
&lt;td&gt;four bare echo trains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Envelope stored as raw coefficients&lt;/td&gt;
&lt;td&gt;every envelope time varied with &lt;code&gt;fs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;12·log2(f/C0)&lt;/code&gt; used as a MIDI note&lt;/td&gt;
&lt;td&gt;every note an octave flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Triangle integrator started from zero&lt;/td&gt;
&lt;td&gt;85 % overshoot for ~16 ms per note&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every single one of these sounded fine.&lt;/p&gt;

&lt;p&gt;One more, since I fixed the filter at the same time: the old lowpass used &lt;code&gt;exp(-2π·fc/fs)&lt;/code&gt;, which is &lt;strong&gt;2 dB out by 20 kHz&lt;/strong&gt;. The replacement is a two-stage TPT with a prewarped coefficient &lt;code&gt;g = tan(π·fc/fs)&lt;/code&gt;, which is exactly −3 dB at the requested cutoff.&lt;/p&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%2Fosfpuxz172hu3ft2qd68.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%2Fosfpuxz172hu3ft2qd68.png" alt="Measured cutoff of the TPT lowpass against the requested frequency" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping the room from playing the instrument
&lt;/h2&gt;

&lt;p&gt;The instrument also takes voice commands, offline, via Vosk, in Spanish. "&lt;em&gt;jarvis, ponme una onda cuadrada.&lt;/em&gt;" This turned into its own measurement problem, for a reason I did not anticipate.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;keyword spotting, not language understanding&lt;/strong&gt;: a result is scanned word by word and anything in the vocabulary fires. Which means ordinary conversation in the room plays the instrument.&lt;/p&gt;

&lt;p&gt;Four gates, each added because a measurement demanded it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;Measured without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Final results only&lt;/td&gt;
&lt;td&gt;is the utterance finished?&lt;/td&gt;
&lt;td&gt;partials fired &lt;strong&gt;11 commands in 25 s of silence&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;min_level&lt;/code&gt; (peak RMS)&lt;/td&gt;
&lt;td&gt;did anyone actually speak?&lt;/td&gt;
&lt;td&gt;room tone fired one per 45 s at high confidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Length triage&lt;/td&gt;
&lt;td&gt;was it meant for us?&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;11 commands in 40 s&lt;/strong&gt; of ordinary room sound&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;min_confidence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;which word was it?&lt;/td&gt;
&lt;td&gt;wrong-word substitutions on noisy input&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third is the interesting one. Requiring a wake word for everything would make the instrument unplayable, because you have to be able to bark "&lt;em&gt;eco&lt;/em&gt;" mid-performance. Requiring nothing lets the room conduct. So &lt;strong&gt;length decides&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Utterance&lt;/th&gt;
&lt;th&gt;Wake word?&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;congela&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;fires, short commands need no ceremony&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sube el eco&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;fires, still short&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pues mira sube un poco mas&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dropped whole&lt;/strong&gt;, despite two command words in it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eter pillame el eco y baja treinta por ciento&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;parsed fully, selects reverb, lowers it 30 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A long utterance without a wake word is discarded &lt;em&gt;entirely&lt;/em&gt;, not scanned for the commands it happens to contain. That one rule is what made voice control usable in a room with other people in it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Traps I could only find by probing the lexicon
&lt;/h3&gt;

&lt;p&gt;A Vosk grammar can only contain words the model already knows. Anything else is silently dropped with a warning on native stderr, so your command simply never fires: no error, no clue.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Spanish lexicon is unaccented.&lt;/strong&gt; &lt;code&gt;mas&lt;/code&gt;, &lt;code&gt;triangulo&lt;/code&gt;, &lt;code&gt;atras&lt;/code&gt; exist. &lt;code&gt;más&lt;/code&gt;, &lt;code&gt;triángulo&lt;/code&gt;, &lt;code&gt;atrás&lt;/code&gt; do not. There is now a test enforcing ASCII-only Spanish keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The obvious technical words are missing.&lt;/strong&gt; Neither &lt;code&gt;sinusoidal&lt;/code&gt; nor &lt;code&gt;reverb&lt;/code&gt; is in the Spanish model, and &lt;code&gt;tremolo&lt;/code&gt; is in &lt;em&gt;neither&lt;/em&gt; model. The plain words (&lt;code&gt;seno&lt;/code&gt;, &lt;code&gt;eco&lt;/code&gt;, &lt;code&gt;temblor&lt;/code&gt;) are what work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;English &lt;code&gt;sine&lt;/code&gt; is /saɪn/&lt;/strong&gt;, not "SEE-neh". A Spanish speaker reading the word aloud produces something the English model cannot map, hence &lt;code&gt;round&lt;/code&gt;, &lt;code&gt;smooth&lt;/code&gt;, &lt;code&gt;pure&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aether&lt;/code&gt; is not in the Spanish lexicon.&lt;/strong&gt; The wake word is &lt;code&gt;eter&lt;/code&gt;, which is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filler words earn their place.&lt;/strong&gt; &lt;code&gt;ponme&lt;/code&gt;, &lt;code&gt;una&lt;/code&gt;, &lt;code&gt;onda&lt;/code&gt; map to nothing, but without them in the grammar the other words of a sentence smear onto commands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every voice command has a keyboard equivalent, so the instrument never depends on the microphone or on Vosk being installed at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HUD: 39 ms per frame, down to 5.7
&lt;/h2&gt;

&lt;p&gt;A different flavour of the same lesson.&lt;/p&gt;

&lt;p&gt;Text goes through Pillow with real TrueType faces rather than &lt;code&gt;cv2.putText&lt;/code&gt;, because the Hershey fonts OpenCV ships are single-stroke vector outlines with no hinting or kerning, and they were the main reason the interface looked unfinished. Panels rasterise into RGBA tiles at 2× and box-filter down, which antialiases Pillow's arcs and rounded corners for free.&lt;/p&gt;

&lt;p&gt;Doing all that from scratch every frame cost &lt;strong&gt;39 ms&lt;/strong&gt;. Four changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cache rasterised tiles keyed by content&lt;/td&gt;
&lt;td&gt;most frames became a blit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premultiply and convert RGB to BGR once at rasterise time&lt;/td&gt;
&lt;td&gt;blit 5.9 ms to 1.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composite with OpenCV instead of numpy expressions&lt;/td&gt;
&lt;td&gt;SIMD, no temporaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Split the hold panel into chrome / dial / rows tiles&lt;/td&gt;
&lt;td&gt;turning a knob 21 ms to ~6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Steady state: &lt;strong&gt;5.7 ms&lt;/strong&gt;, and 11.6 ms while a knob is turning.&lt;/p&gt;

&lt;p&gt;One trap worth the price of admission. The effect-meter loop in &lt;code&gt;draw_status_bar&lt;/code&gt; used &lt;code&gt;key&lt;/code&gt; as its loop variable, which &lt;strong&gt;shadowed the cache key computed just above it&lt;/strong&gt;. Tiles were stored under the wrong key and the cache never hit once. The panel looked completely correct and simply ran at full cost, forever. Only the profiler knew.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make the gesture and the harmony the same object.&lt;/strong&gt; Fingertips are polygon vertices and chord voices at once, so there is nothing to learn twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exact ratios matter for beating, not for pitch.&lt;/strong&gt; 0.43 cents is inaudible as tuning and audible as a seven-second wow across a sustained chord.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name your compromise.&lt;/strong&gt; Just within the chord, tempered between chords, because the Pythagorean comma has to go somewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split your numeric core from your I/O.&lt;/strong&gt; Not for testing purity, so you can &lt;em&gt;measure&lt;/em&gt; it. Every number here exists because &lt;code&gt;dsp.py&lt;/code&gt; imports nothing that needs hardware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"It sounds fine" is not evidence.&lt;/strong&gt; All seven bugs sounded fine, and two of them I had unconsciously reinterpreted as character.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the formula down.&lt;/strong&gt; &lt;code&gt;T60 = -3D/(fs·log10 g)&lt;/code&gt; has &lt;code&gt;D&lt;/code&gt; in it. Reading that once would have saved the shared-gain bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile before you optimise, and read your loop variables.&lt;/strong&gt; A 39 ms frame caused by a shadowed variable is not something you reason your way to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Aetheric Geometry is on &lt;a href="https://github.com/lluisestape-upc/Gestural-Harmonic-Mapping" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, PyInstaller-packaged, with &lt;code&gt;analysis/measure_dsp.py&lt;/code&gt; reproducing every measured figure above.&lt;/p&gt;

&lt;p&gt;It also became the engine underneath &lt;a href="https://www.lalal.ai/blog/how-airstems-uses-lalal-ai-api/" rel="noopener noreferrer"&gt;AirStems&lt;/a&gt;, which won the LALAL.AI Special Prize at the Musixmatch Musicathon 2026: same hands, but what they play is the separated stems of a real song.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an audio DSP student at UPC. I build &lt;a href="https://esp-plugin-store.vercel.app/" rel="noopener noreferrer"&gt;VST plugins&lt;/a&gt; and instruments you play with your hands.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>audio</category>
      <category>showdev</category>
      <category>dsp</category>
    </item>
    <item>
      <title>How I built AirStems: remixing a song's stems with your bare hands</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Thu, 03 Sep 2026 11:50:30 +0000</pubDate>
      <link>https://dev.to/lluisestape/how-i-built-airstems-remixing-a-songs-stems-with-your-bare-hands-20nl</link>
      <guid>https://dev.to/lluisestape/how-i-built-airstems-remixing-a-songs-stems-with-your-bare-hands-20nl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🏆 &lt;strong&gt;AirStems won the LALAL.AI Special Prize at the Musixmatch Musicathon 2026.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;A build log on turning LALAL.AI's separated stems into a real-time, gesture-controlled instrument.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;AirStems lets you conduct and remix a real song with your bare hands in front of a webcam. Raise or fold a finger and a stem — vocals, drums, bass, other — drops in or out. Make a fist and the whole track cuts; open your hand and it all comes back. Your left hand's height opens and closes a low-pass filter, and spreading your fingers adds reverb. Everything is locked to the beat, so a drop always lands musically, and synced lyrics scroll underneath, karaoke-style.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://youtu.be/BnNcGabujgc" rel="noopener noreferrer"&gt;▶ 30-second demo video&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It started at the Musixmatch Musicathon and is built on top of my open-source gesture instrument, Aetheric Geometry. The idea was simple: I already had an engine that let my hands play synthesised oscillators, so what if the thing my hands played was the actual vocals, drums and bass of a real track? LALAL.AI is what turns a finished song back into those independent parts, so it became the source the whole instrument plays.&lt;/p&gt;

&lt;p&gt;LALAL.AI published &lt;a href="https://www.lalal.ai/blog/how-airstems-uses-lalal-ai-api/" rel="noopener noreferrer"&gt;an interview about the story and the idea&lt;/a&gt;; this post is the technical companion — how the stems come in, how they become a real-time instrument, and the beat-sync engine that ties it together.&lt;/p&gt;

&lt;h2&gt;
  
  
  System overview
&lt;/h2&gt;

&lt;p&gt;There are two halves, and keeping them apart is the single most important design decision in the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline preparation&lt;/strong&gt; (runs once per song, and is allowed to touch the network):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LALAL.AI API&lt;/strong&gt; — separates the track into stems (WAV).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Musixmatch&lt;/strong&gt; — time-synced lyrics (line- and word-level).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cyanite&lt;/strong&gt; — BPM / key / mood tags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each writes its output to disk, indexed by the song's name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stems/&amp;lt;song&amp;gt;/vocals.wav  drums.wav  bass.wav  other.wav
lyrics/&amp;lt;song&amp;gt;.lrc   (or .richsync.json)
analysis/&amp;lt;song&amp;gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Real-time app&lt;/strong&gt; (runs every frame, and never touches the network):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;webcam ─► MediaPipe Hands ─► gesture state ─► StemEngine (sounddevice callback) ─► speakers
                                                ▲
                            stems + beat grid, loaded into memory on song load
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live loop only ever reads local files that are already in memory. No part of a network request is on the audio path, and that separation is what keeps the instrument glitch-free while you play.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep dive: stem separation → a real-time instrument
&lt;/h2&gt;

&lt;p&gt;This is the core of the project, so I'll spend the most time here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting the stems.&lt;/strong&gt; The LALAL.AI flow is three steps: upload the file, request a split, poll until it is done, then download the WAVs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# POST /upload/  -&amp;gt; source id
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;stem&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vocals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;piano&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tid&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# POST /split/stem_separator/
&lt;/span&gt;    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# poll POST /check/ until success
&lt;/span&gt;    &lt;span class="nf"&gt;download_tracks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_tracks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# save &amp;lt;stem&amp;gt;.wav
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I request each stem I want and save the result under &lt;code&gt;stems/&amp;lt;song&amp;gt;/&lt;/code&gt;. The app is deliberately source-agnostic: it loads whatever WAVs are in that folder, so during testing I can drop in local Demucs output or files from the LALAL.AI website without changing the engine at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turning files into something playable.&lt;/strong&gt; When a song loads, every stem is read, forced to stereo, resampled to the engine's rate, and — importantly — padded so that all stems share the exact same length. That makes them sample-aligned from time zero, which is what lets me mix them sample-for-sample later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;float32&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;always_2d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&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;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&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="c1"&gt;# mono -&amp;gt; stereo
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt; &lt;span class="o"&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resample&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="n"&gt;sr&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="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&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;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;stems&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pad_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;   &lt;span class="c1"&gt;# align every stem
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is loaded fully into memory up front, so the audio callback never has to read from disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which stems, and why.&lt;/strong&gt; For this instrument, four parts is the sweet spot: vocals, drums, bass, and a fourth ("other", or piano/guitar). Four maps cleanly onto four fingers, and each part is musically meaningful on its own — muting the drums or soloing the vocal are both instantly recognisable moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What mattered about the stems.&lt;/strong&gt; For a &lt;em&gt;live&lt;/em&gt; instrument, separation cleanliness matters far more than it does in a studio mix. In a mix, a little bleed between stems is masked because you hear everything together; here, the moment you solo the vocal or fully mute the drums, any bleed is exposed. Full-bandwidth, consistent-loudness stems delivered as lossless WAV are what make muting and soloing sound convincing in real time. As a useful side effect, the separated stems sum back to roughly the original track, so I can mix at close to unity gain without dividing for headroom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tradeoffs and honest feedback.&lt;/strong&gt; Separation is not instant, which is exactly why it belongs in the offline stage — you would never want to run it inside a live session. Two things would have streamlined my integration: a single call that returns all stems as one task, rather than issuing and polling a separate split per stem; and a firmer response schema, since I ended up writing defensive parsing to locate the download URLs across a couple of possible shapes. Neither is a dealbreaker. The separation quality is the part that actually makes the project possible, and that was consistently strong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The beat-sync engine
&lt;/h2&gt;

&lt;p&gt;This is the most original piece, so it gets real space. The problem it solves: if a hand gesture takes effect the instant you make it, drops and returns land at arbitrary points and it sounds sloppy. The fix is to quantise every change to the song's own beat.&lt;/p&gt;

&lt;p&gt;On load, I run librosa's beat tracker on the drums stem (the cleanest source of a pulse) to get a grid of beat timestamps and the BPM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tempo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;librosa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beat_track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;drums_mono&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;units&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beat_times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;beats&lt;/span&gt;          &lt;span class="c1"&gt;# array of beat timestamps, in seconds
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, in the audio callback, a gesture does not change the mix directly. The hands write &lt;em&gt;pending&lt;/em&gt; gains, and those are only committed to &lt;em&gt;target&lt;/em&gt; gains when a beat boundary falls inside the current audio block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# does a beat land inside this block?
&lt;/span&gt;&lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SR&lt;/span&gt;
&lt;span class="n"&gt;on_beat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beat_times&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;t0&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beat_times&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# commit the hand-wanted gains on the beat (or immediately if quantize is off)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;on_beat&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_gains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the hand decides &lt;em&gt;what&lt;/em&gt; changes, and the beat grid decides &lt;em&gt;when&lt;/em&gt;. A key press turns quantisation off for instant, raw control. The same beat crossing also drives a pulse value that the on-screen display flashes with — a small touch that makes the whole interface feel locked to the music.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand tracking → mix control
&lt;/h2&gt;

&lt;p&gt;Hand tracking is MediaPipe Hands. I assign one hand as left and one as right, and map them differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right hand = stems.&lt;/strong&gt; Each finger toggles a stem by comparing the fingertip's Y position to the knuckle below it. The difficulty is jitter: right at the threshold, a finger flickers on and off. So instead of a single line, I use two margins — the finger has to &lt;em&gt;clearly&lt;/em&gt; cross to flip, and otherwise it holds its state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt;   &lt;span class="n"&gt;tip_y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;pip_y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;UP_MARGIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;finger_up&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;     &lt;span class="c1"&gt;# clearly up
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;tip_y&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pip_y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;DOWN_MARGIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;finger_up&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;    &lt;span class="c1"&gt;# clearly down
# else: hold the previous state  (hysteresis removes the flicker)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Left hand = effects.&lt;/strong&gt; Wrist height maps to the low-pass filter (lower hand, darker sound), and how open the hand is maps to reverb. Openness is measured in a scale-invariant way, as the mean fingertip-to-knuckle distance divided by palm length, so it behaves the same regardless of how far the hand is from the camera:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;openness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;knuckle&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;each&lt;/span&gt; &lt;span class="n"&gt;finger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;palm_length&lt;/span&gt;
&lt;span class="c1"&gt;# roughly 0.3 for a fist, 0.9 for an open hand
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both continuous controls are smoothed with a simple exponential moving average, so they feel responsive without following the natural tremor of the hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gesture map&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hand&lt;/th&gt;
&lt;th&gt;Gesture&lt;/th&gt;
&lt;th&gt;Controls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Right&lt;/td&gt;
&lt;td&gt;fingers up / down&lt;/td&gt;
&lt;td&gt;stem 1–4 in / out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right&lt;/td&gt;
&lt;td&gt;fist / open palm&lt;/td&gt;
&lt;td&gt;full drop / full mix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Left&lt;/td&gt;
&lt;td&gt;wrist height&lt;/td&gt;
&lt;td&gt;low-pass filter (down = dark, up = open)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Left&lt;/td&gt;
&lt;td&gt;open / close hand&lt;/td&gt;
&lt;td&gt;reverb (open = full, fist = dry)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keys&lt;/td&gt;
&lt;td&gt;space · b · n&lt;/td&gt;
&lt;td&gt;play/pause · beat-sync on/off · next song&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Audio engine internals
&lt;/h2&gt;

&lt;p&gt;The engine is a single &lt;code&gt;sounddevice&lt;/code&gt; output stream with a stereo, block-based callback. This is where the audio-focused details live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Click-free mutes.&lt;/strong&gt; Toggling a stem instantly would click, because the gain jumps. Instead, every block ramps each stem's gain from its current value to its target across the length of the block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;audio&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;ramp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;curr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# per-block gain ramp
&lt;/span&gt;    &lt;span class="n"&gt;mix&lt;/span&gt; &lt;span class="o"&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;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;ramp&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;curr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The effects chain&lt;/strong&gt; is reused directly from Aetheric Geometry: a first-order IIR low-pass driven by hand height, a Schroeder reverb (four comb filters) driven by hand spread, and a light tremolo. The reverb's wet level is smoothed inside the callback, so opening your hand fades the space in rather than snapping it on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;200.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;8000.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;200.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;filter_bright&lt;/span&gt;    &lt;span class="c1"&gt;# hand height -&amp;gt; cutoff in Hz
&lt;/span&gt;&lt;span class="n"&gt;reverb_smooth&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reverb_wet&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;reverb_smooth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ease the wet level in/out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parameters cross from the main thread to the audio thread under a short lock, copied once at the top of each callback, so a gesture update never tears a block mid-render.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Making it feel musical rather than glitchy.&lt;/strong&gt; The beat-sync quantiser was the single biggest change: before it, hand-driven changes sounded messy; after it, everything lands in time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing gesture flicker.&lt;/strong&gt; Raw fingertip thresholds jitter, and the two-margin hysteresis above was what made stem toggles trustworthy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time safety.&lt;/strong&gt; The callback copies its parameters under a lock and avoids heavy allocation. The per-sample IIR and comb-reverb loops are the CPU hot path, so block size becomes the main knob for trading latency against stability on a given machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping the network off the audio path.&lt;/strong&gt; Doing all separation, lyrics and analysis ahead of time, and loading the stems fully into memory, is what keeps playback from ever stalling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links &amp;amp; credit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live page:&lt;/strong&gt; &lt;a href="https://lluisestape-upc.github.io/AirStems/" rel="noopener noreferrer"&gt;https://lluisestape-upc.github.io/AirStems/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo video:&lt;/strong&gt; &lt;a href="https://youtu.be/BnNcGabujgc" rel="noopener noreferrer"&gt;https://youtu.be/BnNcGabujgc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/lluisestape-upc/AirStems" rel="noopener noreferrer"&gt;https://github.com/lluisestape-upc/AirStems&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The story (interview):&lt;/strong&gt; &lt;a href="https://www.lalal.ai/blog/how-airstems-uses-lalal-ai-api/" rel="noopener noreferrer"&gt;https://www.lalal.ai/blog/how-airstems-uses-lalal-ai-api/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Built on my open-source gesture instrument &lt;strong&gt;Aetheric Geometry&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Stems by &lt;strong&gt;LALAL.AI&lt;/strong&gt;, lyrics by &lt;strong&gt;Musixmatch&lt;/strong&gt;, analysis by &lt;strong&gt;Cyanite&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Questions, or a gesture you think I should map next? I'd love to hear it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>python</category>
      <category>audio</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Building BoardRoom on Qwen Cloud for the Global AI Hackathon Series. (Track 3: Agent Society)</title>
      <dc:creator>Lluis Estape</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:01:28 +0000</pubDate>
      <link>https://dev.to/lluisestape/building-boardroom-on-qwen-cloud-for-the-global-ai-hackathon-series-track-3-agent-society-5ekb</link>
      <guid>https://dev.to/lluisestape/building-boardroom-on-qwen-cloud-for-the-global-ai-hackathon-series-track-3-agent-society-5ekb</guid>
      <description>&lt;h2&gt;
  
  
  I built a society of AI agents to review circuit boards. Then I tried to prove it was better, and nearly fooled myself three times.
&lt;/h2&gt;




&lt;p&gt;Every hardware engineer knows the specific dread of clicking "order boards." Three weeks&lt;br&gt;
and a few hundred euros later, a package arrives, and you discover you forgot a&lt;br&gt;
decoupling capacitor, or your I²C bus has no pull-ups. Nothing to do but respin.&lt;/p&gt;

&lt;p&gt;So for the Qwen Cloud hackathon I built &lt;strong&gt;BoardRoom&lt;/strong&gt;: not one AI reviewer, but a&lt;br&gt;
&lt;em&gt;society&lt;/em&gt; of five — power integrity, signal integrity, connectivity/ERC, DFM &amp;amp; layout,&lt;br&gt;
and firmware bring-up — that inspect a real KiCad project through the KiCad MCP server,&lt;br&gt;
file findings that must cite tool evidence, and argue with each other under a Moderator&lt;br&gt;
before signing off.&lt;/p&gt;

&lt;p&gt;The build went fine. It's what happened when I tried to &lt;strong&gt;prove&lt;/strong&gt; the society was better&lt;br&gt;
than a single big agent that turned out to be the interesting part. Three separate times,&lt;br&gt;
my benchmark was quietly lying in my favor. Here's each one, because I think the bugs are&lt;br&gt;
more instructive than the architecture.&lt;/p&gt;
&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The measurable claim Track 3 asks for is "a demonstrable efficiency gain over&lt;br&gt;
single-agent baselines." So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Corpus:&lt;/strong&gt; stock KiCad demo boards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seeding:&lt;/strong&gt; a script injects reproducible defects with a ground-truth manifest —
removed decoupling cap, swapped SDA/SCL, missing I²C pull-ups, floated enable pin,
renamed power rail. One board (a pure-analog valve preamp) gets &lt;em&gt;no&lt;/em&gt; defects, as an
honesty check that nobody invents findings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two configs, same inputs, same token gateway:&lt;/strong&gt; the society (five cheap specialists
on &lt;code&gt;qwen-flash&lt;/code&gt;/&lt;code&gt;qwen3-vl&lt;/code&gt;/&lt;code&gt;qwen3-coder&lt;/code&gt;, one &lt;code&gt;qwen3-max&lt;/code&gt; Moderator) versus a baseline
of one &lt;code&gt;qwen3-max&lt;/code&gt; agent with &lt;em&gt;all&lt;/em&gt; the tools and a generalist prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I ran it. &lt;strong&gt;Recall: 0.00.&lt;/strong&gt; The society caught zero of the seeded defects.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lie #1: my agents were right, my scoring was wrong
&lt;/h2&gt;

&lt;p&gt;Rather than tune anything, I dumped the actual findings. And there it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[critical] connectivity_erc: Hierarchical label VCC_PIC has no matching sheet pin
                             in the parent sheet, creating an unconnected rail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;em&gt;exactly&lt;/em&gt; the defect I'd seeded. The society found it. The scorer called it a miss.&lt;/p&gt;

&lt;p&gt;Why? The matcher compares structured fields — &lt;code&gt;affected_nets&lt;/code&gt;, &lt;code&gt;affected_components&lt;/code&gt; —&lt;br&gt;
and the agents had left them &lt;strong&gt;empty&lt;/strong&gt;, naming the net only in the prose of the claim.&lt;/p&gt;

&lt;p&gt;That's not a benchmark artifact. That's a product bug I'd have shipped: &lt;strong&gt;the report's&lt;br&gt;
headline visualization — a blast-radius graph linking findings to the nets and parts they&lt;br&gt;
touch — is built entirely from those fields.&lt;/strong&gt; My centerpiece visual was silently empty&lt;br&gt;
and I hadn't noticed, because I'd been reading the claims, which looked great.&lt;/p&gt;

&lt;p&gt;The prompts already asked for those fields politely. &lt;code&gt;qwen-flash&lt;/code&gt; ignored it. Making the&lt;br&gt;
final instruction blunt ("a claim that names a net while leaving these arrays empty is&lt;br&gt;
INVALID — the impact graph is built from these fields, not from your prose") took&lt;br&gt;
structured coverage from &lt;strong&gt;0/9 findings to 9/15&lt;/strong&gt;, and recall from 0.00 to 0.50.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; when a model "ignores" a field, check whether anything downstream actually&lt;br&gt;
depends on it. Mine did, and the benchmark found the bug my eyes didn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lie #2: I'd accidentally built a strawman
&lt;/h2&gt;

&lt;p&gt;Now the baseline. It scored 0.00, produced zero findings, and burned 6.8K tokens in 29&lt;br&gt;
seconds. I could have shipped that. "Single agent finds nothing, society wins" — a&lt;br&gt;
beautiful chart.&lt;/p&gt;

&lt;p&gt;Except: 6.8K tokens is &lt;em&gt;nothing&lt;/em&gt;. A generalist reviewing a whole board can't be done in&lt;br&gt;
29 seconds. So I looked.&lt;/p&gt;

&lt;p&gt;I'd named the baseline agent &lt;code&gt;moderator&lt;/code&gt; so it would inherit the wildcard "all tools"&lt;br&gt;
entry from my default allowlist. But &lt;code&gt;registry.yaml&lt;/code&gt; &lt;strong&gt;overrides&lt;/strong&gt; the moderator down to&lt;br&gt;
three overview tools — because in the society, the chair doesn't do analysis. My&lt;br&gt;
"single agent with all 24 tools" had been reviewing boards nearly blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool-starved baseline isn't a baseline. It's a strawman&lt;/strong&gt;, and it's exactly the thing&lt;br&gt;
a judge should nuke a submission for. Fixed: the baseline now builds its allowlist from&lt;br&gt;
defaults, genuinely getting every tool. Its token use went 6.8K → 63K.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lie #3: I was grading the baseline on handwriting
&lt;/h2&gt;

&lt;p&gt;Still 0.00, but now doing real work. Fine — maybe one agent really does drown in 24&lt;br&gt;
tools? That's a great narrative. I nearly wrote it.&lt;/p&gt;

&lt;p&gt;Instead I printed its raw output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"B-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hierarchical label mismatch for VCC_PIC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"...breaking connectivity."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"evidence_ids"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"EV-0005"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The baseline found the defect too.&lt;/strong&gt; Every finding was thrown away at the schema&lt;br&gt;
boundary because it wrote &lt;code&gt;title&lt;/code&gt;/&lt;code&gt;description&lt;/code&gt;/&lt;code&gt;evidence_ids&lt;/code&gt; instead of&lt;br&gt;
&lt;code&gt;claim&lt;/code&gt;/&lt;code&gt;evidence&lt;/code&gt;/&lt;code&gt;recommendation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And whose fault was that? Mine. Each specialist prompt carries a full worked JSON example&lt;br&gt;
of the finding shape. The baseline prompt I'd dashed off didn't. &lt;strong&gt;I was measuring prompt&lt;br&gt;
quality and calling it architecture.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I gave the baseline the same worked example the specialists get. I also normalized net&lt;br&gt;
names in the matcher — KiCad legitimately calls the same net &lt;code&gt;VCC_PIC&lt;/code&gt; or&lt;br&gt;
&lt;code&gt;/pic_sockets/VCC_PIC&lt;/code&gt;, and a correct answer shouldn't lose on a naming convention.&lt;/p&gt;

&lt;p&gt;Notice the pattern: &lt;strong&gt;all three bugs pointed the same direction.&lt;/strong&gt; None of them made the&lt;br&gt;
society look worse. That's not coincidence — it's what motivated reasoning looks like&lt;br&gt;
from the inside. You stop debugging when the number agrees with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the honest numbers say
&lt;/h2&gt;

&lt;p&gt;Six boards, twelve seeded defects, &lt;strong&gt;two independent runs per config&lt;/strong&gt;, because LLMs are&lt;br&gt;
nondeterministic and one run isn't a measurement.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;society&lt;/th&gt;
&lt;th&gt;baseline (1× qwen3-max, all tools)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seeded-defect recall (mean)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.29&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per corpus&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.147&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt tokens&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;792K&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;312K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Findings surfaced&lt;/td&gt;
&lt;td&gt;49–56&lt;/td&gt;
&lt;td&gt;6–10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hallucination rate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the token row and the cost row together, because that's the whole story:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The society burns 2.5× MORE tokens and costs 2.9× LESS.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Five cheap specialists doing &lt;em&gt;more&lt;/em&gt; total work are dramatically cheaper than one&lt;br&gt;
expensive generalist doing less. The win isn't "multi-agent is smarter." It's that&lt;br&gt;
decomposition lets you &lt;strong&gt;route by role instead of by model size&lt;/strong&gt; — you only pay&lt;br&gt;
flagship prices for the one job that actually needs a flagship: adjudicating conflicts.&lt;br&gt;
That number reproduced to within 2% across runs.&lt;/p&gt;

&lt;p&gt;And the thing I'm &lt;em&gt;not&lt;/em&gt; claiming: the society led on recall in both runs, but by exactly&lt;br&gt;
&lt;strong&gt;one defect out of twelve&lt;/strong&gt;. With n=2, that's noise — I watched the same config score&lt;br&gt;
0.50 and 0.00 on the same board across runs. So the honest claim is "comparable, possibly&lt;br&gt;
slightly better," and that's what the README says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most useful result is the one that makes my project look bad
&lt;/h2&gt;

&lt;p&gt;Absolute recall is low for &lt;em&gt;both&lt;/em&gt; configs — 0.21 to 0.29. I could have quietly picked&lt;br&gt;
easier defects. Here's why it's low instead:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most of the defects I seeded are invisible to every tool that exists.&lt;/strong&gt; Removing a&lt;br&gt;
decoupling cap creates an &lt;em&gt;absence&lt;/em&gt; — no KiCad tool reports "a part that should be here&lt;br&gt;
isn't." A swapped SDA/SCL is electrically valid, so ERC has nothing to say. The only&lt;br&gt;
class anything reliably caught is rail-rename/floating-pin, because ERC and net listings&lt;br&gt;
actually surface it.&lt;/p&gt;

&lt;p&gt;That's not an indictment of the agents. It's a finding about EDA tooling: &lt;strong&gt;the reasoning&lt;br&gt;
layer is now ahead of the observability layer.&lt;/strong&gt; These models can absolutely reason about&lt;br&gt;
a missing bypass cap — they just have no instrument that reports its absence. The&lt;br&gt;
bottleneck isn't intelligence. It's that we never built the tool, because no human needed&lt;br&gt;
one to eyeball a schematic.&lt;/p&gt;

&lt;p&gt;I think that's the most interesting thing I learned, and it only showed up because I&lt;br&gt;
reported a bad number instead of engineering around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting one of these
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Evidence gating works.&lt;/strong&gt; Every finding must cite a real cached tool-call ID, and
uncited claims are rejected at the boundary — not discouraged in a prompt.
Hallucination rate: &lt;strong&gt;0.00 across every run, both configs.&lt;/strong&gt; Make it structural and
you stop arguing with the model about honesty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce scope in code, not prose.&lt;/strong&gt; Each specialist's tool allowlist is enforced
before the call. Prompts are suggestions; allowlists are physics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route by role, not by size.&lt;/strong&gt; This is where the 2.9× lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debug the results that flatter you.&lt;/strong&gt; All three of my bugs produced &lt;em&gt;better&lt;/em&gt; numbers.
If I'd stopped at the first good chart, I'd have submitted a rigged benchmark with a
straight face.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One last confession: BoardRoom's negotiation engine — bounded two-round debates,&lt;br&gt;
evidence-cited rulings, fully unit-tested — &lt;strong&gt;never fired on real data.&lt;/strong&gt; Across the whole&lt;br&gt;
corpus, no two specialists ever produced conflicting findings. Their scopes are narrow&lt;br&gt;
enough that they rarely collide. The repo ships a clearly-labeled synthetic fixture to&lt;br&gt;
demonstrate the viewer, and says so in the README, because the alternative is faking a&lt;br&gt;
fight and calling it a demo.&lt;/p&gt;

&lt;p&gt;That's a real limitation of the design, discovered by measuring instead of assuming. Which&lt;br&gt;
is, I think, the actual point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;BoardRoom is open source (MIT): &lt;a href="https://github.com/lluisestape-upc/boardroom" rel="noopener noreferrer"&gt;https://github.com/lluisestape-upc/boardroom&lt;/a&gt; — built on&lt;br&gt;
Qwen models via Alibaba Cloud Model Studio, with the KiCad MCP server. Benchmark method,&lt;br&gt;
raw numbers and limitations: &lt;a href="https://github.com/lluisestape-upc/boardroom/blob/main/docs/BENCHMARK.md" rel="noopener noreferrer"&gt;docs/BENCHMARK.md&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>devchallenge</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
