DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
StereoWidth.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
30#include "../Core/DspMath.h"
31#include "../Core/AudioSpec.h"
32#include "../Core/AudioBuffer.h"
33#include "../Core/StateBlob.h"
34
35#include <algorithm>
36#include <atomic>
37#include <cmath>
38#include <cstdint>
39#include <numbers>
40#include <vector>
41
42namespace dspark {
43
50template <FloatType T>
52{
53public:
54 StereoWidth() = default;
55 ~StereoWidth() = default; // Removed virtual to prevent vtable overhead
56
61 void prepare(double sampleRate) noexcept
62 {
63 if (!(sampleRate > 0.0) || !std::isfinite(sampleRate)) return;
64 sampleRate_ = sampleRate;
65 widthMaxStep_ = static_cast<T>(1.0 / (sampleRate * 0.02));
66 updateBassMonoCoeff(bassMonoCutoff_.load(std::memory_order_relaxed));
67 reset();
68 }
69
71 void prepare(const AudioSpec& spec) noexcept { prepare(spec.sampleRate); }
72
77 void processBlock(AudioBufferView<T> buffer) noexcept
78 {
79 if (buffer.getNumChannels() >= 2)
80 process(buffer.getChannel(0), buffer.getChannel(1), buffer.getNumSamples());
81 }
82
89 void setWidth(T width) noexcept
90 {
91 if (!std::isfinite(width)) return;
92 width_.store(std::max(T(0), width), std::memory_order_relaxed);
93 }
94
96 [[nodiscard]] T getWidth() const noexcept { return width_.load(std::memory_order_relaxed); }
97
106 void setBassMono(bool enabled, double cutoffHz = 100.0) noexcept
107 {
108 if (cutoffHz > 0.0 && std::isfinite(cutoffHz))
109 {
110 bassMonoCutoff_.store(cutoffHz, std::memory_order_relaxed);
111 updateBassMonoCoeff(cutoffHz);
112 }
113 bassMonoEnabled_.store(enabled, std::memory_order_release);
114 }
115
122 void process(T* left, T* right, int numSamples) noexcept
123 {
124 if (numSamples <= 0) return;
125
126 // Load atomics once per block to allow tight loop vectorization. The
127 // width ramp is closed form per sample (moveTowards), so the pure-width
128 // loop still vectorizes; settled, it reduces to the constant exactly.
129 const T widthTarget = width_.load(std::memory_order_relaxed);
130 if (snapWidth_)
131 {
132 currentWidth_ = widthTarget;
133 snapWidth_ = false;
134 }
135 const T widthStart = currentWidth_;
136 const T widthStep = widthMaxStep_;
137 const bool bassMono = bassMonoEnabled_.load(std::memory_order_acquire);
138
139 if (bassMono)
140 {
141 const T coeff = bassMonoCoeff_.load(std::memory_order_relaxed);
142
143 // Bass-mono crossover: the side channel loses its lows through a
144 // one-pole high-pass; the mid passes UNTOUCHED. A one-pole split is
145 // complementary (LP + HP == 1 exactly), so no phase "compensation"
146 // of the mid is needed - the previously used first-order allpass
147 // rotated the mid by up to 180 degrees at HF, which swapped and
148 // inverted the channels above the transition band.
149 for (int i = 0; i < numSamples; ++i)
150 {
151 T l = left[i];
152 T r = right[i];
153
154 const T width = moveTowards(widthStart, widthTarget, widthStep * static_cast<T>(i + 1));
155 T mid = (l + r) * T(0.5);
156 T side = (l - r) * T(0.5) * width;
157
158 // Side processing (1-pole high-pass: side - LP(side))
159 T sideLpIn = side;
160 sideState_ += coeff * (sideLpIn - sideState_) + antiDenormal_;
161 sideState_ -= antiDenormal_; // Denormal flush
162 side = sideLpIn - sideState_;
163
164 left[i] = mid + side;
165 right[i] = mid - side;
166 }
167 }
168 else
169 {
170 // Fast-path branch: Pure Width control without filtering overhead
171 for (int i = 0; i < numSamples; ++i)
172 {
173 const T width = moveTowards(widthStart, widthTarget, widthStep * static_cast<T>(i + 1));
174 T mid = (left[i] + right[i]) * T(0.5);
175 T side = (left[i] - right[i]) * T(0.5) * width;
176
177 left[i] = mid + side;
178 right[i] = mid - side;
179 }
180 }
181 currentWidth_ = moveTowards(widthStart, widthTarget, widthStep * static_cast<T>(numSamples));
182 }
183
185 void reset() noexcept
186 {
187 sideState_ = T(0);
188 snapWidth_ = true; // the next block starts settled (no glide on start)
189 }
190
191
193 [[nodiscard]] std::vector<uint8_t> getState() const
194 {
195 StateWriter w(stateId("WIDE"), 1);
196 w.write("width", width_.load(std::memory_order_relaxed));
197 w.write("bassMono", bassMonoEnabled_.load(std::memory_order_relaxed));
198 w.write("bassCutoff", static_cast<float>(bassMonoCutoff_.load(std::memory_order_relaxed)));
199 return w.blob();
200 }
201
203 bool setState(const uint8_t* data, size_t size)
204 {
205 StateReader r(data, size);
206 if (!r.isValid() || r.processorId() != stateId("WIDE")) return false;
207 setWidth(static_cast<T>(r.read("width", 1.0f)));
208 setBassMono(r.read("bassMono", false),
209 static_cast<double>(r.read("bassCutoff", 100.0f)));
210 return true;
211 }
212
213protected:
214 void updateBassMonoCoeff(double cutoff) noexcept
215 {
216 if (sampleRate_ > 0.0)
217 {
218 // Calculate 1-pole coeff. Stored atomically to prevent data races.
219 T coeff = static_cast<T>(1.0 - std::exp(-std::numbers::pi * 2.0 * cutoff / sampleRate_));
220 bassMonoCoeff_.store(coeff, std::memory_order_relaxed);
221 }
222 }
223
224private:
225 double sampleRate_ = 48000.0;
226
227 // Lock-free parameters
228 std::atomic<T> width_ { T(1) };
229 std::atomic<bool> bassMonoEnabled_ { false };
230 std::atomic<double> bassMonoCutoff_ { 100.0 };
231 std::atomic<T> bassMonoCoeff_ { T(0) };
232
233 // Filter states
234 T sideState_ = T(0);
235
236 // Width glide (audio thread)
237 T currentWidth_ = T(1);
238 T widthMaxStep_ = T(1.0 / 960.0);
239 bool snapWidth_ = true;
240
241 // Anti-denormal DC offset (type generic)
242 static constexpr T antiDenormal_ = static_cast<T>(1e-15);
243};
244
245} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:161
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:204
bool isValid() const noexcept
Definition StateBlob.h:199
uint32_t processorId() const noexcept
Definition StateBlob.h:200
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:53
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:105
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:71
High-performance stereo image processor with phase-aligned bass mono.
Definition StereoWidth.h:52
void setWidth(T width) noexcept
Sets the overall stereo width factor.
Definition StereoWidth.h:89
void processBlock(AudioBufferView< T > buffer) noexcept
Processes an AudioBufferView in-place.
Definition StereoWidth.h:77
void process(T *left, T *right, int numSamples) noexcept
Process a full block of audio. Optimized for SIMD vectorization.
void prepare(const AudioSpec &spec) noexcept
Prepares from AudioSpec (unified API).
Definition StereoWidth.h:71
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
T getWidth() const noexcept
Returns current width setting.
Definition StereoWidth.h:96
void prepare(double sampleRate) noexcept
Prepares the processor and resets internal states.
Definition StereoWidth.h:61
void setBassMono(bool enabled, double cutoffHz=100.0) noexcept
Toggles Bass Mono and updates the crossover frequency.
~StereoWidth()=default
void updateBassMonoCoeff(double cutoff) noexcept
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
void reset() noexcept
Clears the internal filter states to prevent artifact ringing.
Main namespace for the DSPark framework.
T moveTowards(T from, T to, T maxDelta) noexcept
Moves a value toward a target by at most a given distance.
Definition DspMath.h:134
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:651
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37