DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
Panner.h
Go to the documentation of this file.
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
32#include "../Core/AudioBuffer.h"
33#include "../Core/AudioSpec.h"
34#include "../Core/Biquad.h"
35#include "../Core/DspMath.h"
36#include "../Core/Smoothers.h"
37#include "../Core/StateBlob.h"
38#include "Delay.h"
39
40#include <algorithm>
41#include <atomic>
42#include <cmath>
43#include <cstdint>
44#include <vector>
45
46namespace dspark {
47
48template <typename T = float>
49class Panner
50{
51public:
52 // Removed virtual destructor to maintain zero-cost abstraction (no vtable).
53 ~Panner() = default;
54
56 enum class Algorithm
57 {
59 Binaural,
60 MidPan,
61 SidePan,
62 Haas,
64 };
65
71 void prepare(const AudioSpec& spec)
72 {
73 if (!spec.isValid()) return;
75 panSmoother_.reset(sampleRate_, smoothingTime_.load(std::memory_order_relaxed));
76
77 float maxMs = std::max(binauralMaxITD_.load(std::memory_order_relaxed),
78 haasMaxDelay_.load(std::memory_order_relaxed));
79
80 AudioSpec monoSpec { sampleRate_, spec.maxBlockSize, 1 };
81 // +1 ms of headroom over the configured maximum: the ITD/Haas paths
82 // add a ~4-sample common base delay on top of the maximum, and the
83 // capacity clamp must never shave it off.
84 delayL_.prepareMs(monoSpec, static_cast<double>(maxMs) + 1.0);
85 delayR_.prepareMs(monoSpec, static_cast<double>(maxMs) + 1.0);
88 delayL_.setSmoothingTime(smoothingTime_);
89 delayR_.setSmoothingTime(smoothingTime_);
90
92 }
93
101 void setAlgorithm(Algorithm algo) noexcept
102 {
103 algo = static_cast<Algorithm>(std::clamp(static_cast<int>(algo), 0,
104 static_cast<int>(Algorithm::Spectral)));
105 algorithm_.store(algo, std::memory_order_relaxed);
106 }
107
113 void setPan(T position) noexcept
114 {
115 if (!std::isfinite(position)) return;
116 // Publish only: the smoother is audio-thread state, so the target is
117 // applied at the top of the next processBlock() (writing it here from
118 // a control thread raced against getNextValue()).
119 pan_.store(std::clamp(position, T(-1), T(1)), std::memory_order_relaxed);
120 }
121
127 void processBlock(AudioBufferView<T> buffer) noexcept
128 {
129 if (buffer.getNumChannels() < 2) return;
130
131 // Front-door non-finite guard (M-006 C1): the Spectral algorithm's
132 // Biquad shelves and the Binaural/Haas delay lines carry recursive state
133 // that a NaN/Inf input would poison permanently. Scrub non-finite input
134 // to 0 before any algorithm runs. No-op on finite input (metrics
135 // byte-identical).
136 {
137 const int gN = buffer.getNumSamples();
138 for (int ch = 0; ch < buffer.getNumChannels(); ++ch)
139 {
140 T* d = buffer.getChannel(ch);
141 for (int i = 0; i < gN; ++i)
142 if (!std::isfinite(d[i])) d[i] = T(0);
143 }
144 }
145
146 // Apply control-thread publications (audio-thread application point).
147 if (smoothingDirty_.exchange(false, std::memory_order_acquire))
149 smoothingTime_.load(std::memory_order_relaxed),
151
152 float pTarget = static_cast<float>(pan_.load(std::memory_order_relaxed));
154
155 switch (algorithm_.load(std::memory_order_relaxed))
156 {
157 case Algorithm::EqualPower: applyEqualPower(buffer, pTarget); break;
158 case Algorithm::Binaural: applyCombinedBinaural(buffer, pTarget); break;
159 case Algorithm::MidPan: applyMidPan(buffer, pTarget); break;
160 case Algorithm::SidePan: applySidePan(buffer, pTarget); break;
161 case Algorithm::Haas: applyHaas(buffer, pTarget); break;
162 case Algorithm::Spectral: applySpectral(buffer, pTarget); break;
163 }
164 }
165
167 void reset() noexcept
168 {
169 delayL_.reset();
170 delayR_.reset();
174 }
175
176 // -- Configuration -------------------------------------------------------
177 // All thread-safe; non-finite values are ignored. The ITD / Haas maxima
178 // size the delay lines in prepare(): raising them beyond the prepared
179 // value takes full effect on the next prepare().
180
181 void setBinauralMaxITD(float ms) noexcept
182 {
183 if (!std::isfinite(ms)) return;
184 binauralMaxITD_.store(std::max(0.0f, ms), std::memory_order_relaxed);
185 }
186 void setHaasMaxDelay(float ms) noexcept
187 {
188 if (!std::isfinite(ms)) return;
189 haasMaxDelay_.store(std::max(0.0f, ms), std::memory_order_relaxed);
190 }
191 void setSpectralFrequency(float hz) noexcept
192 {
193 if (!std::isfinite(hz)) return;
194 spectralFreq_.store(std::clamp(hz, 20.0f, 20000.0f), std::memory_order_relaxed);
195 }
196 void setSpectralMaxGain(float dB) noexcept
197 {
198 if (!std::isfinite(dB)) return;
199 spectralMaxGain_.store(dB, std::memory_order_relaxed);
200 }
201
205 void setSmoothingTime(float ms) noexcept
206 {
207 if (!std::isfinite(ms)) return;
208 smoothingTime_.store(std::max(0.0f, ms), std::memory_order_relaxed);
209 delayL_.setSmoothingTime(ms); // Delay's own publish/apply handoff
210 delayR_.setSmoothingTime(ms);
211 smoothingDirty_.store(true, std::memory_order_release);
212 }
213
214
216 [[nodiscard]] std::vector<uint8_t> getState() const
217 {
218 StateWriter w(stateId("PANR"), 1);
219 w.write("pan", pan_.load(std::memory_order_relaxed));
220 w.write("algorithm", static_cast<int32_t>(algorithm_.load(std::memory_order_relaxed)));
221 w.write("smoothing", smoothingTime_.load(std::memory_order_relaxed));
222 w.write("binauralITD", binauralMaxITD_.load(std::memory_order_relaxed));
223 w.write("haasDelay", haasMaxDelay_.load(std::memory_order_relaxed));
224 w.write("spectralFreq", spectralFreq_.load(std::memory_order_relaxed));
225 w.write("spectralGain", spectralMaxGain_.load(std::memory_order_relaxed));
226 return w.blob();
227 }
228
230 bool setState(const uint8_t* data, size_t size)
231 {
232 StateReader r(data, size);
233 if (!r.isValid() || r.processorId() != stateId("PANR")) return false;
234 setPan(static_cast<T>(r.read("pan", 0.0f)));
235 setAlgorithm(static_cast<Algorithm>(r.read("algorithm", 0))); // clamped inside
236 setSmoothingTime(r.read("smoothing", 50.0f));
237 setBinauralMaxITD(r.read("binauralITD", 0.66f));
238 setHaasMaxDelay(r.read("haasDelay", 30.0f));
239 setSpectralFrequency(r.read("spectralFreq", 4000.0f));
240 setSpectralMaxGain(r.read("spectralGain", 6.0f));
241 return true;
242 }
243
244protected:
245 void applyEqualPower(AudioBufferView<T> buffer, float /*panTarget*/) noexcept
246 {
247 T* L = buffer.getChannel(0);
248 T* R = buffer.getChannel(1);
249 const int n = buffer.getNumSamples();
250 constexpr T halfPi = pi<T> / T(2);
251
253 {
254 // Static pan: hoist the trig out of the loop entirely.
255 const T angle = (static_cast<T>(panSmoother_.getCurrentValue()) * T(0.5) + T(0.5)) * halfPi;
256 const T gL = std::cos(angle);
257 const T gR = std::sin(angle);
258 for (int i = 0; i < n; ++i)
259 {
260 L[i] *= gL;
261 R[i] *= gR;
262 }
263 return;
264 }
265
266 for (int i = 0; i < n; ++i)
267 {
268 T p = static_cast<T>(panSmoother_.getNextValue());
269 T angle = (p * T(0.5) + T(0.5)) * halfPi;
270
271 // fastSin/fastCos: > 100 dB accurate, several times cheaper than libm.
272 L[i] *= fastCos(angle);
273 R[i] *= fastSin(angle);
274 }
275 }
276
277 void applyCombinedBinaural(AudioBufferView<T> buffer, float panTarget) noexcept
278 {
279 T* L = buffer.getChannel(0);
280 T* R = buffer.getChannel(1);
281 const int n = buffer.getNumSamples();
282
283 T itdMax = T(binauralMaxITD_.load(std::memory_order_relaxed));
284 T targetP = static_cast<T>(panTarget);
285
286 // Push the new ITD targets. A small COMMON base delay is added to both ears
287 // so neither hits the delay line's 3-sample interpolation floor: that floor
288 // used to clamp both ears to 3 for |pan| < ~0.1, killing the ITD cue near
289 // centre (a localization dead-zone). With the base offset the ITD difference
290 // is linear from the centre, and the shared delay is inaudible.
291 const T baseMs = T(4000) / static_cast<T>(sampleRate_); // ~4 samples
292 delayL_.setDelayMs(baseMs + itdMax * std::max(T(0), targetP)); // pan>0 => L delayed
293 delayR_.setDelayMs(baseMs + itdMax * std::max(T(0), -targetP)); // pan<0 => R delayed
294
295 for (int i = 0; i < n; ++i)
296 {
297 T p = static_cast<T>(panSmoother_.getNextValue());
298
299 // Binaural cross-feed model:
300 // - The ipsilateral ear (closer to the source) receives the
301 // near channel essentially intact + a small leakage from the
302 // far channel.
303 // - The contralateral ear (far ear) receives the far channel
304 // attenuated + a leakage from the near channel + the ITD
305 // delay applied later by delayL_/delayR_.
306 // This preserves audibility on both ears at hard pan, unlike a
307 // straight mute, and produces the head-shadowing illusion typical
308 // of real-world hearing (~6 dB ILD between ears at 90 deg).
309 T absp = std::abs(p); // 0..1
310 T farAtten = T(1) - absp * T(0.5); // 1.0 -> 0.5 at extreme
311 T leakage = absp * T(0.3); // 0 -> 0.3 cross-bleed
312
313 T l_temp, r_temp;
314 if (p >= T(0))
315 {
316 // Pan right -> L is the contralateral (delayed) ear.
317 l_temp = L[i] * farAtten + R[i] * leakage;
318 r_temp = R[i] + L[i] * leakage * T(0.5); // gentle near-ear bleed
319 }
320 else
321 {
322 // Pan left -> R is the contralateral ear.
323 l_temp = L[i] + R[i] * leakage * T(0.5);
324 r_temp = R[i] * farAtten + L[i] * leakage;
325 }
326
327 // Delay::processSample already advances its write index, so we
328 // must NOT call advanceWriteIndex() afterwards.
329 L[i] = delayL_.processSample(0, l_temp);
330 R[i] = delayR_.processSample(0, r_temp);
331 }
332 }
333
334 void applyMidPan(AudioBufferView<T> buffer, float /*panTarget*/) noexcept
335 {
336 T* L = buffer.getChannel(0);
337 T* R = buffer.getChannel(1);
338 const int n = buffer.getNumSamples();
339 constexpr T halfPi = pi<T> / T(2);
340
341 for (int i = 0; i < n; ++i)
342 {
343 T p = static_cast<T>(panSmoother_.getNextValue());
344 T mid = (L[i] + R[i]) * T(0.5);
345 T side = (L[i] - R[i]) * T(0.5);
346
347 // Equal-power mid pan: gL^2 + gR^2 == 2 for every position, with
348 // gL == gR == 1 at centre (transparent to within the fast-trig
349 // accuracy, > 100 dB). A hard pan peaks at
350 // +3 dB instead of the +6 dB of the old constant-voltage law,
351 // keeping headroom predictable while staying mono-compatible.
352 T angle = (p * T(0.5) + T(0.5)) * halfPi;
353 T gL = fastCos(angle) * sqrt2<T>;
354 T gR = fastSin(angle) * sqrt2<T>;
355
356 L[i] = mid * gL + side;
357 R[i] = mid * gR - side;
358 }
359 }
360
361 void applySidePan(AudioBufferView<T> buffer, float /*panTarget*/) noexcept
362 {
363 T* L = buffer.getChannel(0);
364 T* R = buffer.getChannel(1);
365 const int n = buffer.getNumSamples();
366 constexpr T halfPi = pi<T> / T(2);
367
368 for (int i = 0; i < n; ++i)
369 {
370 T p = static_cast<T>(panSmoother_.getNextValue());
371 T mid = (L[i] + R[i]) * T(0.5);
372 T side = (L[i] - R[i]) * T(0.5);
373
374 T angle = (p * T(0.5) + T(0.5)) * halfPi;
375
376 // Normalized by sqrt(2) to prevent -3dB attenuation at dead center.
377 // Clamped to avoid massive boosts at hard extremes.
378 T sideGainL = std::clamp(fastCos(angle) * sqrt2<T>, T(0), T(1));
379 T sideGainR = std::clamp(fastSin(angle) * sqrt2<T>, T(0), T(1));
380
381 L[i] = mid + (side * sideGainL);
382 R[i] = mid - (side * sideGainR);
383 }
384 }
385
386 void applyHaas(AudioBufferView<T> buffer, float panTarget) noexcept
387 {
388 T haasMax = T(haasMaxDelay_.load(std::memory_order_relaxed));
389 T pT = static_cast<T>(panTarget);
390
391 // Smoothed inside Delay - no abrupt pointer jumps even on fast moves.
392 // Common base delay keeps both ears above the 3-sample interpolation floor
393 // so the inter-channel delay is linear from centre (no dead-zone).
394 const T baseMs = T(4000) / static_cast<T>(sampleRate_); // ~4 samples
395 delayL_.setDelayMs(baseMs + haasMax * std::max(T(0), pT));
396 delayR_.setDelayMs(baseMs + haasMax * std::max(T(0), -pT));
397
398 T* L = buffer.getChannel(0);
399 T* R = buffer.getChannel(1);
400 const int n = buffer.getNumSamples();
401
402 for (int i = 0; i < n; ++i)
403 {
404 // Keep the pan smoother stepping in sync with the block, even
405 // though Haas does not use the smoothed pan value directly.
407
408 // processSample advances the write index by itself; calling
409 // advanceWriteIndex() afterwards corrupts the delay line.
410 L[i] = delayL_.processSample(0, L[i]);
411 R[i] = delayR_.processSample(0, R[i]);
412 }
413 }
414
415 void applySpectral(AudioBufferView<T> buffer, float panTarget) noexcept
416 {
417 // Recompute high-shelf coefficients based on the current pan target
418 // (cheap, once per block - the trig math is hoisted out of the inner
419 // loop). Biquad::processSample picks up the new coefficients on the
420 // first sample via its lock-free fast path, so no extra sync needed.
421 updateSpectralFilters(static_cast<T>(panTarget));
422
423 T* L = buffer.getChannel(0);
424 T* R = buffer.getChannel(1);
425 const int n = buffer.getNumSamples();
426
427 for (int i = 0; i < n; ++i)
428 {
429 (void)panSmoother_.getNextValue(); // keep smoother in step
430 L[i] = spectralL_.processSample(L[i], 0);
431 R[i] = spectralR_.processSample(R[i], 0);
432 }
433 }
434
435 void updateSpectralFilters(T targetPan) noexcept
436 {
437 float sMaxGain = spectralMaxGain_.load(std::memory_order_relaxed);
438 float sFreq = spectralFreq_.load(std::memory_order_relaxed);
439
440 T gainLdB = -targetPan * static_cast<T>(sMaxGain);
441 T gainRdB = targetPan * static_cast<T>(sMaxGain);
442
444 sampleRate_, static_cast<double>(sFreq), static_cast<double>(gainLdB)));
446 sampleRate_, static_cast<double>(sFreq), static_cast<double>(gainRdB)));
447 }
448
449 double sampleRate_ = 48000.0;
450 std::atomic<Algorithm> algorithm_ { Algorithm::EqualPower };
451 std::atomic<T> pan_ { T(0) };
452
456
457 std::atomic<float> smoothingTime_ { 50.0f };
458 std::atomic<bool> smoothingDirty_ { false };
459 std::atomic<float> binauralMaxITD_ { 0.66f };
460 std::atomic<float> haasMaxDelay_ { 30.0f };
461 std::atomic<float> spectralFreq_ { 4000.0f };
462 std::atomic<float> spectralMaxGain_ { 6.0f };
463};
464
465} // namespace dspark
Professional delay line with Hermite interpolation, analog feedback saturation, and stereo processing...
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Biquad filter using Transposed Direct Form II (TDF-II) with thread-safe updates.
Definition Biquad.h:556
void setCoeffs(const BiquadCoeffs &c) noexcept
Sets the filter coefficients asynchronously.
Definition Biquad.h:599
void reset() noexcept
Resets all per-channel filter states to zero to avoid ringing/clicks.
Definition Biquad.h:668
T processSample(T input, int channel) noexcept
Processes a single sample for a specific channel.
Definition Biquad.h:694
void setHaasMaxDelay(float ms) noexcept
Definition Panner.h:186
void setAlgorithm(Algorithm algo) noexcept
Sets the active panning algorithm safely from any thread.
Definition Panner.h:101
~Panner()=default
std::atomic< float > binauralMaxITD_
Definition Panner.h:459
void prepare(const AudioSpec &spec)
Initializes internal delays, filters, and smoothers.
Definition Panner.h:71
Delay< T > delayR_
Definition Panner.h:453
Biquad< T, 1 > spectralR_
Definition Panner.h:455
std::atomic< Algorithm > algorithm_
Definition Panner.h:450
void setSmoothingTime(float ms) noexcept
Sets the pan smoothing time. Thread-safe; applied at the top of the next processBlock() (previously i...
Definition Panner.h:205
Delay< T > delayL_
Definition Panner.h:453
std::atomic< float > spectralMaxGain_
Definition Panner.h:462
std::vector< uint8_t > getState() const
Serializes the parameter state (setup/UI threads; allocates).
Definition Panner.h:216
Biquad< T, 1 > spectralL_
Definition Panner.h:455
void setPan(T position) noexcept
Sets the target pan position (automatable, smoothed).
Definition Panner.h:113
void setBinauralMaxITD(float ms) noexcept
Definition Panner.h:181
void applyEqualPower(AudioBufferView< T > buffer, float) noexcept
Definition Panner.h:245
Smoothers::LinearSmoother panSmoother_
Definition Panner.h:454
std::atomic< float > haasMaxDelay_
Definition Panner.h:460
void setSpectralMaxGain(float dB) noexcept
Definition Panner.h:196
double sampleRate_
Definition Panner.h:449
void reset() noexcept
Clears delay lines and filter states to prevent ghost echoes.
Definition Panner.h:167
void applyMidPan(AudioBufferView< T > buffer, float) noexcept
Definition Panner.h:334
void applySpectral(AudioBufferView< T > buffer, float panTarget) noexcept
Definition Panner.h:415
std::atomic< T > pan_
Definition Panner.h:451
std::atomic< bool > smoothingDirty_
Pan smoother re-config pending.
Definition Panner.h:458
std::atomic< float > smoothingTime_
Definition Panner.h:457
void processBlock(AudioBufferView< T > buffer) noexcept
Processes an audio block in-place. Real-time safe.
Definition Panner.h:127
void applyHaas(AudioBufferView< T > buffer, float panTarget) noexcept
Definition Panner.h:386
void setSpectralFrequency(float hz) noexcept
Definition Panner.h:191
void applyCombinedBinaural(AudioBufferView< T > buffer, float panTarget) noexcept
Definition Panner.h:277
void updateSpectralFilters(T targetPan) noexcept
Definition Panner.h:435
std::atomic< float > spectralFreq_
Definition Panner.h:461
Algorithm
Available panning algorithms.
Definition Panner.h:57
@ Binaural
Cross-feeding + ITD delay.
@ EqualPower
Standard -3 dB constant-power pan.
@ MidPan
Pans only the centre (mid) image.
@ SidePan
Pans only the stereo (side) image.
@ Haas
Precedence effect via inter-channel delay.
@ Spectral
Frequency-dependent panning via high-shelf.
bool setState(const uint8_t *data, size_t size)
Restores parameters from a blob (tolerant; rejects foreign ids).
Definition Panner.h:230
void applySidePan(AudioBufferView< T > buffer, float) noexcept
Definition Panner.h:361
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:160
float read(const char *key, float defaultValue) const
Reads a float, or defaultValue when the key is absent.
Definition StateBlob.h:203
bool isValid() const noexcept
Definition StateBlob.h:198
uint32_t processorId() const noexcept
Definition StateBlob.h:199
Serializes key/value parameters into a versioned blob.
Definition StateBlob.h:52
std::vector< uint8_t > blob() const
Finalizes and returns the blob.
Definition StateBlob.h:104
void write(const char *key, float value)
Writes a float parameter.
Definition StateBlob.h:70
Main namespace for the DSPark framework.
T fastSin(T x) noexcept
Fast sine approximation (degree-9 odd minimax polynomial).
Definition DspMath.h:213
T fastCos(T x) noexcept
Fast cosine approximation. See fastSin() for accuracy notes (float error is ~7e-6 here: half an ulp m...
Definition DspMath.h:244
constexpr uint32_t stateId(const char(&tag)[5]) noexcept
Builds a FOURCC processor id, e.g. dspark::stateId("COMP").
Definition StateBlob.h:639
constexpr T halfPi
Pi / 2 (1.57079...). Quarter period; sin/cos phase offset.
Definition DspMath.h:45
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:35
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:67
int maxBlockSize
Maximum number of samples per processing block.
Definition AudioSpec.h:51
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43
static BiquadCoeffs makeHighShelf(double sampleRate, double freq, double gainDb, double slope=1.0) noexcept
High-shelf filter.
Definition Biquad.h:313
Linear ramp smoother for predictable, uniform interpolation.
Definition Smoothers.h:56
void reset(double sampleRate, float rampTimeMilliseconds, float initialValue=0.0f) noexcept
Definition Smoothers.h:327
void setTargetValue(float newTarget) noexcept
Definition Smoothers.h:336
bool isSmoothing() const noexcept
Definition Smoothers.h:364
float getCurrentValue() const noexcept
Definition Smoothers.h:64