<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Development on Charlie Chiang's blog</title><link>https://blog.chlc.cc/categories/development/</link><description>Recent content in Development on Charlie Chiang's blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sun, 16 Aug 2026 16:42:45 +0800</lastBuildDate><atom:link href="https://blog.chlc.cc/categories/development/index.xml" rel="self" type="application/rss+xml"/><item><title>Building Audio Insight: An Audio Analyzer That Actually Renders Smoothly</title><link>https://blog.chlc.cc/p/building-audio-insight/</link><pubDate>Sun, 16 Aug 2026 16:42:45 +0800</pubDate><guid>https://blog.chlc.cc/p/building-audio-insight/</guid><description>&lt;p>I like audio analyzers. It answers questions that my ears alone cannot answer quickly. Where is that resonance? Is the low end actually mono? How loud is this over the whole track? Is a limiter catching an occasional peak, or working all the time?&lt;/p>
&lt;p>What I do not like is an analyzer whose interface feels slower than the display it runs on.&lt;/p>
&lt;p>On my M1 Max, Excite Audio VISION 4X appeared to top out at roughly 30 FPS, with inconsistent frame timing, while consuming about one CPU core. iZotope Insight 2 looked smoother, but in my experience it was comparatively resource-heavy and expensive. These were observations from my own setup, not controlled benchmarks that apply to every machine, host, and plugin version. Still, they were enough to make me wonder: how difficult would it be to build the analyzer I wanted?&lt;/p>
&lt;p>That became &lt;a class="link" href="https://github.com/charlie0129/audio-insight" target="_blank" rel="noopener"
>Audio Insight&lt;/a>, an open-source AUv2 and VST3 analyzer for macOS. Its first goal is deliberately narrow: show useful measurements, leave the audio unchanged, keep real-time callback work bounded, and make the interface feel native on a high-refresh-rate display.&lt;/p>
&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard.png"
width="2400"
height="1600"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_65720109d0b02a45.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_bef71f94e6a17226.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_69cd2a1a5c959a9.webp 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_90165cad42ea4598.jpg 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_e5c6a27b39095609.webp 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_a4aec93b81a0f8da.jpg 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_6b3fb61cc0a9dbfe.webp 2048w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_af4a7725d3b77d03.jpg 2048w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_879369758ebcb0fa.webp 2400w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-dashboard_hu_b23cace1a05ee5e4.jpg 2400w"
loading="lazy"
alt="Audio Insight dashboard with all five analyzers"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>The project is still early, but it already has a Spectrum, Spectrogram, Peak/RMS meter, stereo vectorscope and correlation meter, and BS.1770 loudness measurements. Four grid-snapped splitters resize the dashboard tiles, the analysis parameters are adjustable, and a built-in metrics panel makes the renderer&amp;rsquo;s behavior visible instead of leaving performance to intuition.&lt;/p>
&lt;p>This post is about how it works, but mostly about the unexpectedly interesting work required to make a meter move smoothly.&lt;/p>
&lt;h2 id="what-an-audio-plugin-actually-does">What an audio plugin actually does
&lt;/h2>&lt;p>People who use plugins often picture them as little applications inside a DAW. That is a useful mental model for the interface, but not for the audio path.&lt;/p>
&lt;p>An AU or VST3 plugin is code loaded by a host (or, in some hosts, a separate hosting service). The host repeatedly gives the plugin a small block of samples by calling its processing function. At 48 kHz with 512-sample blocks, a new block arrives about every 10.7 milliseconds. The plugin has to finish before the hardware needs the result. Missing that deadline can produce a click or dropout.&lt;/p>
&lt;p>Audio Insight is a transparent effect: it observes supported mono or stereo audio and leaves the samples unchanged. Even so, its callback has to follow the same real-time rules as a compressor or synthesizer. It cannot allocate memory, take a lock, wait for another thread, write a log, open a file, call the UI, or ask the GPU to draw something. Any of those operations can take an unpredictable amount of time.&lt;/p>
&lt;p>The resulting design looks like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">host audio callback
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bounded, non-blocking capture
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">per-instance analysis coordinator
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">shared two-worker analysis pool
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">immutable measurement snapshots
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">display-linked Metal renderer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The callback only captures bounded chunks into preallocated storage and updates the few measurements that must inspect every sample. A per-instance coordinator coalesces work, and all instances loaded in the same plugin module share two analysis workers. There is at most one running or queued job per instance, so opening many plugin windows does not create a thread for every visualization.&lt;/p>
&lt;p>The workers publish immutable snapshots. The UI reads the newest complete snapshot whenever it draws; it never waits for analysis to finish. This separation matters. Analysis targets 60 slices per second, but the latest-wins scheduler can skip stale work rather than build a backlog. Meanwhile, a ProMotion display can render at around 120 Hz. The renderer can advance display motion between discrete analysis updates without running twice as many FFTs.&lt;/p>
&lt;p>All five visualizations share one Metal canvas, drawable, command buffer, and render pass. JUCE supplies the plugin shell and cross-format plumbing, while the visual layer is native Metal. Coordinates and layout use logical points, and drawable and text resources follow the current backing scale. The implementation is therefore designed to support both regular-density and Retina displays, including live backing-scale changes.&lt;/p>
&lt;p>Both paths are highly optimized. The shared transform uses &lt;code>juce::dsp::FFT&lt;/code>, which selects Apple&amp;rsquo;s Accelerate/vDSP implementation on macOS, and Spectrum and Spectrogram reuse each calibrated result. On the GPU side, the Spectrogram stores calibrated dB in a circular 16-bit-float (&lt;code>R16Float&lt;/code>) texture: scrolling remaps texture coordinates, while shader controls recolor retained history without another FFT or a whole-texture copy. High display cadence therefore does not multiply the default 60 Hz FFT workload.&lt;/p>
&lt;p>When the editor is closed, there is nothing to display, so capture, analysis, history, display-link callbacks, and Metal submissions stop. Audio still passes through normally. Reopening the editor begins fresh rather than silently spending host resources on invisible history.&lt;/p>
&lt;h2 id="turning-samples-into-pictures">Turning samples into pictures
&lt;/h2>&lt;p>The analyzers share infrastructure, but each one answers a different question. Here is the calculation path in a little more detail.&lt;/p>
&lt;h3 id="spectrum-what-frequencies-exist-now">Spectrum: what frequencies exist now?
&lt;/h3>&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum.png"
width="1964"
height="686"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_2387749c0d8ba064.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_664cee83eb2c0ad.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_fbdd5b971d8cb430.webp 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_deaf9f5d2535fbbb.jpg 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_c52894cca3685b9.webp 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_b740d68172460d99.jpg 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_bdaa1a07fea7d5df.webp 1964w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum_hu_1cda9b55c8a5a78c.jpg 1964w"
loading="lazy"
alt="Audio Insight Spectrum"
class="gallery-image"
data-flex-grow="286"
data-flex-basis="687px"
>
&lt;/p>
&lt;p>The Spectrum takes a short window of recent audio and uses a fast Fourier transform (FFT) to divide it into frequency bins. By default the transform size is \(N=8192\) samples, or about 171 milliseconds at a sample rate \(F_s=48\) kHz. This does &lt;strong>not&lt;/strong> delay the audio by 171 ms; the plugin passes audio through immediately. It means the displayed estimate describes roughly that much recent history.&lt;/p>
&lt;p>The FFT bin centers are separated by \(\Delta f=F_s/N\). With the defaults, that is approximately 5.86 Hz. This number is useful, but it is not the same as saying two tones 5.86 Hz apart can always be resolved: the selected window also determines how widely a tone spreads into nearby bins.&lt;/p>
&lt;p>Before the FFT, samples are multiplied by a periodic five-term flat-top window \(w[n]\). Cutting an arbitrary piece from a continuous waveform creates artificial edges, which spread energy across the spectrum. A window tapers the data to control that leakage. A flat-top window trades some ability to separate nearby tones for better amplitude accuracy, which is a useful default for a measurement tool.&lt;/p>
&lt;p>For channel \(c\), the transform is:&lt;/p>
\[
\begin{aligned}
X_c[k]&amp;=\sum_{n=0}^{N-1}x_c[n]\,w[n]e^{-j2\pi kn/N},\\
f_k&amp;=\frac{kF_s}{N}.
\end{aligned}
\]&lt;p>Audio Insight corrects the window&amp;rsquo;s coherent gain—the amplitude scaling introduced by multiplying by \(w[n]\)—with \(W=\sum_n w[n]\). Real-valued audio has mirrored positive- and negative-frequency FFT bins, but the graph needs only the nonnegative half. In this one-sided view, the DC bin at 0 Hz and the Nyquist bin at \(F_s/2\) use \(1/W\); every bin between them represents both mirrored sides and uses \(2/W\). The calibrated stereo power and level are therefore:&lt;/p>
\[
\begin{aligned}
a_k&amp;=
\begin{cases}
1/W, &amp; k=0\ \text{or}\ k=N/2,\\
2/W, &amp; \text{otherwise},
\end{cases}\\[3pt]
P[k]&amp;=\max_c\left(a_k|X_c[k]|\right)^2,\\
D[k]&amp;=10\log_{10}P[k].
\end{aligned}
\]&lt;p>For mono, the maximum contains only one channel. For stereo, taking the larger channel magnitude avoids first mixing the waveforms to mono, where out-of-phase content could cancel. The calibration makes a bin-centered full-scale sine read 0 dB internally; powers at or below \(10^{-18}\) are displayed at the \(-180\) dB analysis floor.&lt;/p>
&lt;p>Attack and Release then smooth each bin in &lt;strong>linear power&lt;/strong>, not in dB. Given the elapsed time \(\Delta t\) and the selected time constant \(\tau_d\):&lt;/p>
\[
\begin{aligned}
\alpha_d&amp;=
\begin{cases}
0, &amp; d\text{ is Off},\\
e^{-\Delta t/\tau_d}, &amp; d\text{ is enabled},
\end{cases}\\[3pt]
\bar P_t[k]&amp;=\alpha_d\bar P_{t-1}[k]+(1-\alpha_d)P_t[k].
\end{aligned}
\]&lt;p>The direction \(d\) is Attack when \(P_t[k]\geq\bar P_{t-1}[k]\), otherwise Release. An Off direction follows the current FFT immediately. The default Attack is Off, allowing a short burst to appear at once, while the default 250 ms Release lets the trace fall more slowly. Peak hold, when enabled, operates on unsmoothed power instead of \(\bar P\).&lt;/p>
&lt;p>Transforms target a slice rate \(R_s\) using a hop of \(H=\max(1,\operatorname{round}(F_s/R_s))\) new samples. At 48 kHz and 60 slices per second, \(H=800\), so adjacent 8,192-sample windows overlap by about 90.2%. The first result still waits for one complete window, and the latest-wins scheduler may skip stale transforms under load instead of building a backlog.&lt;/p>
&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings.png"
width="2400"
height="1600"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_9787958d4bb832d.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_e4c42b32afd89577.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_b65c00a7ce269ec9.webp 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_4e962807fc7188dc.jpg 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_e2311e7526ab5e66.webp 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_c78d3014e83d6dbb.jpg 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_6528c9c2772ad63b.webp 2048w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_9bfee6b347dac769.jpg 2048w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_796a3c92f524a6a9.webp 2400w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_e5c6f6a008c1f78b.jpg 2400w"
loading="lazy"
alt="Spectrum Attack and Release controls"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>Spectrum and Spectrogram use the same continuously adjustable frequency scale. For a frequency \(f\) between \(f_0\) and \(f_1\), the scale control \(s\) blends normalized linear and logarithmic coordinates:&lt;/p>
\[
\begin{aligned}
u_{\mathrm{lin}}(f)&amp;=\frac{f-f_0}{f_1-f_0},\\
u_{\log}(f)&amp;=\frac{\ln(f/f_0)}{\ln(f_1/f_0)},\\
u(f,s)&amp;=(1-s)u_{\mathrm{lin}}(f)+s\,u_{\log}(f).
\end{aligned}
\]&lt;p>Here \(f_0=20\) Hz and \(f_1=\min(20\text{ kHz},F_s/2)\). The default is \(s=0.8\). At \(s=0\), equal distances represent equal numbers of hertz. At \(s=1\), equal ratios such as 100→200 Hz and 1→2 kHz occupy equal distances. Values in between preserve more low-frequency detail without compressing the entire treble into a tiny area. Spectrum uses \(x=u\), while Spectrogram uses \(y=1-u\) so high frequencies appear at the top. Axis labels are chosen dynamically: important anchors win first, then extra candidates fill only the space that remains.&lt;/p>
&lt;h3 id="spectrogram-how-did-the-spectrum-change">Spectrogram: how did the spectrum change?
&lt;/h3>&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram.png"
width="1570"
height="758"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_7f3da03990dee5bf.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_e61b0ccc823d3d7d.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_3e3ec7e159f2c15d.webp 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_d8e057932d6094d3.jpg 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_ff773f267ecd87d6.webp 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_2135715b92aae3fd.jpg 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_e23e3c8120487b46.webp 1570w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-spectrogram_hu_1b464000e76048f6.jpg 1570w"
loading="lazy"
alt="Audio Insight Spectrogram"
class="gallery-image"
data-flex-grow="207"
data-flex-basis="497px"
>
&lt;/p>
&lt;p>A Spectrum is one slice through time. A Spectrogram keeps those slices and scrolls them sideways, using color for level. Transients become vertical marks, steady tones become horizontal lines, and harmonics become stacks of related lines.&lt;/p>
&lt;p>Each Spectrogram column starts from the same raw power \(P[k]\) as Spectrum, before Spectrum&amp;rsquo;s Attack/Release averaging. Let \(\mathcal K\) contain only usable bin centers from 20 Hz through \(f_1\), and let \(R_f=\min(1024,|\mathcal K|)\) be the texture&amp;rsquo;s frequency-row count. For a usable bin at \(f_k=kF_s/N\), define \(q_k=u(f_k,s)\). Its row is:&lt;/p>
\[
r(k)=\min\left(R_f-1,\left\lfloor R_f q_k\right\rfloor\right).
\]&lt;p>For a row containing one or more bin centers, \(P_r\) is the greatest \(P[k]\) assigned to that row. Taking the maximum, rather than the average, helps a narrow tonal trace survive when several FFT bins land in one display row. If a row contains no bin center—common at low frequencies with a small FFT—the mapper inverse-maps the row center and linearly interpolates the two surrounding bins in power. It is honest interpolation between available samples, not a claim of extra frequency resolution.&lt;/p>
&lt;p>Power at or below \(10^{-18}\) becomes \(-180\) dB; otherwise the mapper stores \(D_r=10\log_{10}P_r\). These values go into a circular Metal texture with one 16-bit floating-point level per cell (R16Float). The texture stores calibrated dB rather than finished colors. In the shader, let \(F\) be the selected floor, \(C\) the ceiling, and \(\eta\) the Color response:&lt;/p>
\[
\begin{aligned}
v&amp;=\operatorname{clamp}\left(
\frac{D_r-F}{C-F},0,1\right),\\
\gamma&amp;=2^\eta,\\
c_{\mathrm{palette}}&amp;=v^\gamma.
\end{aligned}
\]&lt;p>The value \(c_{\mathrm{palette}}\) selects a point in the chosen palette. Response 0 is linear in dB; negative values reveal quieter detail, while positive values suppress low energy and emphasize stronger traces. Because this work happens in the shader, changing palette, range, or response recolors existing history without rerunning the FFT.&lt;/p>
&lt;p>For a history duration \(T\) and requested slice rate \(R_s\), the texture uses \(\min(8192,\lceil TR_s\rceil)\) columns. The default ten seconds at 60 slices per second therefore needs 600 columns. A write index wraps around the texture, and the renderer changes texture coordinates instead of copying the whole image to scroll it. Missing timestamp intervals become black columns rather than stretching old information across time.&lt;/p>
&lt;h3 id="peak-and-rms-how-strong-is-the-signal">Peak and RMS: how strong is the signal?
&lt;/h3>&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-peak-rms.png"
width="534"
height="686"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-peak-rms_hu_52e90250c2611238.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-peak-rms_hu_5a4dbf3bd0e0c780.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-peak-rms_hu_3a0f949a87b5d6b1.webp 534w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-peak-rms_hu_de0186bebe1eb659.jpg 534w"
loading="lazy"
alt="Audio Insight Peak/RMS meter"
class="gallery-image"
data-flex-grow="77"
data-flex-basis="186px"
>
&lt;/p>
&lt;p>Peak and RMS intentionally describe different things.&lt;/p>
&lt;p>Sample peak examines every sample. Its live value has instantaneous attack and a 20 dB/s release:&lt;/p>
\[
\begin{aligned}
\lambda_p&amp;=10^{-20/(20F_s)},\\
p[n]&amp;=\max\left(|x[n]|,\lambda_p p[n-1]\right).
\end{aligned}
\]&lt;p>In other words, a new larger sample wins immediately; otherwise the old indication decays by the amount corresponding to one sample period. A separate hold marker keeps a new maximum for two seconds, then falls at the same 20 dB/s rate. The OVER indicator latches when \(|x[n]|\geq1\), although the label deliberately does not claim that floating-point audio at 0 dBFS proves waveform clipping.&lt;/p>
&lt;p>RMS estimates sustained signal power. With the 300 ms time constant \(\tau=0.300\) s, Audio Insight updates an exponential mean square for every sample:&lt;/p>
\[
\begin{aligned}
\alpha&amp;=e^{-1/(F_s\tau)},\\
q[n]&amp;=\alpha q[n-1]+(1-\alpha)x[n]^2,\\
\operatorname{RMS}[n]&amp;=\sqrt{q[n]},\\
D_{\mathrm{RMS}}[n]&amp;=20\log_{10}\operatorname{RMS}[n].
\end{aligned}
\]&lt;p>This is an exponential response, not a rectangular box containing exactly the latest 300 ms. It also has no AES17 \(+3.01\) dB calibration offset, so a full-scale sine reads approximately \(-3.01\) dBFS RMS. Peak reveals brief extremes; RMS behaves more like a view of sustained energy. The peak remains a &lt;strong>sample peak&lt;/strong>, not an oversampled true-peak/dBTP measurement, so it does not predict a possibly larger value between stored samples.&lt;/p>
&lt;p>These ballistics run on the bounded real-time capture path and inspect every sample. Their meaning therefore does not change if an analysis worker is briefly late.&lt;/p>
&lt;h3 id="stereo-how-are-left-and-right-related">Stereo: how are left and right related?
&lt;/h3>&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-stereo-correlation.png"
width="576"
height="650"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_263c226881c3769f.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_75cef76ae6f2d730.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_23357ccd719a8e4e.webp 576w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_81e9cc669ba79053.jpg 576w"
loading="lazy"
alt="Audio Insight Vectorscope and Correlation meter"
class="gallery-image"
data-flex-grow="88"
data-flex-basis="212px"
>
&lt;/p>
&lt;p>The vectorscope turns each stereo sample pair into a point:&lt;/p>
\[
x_{\mathrm{scope}}=\frac{R-L}{2},
\qquad
y_{\mathrm{scope}}=\frac{L+R}{2}.
\]&lt;p>Audio shared equally by both channels has \(x=0\) and lies on the vertical center axis. Opposite-phase audio has \(y=0\) and spreads horizontally. The coordinates remain tied to full scale rather than being normalized independently on every frame, so a quiet signal is not made to look artificially loud.&lt;/p>
&lt;p>The field keeps the latest 250 ms but bounds its GPU data. For \(W_f=\lceil0.25F_s\rceil\) captured frames, the worker selects one pair every:&lt;/p>
\[
d=\left\lceil\frac{W_f}{4096}\right\rceil
\]&lt;p>frames. At 48 kHz, \(W_f=12000\), \(d=3\), and the cloud contains about 4,000 uniformly spaced points. Their opacity fades with age. This decimation changes only the picture; it does not change the correlation measurement.&lt;/p>
&lt;p>The adjacent correlation value uses every sample, with 300 ms exponentially weighted averages:&lt;/p>
\[
\begin{aligned}
\alpha&amp;=e^{-1/(F_s\cdot0.300)},\\
E_n[z]&amp;=\alpha E_{n-1}[z]+(1-\alpha)z[n].
\end{aligned}
\]\[
\rho[n]=\frac{E_n[LR]}{\sqrt{E_n[L^2]E_n[R^2]}}.
\]&lt;p>The three running values \(E[L^2]\), \(E[R^2]\), and \(E[LR]\) advance in source-sample order on the real-time side, and the implementation clamps the final ratio to \([-1,1]\) against numerical error. A value near \(+1\) means the channels are strongly alike, \(0\) means little linear relationship, and a negative value warns that mono playback may cancel important content. If either averaged channel power is below \(10^{-9}\), equivalent to \(-90\) dBFS RMS, correlation is reported as unavailable rather than dividing by a nearly zero value. A genuinely mono input is labeled MONO and likewise does not receive a synthetic \(+1\) correlation.&lt;/p>
&lt;h3 id="loudness-how-loud-does-it-feel-over-time">Loudness: how loud does it feel over time?
&lt;/h3>&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-loudness.png"
width="384"
height="650"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-loudness_hu_7320b9503e1a0dce.webp 384w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-loudness_hu_ca5aed9118a6d0df.jpg 384w"
loading="lazy"
alt="Audio Insight Loudness meters"
class="gallery-image"
data-flex-grow="59"
data-flex-basis="141px"
>
&lt;/p>
&lt;p>Raw peak level is not perceived loudness. Audio Insight implements BS.1770-5 K-weighting with the Momentary, Short-term, and Integrated semantics commonly used with EBU R128. K-weighting is a pair of filters: a high-frequency shelf models the head&amp;rsquo;s acoustic effect, and a high-pass stage reduces the contribution of very low frequencies. The code derives their coefficients for the current sample rate.&lt;/p>
&lt;p>If \(y_c[n]\) is the K-weighted output of channel \(c\), Audio Insight forms the per-sample energy and a window mean:&lt;/p>
\[
\begin{aligned}
e[n]&amp;=\sum_c y_c[n]^2,\\
z_W&amp;=\frac{1}{N_W}\sum_{n\in W}e[n].
\end{aligned}
\]&lt;p>For the supported mono and stereo layouts, every actual channel has unit weight. Mono therefore contributes once; it is never duplicated into synthetic left and right channels. Surround layouts and their channel weights are outside the current scope. Window energy becomes LUFS (Loudness Units relative to Full Scale) using the BS.1770 offset:&lt;/p>
\[
L_W=-0.691+10\log_{10}z_W.
\]&lt;ul>
&lt;li>Momentary loudness covers 400 ms.&lt;/li>
&lt;li>Short-term loudness covers 3 seconds.&lt;/li>
&lt;li>Integrated loudness uses 400 ms blocks completed every 100 ms—75% overlap—from the latest Reset within the current uninterrupted, visible analysis interval.&lt;/li>
&lt;/ul>
&lt;p>Momentary and Short-term are simple ungated window measurements. Integrated loudness applies gates: thresholds that exclude blocks from the long-term average. For each 400 ms block \(i\), let its mean-square energy be \(z_i\) and its loudness be \(L_i=-0.691+10\log_{10}z_i\). The absolute-passing set is:&lt;/p>
\[
\begin{aligned}
\mathcal A&amp;=\{i\mid L_i>-70\ \mathrm{LUFS}\},\\
\mu_{\mathcal A}&amp;=\frac{1}{|\mathcal A|}\sum_{i\in\mathcal A}z_i.
\end{aligned}
\]&lt;p>At each Integrated update, one non-iterative relative threshold is calculated 10 LU below the preliminary absolute-gated mean of the history accumulated so far:&lt;/p>
\[
\begin{aligned}
\Gamma_{\mathrm{rel}}&amp;=-0.691+10\log_{10}\mu_{\mathcal A}-10,\\
\mathcal R&amp;=\{i\in\mathcal A\mid L_i>\Gamma_{\mathrm{rel}}\}.
\end{aligned}
\]&lt;p>Finally:&lt;/p>
\[
\begin{aligned}
\bar z_{\mathcal R}&amp;=\frac{1}{|\mathcal R|}\sum_{i\in\mathcal R}z_i,\\
L_I&amp;=-0.691+10\log_{10}\bar z_{\mathcal R}.
\end{aligned}
\]&lt;p>Both comparisons are strict \(>\), and the relative gate is not iterated repeatedly. This two-stage gate prevents silence and very quiet passages from dragging the program average down indefinitely. The tile&amp;rsquo;s Reset command restarts Integrated loudness while ready Momentary/Short-term values and K-weighting continuity remain intact. Editor reactivation, an audio discontinuity, or a format change resets the complete loudness analyzer.&lt;/p>
&lt;p>The empty cases are explicit too. If \(\mathcal A\) contains no blocks, the preliminary mean and relative gate remain unavailable. If \(\mathcal R\) is empty, Integrated loudness remains \(-\infty\). The implementation never divides by an empty set.&lt;/p>
&lt;p>The implementation does not claim complete EBU Mode compliance: it does not yet include LRA or true peak, for example. The label describes its M/S/I measurement semantics, not a certification.&lt;/p>
&lt;p>There is an interesting performance problem hiding in Integrated loudness. Within one uninterrupted visible measurement, the exact answer can cover 24 hours: up to 864,000 blocks. Rescanning every qualifying block every 100 ms would make the cost grow throughout the measurement.&lt;/p>
&lt;p>The implementation uses a preallocated sorted index called a B+ tree. It contains finite block energies above the absolute gate and keeps aggregate counts and sums in its branches; all completed blocks still count toward the 24-hour limit. A new relative-gate boundary can be answered by finding one boundary leaf and combining a bounded number of branch totals. Capacity for the worst case occupies about 7.25 MiB on arm64, and the structure never allocates while processing.&lt;/p>
&lt;h2 id="smooth-is-a-timing-property-not-an-fps-number">Smooth is a timing property, not an FPS number
&lt;/h2>&lt;p>The renderer uses &lt;code>CAMetalDisplayLink&lt;/code>, which supplies a drawable in step with a display. While visible, Audio Insight requests the active display&amp;rsquo;s exact reported maximum refresh rate, with a 60 Hz fallback. That request is best effort—Core Animation and the compositor still control actual presentation—so measured presentation timestamps are the truth.&lt;/p>
&lt;p>This distinction became important repeatedly. A counter can say 120 callbacks per second while the screen still changes only 60 times. An average can say 120 FPS while an occasional doubled interval makes scrolling visibly hitch. Smoothness is about the complete chain from callback to presentation and about the distribution of frame intervals, not just one large number.&lt;/p>
&lt;p>&lt;img src="https://blog.chlc.cc/images/audio-insight-soundsource.png"
loading="lazy"
alt="Audio Insight running inside SoundSource"
>
&lt;/p>
&lt;h3 id="the-plugin-that-crashed-its-host">The plugin that crashed its host
&lt;/h3>&lt;p>The first AU build appeared for a moment in SoundSource and then disappeared. The host reported only that its Audio Unit hosting service had crashed.&lt;/p>
&lt;p>The detailed log led to an assertion in timed drawable presentation. A normal Metal application may call an API such as timed &lt;code>present&lt;/code>, but a drawable delivered by &lt;code>CAMetalDisplayLink&lt;/code> has different presentation ownership. Combining the two caused the hosting process to assert. The correct sequence is to commit the command buffer and call plain &lt;code>present()&lt;/code> on that drawable, while using the display link&amp;rsquo;s target timestamp only for telemetry and scheduling.&lt;/p>
&lt;p>This is one reason plugin development needs testing in real hosts. SoundSource exposed an API misuse that a successful build or unit test had not.&lt;/p>
&lt;h3 id="why-120-display-callbacks-produced-60-frames">Why 120 display callbacks produced 60 frames
&lt;/h3>&lt;p>After the crash was fixed, the display link was firing close to 120 times per second, but only about 60 frames were submitted. The built-in metrics captured the pattern:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Counter&lt;/th>
&lt;th style="text-align: right">Before the fix&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Display-link callbacks&lt;/td>
&lt;td style="text-align: right">6,453&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metal submissions&lt;/td>
&lt;td style="text-align: right">3,230&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU-backpressure drops&lt;/td>
&lt;td style="text-align: right">3,223&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sampled display-link callback rate&lt;/td>
&lt;td style="text-align: right">~111/s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sampled Metal submission rate&lt;/td>
&lt;td style="text-align: right">~59.6/s&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The cumulative counters cover the full telemetry epoch; the two rates are a sample from its final roughly 0.25 seconds. Almost exactly every other display-link callback was being rejected by the in-flight buffer pool.&lt;/p>
&lt;p>The surprising part was that the GPU was not necessarily too slow. Reusable vertex buffers were retained until the drawable was actually presented. The compositor may hold a drawable for several refresh periods even after GPU execution has completed, so all reusable buffers became occupied and the next callback had nowhere to write.&lt;/p>
&lt;p>The fix was to separate two lifetimes. GPU command completion now releases the reusable buffers immediately. A small, independent object survives only to correlate the later presentation timestamp. The renderer no longer holds large working resources hostage to compositor timing.&lt;/p>
&lt;p>In a later point-in-time M1 Max capture from another development build, with the Metrics panel visible, the drawable was 2,400×1,496 pixels at 2× backing scale. The run recorded 1,188 display-link callbacks, 1,188 submissions, and zero GPU-backpressure drops. Across the most recent 240 presented intervals, the average was 8.438 ms, or 118.52 Hz; 237 intervals were the normal 8.333 ms and three doubled to 16.667 ms. Telemetry also counted 11 skipped presentations over the run. That capture used the then-selected 16,384-point FFT; today&amp;rsquo;s default is 8,192.&lt;/p>
&lt;p>The capture demonstrates that the buffer-lifetime bottleneck and its GPU-backpressure drops were gone. It is evidence of approximately display-rate presentation in that run, not a perfect-pacing claim or a controlled comparison with another plugin.&lt;/p>
&lt;h3 id="making-60-hz-data-scroll-on-a-120-hz-display">Making 60 Hz data scroll on a 120 Hz display
&lt;/h3>&lt;p>The Spectrogram exposed a second kind of stutter. New analysis columns arrive at 60 Hz. If the image moves forward only when a complete column arrives, it necessarily steps every other frame on a 120 Hz display.&lt;/p>
&lt;p>The solution was not to double the FFT workload. The renderer advances a fractional scroll head from the target presentation clock while keeping the actual dB cells discrete. A one-slice cushion absorbs ordinary analysis scheduling jitter. If a texture upload is briefly busy, the renderer postpones that upload while continuing to draw the rest of the dashboard.&lt;/p>
&lt;p>The result is much smoother motion from the same 60-slices-per-second target. This also explains why raising thread priority would have been the wrong first response: the main issue was the relationship between two clocks, not a shortage of real-time privileges.&lt;/p>
&lt;h3 id="the-random-resets-that-were-not-random">The random resets that were not random
&lt;/h3>&lt;p>During longer sessions, all graphs would occasionally reset. The recovery was intentional—when audio history has a real gap, temporal analyzers must not pretend the samples on either side were adjacent—but the handoff overflowed far too easily even while host audio was continuous. Sequence tracking then correctly detected the resulting loss.&lt;/p>
&lt;p>The original capture queue had 16 logical slots and consumed one for each host callback. Its time capacity therefore depended on the host&amp;rsquo;s block size. A metrics capture reached all 16 ready slots, discarded 20 queued chunks to make room for newer audio, and recorded three consumer discontinuities followed by three Loudness resets.&lt;/p>
&lt;p>The redesigned queue packs audio across callback boundaries into 128 slots of 256 frames, retaining 32,768 frames regardless of host callback size. That is about 683 ms at 48 kHz, 341 ms at 96 kHz, or 171 ms at 192 kHz. The capacity and overflow behavior are covered by implementation tests; longer post-redesign host runs remain part of validation. A sufficiently long stall can still overflow it. When that happens, latest data wins and temporal analyzers reset, because joining unrelated pieces of audio would produce convincing but false measurements.&lt;/p>
&lt;h2 id="building-observability-into-the-plugin">Building observability into the plugin
&lt;/h2>&lt;p>Apple&amp;rsquo;s Metal HUD is useful for applications that enable it before creating their first Metal device. A plugin usually arrives after its host has already done that, so it cannot reliably switch the HUD on from a settings button. I replaced that idea with a built-in performance panel available in Release builds.&lt;/p>
&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics.png"
width="2400"
height="1600"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_3e35564a57531db1.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_17a99fe9b1840b47.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_a50d2e90557737cb.webp 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_56e8ba0c2aa2fcf5.jpg 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_c16f0ce7449dffc6.webp 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_7ee062fc54661c12.jpg 1536w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_91a2ab2ced51d103.webp 2048w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_5058b4d74de2b309.jpg 2048w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_8f5e390e440110af.webp 2400w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics_hu_f2af51aec60caaa4.jpg 2400w"
loading="lazy"
alt="Frame pacing and latency composition in the Metrics panel"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>&lt;img src="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics-details.png"
width="1030"
height="1492"
srcset="https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics-details_hu_8fc88fe5b45184b.webp 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics-details_hu_6855df2f78d2e266.jpg 480w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics-details_hu_7bb34ff9e83af0ff.webp 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics-details_hu_ece4367265690785.jpg 1024w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics-details_hu_ce43a78db3f44ba9.webp 1030w, https://blog.chlc.cc/p/building-audio-insight/images/audio-insight-metrics-details_hu_392c9d2f73db95ad.jpg 1030w"
loading="lazy"
alt="Metric details"
class="gallery-image"
data-flex-grow="69"
data-flex-basis="165px"
>
&lt;/p>
&lt;p>It reports exact frame pacing over the latest 240 presentation intervals, derived from 241 timestamps; CPU, submit, GPU, and compositor latency composition; display-link scheduling; audio-callback histograms; queue occupancy and discontinuities; analyzer freshness; and raw copyable metrics for offline inspection. The stacked latency bar covers the pipeline from a display-link callback to presentation. It is not a breakdown of an 8.33 ms frame budget: several frames can overlap in flight, so its total can exceed one refresh interval without reducing presentation cadence.&lt;/p>
&lt;p>Graphs move at vblank, headline numbers refresh at no more than 10 Hz, and the full text table refreshes at 4 Hz. That keeps the visual feedback immediate without rebuilding lots of text 120 times per second. Instrumentation turned several vague reports—“it looks a bit laggy,” “it seems to reset”—into specific, actionable failures.&lt;/p>
&lt;h2 id="an-agent-assisted-human-tested-development-loop">An agent-assisted, human-tested development loop
&lt;/h2>&lt;p>I used coding agents to implement much of Audio Insight. That did not remove the need for a tight feedback loop; it made the loop more important.&lt;/p>
&lt;p>The agents could design the threading model, inspect crash logs, add instrumentation, and reason from raw captures. They could build the plugin, but they could not reliably judge how motion felt inside my particular SoundSource setup. At runnable milestones I installed the AU, watched it on the M1 Max, adjusted settings, and returned observations, screenshots, logs, or copied metrics. Those reports led directly to the presentation-lifetime fix, the fractional Spectrogram scroll, dynamic axis labeling, and the queue redesign.&lt;/p>
&lt;p>For visual and real-time software, “the code is correct” and “the product feels right” are different claims. An instrumented implementation plus a person looking at the actual display proved far more useful than guessing at either one in isolation.&lt;/p>
&lt;h2 id="what-is-open-source-today">What is open source today
&lt;/h2>&lt;p>The current code identifies itself as Audio Insight 0.1.0. It targets macOS 15 on arm64 and builds AUv2 and VST3. It uses C++20, CMake, a pinned JUCE submodule for the plugin shell, CPU FFT analysis accelerated by Apple&amp;rsquo;s vDSP on macOS, and a native Metal renderer. Project-owned code is licensed under AGPL-3.0-or-later; JUCE retains its own upstream AGPL terms.&lt;/p>
&lt;p>The current release policy is pragmatic for a small open-source project: builds use ad hoc signing, and Developer ID signing and notarization are out of scope. Users can build from source. For a downloaded bundle, the documented flow is to verify the published checksum, extract it, clear quarantine only on the intended bundle, apply an ad hoc signature, and verify that signature.&lt;/p>
&lt;p>Older macOS versions, Intel/Universal builds, Windows, and AUv3 are architectural possibilities rather than current support promises. Logic compatibility, broader VST3 host coverage, multi-instance stress testing, and several formal performance gates also remain work in progress.&lt;/p>
&lt;p>The source, build instructions, and current limitations are all in the &lt;a class="link" href="https://github.com/charlie0129/audio-insight" target="_blank" rel="noopener"
>Audio Insight repository&lt;/a>. If you use analyzers but have never looked inside one, I hope the code makes the path from samples to pixels a little less mysterious. And if the Spectrogram glides across a 120 Hz display without drawing attention to the renderer, that is exactly the point.&lt;/p></description></item></channel></rss>