<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: nadai</title>
    <description>The latest articles on DEV Community by nadai (@nadai).</description>
    <link>https://dev.to/nadai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3935183%2Fc33c337b-5e5a-4052-ac05-3bc8b6c2f5d4.png</url>
      <title>DEV Community: nadai</title>
      <link>https://dev.to/nadai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nadai"/>
    <language>en</language>
    <item>
      <title>I built a Mac app that routes all system audio through a DSP chain you design yourself</title>
      <dc:creator>nadai</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:47:33 +0000</pubDate>
      <link>https://dev.to/nadai/i-built-a-mac-app-that-routes-all-system-audio-through-a-dsp-chain-you-design-yourself-18co</link>
      <guid>https://dev.to/nadai/i-built-a-mac-app-that-routes-all-system-audio-through-a-dsp-chain-you-design-yourself-18co</guid>
      <description>&lt;p&gt;Sonir Bench is a macOS app that takes every sound your Mac plays, runs it through signal processing you design, and sends the result to your DAC. The beta went live in October 2026.&lt;/p&gt;

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

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

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

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Virtual audio device&lt;/strong&gt;: an AudioServerPlugIn written in C, running inside &lt;code&gt;coreaudiod&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daemon&lt;/strong&gt;: a headless Rust process that owns all audio processing and device I/O&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI&lt;/strong&gt;: Tauri 2 + React 19, in a separate process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2elgconpyvk0h3l3pew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi2elgconpyvk0h3l3pew.png" alt="Overview" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;That's enforced by types, not by a setting or a warning. Every audio buffer carries its origin as a type parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Two origins: captured from the virtual device, or not&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nb"&gt;Owned&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Captured&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Only Owned audio can be written to a file&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;trait&lt;/span&gt; &lt;span class="n"&gt;FileSink&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Sample&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Sample&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Audio&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Owned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Sample&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no conversion from &lt;code&gt;Captured&lt;/code&gt; to &lt;code&gt;Owned&lt;/code&gt;. Mixing two signals yields &lt;code&gt;Captured&lt;/code&gt; if either side was captured. "You can't write captured audio to a file" is tested as a &lt;code&gt;compile_fail&lt;/code&gt; doctest: code that tries it must not compile.&lt;/p&gt;

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

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

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

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

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

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

&lt;p&gt;Next in the series: running a 1,048,576-tap convolution in real time without adding latency.&lt;/p&gt;




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

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