Real-time 3D audio spectrum waterfall for JUCE 8, accelerated end-to-end on
CUDA with zero-copy CUDA↔OpenGL interop. Targets NVIDIA Ada (RTX 4070
Laptop, sm_89). Two render modes share a single shader: a rolling
60 Hz live stream from the plugin's audio thread, and a one-shot batched
FFT of an entire audio file.
CudaProcessor.his pure C++. No CUDA, no JUCE. The host plugin includes it freely; everything heavy lives behind a Pimpl.- Audio thread never touches CUDA. It only calls
pushAudioBlock(monoFloat*, n). If the SPSC FIFO is full, samples are silently dropped — by design, this is the only RT-safe failure mode. - All allocation up-front.
cudaMallocandcufftPlanManyhappen ininitialize()(live mode) or once per file inprocessStaticFile(). The render thread allocates nothing. - One shader, two modes. Static mode pins the frame index to
numRows - 1so the end of the file lands at the camera (time flows toward the viewer). Live mode tracks the just-written ring slot. Same GLSL, same Z mapping, no branching in the vertex shader. - One float per vertex. The VBO stores only height; the vertex
shader reconstructs
(x, y, z)fromgl_VertexID, and the fragment shader derives true per-pixel surface normals via screen-space derivatives (dFdx/dFdyofvWorldPos). No per-vertex normals stored, no neighbour samples needed, full per-pixel topology shading on the RTX 4070's spare fragment headroom.
processBlock ──► SpscFifo (lock-free) ──► GL render thread (60 Hz)
│
▼
pop HOP_SIZE samples → pinned staging
│ cudaMemcpyAsync (split for ring-wrap)
▼
Hann window kernel ┐
cufftExecR2C │ all on a
log-freq + dB map │ non-blocking stream
write into mapped VBO ┘
│
▼
glDrawElements ── vertex shader reconstructs
(x,y,z), warps Z via
uCurrentFrameIndex
| File | Role |
|---|---|
CudaProcessor.h |
Opaque C++ facade — public API for the host plugin |
CudaProcessor.cu |
All CUDA: kernels, cuFFT, GL interop, SPSC FIFO |
SpectralWaterfallComponent.h |
juce::Component + juce::OpenGLRenderer |
SpectralWaterfallComponent.cpp |
GLSL shaders, geometry, camera, render loop |
Requires CUDA Toolkit 12+, JUCE 8, CMake 3.22+, and Ninja. On Windows open a VS 2022 x64 Developer PowerShell:
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release `
-DJUCE_PATH="C:/path/to/JUCE"
cmake --build build --parallelOutputs:
build/SpectralWaterfall_artefacts/Release/Standalone/Spectral Waterfall.exebuild/SpectralWaterfall_artefacts/Release/VST3/Spectral Waterfall.vst3/
Override the CUDA architecture with -DSPECTRAL_CUDA_ARCH=86 (Ampere) or
-DSPECTRAL_CUDA_ARCH=75 (Turing). Default is 89 (Ada / RTX 4000 series).
// PluginProcessor
void prepareToPlay (double sr, int) override {
editor->waterfall.setSampleRate (sr); // forwards to CudaProcessor::initialize
}
void processBlock (juce::AudioBuffer<float>& buf, juce::MidiBuffer&) override {
// Sum to mono into a stack scratch (NOT heap) — RT safe
float mono[2048];
const int n = juce::jmin (buf.getNumSamples(), 2048);
const int chans = buf.getNumChannels();
for (int i = 0; i < n; ++i) {
float s = 0.0f;
for (int c = 0; c < chans; ++c) s += buf.getReadPointer (c)[i];
mono[i] = s / (float) chans;
}
editor->waterfall.pushAudioBlock (mono, n);
}UI flips modes via loadStaticFile(file) / switchToLiveStream().
The compile-time constants in CudaProcessor.h are not all free
parameters — some have hard alignment / size requirements baked into
the kernels.
| Constant | Default | Constraint |
|---|---|---|
FFT_SIZE |
2048 | Power of two (cuFFT R2C plan, Hann LUT, ring math) |
HOP_SIZE |
512 | Any positive int ≤ FFT_SIZE; 512 = 75 % overlap at FFT 2048 |
NUM_OUTPUT_BINS |
512 | Power of two, ideally ≥ 128. See below. |
NUM_HISTORY_ROWS |
256 | Any positive int; affects live VRAM only |
PCM_RING_SIZE |
FFT_SIZE * 4 |
Must be ≥ FFT_SIZE + HOP_SIZE, power of two preferred |
Why NUM_OUTPUT_BINS must stay power-of-two. Each mesh row is laid
out contiguously in the VBO as NUM_OUTPUT_BINS × sizeof(float) = 2048 B, a multiple of 128 bits and 256 bits. That alignment lets
spectrumToVboRowLive issue fully coalesced 128-bit stores straight
into the mapped VBO — one transaction per warp, no scatter. Changing
to e.g. 500 or 768 will still produce correct output but uncoalesces
the VBO writes; expect a 3–5× drop in write throughput and visible
scrolling stutter under load. Going non-aligned would require
cudaMallocPitch and a pitched VBO layout that the current interop
path doesn't support.
-
VBO size matches. Vertex count in
buildLiveGeometry/buildStaticGeometrymust equalNUM_OUTPUT_BINS * numRows. The CUDA-siderebindVbo(handle, numRows)keys off the second arg for bounds checks in the bin-write kernel — mismatch will silently truncate the spectrum, not crash. -
OpenGL context profile is 3.2+ core. Must be requested before
attachTo(*this). Already done in the component constructor, but if you embed this inside another GL-owning parent, make sure the outer context isn't compat profile —cudaGraphicsGLRegisterBufferreturnscudaErrorInvalidGraphicsContexton a compat context on some driver versions. -
Surface-normal orientation looks right. The fragment shader derives normals via
cross(dFdx(vWorldPos), dFdy(vWorldPos)). The sign depends on triangle winding × screen-space projection orientation; the current orbit camera + CCW mesh should produce outward-facing normals, but if the surface comes back uniformly dark (diffuse pinned near zero, fog dominating), swap the cross argument order tocross(dFdy(vWorldPos), dFdx(vWorldPos)). One character; flips the normal.
If you want a moving "now" cursor instead of pinning to end-of-file in
static mode, expose setStaticPlayhead(int rowIdx) and override
frameIndex in the static branch of renderOpenGL. The shader already
does the right thing for any value in [0, numRows-1].