Skip to content

Commit 0ac85b4

Browse files
committed
oscilloscope: Implement MinMaxRenderer for faster rendering
1 parent 42c5bb7 commit 0ac85b4

4 files changed

Lines changed: 333 additions & 76 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
//==============================================================================
2+
/* ██████╗ ██╗███╗ ███╗███████╗████████╗██╗ ██╗ ██████╗ ██╗ ██╗██╗ ██╗
3+
* ██╔══██╗██║████╗ ████║██╔════╝╚══██╔══╝██║ ██║██╔═══██╗╚██╗██╔╝╚██╗ ██╔╝
4+
* ██║ ██║██║██╔████╔██║█████╗ ██║ ███████║██║ ██║ ╚███╔╝ ╚████╔╝
5+
* ██║ ██║██║██║╚██╔╝██║██╔══╝ ██║ ██╔══██║██║ ██║ ██╔██╗ ╚██╔╝
6+
* ██████╔╝██║██║ ╚═╝ ██║███████╗ ██║ ██║ ██║╚██████╔╝██╔╝ ██╗ ██║
7+
* ╚═════╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
8+
* Copyright (C) 2024 Dimethoxy Audio (https://dimethoxy.com)
9+
*
10+
* Part of the Dimethoxy Library, primarily intended for Dimethoxy plugins.
11+
* External use is permitted but not recommended.
12+
* No support or compatibility guarantees are provided.
13+
*
14+
* License:
15+
* This code is licensed under the GPLv3 license. You are permitted to use and
16+
* modify this code under the terms of this license.
17+
* You must adhere GPLv3 license for any project using this code or parts of it.
18+
* Your are not allowed to use this code in any closed-source project.
19+
*
20+
* Description:
21+
* Min/Max binned oscilloscope renderer optimized for high frame rates. Bins
22+
* samples to pixel columns, drawing only the minimum and maximum sample per
23+
* bin while preserving temporal order. This dramatically reduces path
24+
* complexity compared to per-sample rendering, trading visual fidelity for
25+
* significantly lower CPU usage at high sample densities.
26+
*
27+
* Authors:
28+
* Lunix-420 (Primary Author)
29+
*/
30+
//==============================================================================
31+
32+
#pragma once
33+
34+
//==============================================================================
35+
36+
#include "gui/widget/OscilloscopeRenderer.h"
37+
#include <JuceHeader.h>
38+
39+
//==============================================================================
40+
41+
namespace dmt {
42+
namespace gui {
43+
namespace widget {
44+
45+
//==============================================================================
46+
/**
47+
* @brief Min/Max binned oscilloscope renderer for high-framerate waveform
48+
* drawing.
49+
*
50+
* @tparam SampleType The sample type (e.g., float, double) used for audio data.
51+
*
52+
* @details
53+
* Instead of drawing one point per sample like PathStrokeRenderer, this
54+
* renderer bins samples into pixel columns and draws only two points per bin:
55+
* the minimum and maximum sample values. The temporal order of min and max
56+
* within each bin is tracked, so the path correctly represents whether the
57+
* waveform went up or down first in that pixel column.
58+
*
59+
* This produces at most 2 path points per pixel column instead of potentially
60+
* many points per pixel, resulting in a much simpler path that is far cheaper
61+
* to stroke. The visual result is nearly identical to per-sample rendering at
62+
* typical zoom levels where multiple samples map to a single pixel.
63+
*
64+
* The renderer maintains persistent state (currentX and currentSample) between
65+
* frames to ensure visual continuity of the waveform across render calls.
66+
*/
67+
template<typename SampleType>
68+
class MinMaxRenderer : public OscilloscopeRenderer<SampleType>
69+
{
70+
//============================================================================
71+
public:
72+
using RingBuffer = typename OscilloscopeRenderer<SampleType>::RingBuffer;
73+
using RenderContext =
74+
typename OscilloscopeRenderer<SampleType>::RenderContext;
75+
76+
//============================================================================
77+
/**
78+
* @brief Draws a waveform segment using min/max binned path stroking.
79+
*
80+
* @param _graphics The JUCE Graphics context targeting the oscilloscope
81+
* image.
82+
* @param _ringBuffer Reference to the ring buffer containing audio samples.
83+
* @param _channel The audio channel index to read from.
84+
* @param _context Pre-computed rendering parameters for this frame.
85+
*
86+
* @details
87+
* Bins samples to pixel columns and builds a path through the min/max
88+
* extremes of each bin, preserving temporal order for accurate waveform
89+
* representation. The path is then stroked using the shared strokePath.
90+
*/
91+
inline void draw(juce::Graphics& _graphics,
92+
RingBuffer& _ringBuffer,
93+
int _channel,
94+
const RenderContext& _context) override
95+
{
96+
this->currentX = _context.drawStartX;
97+
const auto path = buildPath(_ringBuffer, _channel, _context);
98+
this->strokePath(_graphics, path, _context);
99+
}
100+
101+
//============================================================================
102+
private:
103+
//============================================================================
104+
/**
105+
* @brief Holds the min/max state for a single pixel column bin.
106+
*
107+
* @details
108+
* Tracks the minimum and maximum sample values encountered within a pixel
109+
* column, along with their sample indices to determine temporal ordering.
110+
*/
111+
struct Bin
112+
{
113+
SampleType minSample = static_cast<SampleType>(0.0f);
114+
SampleType maxSample = static_cast<SampleType>(0.0f);
115+
size_t minIndex = 0;
116+
size_t maxIndex = 0;
117+
size_t count = 0;
118+
119+
/** @brief Resets the bin with the first sample of a new pixel column. */
120+
inline void reset(SampleType _sample, size_t _index) noexcept
121+
{
122+
minSample = _sample;
123+
maxSample = _sample;
124+
minIndex = _index;
125+
maxIndex = _index;
126+
count = 1;
127+
}
128+
129+
/** @brief Adds a sample to the bin, updating min/max as needed. */
130+
inline void addSample(SampleType _sample, size_t _index) noexcept
131+
{
132+
if (_sample < minSample) {
133+
minSample = _sample;
134+
minIndex = _index;
135+
}
136+
if (_sample > maxSample) {
137+
maxSample = _sample;
138+
maxIndex = _index;
139+
}
140+
++count;
141+
}
142+
143+
/** @brief Returns true if the minimum sample occurred before the max. */
144+
[[nodiscard]] inline bool minFirst() const noexcept
145+
{
146+
return minIndex <= maxIndex;
147+
}
148+
};
149+
150+
//============================================================================
151+
/**
152+
* @brief Builds a min/max binned path from the ring buffer samples.
153+
*
154+
* @param _ringBuffer Reference to the ring buffer containing audio samples.
155+
* @param _channel The audio channel index to read from.
156+
* @param _context Pre-computed rendering parameters for this frame.
157+
*
158+
* @return The constructed JUCE Path representing the waveform segment.
159+
*
160+
* @details
161+
* Iterates through all samples, grouping them into pixel column bins.
162+
* For each completed bin, two points are added to the path in temporal
163+
* order (whichever extreme occurred first is drawn first). This preserves
164+
* the waveform's directional movement while reducing the point count to
165+
* at most two per pixel column.
166+
*
167+
* When only one sample falls in a bin, a single point is drawn.
168+
* The persistent currentX tracks the pixel column boundary for sub-pixel
169+
* continuity between frames.
170+
*/
171+
[[nodiscard]] inline juce::Path buildPath(RingBuffer& _ringBuffer,
172+
int _channel,
173+
const RenderContext& _context)
174+
{
175+
juce::Path path;
176+
177+
// Start the path from the last sample of the previous frame
178+
path.startNewSubPath(this->currentX,
179+
this->sampleToY(this->currentSample,
180+
_context.halfHeight,
181+
_context.amplitude));
182+
183+
const float samplesPerPixel = 1.0f / _context.pixelsPerSample;
184+
float nextBinBoundary = this->currentX + 1.0f;
185+
186+
Bin bin;
187+
bool binActive = false;
188+
189+
for (size_t i = 0; i < static_cast<size_t>(_context.sampleCount); ++i) {
190+
const int sampleIndex = _context.firstSampleIndex + static_cast<int>(i);
191+
const SampleType sample = _ringBuffer.getSample(_channel, sampleIndex);
192+
const float sampleX =
193+
this->currentX + static_cast<float>(i + 1) * _context.pixelsPerSample;
194+
195+
if (!binActive) {
196+
bin.reset(sample, i);
197+
binActive = true;
198+
} else {
199+
bin.addSample(sample, i);
200+
}
201+
202+
// Check if the next sample would cross into a new pixel column
203+
const float nextSampleX =
204+
this->currentX + static_cast<float>(i + 2) * _context.pixelsPerSample;
205+
const bool isLastSample =
206+
(i + 1 >= static_cast<size_t>(_context.sampleCount));
207+
const bool crossesBoundary = (nextSampleX >= nextBinBoundary);
208+
209+
if (isLastSample || crossesBoundary) {
210+
// Finalize the current bin
211+
const float binX = std::min(sampleX, nextBinBoundary - 0.5f);
212+
213+
if (bin.count == 1) {
214+
// Single sample in bin — draw one point
215+
path.lineTo(binX,
216+
this->sampleToY(bin.minSample,
217+
_context.halfHeight,
218+
_context.amplitude));
219+
} else {
220+
// Multiple samples — draw min and max in temporal order
221+
if (bin.minFirst()) {
222+
path.lineTo(binX,
223+
this->sampleToY(bin.minSample,
224+
_context.halfHeight,
225+
_context.amplitude));
226+
path.lineTo(binX,
227+
this->sampleToY(bin.maxSample,
228+
_context.halfHeight,
229+
_context.amplitude));
230+
} else {
231+
path.lineTo(binX,
232+
this->sampleToY(bin.maxSample,
233+
_context.halfHeight,
234+
_context.amplitude));
235+
path.lineTo(binX,
236+
this->sampleToY(bin.minSample,
237+
_context.halfHeight,
238+
_context.amplitude));
239+
}
240+
}
241+
242+
if (crossesBoundary) {
243+
nextBinBoundary = std::floor(nextSampleX) + 1.0f;
244+
binActive = false;
245+
}
246+
}
247+
}
248+
249+
// Update persistent state for frame continuity
250+
const float totalAdvance =
251+
static_cast<float>(_context.sampleCount) * _context.pixelsPerSample;
252+
this->currentX += totalAdvance;
253+
if (_context.sampleCount > 0) {
254+
const int lastIndex =
255+
_context.firstSampleIndex + _context.sampleCount - 1;
256+
this->currentSample = _ringBuffer.getSample(_channel, lastIndex);
257+
}
258+
259+
return path;
260+
}
261+
};
262+
263+
} // namespace widget
264+
} // namespace gui
265+
} // namespace dmt

