DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
RingModulator.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
33#include "../Core/AudioBuffer.h"
34#include "../Core/AudioSpec.h"
35#include "../Core/DenormalGuard.h"
36#include "../Core/DspMath.h"
37#include "../Core/Phasor.h"
38#include "../Core/StateBlob.h"
39
40#include <algorithm>
41#include <array>
42#include <atomic>
43#include <cmath>
44#include <cstddef>
45#include <cstdint>
46#include <numbers>
47#include <vector>
48
49namespace dspark {
50
65template <FloatType T>
67{
68public:
70 enum class Mode
71 {
72 Classic,
74 };
75
85 void prepare(const AudioSpec& spec) noexcept
86 {
87 if (!spec.isValid()) return; // release-safe: keep previous state
88
89 phasor_.prepare(spec.sampleRate);
90
91 const T initialFreq = frequency_.load(std::memory_order_relaxed);
92 const T initialMix = mix_.load(std::memory_order_relaxed);
93
94 phasor_.setFrequency(initialFreq);
95 currentFreq_ = initialFreq;
96 currentMix_ = initialMix;
97 mixMaxStep_ = static_cast<T>(1.0 / (spec.sampleRate * 0.02));
98 numChannels_ = spec.numChannels;
99 }
100
105 void processBlock(AudioBufferView<T> buffer) noexcept
106 {
107 const int numCh = std::min(buffer.getNumChannels(), numChannels_);
108 const int numSamples = buffer.getNumSamples();
109 if (numSamples <= 0 || numCh <= 0) return;
110
111 // RT denormal hygiene: consistent with the framework's other audio-path
112 // processors (the GeometricMean sqrt/copysign chain can carry denormal
113 // inputs into the slow path).
114 DenormalGuard guard;
115
116 // Fetch targets for smoothing
117 const T targetFreq = frequency_.load(std::memory_order_relaxed);
118 const T targetMix = mix_.load(std::memory_order_relaxed);
119 const T soarVal = soar_.load(std::memory_order_relaxed);
120 const auto modeVal = mode_.load(std::memory_order_relaxed);
121
122 // Calculate smoothing steps
123 // The frequency glides linearly across the block (phase continuous,
124 // so the block length only sets the glide speed); the mix is rate
125 // limited to full scale per 20 ms (a per-block ramp landed in 0.7 ms
126 // with 32-sample blocks and clicked).
127 const T invSamples = T(1) / static_cast<T>(numSamples);
128 const T freqStep = (targetFreq - currentFreq_) * invSamples;
129 const T mixStart = currentMix_;
130
131 // Process in L1-cache friendly chunks to allow outer channel loop
132 constexpr int CHUNK_SIZE = 64;
133 std::array<T, CHUNK_SIZE> carrierChunk;
134 std::array<T, CHUNK_SIZE> mixChunk;
135
136 const T twoPi = T(2) * static_cast<T>(std::numbers::pi);
137
138 for (int start = 0; start < numSamples; start += CHUNK_SIZE)
139 {
140 const int chunkLen = std::min(CHUNK_SIZE, numSamples - start);
141
142 // 1. Precalculate shared carrier and mix block
143 for (int i = 0; i < chunkLen; ++i)
144 {
145 currentFreq_ += freqStep;
146
147 phasor_.setFrequency(currentFreq_);
148 T phase = phasor_.advance();
149
150 // fastSin: error > 100 dB below the carrier - inaudible even
151 // though the carrier itself is audible in ring modulation.
152 carrierChunk[i] = fastSin(phase * twoPi);
153 mixChunk[i] = moveTowards(mixStart, targetMix,
154 mixMaxStep_ * static_cast<T>(start + i + 1));
155 }
156
157 // 2. Process channels with hoisted branches and SIMD-friendly loops
158 if (modeVal == Mode::GeometricMean)
159 {
160 for (int ch = 0; ch < numCh; ++ch)
161 {
162 T* data = buffer.getChannel(ch) + start;
163
164 for (int i = 0; i < chunkLen; ++i)
165 {
166 const T dry = data[i];
167 const T carrier = carrierChunk[i];
168
169 const T absIn = std::abs(dry);
170 const T absCarrier = std::abs(carrier);
171
172 // Scaled soarVal prevents DC offset when input is zero
173 const T gm = std::sqrt(absIn * absCarrier + soarVal * absIn);
174
175 // Ultra-fast sign evaluation without branching
176 const T wet = gm * std::copysign(T(1), dry * carrier);
177
178 data[i] = dry + (wet - dry) * mixChunk[i];
179 }
180 }
181 }
182 else // Mode::Classic
183 {
184 for (int ch = 0; ch < numCh; ++ch)
185 {
186 T* data = buffer.getChannel(ch) + start;
187
188 for (int i = 0; i < chunkLen; ++i)
189 {
190 const T dry = data[i];
191 const T wet = dry * carrierChunk[i];
192 data[i] = dry + (wet - dry) * mixChunk[i];
193 }
194 }
195 }
196 }
197
198 // Land exactly on the published targets: the accumulated ramp ends
199 // within rounding of them, and the next block must start settled
200 // (matches the framework's smoothing convention).
201 currentFreq_ = targetFreq;
202 currentMix_ = moveTowards(mixStart, targetMix, mixMaxStep_ * static_cast<T>(numSamples));
203 }
204
206 void reset() noexcept { phasor_.reset(); }
207
214 void setFrequency(T hz) noexcept
215 {
216 if (!std::isfinite(hz)) return; // NaN/Inf would poison the smoothed ramp
217 frequency_.store(hz, std::memory_order_relaxed);
218 }
219
225 void setMix(T mix) noexcept
226 {
227 if (!std::isfinite(mix)) return;
228 mix_.store(std::clamp(mix, T(0), T(1)), std::memory_order_relaxed);
229 }
230
239 void setMode(Mode m) noexcept
240 {
241 const int v = std::clamp(static_cast<int>(m),
242 static_cast<int>(Mode::Classic),
243 static_cast<int>(Mode::GeometricMean));
244 mode_.store(static_cast<Mode>(v), std::memory_order_relaxed);
245 }
246
259 void setSoar(T amount) noexcept
260 {
261 if (!std::isfinite(amount)) return;
262 soar_.store(std::max(amount, T(0)), std::memory_order_relaxed);
263 }
264
265 [[nodiscard]] T getFrequency() const noexcept { return frequency_.load(std::memory_order_relaxed); }
266 [[nodiscard]] T getMix() const noexcept { return mix_.load(std::memory_order_relaxed); }
267 [[nodiscard]] Mode getMode() const noexcept { return mode_.load(std::memory_order_relaxed); }
268 [[nodiscard]] T getSoar() const noexcept { return soar_.load(std::memory_order_relaxed); }
269
271 [[nodiscard]] std::vector<uint8_t> getState() const
272 {
273 // The blob stores float (setState reads float back); the explicit
274 // casts also keep this overload resolvable when T is double.
275 StateWriter w(stateId("RING"), 1);
276 w.write("frequency", static_cast<float>(frequency_.load(std::memory_order_relaxed)));
277 w.write("mix", static_cast<float>(mix_.load(std::memory_order_relaxed)));
278 w.write("mode", static_cast<int32_t>(mode_.load(std::memory_order_relaxed)));
279 w.write("soar", static_cast<float>(soar_.load(std::memory_order_relaxed)));
280 return w.blob();
281 }
282
284 bool setState(const uint8_t* data, size_t size)
285 {
286 StateReader r(data, size);
287 if (!r.isValid() || r.processorId() != stateId("RING")) return false;
288 setFrequency(static_cast<T>(r.read("frequency", 440.0f)));
289 setMix(static_cast<T>(r.read("mix", 1.0f)));
290 setMode(static_cast<Mode>(r.read("mode", 0)));
291 setSoar(static_cast<T>(r.read("soar", 0.0f)));
292 return true;
293 }
294
295private:
296 int numChannels_ = 0;
297
298 // Atomic targets for lock-free UI/Thread communication
299 std::atomic<T> frequency_ { T(440) };
300 std::atomic<T> mix_ { T(1) };
301 std::atomic<Mode> mode_ { Mode::Classic };
302 std::atomic<T> soar_ { T(0) };
303
304 // DSP State (Internal audio-thread only, no atomics required)
305 T currentFreq_ { T(440) };
306 T currentMix_ { T(1) };
307 T mixMaxStep_ { T(1.0 / 960.0) };
308 Phasor<T> phasor_;
309};
310
311} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
RAII scope guard to disable denormalised (subnormal) floating-point numbers.
Signal x carrier ring modulation with mix control and zero-latency smoothing.
Mode getMode() const noexcept
void reset() noexcept
Resets the internal phase of the carrier oscillator.
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a block of audio in-place.
T getSoar() const noexcept
void setMix(T mix) noexcept
Sets the target dry/wet mix. Smoothed internally.
T getFrequency() const noexcept
Mode
Modulation mathematical mode.
@ GeometricMean
Geometric-mean mode: sqrt(|in|*|carrier|) * sign - richer, more musical.
@ Classic
Standard multiplication (sum & difference frequencies).
void prepare(const AudioSpec &spec) noexcept
Prepares the modulator for audio processing.
T getMix() const noexcept
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
void setSoar(T amount) noexcept
Sets the soar threshold for Geometric Mean mode.
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
void setMode(Mode m) noexcept
Sets the modulation mode.
void setFrequency(T hz) noexcept
Sets the target carrier frequency. Smoothed internally.
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
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
T fastSin(T x) noexcept
Fast sine approximation (degree-9 odd minimax polynomial).
Definition DspMath.h:248
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:651
constexpr T twoPi
2 * Pi (6.28318...).
Definition DspMath.h:48
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37