9.9 KiB
Visual Data Pipeline
This document describes CubicSDR's visual data processing pipeline: the VisualProcessor template, distribution modes, FFT/scope processing, and the thread safety model for visual data. For the rendering/UI layer (canvases, GLPanel, fonts, themes), see visual-rendering.md.
Pipeline Topology
SDRPostThread (produces IQ data into pipe queues owned by CubicSDR)
|
+--[pipeIQVisualData]--------> SpectrumVisualDataThread --> SpectrumCanvas
+--[pipeWaterfallIQVisualData]-> FFTVisualDataThread --> WaterfallCanvas
+--[pipeDemodIQVisualData]----> SpectrumVisualDataThread (demodVisualThread) --> DemodSpectrumCanvas + DemodWaterfallCanvas
DemodulatorThread
|
+--[audioVisOutputQueue]------> ScopeVisualProcessor --> ScopeCanvas
SpectrumVisualDataThread (src/process/SpectrumVisualDataThread.h) is a dedicated IOThread that wraps a SpectrumVisualProcessor and runs it in its own thread. Two separate instances exist: one for the main spectrum (spectrumVisualThread) and one for the demod spectrum (demodVisualThread). FFTVisualDataThread wraps its own processing pipeline (containing an FFTDataDistributor and a SpectrumVisualProcessor) for waterfall data.
For the queue wiring (which queues connect which threads, queue types, and capacity limits), see signal-flow.md "Queue Wiring".
VisualProcessor Template (src/process/VisualProcessor.h)
Base template for all visual data processors. Implements a generic 1:N pipeline with thread-safe queue attachment:
InputQueue → process() → distribute() → OutputQueues[]
Core API:
setInput(queue)— attach input queue (protected bybusy_updatemutex)attachOutput(queue)/removeOutput(queue)— manage output queue listrun()— captures a local copy of input, callsprocess()if input is non-emptyprocess()— pure virtual, implemented by subclassesdistribute(item, timeout, errorMessage)— pushes output to all attached output queues (locked iteration)flushQueues()— flushes both input and all outputsisInputEmpty()/isOutputEmpty()/isAnyOutputEmpty()— query queue states. Note:isOutputEmpty()returns true only when all output queues have room for more data (i.e., all are not full).isAnyOutputEmpty()returns true when any single output has room. Processors useisOutputEmpty()to skip processing when any consumer is backed up (backpressure).
Synchronization: busy_update (std::mutex) protects the input and outputs vectors. Processors call isOutputEmpty() before processing to avoid redundant work when consumers are backed up.
Distribution Modes
Two distribution strategies handle different multicast patterns:
VisualDataDistributor<T> — Zero-copy shared dispatch. Pops each input item and pushes the same shared_ptr to all output queues. Stops pushing when all outputs are full (backpressure). Used when consumers only read the data.
VisualDataReDistributor<T> — Deep-copy dispatch via ReBuffer pool. Each input item is deep-copied once into a pooled buffer and the resulting shared_ptr is pushed to every output (all outputs share the same copy). Used when consumers modify or consume the data and the source buffer must be decoupled.
Both VisualDataDistributor and VisualDataReDistributor are defined inline in VisualProcessor.h. They are currently declared-but-unused scaffolding: neither is instantiated in the live pipeline. Actual distribution is done by FFTDataDistributor, SpectrumVisualProcessor, and ScopeVisualProcessor, which dispatch to outputs via the base VisualProcessor::distribute().
FFTDataDistributor (src/process/FFTDataDistributor.h)
Specialized rate-limited distributor for IQ-to-FFT batching. Inherits from VisualProcessor and implements:
- Rate limiting via
lineRateAccum/linesPerSecondto control FFT execution pace - Internal buffering with
bufferMax,bufferOffset,bufferedItemsto batch IQ packets into FFT-sized chunks - Uses non-blocking
distribute()push (unlike the blocking push in baseVisualProcessor) - This is the class used by
FFTVisualDataThread(asfftDistrib), notVisualDataDistributor
SpectrumVisualProcessor — Full Per-Frame Pipeline
The most complex processor. Converts raw IQ samples into display-ready spectrum points.
Input: DemodulatorThreadIQData (IQ samples with sample rate, center frequency metadata)
Output: SpectrumVisualData (interleaved [x,y] spectrum points, floor/ceiling, metadata)
Processing sequence:
-
Guard check — Skip if any output queue is full (backpressure from consumers) or input is empty
-
Pop IQ data — Blocking pop with 50ms timeout (
HEARTBEAT_CHECK_PERIOD_MICROS) -
View mode resampling — If viewing a sub-band:
- Compute the resample ratio from the bandwidth: the sample rate is halved (
/= SPECTRUM_VZM) while the next halving stays at or above the bandwidth, thenresamplerRatio = resampleBw / sampleRate - Frequency-shift the center-frequency offset to baseband using NCO (
nco_crcf_mix_block_up/down), driven byshiftFrequency = centerFreq - iqData->frequency - Resample to FFT input size (
msresamp_crcf_execute)
- Compute the resample ratio from the bandwidth: the sample rate is halved (
-
FFT execution —
fft_execute(fftPlan)(liquid-dsp FFT, internal size =DEFAULT_FFT_SIZE * SPECTRUM_VZM= 4096 points; user-facing display resolution = 2048 points) -
Magnitude computation —
sqrt(real² + imag²)with FFT shift (swap halves to center DC) -
Smoothing — Double exponential moving average with NaN guards (note:
maais updated first, using the previous frame'smavalue, thenmais updated from raw input):if (fft_result_maa != fft_result_maa) fft_result_maa = fft_result(NaN guard)fft_result_maa += (fft_result_ma - fft_result_maa) * fft_average_rate(uses oldma)if (fft_result_ma != fft_result_ma) fft_result_ma = fft_result(NaN guard)fft_result_ma += (fft_result - fft_result_ma) * fft_average_rate- Initial
fft_average_rate = 0.65f
Note:
ScopeVisualProcessoruses a different update order within each iteration —mais updated first from raw input, thenmaais updated using the newma. InSpectrumVisualProcessor, the order is reversed:maais updated first using the oldma, thenmais updated from raw input. -
Floor/ceiling tracking — Slow-moving averages (0.05 rate) with NaN guards; peak hold if enabled
-
Log-scale normalization — Maps FFT bins to [0,1] spectrum points using floor/ceiling
-
DC spike removal — If
hideDCenabled, interpolates over ±2kHz around DC -
Distribute — Push
SpectrumVisualDatato all attached output queues
Key constants:
| Constant | Value | Purpose |
|---|---|---|
HEARTBEAT_CHECK_PERIOD_MICROS |
50,000 (50ms) | Input pop timeout |
DEFAULT_FFT_SIZE |
2048 | User-facing FFT bin count |
SPECTRUM_VZM |
2 | Internal FFT multiplier (actual FFT = DEFAULT_FFT_SIZE * SPECTRUM_VZM = 4096 points) |
PEAK_RESET_COUNT |
30 | Frames before peak hold resets |
fft_average_rate |
0.65f | Smoothing factor (higher = more responsive) |
FFTVisualDataThread — The Glue Thread
A dedicated thread that bridges IQ data to the waterfall display:
pipeIQDataIn → fftDistrib → fftQueue → wproc → pipeFFTDataOut
The thread loop:
- Sleep ~10ms between iterations
fftDistrib.run()— packages IQ data into FFT-ready batches (input buffer sized byFFT_DISTRIBUTOR_BUFFER_IN_SECONDS = 0.250s)wproc.run()— executes FFT processing in a tight loop until input is drained (one FFT per iteration)
This thread bridges IQ data to the waterfall display by running the FFT distributor and processor in a tight loop. The FFT distributor batches incoming IQ packets into FFT-sized chunks (its execution pace is rate-limited separately by linesPerSecond/lineRateAccum), and the processor executes one FFT per batch. Note: while FFTDataDistributor supports multiple outputs, FFTVisualDataThread attaches only one (fftQueue). The multi-consumer distribution (to both waterfall and spectrum) happens at the SDRPostThread level, which attaches separate queues to each consumer.
ScopeVisualProcessor
Processes demodulated audio for scope/spectrum display.
Input: AudioThreadInput (audio samples with sample rate)
Output: ScopeRenderData (waveform points or FFT spectrum)
Display modes (defined in ScopePanel.h):
SCOPE_MODE_Y— Single-channel time waveformSCOPE_MODE_2Y— Dual-channel overlaid waveformsSCOPE_MODE_XY— Lissajous figure (phase display)- Spectrum mode — FFT of demodulated audio; FFT size defaults to 1024 (
DEFAULT_SCOPE_FFT_SIZE), producing 512 output spectrum points (fftSize/2); enabled via the atomic memberspectrumEnabled(toggled bysetSpectrumEnabled()), whilerenderData->spectrumis a descriptor set totrueon the produced output
Uses try_pop (non-blocking) instead of blocking pop, since audio data arrives at a fixed rate and stale data should be dropped.
ScopeVisualProcessor runs on the wxWidgets main thread via AppFrame::handleScopeProcessor() (see threading.md Pattern 7), not on a dedicated IOThread.
Thread Safety Summary
The visual data pipeline uses two synchronization mechanisms for processor-internal state:
| Mechanism | Protects | Used By |
|---|---|---|
busy_update (std::mutex) |
Input/output queue pointers | VisualProcessor base |
busy_run (std::mutex) |
All processor state (FFT buffers, settings) | SpectrumVisualProcessor |
For the canonical lock inventory (including SpinMutex, ThreadBlockingQueue, std::shared_ptr, and all other synchronization mechanisms in the codebase), see threading.md "Synchronization Mechanisms".
For the ReBuffer pool mechanics (used by VisualDataReDistributor for deep-copy distribution), see signal-flow.md "Buffer Management".