src/dmt/gui/widget/Oscilloscope.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
//==============================================================================
3232

33+
#include "gui/widget/MinMaxRenderer.h"
3334
#include "gui/widget/PathStrokeRenderer.h"
3435
#include <JuceHeader.h>
3536
#include <memory>
@@ -93,7 +94,7 @@ class alignas(64) Oscilloscope : public juce::Thread
9394
, ringBuffer(_ringBuffer)
9495
, channel(_channel)
9596
, size(_sizeFactor)
96-
, renderer(std::make_unique<PathStrokeRenderer<SampleType>>())
97+
, renderer(std::make_unique<MinMaxRenderer<SampleType>>())
9798
{
9899
startThread();
99100
}

src/dmt/gui/widget/OscilloscopeRenderer.h

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ namespace widget {
5959
*
6060
* Renderers may maintain their own persistent state between frames (e.g.,
6161
* sub-pixel position tracking) to ensure visual continuity.
62+
*
63+
* Common helper functions for coordinate conversion and path stroking are
64+
* provided here to avoid duplication across renderer implementations.
6265
*/
6366
template<typename SampleType>
6467
class OscilloscopeRenderer
@@ -131,6 +134,56 @@ class OscilloscopeRenderer
131134
RingBuffer& _ringBuffer,
132135
int _channel,
133136
const RenderContext& _context) = 0;
137+
138+
//============================================================================
139+
protected:
140+
//============================================================================
141+
/**
142+
* @brief Converts a sample value to a Y pixel coordinate.
143+
*
144+
* @param _sample The sample value to convert.
145+
* @param _halfHeight The vertical center of the drawing area in pixels.
146+
* @param _amplitude The amplitude scaling factor.
147+
*
148+
* @return The Y coordinate in pixels.
149+
*/
150+
[[nodiscard]] inline float sampleToY(SampleType _sample,
151+
int _halfHeight,
152+
float _amplitude) const noexcept
153+
{
154+
return static_cast<float>(_halfHeight) + _sample * _halfHeight * _amplitude;
155+
}
156+
157+
//============================================================================
158+
/**
159+
* @brief Strokes the given path onto the graphics context.
160+
*
161+
* @param _graphics The JUCE Graphics context targeting the oscilloscope
162+
* image.
163+
* @param _path The waveform path to stroke.
164+
* @param _context Pre-computed rendering parameters for this frame.
165+
*
166+
* @details
167+
* Configures the stroke type with beveled joints and rounded end caps,
168+
* and uses a solid white colour for the stroke.
169+
*/
170+
inline void strokePath(juce::Graphics& _graphics,
171+
const juce::Path& _path,
172+
const RenderContext& _context) const
173+
{
174+
juce::PathStrokeType strokeType(_context.thickness * _context.sizeFactor,
175+
juce::PathStrokeType::JointStyle::beveled,
176+
juce::PathStrokeType::EndCapStyle::rounded);
177+
_graphics.setColour(juce::Colours::white);
178+
_graphics.strokePath(_path, strokeType);
179+
}
180+
181+
//============================================================================
182+
/** Last sample value for waveform continuity between frames. */
183+
SampleType currentSample = static_cast<SampleType>(0.0f);
184+
185+
/** Current X position with sub-pixel precision for visual continuity. */
186+
float currentX = 0.0f;
134187
};
135188

136189
} // namespace widget

0 commit comments

Comments
 (0)