<?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>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>
