DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
DelayEstimator.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
65#include "../Core/DspMath.h"
66#include "../Core/AudioBuffer.h"
67#include "../Core/FFT.h"
68
69#include <algorithm>
70#include <cmath>
71#include <complex>
72#include <cstdint>
73#include <memory>
74#include <vector>
75
76namespace dspark {
77
92template <FloatType T>
94{
95public:
97 struct Result
98 {
99 double delaySamples = 0.0;
100 double delaySeconds = 0.0;
101 double confidence = 0.0;
102 bool inverted = false;
103 bool valid = false;
104 };
105
113 void prepare(double sampleRate, int maxDelaySamples)
114 {
115 if (!(sampleRate > 0.0) || !std::isfinite(sampleRate)) return;
116 sampleRate_ = sampleRate;
117 maxDelay_ = std::max(1, maxDelaySamples);
118 frame_ = 4096;
119 while (frame_ < 8 * maxDelay_ && frame_ < (1 << 26)) frame_ <<= 1;
120 hop_ = frame_ / 2;
121 fft_ = std::make_unique<FFTReal<double>>(static_cast<size_t>(frame_));
122 window_.resize(static_cast<size_t>(frame_));
123 for (int i = 0; i < frame_; ++i)
124 window_[static_cast<size_t>(i)] = 0.5 - 0.5 * std::cos(2.0 * pi<double> * i / frame_);
125 // The reference needs one frame; the delivery a frame plus the delay
126 // it is framed at, either side.
127 historyLength_ = frame_ + 2 * maxDelay_ + 2;
128 refHistory_.assign(static_cast<size_t>(historyLength_), 0.0);
129 delHistory_.assign(static_cast<size_t>(historyLength_), 0.0);
130 bufA_.assign(static_cast<size_t>(frame_ + 2), 0.0);
131 bufB_.assign(static_cast<size_t>(frame_ + 2), 0.0);
132 cross_.assign(static_cast<size_t>(frame_ / 2 + 1), std::complex<double> {});
133 corr_.assign(static_cast<size_t>(frame_ + 2), 0.0);
134 prepared_ = true;
135 reset();
136 }
137
139 void reset() noexcept
140 {
141 std::fill(refHistory_.begin(), refHistory_.end(), 0.0);
142 std::fill(delHistory_.begin(), delHistory_.end(), 0.0);
143 std::fill(cross_.begin(), cross_.end(), std::complex<double> {});
144 written_ = 0;
145 nextFrameEnd_ = static_cast<int64_t>(frame_) + maxDelay_;
146 frames_ = 0;
147 offset_ = 0;
148 }
149
154 void push(AudioBufferView<const T> reference, AudioBufferView<const T> delivery) noexcept
155 {
156 if (!prepared_) return;
157 const int n = std::min(reference.getNumSamples(), delivery.getNumSamples());
158 const int rc = reference.getNumChannels(), dc = delivery.getNumChannels();
159 if (n <= 0 || rc <= 0 || dc <= 0) return;
160 for (int i = 0; i < n; ++i)
161 {
162 double r = 0.0, d = 0.0;
163 for (int c = 0; c < rc; ++c) r += static_cast<double>(reference.getChannel(c)[i]);
164 for (int c = 0; c < dc; ++c) d += static_cast<double>(delivery.getChannel(c)[i]);
165 const size_t slot = static_cast<size_t>(written_ % historyLength_);
166 refHistory_[slot] = std::isfinite(r) ? r / rc : 0.0;
167 delHistory_[slot] = std::isfinite(d) ? d / dc : 0.0;
168 ++written_;
169 if (written_ == nextFrameEnd_)
170 {
171 accumulateFrame();
172 nextFrameEnd_ += hop_;
173 }
174 }
175 }
176
178 [[nodiscard]] Result estimate() noexcept
179 {
180 Result out;
181 if (!prepared_ || frames_ < 1) return out;
182 double lag = 0.0;
183 bool inverted = false;
184 if (!searchPeak(lag, inverted)) return out;
185 const double sign = inverted ? -1.0 : 1.0;
186
187 // Refine on the phase slope of the averaged cross-spectrum. The
188 // delivery was framed offset_ late, so the spectrum holds the delay
189 // minus offset_.
190 double d = lag - static_cast<double>(offset_);
191 for (int it = 0; it < 8; ++it)
192 {
193 double num = 0.0, den = 0.0;
194 for (int k = 1; k < frame_ / 2; ++k)
195 {
196 const double w = 2.0 * pi<double> * k / frame_;
197 const std::complex<double> s = cross_[static_cast<size_t>(k)] * sign
198 * std::polar(1.0, w * d);
199 const double mag = std::abs(s);
200 if (!(mag > 0.0)) continue;
201 const double phi = std::arg(s); // ~ -w * (delay - d)
202 num += mag * w * phi;
203 den += mag * w * w;
204 }
205 if (!(den > 0.0)) break;
206 const double step = -num / den;
207 d += step;
208 if (std::abs(step) < 1e-9) break;
209 }
210
211 std::complex<double> agree {};
212 double total = 0.0;
213 for (int k = 1; k < frame_ / 2; ++k)
214 {
215 const double w = 2.0 * pi<double> * k / frame_;
216 const std::complex<double> s = cross_[static_cast<size_t>(k)] * sign;
217 agree += s * std::polar(1.0, w * d);
218 total += std::abs(s);
219 }
220 out.delaySamples = d + static_cast<double>(offset_);
221 out.delaySeconds = out.delaySamples / sampleRate_;
222 out.confidence = (total > 0.0) ? std::clamp(std::abs(agree) / total, 0.0, 1.0) : 0.0;
223 out.inverted = inverted;
224 out.valid = total > 0.0;
225 return out;
226 }
227
229 [[nodiscard]] int getFrameSize() const noexcept { return frame_; }
230
233 double sampleRate, int maxDelaySamples)
234 {
235 DelayEstimator est;
236 est.prepare(sampleRate, maxDelaySamples);
237 est.push(reference, delivery);
238 // Flush: the last frames need the tail of the delivery.
239 const int flush = est.frame_ + 2 * est.maxDelay_;
240 std::vector<T> zeros(static_cast<size_t>(std::min(flush, 1 << 16)), T(0));
241 const T* z = zeros.data();
242 for (int left = flush; left > 0; left -= static_cast<int>(zeros.size()))
243 {
244 const int n = std::min(left, static_cast<int>(zeros.size()));
246 }
247 return est.estimate();
248 }
249
250private:
253 void accumulateFrame() noexcept
254 {
255 // Frame the delivery at the whole-sample delay found so far (see the
256 // file header); it settles within the first frames that carry
257 // material and moves only if the evidence moves it.
258 if (frames_ >= 1)
259 {
260 double lag = 0.0;
261 bool inv = false;
262 if (searchPeak(lag, inv))
263 {
264 const int newOffset = static_cast<int>(std::lround(lag));
265 if (newOffset != offset_)
266 {
267 // The spectrum so far was taken at the old offset: its
268 // phase holds (delay - old offset). Rotate it to the new.
269 for (int k = 0; k <= frame_ / 2; ++k)
270 {
271 const double w = 2.0 * pi<double> * k / frame_;
272 cross_[static_cast<size_t>(k)] *= std::polar(1.0, w * (newOffset - offset_));
273 }
274 offset_ = newOffset;
275 }
276 }
277 }
278
279 const int64_t refStart = written_ - maxDelay_ - frame_;
280 const int64_t delStart = refStart + offset_;
281 for (int i = 0; i < frame_; ++i)
282 {
283 const double w = window_[static_cast<size_t>(i)];
284 bufA_[static_cast<size_t>(i)] = w * sample(refHistory_, refStart + i);
285 bufB_[static_cast<size_t>(i)] = w * sample(delHistory_, delStart + i);
286 }
287 fft_->forward(bufA_.data(), bufA_.data());
288 fft_->forward(bufB_.data(), bufB_.data());
289 for (int k = 0; k <= frame_ / 2; ++k)
290 {
291 const std::complex<double> a(bufA_[static_cast<size_t>(2 * k)], bufA_[static_cast<size_t>(2 * k + 1)]);
292 const std::complex<double> b(bufB_[static_cast<size_t>(2 * k)], bufB_[static_cast<size_t>(2 * k + 1)]);
293 cross_[static_cast<size_t>(k)] += b * std::conj(a);
294 }
295 ++frames_;
296 }
297
298 [[nodiscard]] double sample(const std::vector<double>& h, int64_t index) const noexcept
299 {
300 if (index < 0 || index >= written_ || index < written_ - historyLength_) return 0.0;
301 return h[static_cast<size_t>(index % historyLength_)];
302 }
303
305 bool searchPeak(double& lag, bool& inverted) noexcept
306 {
307 double peakMag = 0.0;
308 for (const auto& s : cross_) peakMag = std::max(peakMag, std::abs(s));
309 if (!(peakMag > 0.0)) return false;
310 const double floor = peakMag * 1e-9;
311 for (int k = 0; k <= frame_ / 2; ++k)
312 {
313 // Undo the framing offset so the correlation is in absolute lag.
314 const double w = 2.0 * pi<double> * k / frame_;
315 std::complex<double> s = cross_[static_cast<size_t>(k)] * std::polar(1.0, -w * offset_);
316 const double m = std::abs(s);
317 s = (m > floor) ? s / m : std::complex<double> {};
318 corr_[static_cast<size_t>(2 * k)] = s.real();
319 corr_[static_cast<size_t>(2 * k + 1)] = s.imag();
320 }
321 fft_->inverse(corr_.data(), corr_.data());
322 auto at = [&](int tau) {
323 const int m = ((tau % frame_) + frame_) % frame_;
324 return corr_[static_cast<size_t>(m)];
325 };
326 int best = 0;
327 double bestAbs = -1.0;
328 for (int tau = -maxDelay_; tau <= maxDelay_; ++tau)
329 {
330 const double v = std::abs(at(tau));
331 if (v > bestAbs) { bestAbs = v; best = tau; }
332 }
333 inverted = at(best) < 0.0;
334 const double s = inverted ? -1.0 : 1.0;
335 const double ym = s * at(best - 1), y0 = s * at(best), yp = s * at(best + 1);
336 const double den = ym - 2.0 * y0 + yp;
337 const double frac = (den < 0.0) ? std::clamp(0.5 * (ym - yp) / den, -0.5, 0.5) : 0.0;
338 lag = static_cast<double>(best) + frac;
339 return true;
340 }
341
342 double sampleRate_ = 48000.0;
343 int maxDelay_ = 1;
344 int frame_ = 4096;
345 int hop_ = 2048;
346 int historyLength_ = 0;
347 bool prepared_ = false;
348 std::unique_ptr<FFTReal<double>> fft_;
349 std::vector<double> window_, refHistory_, delHistory_, bufA_, bufB_, corr_;
350 std::vector<std::complex<double>> cross_;
351 int64_t written_ = 0;
352 int64_t nextFrameEnd_ = 0;
353 int64_t frames_ = 0;
354 int offset_ = 0;
355};
356
357} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Streaming GCC-PHAT delay estimator with sub-sample refinement.
void reset() noexcept
Forgets everything pushed.
Result estimate() noexcept
The delay over everything pushed so far. Allocation free.
void prepare(double sampleRate, int maxDelaySamples)
Allocates for a search range.
static Result estimate(AudioBufferView< const T > reference, AudioBufferView< const T > delivery, double sampleRate, int maxDelaySamples)
Whole-signal convenience: one estimate over two buffers. Allocates.
void push(AudioBufferView< const T > reference, AudioBufferView< const T > delivery) noexcept
Feeds the next stretch of both signals (same length; channels averaged). Allocation free.
int getFrameSize() const noexcept
Frame length in samples (the analysis resolution in time).
Main namespace for the DSPark framework.
What estimate() returns.
double delaySamples
Positive: the delivery is late.
double delaySeconds
The same in seconds.
bool inverted
The delivery is polarity-inverted.
bool valid
Enough material was pushed to estimate.
double confidence
Share of the cross-spectrum consistent with it, 0..1.