Building X-Ray
In short›
- What it is
- A mix analysis plug-in. It sits on one track, listens to a second one through a sidechain, and shows where the two are competing for the same frequency band. It never processes the audio.
- What it measures
- Per band, ten times a second, it takes the smaller of the two tracks' energy and divides it by the pair's total. A band only counts as contested when both tracks are actually in it, which is what keeps it quiet on 90.3 percent of track pairs.
- How it was checked
- Blind listening on positions picked by measurement rather than by the detector, across 51 folders of stems. The result that shaped the product: masking does not make things disappear, it costs contour, and two tracks at equal level cost more of it than one burying the other.
- How it is built
- Python prototype first, then a JUCE-free C++ core verified against it: a 4096 point FFT grouped into 30 bands on a background thread, fed by a lock free ring buffer, costing 0.002 percent of the audio thread's budget. The interface is a JUCE WebView.
The case it was built for is kick against bass: two elements that both want the bottom two octaves, and turn each other to mush when they get them. You can hear that something is wrong long before you can point at the band it is happening in. X-Ray points at the band.
It went the same way every DSP project of mine goes, and the order is the point: read up on the problem first, build it in Python where it is cheap to be wrong, put it in front of my ears before trusting a single number, and only then write the C++ that has to survive a real-time audio thread. This is that walk, one stage at a time.
01 · Research: what counts as a conflict
Two tracks clash when they are both loud in the same part of the spectrum at the same moment. The trick is turning that sentence into a number that behaves.
X-Ray splits the signal into 30 frequency bands and, ten times a second, asks one question per band: how much energy does the main track have here, and how much does the sidechain track have here? It then keeps the smaller of the two.
That single choice does most of the work. If the kick is loud at 60 Hz and the bass is not, the smaller value is near zero: nobody is fighting, one track owns that band. Only when both tracks are pushing hard in the same band does the smaller value get big. A conflict needs two participants, and the minimum is what refuses to report unless both showed up.
The last step is to divide that value by the total energy the pair is putting out. Without it, loud passages would flag constantly and quiet ones never would, and you would just be building a loudness meter with extra steps. With it, the number asks "how much of what these two tracks are doing right now is them being in each other's way", which is the actual question. I called it contention.
02 · Prototype: Python first, always
None of that goes into C++ until it is settled, because in Python I can rewrite the whole detector over a coffee and in C++ I cannot. The prototype reads stems from disk, runs the analysis offline, and dumps every intermediate value to a file I can plot.
It ran across 51 folders of stems from my own beats, which meant cleaning the corpus first: FL Studio quietly writes the full mix into every stem export under a track's name, and a full mix "conflicts" with every track in the project because it contains all of them. Filenames could not catch it, so sums get found by measurement instead. Cross correlate a candidate against the sum of everything else, then pull its one partner out and look again. A doubled 808 layer correlates just as hard as a real mix does, but it collapses on that second test. A real mix does not.
On a clean corpus, contention stays silent on 90.3 percent of all track pairs. That number mattered more to me than any single detection. A conflict detector that speaks all the time is wallpaper, and this is a plug-in you leave open in every session.
03 · Listen: does it match my ears?
A number can be defensible on paper and still flag things nobody hears. So before writing a line of C++, I checked it against the only instrument that gets the final say.
The setup: pick positions in the music, listen to each one blind, and write down whether the target element sounds clear or smeared. The positions were chosen by a measured level ratio rather than by anything the detector said, so the test was free to disagree with me. Scores went on afterwards, as an overlay. A first attempt did not count, because I had let the detector pick its own control group, which can only ever produce agreement.
The answer was not the one I expected. Nothing ever disappeared. Not once, not even where an element sat 17 to 32 dB below the louder track. Masking between two tracks does not make things vanish. It costs them contour, and plain mud, two tracks at roughly equal level in the same region, cost more of it than being buried did.
That changed the product. X-Ray measures mud, because that is what the listening supported, and it goes quiet on purpose once one track is more than about 10 dB louder than the other. Of the positions it does flag, three in four sound smeared. Of the ones it stays quiet on, two in eight do. Every miss it has is a buried pair, which is a named limitation rather than a vague one.
04 · Port: inside the plug-in
Only now does it become C++. The analysis lives in a plain core with no JUCE anywhere in it, in my own DSP library, so it can be unit tested and reused without dragging a plug-in framework along. It was checked against the Python prototype until both produced the same values on the same input, which is what makes the port a port rather than a rewrite.
What runs, per block: a 4096 point FFT, its bins grouped into 30 bands, a new frame every 100 ms. The core also reports how many FFT bins landed in each band, which sounds like bookkeeping and is not. At 48 kHz the band from 25 to 32 Hz is 6.5 Hz wide while the bins sit 11.7 Hz apart, so it holds no bin at all and would read as permanent silence. The display uses that count to skip the band rather than plot it, so the curve never states a level nobody measured.
The audio thread gets none of this. It has a hard deadline and may not allocate, lock, or wait, so it does one thing: copy the incoming samples into a lock free ring buffer and return. A background thread drains the buffer, runs the FFT and the clustering, and hands finished frames to the editor. Measured, the whole thing costs about 0.7 percent of one core, which is 0.002 percent of the audio thread's budget.
The interface is a JUCE WebView loading a Vite build, the same approach I use on my other plug-ins, which is why the display you see at the top of this page is the real front end rather than a mock-up: browser and plug-in load identical files and differ only in where the data comes from.
One instance, one sidechain input. The obvious alternative is a sender on every track reporting into one window, and it rests on an assumption that is not safe: that plug-in instances share a process. Bitwig sandboxes them. Then shared memory is gone, you are writing inter-process communication, and you spend your evenings answering tickets about hosts behaving differently. Kick against bass is the common case anyway, JUCE handles sidechain buses natively, and there is nothing there to break. Running it in FL turned up one bug nothing on my own machine could have found: it read a sidechain bus the host had never actually connected.
05 · The display
I walked past this on holiday in Dallas.

And thought that would be a cool way to build X-Ray. I like the retro look, the big display with the knob sitting next to it, so that is what I went with. Scroll back up to the demo and you can see where it landed.
What goes on that screen was decided by counting. My first plan was a heat map, frequency against time, colour showing contention. Before building it I checked how much of that map would ever have something in it.
Conflicts are rare, and that is the whole design constraint. A ten second view holds about 3000 of those cells, and a typical flagged conflict lights three of them. A quarter of the time there is nothing to report at all. That is the detector behaving, not failing, but it means a heat map would be a dark rectangle that a reader learns to ignore.
So the screen shows what is always there instead: the spectrum of both tracks, large, with the flagged bands lit as a rail on the frequency axis and a strip underneath showing how often the conflict has come back. Amber is the only colour on the display and means exactly one thing, which is why it is worth looking at. Nothing gets a box drawn around it, because a box reads as a selection and this is a value, so brightness carries the score. The marking only has to say where; the number is already in the readout.
06 · Where it stands
The engine is done and real-time safe, the front end is complete in the browser, and what is left is the C++ side of the display plus a second pass through hosts.
The order is the part I would defend anywhere: research, then a prototype cheap enough to throw away, then listening, and only then the code that has to run inside somebody else's DAW. Checking that code is correct and checking that it measures the right thing are two different jobs. The first one is a test suite. The second one takes a bench, a corpus, and somebody listening.
I build DSP and audio plug-ins as a freelancer, from research and prototyping through to a shipped, real-time-safe C++ implementation. If you have something in this territory, robin@robinbusse.dev or the rest of the work.