CircularBuffer
Tier: Core (Internal Utility) | No ComponentType | No Parameters
Header-only power-of-2 template circular buffer with mask-based wrapping and linear interpolation.
Overview
CircularBuffer is a fixed-size ring buffer designed for real-time audio use. It stores a continuous stream of samples and provides efficient random-access reads at both integer and fractional positions. The size is a compile-time template parameter that must be a power of 2, enabling the critical optimization of replacing modulo operations with bitwise AND masking.
The buffer supports four read modes: delay-relative (read \(N\) samples behind the write head), absolute integer (direct index), absolute fractional (with linear interpolation), and the delay-relative mode also uses linear interpolation for fractional delay values. This covers the needs of delay lines, grain readers, slice players, and loopers.
Being header-only with a std::array backing store, CircularBuffer makes zero heap allocations. The entire buffer lives inline in the owning component's memory, making it fully real-time safe.
File Locations
| Path | |
|---|---|
| Header | Sources/FolioDSP/include/FolioDSP/Core/CircularBuffer.h |
| Tests | (tested indirectly through component tests) |
API / Interface
namespace folio::dsp {
template<uint32_t Size>
class CircularBuffer {
static_assert((Size & (Size - 1)) == 0, "Size must be power of 2");
public:
/// Write one sample at the current write position and advance.
void write(float sample);
/// Read with fractional delay from the write head (linear interpolation).
float read(float delaySamples) const;
/// Read at an absolute index with mask-based wrapping.
float readAbsolute(uint32_t absPos) const;
/// Read at an absolute fractional position with linear interpolation.
float readAbsoluteInterp(float absPos) const;
/// Current write position (monotonically increasing, masked on access).
uint32_t writePosition() const;
/// Zero the entire buffer and reset write position.
void clear();
/// Buffer size (compile-time constant).
static constexpr uint32_t size();
private:
std::array<float, Size> buffer_{};
uint32_t writePos_ = 0;
static constexpr uint32_t kMask = Size - 1;
};
}
Algorithm
Power-of-2 Masking
The fundamental optimization: for any power-of-2 size \(N\), the mask \(M = N - 1\) allows wrapping via bitwise AND instead of modulo:
This is equivalent to \(\text{pos} \bmod N\) but compiles to a single AND instruction instead of a division. For example, with \(N = 262144\) (\(2^{18}\)):
Any 32-bit unsigned position wraps correctly, including values far beyond the buffer size, because the upper bits are simply masked off.
Write
Writing stores a sample at the masked write position and increments the write head:
The write position is allowed to overflow uint32_t naturally. Since all reads use masking, this is safe and avoids the need for explicit wrap-around checks.
Read (Delay-Relative)
Given a fractional delay \(d\) in samples, the read position is computed relative to the write head:
Two adjacent samples are read with linear interpolation:
Note that \(i_1 = i_0 - 1\) (not \(i_0 + 1\)) because the buffer is written in forward order --- the sample one position earlier in the buffer is the sample one step further back in time.
Read Absolute (Integer)
Direct index access with wrapping:
Used when a component needs to read a specific slice or grain at a known buffer position.
Read Absolute (Fractional)
Fractional position with forward-direction interpolation:
Here \(i_1 = i_0 + 1\) because the caller is scanning forward through the buffer at a known absolute position (e.g., a grain reader scanning through recorded audio).
Clear
Zeroes the entire backing array with memset and resets the write position to 0.
Implementation Notes
- Real-time safe: No heap allocations. The backing
std::array<float, Size>is allocated inline, typically on the stack of the owning component. No locks, no system calls. - Zero-initialized: The backing array is value-initialized to zero (
{}), so a freshly constructed buffer reads silence everywhere. static_assertenforcement: Attempting to instantiate with a non-power-of-2 size produces a compile-time error.- Overflow safety: The 32-bit write position overflows after approximately 27 hours at 44.1 kHz (\(2^{32} / 44100 \approx 97391\) seconds). Since all reads use masking, overflow is transparent and correct.
- Linear interpolation only: No cubic or sinc interpolation is provided. For the use cases in FolioDSP (granular grains, delay taps, slice readers), linear interpolation provides sufficient quality at minimal cost. Components needing higher-quality interpolation (e.g., PitchShifter) use their own custom delay buffers.
- Memory footprint: Each buffer consumes exactly \(\text{Size} \times 4\) bytes plus 4 bytes for the write position. The largest instance (MarkovBuffer at \(2^{20}\)) uses 4 MB.
Used By
| Component | Size | Memory | Purpose |
|---|---|---|---|
| GranularEngine | \(2^{18}\) (262,144) | 1 MB | Recording buffer for grain extraction (~6 seconds at 44.1 kHz) |
| FeedbackNetwork | \(8 \times 2^{15}\) (8 x 32,768) | 8 x 128 KB | Eight delay lines for the feedback delay network |
| MicroLooper | \(2^{19}\) (524,288) | 2 MB | Loop capture buffer (~12 seconds at 44.1 kHz) |
| MarkovBuffer | \(2^{20}\) (1,048,576) | 4 MB | Slice storage for probabilistic reordering (~24 seconds at 44.1 kHz) |