DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
StereoGenerator.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
9#include "../Core/AudioBuffer.h"
10#include "../Core/AudioSpec.h"
11#include "../Core/Convolver.h"
12#include "../Core/DryWetMixer.h"
13#include "../Core/FIRFilter.h"
14#include "../Core/SmoothedValue.h"
15#include "../Core/StateBlob.h"
16#include "../Core/detail/StereoColor.h"
17#include "../Core/detail/TptSvf.h"
18#include <algorithm>
19#include <array>
20#include <atomic>
21#include <cmath>
22#include <cstdint>
23#include <limits>
24#include <memory>
25#include <span>
26#include <type_traits>
27#include <vector>
28
29namespace dspark
30{
60template <typename T> class StereoGenerator final
61{
62 static_assert(std::is_same_v<T, float> || std::is_same_v<T, double>);
63 static_assert(std::atomic<float>::is_always_lock_free && std::atomic<int>::is_always_lock_free);
64
65 public:
67 struct Options
68 {
69 int oversampling = 1;
70 float lowCutHz = 0;
71 bool operator==(const Options &) const noexcept = default;
72 };
73
75 enum class Status
76 {
77 Ok,
82 };
83
84 StereoGenerator() = default;
87
92 [[nodiscard]] static std::size_t getPrepareMemoryBound(const AudioSpec &spec,
93 Options options) noexcept
94 {
95 if (!validConfiguration(spec, options))
96 return 0;
98 const int source = State::sourceFactor(spec.sampleRate);
99 const int colorLatency = Color::latencyFor(options.oversampling, source);
100 const auto hp = highPassLayout(spec.sampleRate, options.lowCutHz);
101 const int totalLatency = colorLatency + hp.order / 2 + hp.block;
102 const auto buffer = [](std::size_t channels, std::size_t frames, std::size_t bytes) {
103 return channels * ((frames * bytes + 31) / 32 * 32) + 64;
104 };
105 auto bytes = sizeof(State) + 64 + 2 * Color::allocationBound(options.oversampling, source) +
106 buffer(2, 256, sizeof(double)) + buffer(1, 256, sizeof(double)) +
107 buffer(1, 256, sizeof(double)) + buffer(1, colorLatency, sizeof(double)) +
108 buffer(2, 256, sizeof(T)) + buffer(2, totalLatency, sizeof(T));
109 if (hp.block)
110 bytes += 3 * ((static_cast<std::size_t>(hp.order) + 1) * sizeof(double) + 64) +
112 return bytes;
113 }
114
119 bool prepare(const AudioSpec &spec)
120 {
121 return prepare(spec, options_);
122 }
123
125 bool prepare(const AudioSpec &spec, Options options)
126 {
127 if (!validConfiguration(spec, options))
128 return false;
129 auto next = std::make_unique<State>(spec, options, getWidth());
130 state_ = std::move(next);
131 options_ = options;
132 latency_.store(state_->latency, std::memory_order_relaxed);
133 publish(Status::Ok);
134 return true;
135 }
136
138 bool setWidth(float width) noexcept
139 {
140 if (!std::isfinite(width) || width < 0 || width > 1)
141 return false;
142 width_.store(width, std::memory_order_relaxed);
143 return true;
144 }
146 [[nodiscard]] float getWidth() const noexcept
147 {
148 return width_.load(std::memory_order_relaxed);
149 }
151 [[nodiscard]] Options getOptions() const noexcept
152 {
153 return options_;
154 }
156 [[nodiscard]] int getLatency() const noexcept
157 {
158 return latency_.load(std::memory_order_relaxed);
159 }
161 [[nodiscard]] Status getStatus() const noexcept
162 {
163 return static_cast<Status>(status_.load(std::memory_order_relaxed));
164 }
166 [[nodiscard]] std::uint64_t getSourceFrame() const noexcept
167 {
168 return state_ ? state_->frame : 0;
169 }
171 void reset() noexcept
172 {
173 resetAtFrame(0);
174 }
175
181 void resetAtFrame(std::uint64_t frame) noexcept
182 {
183 if (!state_)
184 return;
185 state_->reset(frame, getWidth());
186 publish(Status::Ok);
187 }
188
196 bool processBlock(AudioBufferView<T> buffer) noexcept
197 {
198 return processBlock(buffer, {});
199 }
200
208 bool processBlock(AudioBufferView<T> buffer, std::span<double> generatedDelta) noexcept
209 {
210 if (!state_)
211 return publish(Status::NotPrepared);
212 const int count = buffer.getNumSamples();
213 if (buffer.getNumChannels() != 2 || count > state_->spec.maxBlockSize ||
214 (!generatedDelta.empty() && generatedDelta.size() != static_cast<std::size_t>(count)))
215 return publish(Status::InvalidInput);
216 if (static_cast<std::uint64_t>(count) >
217 std::numeric_limits<std::uint64_t>::max() - state_->frame)
218 return publish(Status::ClockOverflow);
219 if (count == 0)
220 return publish(state_->fault ? Status::NumericalFailure : Status::Ok);
221 auto *left = buffer.getChannel(0);
222 auto *right = buffer.getChannel(1);
223 if (!left || !right)
224 return publish(Status::InvalidInput);
225 if (!generatedDelta.empty() &&
226 (!generatedDelta.data() ||
227 overlaps(generatedDelta.data(),
228 static_cast<std::uint64_t>(generatedDelta.size()) * sizeof(double), left,
229 static_cast<std::uint64_t>(count) * sizeof(T)) ||
230 overlaps(generatedDelta.data(),
231 static_cast<std::uint64_t>(generatedDelta.size()) * sizeof(double), right,
232 static_cast<std::uint64_t>(count) * sizeof(T))))
233 return publish(Status::InvalidInput);
234 const auto a = reinterpret_cast<std::uintptr_t>(left);
235 const auto b = reinterpret_cast<std::uintptr_t>(right);
236 if (static_cast<std::uint64_t>(a > b ? a - b : b - a) <
237 static_cast<std::uint64_t>(count) * sizeof(T))
238 return publish(Status::InvalidInput);
239 for (int i = 0; i < count; ++i)
240 if (!std::isfinite(left[i]) || !std::isfinite(right[i]))
241 return publish(Status::InvalidInput);
242 state_->width.setTargetValue(getWidth());
243 for (int offset = 0; offset < count;)
244 {
245 const int size = std::min(256, count - offset);
246 state_->process(buffer.getSubView(offset, size),
247 generatedDelta.empty()
248 ? std::span<double>{}
249 : generatedDelta.subspan(static_cast<std::size_t>(offset),
250 static_cast<std::size_t>(size)));
251 offset += size;
252 }
253 return publish(state_->fault ? Status::NumericalFailure : Status::Ok);
254 }
255
257 [[nodiscard]] std::vector<std::uint8_t> getState() const
258 {
259 StateWriter writer(stateId("STGN"), 1);
260 writer.write("width", getWidth());
261 writer.write("oversampling", static_cast<std::int32_t>(options_.oversampling));
262 writer.write("lowCutHz", options_.lowCutHz);
263 return writer.blob();
264 }
265
270 bool setState(const std::uint8_t *data, std::size_t size)
271 {
272 StateReader reader(data, size);
273 if (!reader.isValid() || reader.processorId() != stateId("STGN") ||
274 reader.processorVersion() != 1)
275 return false;
276 std::array<bool, 3> seen{};
277 for (const auto &entry : reader.entries())
278 {
279 const int index = entry.key == "width" ? 0
280 : entry.key == "oversampling" ? 1
281 : entry.key == "lowCutHz" ? 2
282 : -1;
283 if (index < 0)
284 continue;
285 if (seen[index] || entry.type != (index == 1 ? 1 : 0))
286 return false;
287 seen[index] = true;
288 }
289 const float width = reader.read("width", getWidth());
290 const Options options{reader.read("oversampling", options_.oversampling),
291 reader.read("lowCutHz", options_.lowCutHz)};
292 if (!std::isfinite(width) || width < 0 || width > 1 || !validOptions(options))
293 return false;
294 options_ = options;
295 width_.store(width, std::memory_order_relaxed);
296 return true;
297 }
298
299 private:
300 struct HighPassLayout
301 {
302 int order = 0, block = 0;
303 bool allReject = false;
304 };
305 static HighPassLayout highPassLayout(double rate, double lowCut) noexcept
306 {
307 HighPassLayout result;
308 if (lowCut <= 0)
309 return result;
310 const double transition =
311 std::min(std::max(50., lowCut * .25), std::min(lowCut * 1.5, rate - 2 * lowCut));
312 if (lowCut >= rate * .5 || transition < 30)
313 result.allReject = true;
314 else
315 {
316 result.order =
317 static_cast<int>(std::ceil(94 / (2.285 * twoPi<double> * transition / rate)));
318 if (result.order & 1)
319 ++result.order;
320 result.block = 1;
321 while (result.block * 2 <= std::max(64, static_cast<int>(std::round(rate / 93.75))))
322 result.block *= 2;
323 }
324 return result;
325 }
326 static bool overlaps(const void *left, std::uint64_t leftBytes, const void *right,
327 std::uint64_t rightBytes) noexcept
328 {
329 const auto a = reinterpret_cast<std::uintptr_t>(left);
330 const auto b = reinterpret_cast<std::uintptr_t>(right);
331 return a <= b ? b - a < leftBytes : a - b < rightBytes;
332 }
333 struct Band
334 {
335 detail::TptSvfState<double> state{};
336 int index;
337 double rate, base, corridor, g = 0, gain = 0, dg = 0, dGain = 0;
338 int untilUpdate = 0;
339
340 void prepare(int i, double fs) noexcept
341 {
342 index = i;
343 rate = fs;
344 const double first = i < 16 ? 210 : 45;
345 const double last = i < 16 ? std::min(14000., fs * .35) : 165;
346 corridor = std::log(last / first) / (i < 16 ? 15 : 3);
347 base = first * std::exp((i < 16 ? i : i - 16) * corridor);
348 }
349 double coefficient(double t) const noexcept
350 {
351 const double f =
352 base *
353 std::exp(.22 * corridor *
354 std::sin(twoPi<double> * (.029 + index * .0017) * t + index * 2.399963));
355 return std::tan(pi<double> * f / rate);
356 }
357 double amplitude(double t) const noexcept
358 {
359 const double db = (((index / 2 + index % 2) & 1) == 0 ? 1 : -1) *
360 (2.6 + .4 * std::sin(twoPi<double> * (.037 + index * .0013) * t +
361 index * 1.618034));
362 return std::expm1(db * std::log(10.) / 20);
363 }
364 void position(std::uint64_t frame) noexcept
365 {
366 const auto offset = static_cast<int>(frame % 64);
367 const double t = static_cast<double>(frame - offset) / rate;
368 const double future = t + 64. / rate;
369 g = coefficient(t);
370 gain = amplitude(t);
371 dg = (coefficient(future) - g) / 64;
372 dGain = (amplitude(future) - gain) / 64;
373 for (int i = 0; i < offset; ++i)
374 {
375 g += dg;
376 gain += dGain;
377 }
378 untilUpdate = 64 - offset;
379 }
380 double process(double input, std::uint64_t frame) noexcept
381 {
382 if (untilUpdate == 0)
383 position(frame);
384 const double a1 = 1 / (1 + g * (g + .5));
385 const double a2 = g * a1;
386 const double a3 = g * a2;
387 const double y = gain * .5 * detail::tptSvfStep(input, state, a1, a2, a3).bandpass;
388 g += dg;
389 gain += dGain;
390 --untilUpdate;
391 return y;
392 }
393 };
394
395 struct State
396 {
397 AudioSpec spec;
398 std::array<Band, 20> bands{};
399 AudioBuffer<double> copy, delta;
400 DryWetMixer<T, 2> dry;
401 DryWetMixer<double, 1> linear;
402 Convolver<double> highPass;
403 detail::StereoColor<detail::ClipperCurve::AsymmetricKnee> tube;
404 detail::StereoColor<detail::ClipperCurve::SymmetricKnee> tape;
405 SmoothedValue<double> width;
406 std::uint64_t frame = 0;
407 int latency;
408 bool useHighPass = false, allReject = false, fault = false;
409
410 State(AudioSpec environment, Options options, float initialWidth)
411 : spec(environment), tube(options.oversampling, sourceFactor(spec.sampleRate)),
412 tape(options.oversampling, sourceFactor(spec.sampleRate)), latency(tube.latency())
413 {
414 for (int i = 0; i < 20; ++i)
415 bands[i].prepare(i, spec.sampleRate);
416 copy.resize(2, 256);
417 delta.resize(1, 256);
418 linear.prepare({spec.sampleRate, 256, 1});
419 linear.setLatencyCompensation(tube.latency());
420 const auto hp = highPassLayout(spec.sampleRate, options.lowCutHz);
421 allReject = hp.allReject;
422 if (hp.block)
423 {
424 const auto taps = FIRDesign<double>::highPass(spec.sampleRate, options.lowCutHz,
425 hp.order + 1, 10.2);
426 highPass.prepare(hp.block, taps.data(), static_cast<int>(taps.size()));
427 latency += hp.order / 2 + hp.block;
428 useHighPass = true;
429 }
430 dry.prepare({spec.sampleRate, 256, 2});
431 dry.setLatencyCompensation(latency);
432 width.prepare(spec.sampleRate, 5);
433 width.setSmoothingType(SmoothedValue<double>::SmoothingType::Linear);
434 reset(0, initialWidth);
435 }
436 static int sourceFactor(double rate) noexcept
437 {
438 return rate <= 48000 ? 8 : rate <= 96000 ? 4 : rate <= 192000 ? 2 : 1;
439 }
440 void reset(std::uint64_t position, float targetWidth) noexcept
441 {
442 frame = position;
443 for (auto &band : bands)
444 {
445 band.state = {};
446 band.position(position);
447 }
448 tube.reset();
449 tape.reset();
450 highPass.reset();
451 dry.reset();
452 linear.reset();
453 width.reset(targetWidth);
454 fault = false;
455 }
456 void process(AudioBufferView<T> input, std::span<double> generatedDelta) noexcept
457 {
458 const int count = input.getNumSamples();
459 dry.pushDry(input);
460 double *l = copy.getChannel(0), *r = copy.getChannel(1), *d = delta.getChannel(0);
461 if (!fault)
462 {
463 for (int i = 0; i < count; ++i)
464 {
465 l[i] = r[i] = 0;
466 for (int j = 0; j < 20; ++j)
467 (j & 1 ? r[i] : l[i]) +=
468 bands[j].process(input.getChannel(j & 1)[i], frame + i);
469 d[i] = .5 * (l[i] - r[i]);
470 if (!std::isfinite(l[i]) || !std::isfinite(r[i]) || !std::isfinite(d[i]))
471 fault = true;
472 }
473 if (!fault)
474 {
475 linear.pushDry(delta.toView().getSubView(0, count));
476 double *lc[]{l}, *rc[]{r};
477 tube.process({lc, 1, count});
478 tape.process({rc, 1, count});
479 const auto *aligned = linear.getDryChannel(0);
480 for (int i = 0; i < count; ++i)
481 d[i] = aligned[i] + .1 * (.5 * (l[i] - r[i]) - aligned[i]);
482 if (useHighPass)
483 highPass.processInPlace(d, count);
484 for (int i = 0; i < count; ++i)
485 if (!std::isfinite(d[i]))
486 fault = true;
487 if (allReject)
488 std::fill_n(d, count, 0.);
489 }
490 }
491 const auto *dl = dry.getDryChannel(0), *dr = dry.getDryChannel(1);
492 std::array<double, 256> changes{};
493 for (int i = 0; i < count; ++i)
494 {
495 const double w = width.getNextValue();
496 const double change = fault || w == 0 ? 0 : 8 * w * d[i];
497 const double outL = static_cast<double>(dl[i]) + change;
498 const double outR = static_cast<double>(dr[i]) - change;
499 if (!std::isfinite(outL) || !std::isfinite(outR) ||
500 std::abs(outL) > std::numeric_limits<T>::max() ||
501 std::abs(outR) > std::numeric_limits<T>::max())
502 fault = true;
503 changes[i] = change;
504 }
505 for (int i = 0; i < count; ++i)
506 {
507 if (!generatedDelta.empty())
508 generatedDelta[i] = fault ? 0 : changes[i];
509 // Assignment preserves signed zero and exact dry PCM at zero width.
510 input.getChannel(0)[i] =
511 fault || changes[i] == 0 ? dl[i] : static_cast<T>(dl[i] + changes[i]);
512 input.getChannel(1)[i] =
513 fault || changes[i] == 0 ? dr[i] : static_cast<T>(dr[i] - changes[i]);
514 }
515 frame += static_cast<std::uint64_t>(count);
516 }
517 };
518
519 static bool validConfiguration(const AudioSpec &spec, Options options) noexcept
520 {
521 return spec.isValid() && spec.numChannels == 2 && (spec.sampleRate >= 8000) &&
522 (spec.sampleRate <= 384000) && validOptions(options);
523 }
524 static bool validOptions(Options options) noexcept
525 {
526 return options.oversampling >= 1 && options.oversampling <= 16 &&
527 (options.oversampling & (options.oversampling - 1)) == 0 &&
528 std::isfinite(options.lowCutHz) &&
529 (options.lowCutHz == 0 || (options.lowCutHz >= 20 && options.lowCutHz <= 5000));
530 }
531 bool publish(Status status) noexcept
532 {
533 status_.store(static_cast<int>(status), std::memory_order_relaxed);
534 return status == Status::Ok;
535 }
536 std::unique_ptr<State> state_;
537 Options options_;
538 std::atomic<float> width_{0};
539 std::atomic<int> status_{static_cast<int>(Status::NotPrepared)}, latency_{0};
540};
541} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
static std::vector< T > highPass(double sampleRate, double cutoffHz, int numTaps, T beta=T(5))
Designs a high-pass FIR filter.
Definition FIRFilter.h:108
Tolerant reader: missing keys yield defaults, unknown keys are skipped.
Definition StateBlob.h:161
uint16_t processorVersion() const noexcept
Definition StateBlob.h:201
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
const std::vector< Entry > & entries() const noexcept
Definition StateBlob.h:247
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
A stereo generator that preserves the delayed original mid signal.
StereoGenerator(const StereoGenerator &)=delete
Status
Result of the last processing call (also readable by a UI).
Status getStatus() const noexcept
Returns the last processing status. Numerical failure latches until reset.
bool prepare(const AudioSpec &spec, Options options)
Prepares explicit options; on success clears all stream history.
void reset() noexcept
Clears history and restarts modulation at source frame zero.
bool setState(const std::uint8_t *data, std::size_t size)
Restores a validated preset. Factor/low cut apply at the next prepare. Missing keys retain their sett...
int getLatency() const noexcept
Exact active delay in source frames, including the optional FIR.
bool prepare(const AudioSpec &spec)
Prepares pending options for stereo at 8 to 384 kHz. Invalid settings return false,...
std::vector< std::uint8_t > getState() const
Serializes parameters only; filter history and source clock are excluded.
bool processBlock(AudioBufferView< T > buffer, std::span< double > generatedDelta) noexcept
Also captures the generated mono delta before rounded L/R add-back. An empty span disables capture....
bool setWidth(float width) noexcept
Publishes width in [0,1]. Invalid values return false unchanged.
float getWidth() const noexcept
Returns the published target, not the instantaneous ramp value.
Options getOptions() const noexcept
Returns options for the next prepare (including restored presets).
std::uint64_t getSourceFrame() const noexcept
Returns the next input frame on this stream's source clock.
static std::size_t getPrepareMemoryBound(const AudioSpec &spec, Options options) noexcept
Conservative cumulative setup allocation bound, or zero for invalid settings. Includes state,...
bool processBlock(AudioBufferView< T > buffer) noexcept
Processes writable, nonoverlapping stereo channels in place. Returns false without changing input or ...
void resetAtFrame(std::uint64_t frame) noexcept
Clears history and aligns modulation to an absolute source frame. This is not a seek restore: replay ...
StereoGenerator & operator=(const StereoGenerator &)=delete
static std::size_t convolutionAllocationBound(std::size_t block, std::size_t length) noexcept
TptSvfOutput< T > tptSvfStep(T input, TptSvfState< T > &state, T a1, T a2, T a3) noexcept
Advances the expanded Simper TPT recurrence by one sample.
Definition TptSvf.h:43
constexpr void copy(char *dst, std::string_view src, std::size_t dstCapacity) noexcept
Safe small string copy used only for compile-time-friendly name building.
Main namespace for the DSPark framework.
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
Configuration applied by prepare(), never by the audio callback.
int oversampling
Local color-branch factor: 1, 2, 4, 8 or 16.
bool operator==(const Options &) const noexcept=default
float lowCutHz
Generated delta only: off, or 20 to 5000 Hz.