DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineTransientAnalyzer.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
31#include "OfflineEnergyAnalyzer.h"
32
33#if DSPARK_HAS_OFFLINE
34
35#include "detail/OnsetFeatures.h"
36#include "../Core/Biquad.h"
37#include <algorithm>
38#include <array>
39#include <cmath>
40#include <cstddef>
41#include <cstdint>
42#include <limits>
43#include <memory>
44#include <span>
45#include <utility>
46
47namespace dspark
48{
49
51template <FloatType T> class OfflineTransientAnalyzer final
52{
53 public:
54 static constexpr std::uint32_t algorithmRevision = 4;
55 struct Options
56 {
57 bool attacks = true;
58 bool pulses = true;
59 bool retainFeatures = false;
60 };
61
64 struct Event
65 {
66 std::int64_t begin = 0;
67 std::int64_t peak = 0;
68 std::int64_t end = 0;
69 double strength = 0;
70 double contrast = 0;
71 double peakAmplitude = 0;
72 double sustainRms = 0;
73 bool endLimited = false;
74 bool overlapsNext = false;
75 bool energyRestart = false;
76 bool bandRefined = false;
77 };
78
82 {
83 T attack = T(0);
84 T pulse = T(0);
85 std::array<T, 4> registers{};
86 double windowPower = 0;
87 double lowEnergyShare = 0;
88 };
89
96 class Analysis final
97 {
98 public:
99 Analysis() = default;
100 Analysis(Analysis &&) noexcept = default;
101 Analysis &operator=(Analysis &&) noexcept = default;
102 [[nodiscard]] bool isValid() const noexcept
103 {
104 return energy_.isValid();
105 }
111 [[nodiscard]] const typename OfflineEnergyAnalyzer<T>::Analysis &energy() const noexcept
112 {
113 return energy_;
114 }
115 [[nodiscard]] std::span<const Event> attacks() const noexcept
116 {
117 return isValid() ? std::span<const Event>(attacks_.get(), attackCount_)
118 : std::span<const Event>();
119 }
120 [[nodiscard]] std::span<const Event> pulses() const noexcept
121 {
122 return isValid() ? std::span<const Event>(pulses_.get(), pulseCount_)
123 : std::span<const Event>();
124 }
125 [[nodiscard]] std::span<const FeatureFrame> features() const noexcept
126 {
127 return isValid() && features_
128 ? std::span<const FeatureFrame>(features_.get(), featureCount_)
129 : std::span<const FeatureFrame>();
130 }
131 [[nodiscard]] int frameSize() const noexcept
132 {
133 return frameSize_;
134 }
135 [[nodiscard]] int hopFrames() const noexcept
136 {
137 return hop_;
138 }
140 [[nodiscard]] std::int64_t featureCenter(std::size_t index) const noexcept
141 {
142 return index < featureCount_
143 ? static_cast<std::int64_t>(index + 1) * hop_ - frameSize_ / 2
144 : 0;
145 }
146 [[nodiscard]] Options getOptions() const noexcept
147 {
148 return options_;
149 }
150 [[nodiscard]] std::size_t retainedBytes() const noexcept
151 {
152 return isValid()
153 ? energy_.retainedBytes() + (attackCount_ + pulseCount_) * sizeof(Event) +
154 (features_ ? featureCount_ * sizeof(FeatureFrame) : 0)
155 : 0;
156 }
157
158 private:
160 typename OfflineEnergyAnalyzer<T>::Analysis energy_;
161 Options options_;
162 int frameSize_ = 0, hop_ = 0;
163 std::size_t attackCount_ = 0, pulseCount_ = 0, featureCount_ = 0;
164 std::unique_ptr<Event[]> attacks_, pulses_;
165 std::unique_ptr<FeatureFrame[]> features_;
166 };
167
168 struct Result
169 {
172 std::size_t memoryBytes =
173 0;
174 [[nodiscard]] bool succeeded() const noexcept
175 {
176 return offlineSucceeded(status);
177 }
178 };
179
191 [[nodiscard]] Result analyze(OfflineAudioSource<T> &source, Options options = {},
192 const OfflineJobOptions &jobOptions = {}) const
193 {
194 Result result;
195 try
196 {
197 detail::OfflineSession job(jobOptions);
198 Analysis candidate;
199 candidate.options_ = options;
200 const auto spec = source.getSpec();
202 if ((spec.sampleRate < 8000) || (spec.sampleRate > 384000))
204 job.checkpoint(OfflinePhase::Analyze, 0, spec.frames);
205 const bool spectrumNeeded = options.attacks || options.pulses || options.retainFeatures;
206 candidate.frameSize_ = detail::OnsetSpectrum<T>::defaultFrameSize(spec.sampleRate);
207 candidate.hop_ = std::max(1, static_cast<int>(std::lround(spec.sampleRate / 200)));
208 const int microHop = std::max(1, static_cast<int>(std::lround(spec.sampleRate / 1000)));
209 const auto microCount = static_cast<std::uint64_t>((spec.frames - 1) / microHop + 1);
210 if (spec.frames > std::numeric_limits<std::int64_t>::max() - candidate.frameSize_)
212 const auto featureCount =
213 static_cast<std::uint64_t>((spec.frames + candidate.frameSize_) / candidate.hop_);
214 if (spectrumNeeded)
215 {
216 // Charge all length-dependent feature work before scanning any PCM.
217 job.charge(detail::offlineBytes(microCount, sizeof(MicroBin)));
218 job.charge(detail::offlineBytes(featureCount, sizeof(FeatureFrame)));
219 if (options.attacks)
220 {
221 job.charge(detail::offlineBytes(microCount, sizeof(double)));
222 job.charge(detail::offlineBytes(featureCount, sizeof(bool)));
223 job.charge(detail::offlineBytes(
224 static_cast<std::uint64_t>(candidate.frameSize_ / 2 + 1), sizeof(double)));
225 }
226 job.charge(detail::offlineBytes(static_cast<std::uint64_t>(candidate.frameSize_) *
227 2 * spec.channels,
228 sizeof(T)));
229 job.charge(detail::OnsetSpectrum<T>::preparationBytes(candidate.frameSize_));
230 job.charge(detail::OnsetNovelty<T>::preparationBytes(candidate.frameSize_));
231 }
232 auto energy = OfflineEnergyAnalyzer<T>().analyze(
233 source, detail::offlineRemaining(jobOptions, job.bytes()));
234 if (!energy.succeeded())
235 detail::offlineFail(energy.status);
236 job.charge(energy.memoryBytes);
237 if (energy.analysis.getSpec() != spec)
239 candidate.energy_ = std::move(energy.analysis);
240 if (spectrumNeeded && candidate.energy_.samplePeak() > 0)
241 {
242 auto micro = std::make_unique<MicroBin[]>(static_cast<std::size_t>(microCount));
243 auto features =
244 std::make_unique<FeatureFrame[]>(static_cast<std::size_t>(featureCount));
245 auto highPower = options.attacks
246 ? std::make_unique<double[]>(static_cast<std::size_t>(microCount))
247 : nullptr;
248 auto highEvidence = options.attacks
249 ? std::make_unique<bool[]>(static_cast<std::size_t>(featureCount))
250 : nullptr;
251 candidate.featureCount_ = static_cast<std::size_t>(featureCount);
252 scanFeatures(source, spec, candidate, microHop, micro.get(), highPower.get(),
253 highEvidence.get(), features.get(), job, jobOptions.blockFrames);
254 const auto microView =
255 std::span<const MicroBin>(micro.get(), static_cast<std::size_t>(microCount));
256 const auto featureView =
257 std::span<const FeatureFrame>(features.get(), candidate.featureCount_);
258 const auto highView = std::span<const double>(
259 highPower.get(), highPower ? static_cast<std::size_t>(microCount) : 0);
260 const auto evidenceView = std::span<const bool>(
261 highEvidence.get(), highEvidence ? candidate.featureCount_ : 0);
262 if (options.attacks)
263 buildEvents(candidate, featureView, microView, highView, evidenceView,
264 microHop, false, job);
265 if (options.pulses)
266 buildEvents(candidate, featureView, microView, {}, {}, microHop, true, job);
267 if (options.retainFeatures)
268 candidate.features_ = std::move(features);
269 }
270 if (source.getSpec() != spec)
272 job.checkpoint(OfflinePhase::Analyze, spec.frames, spec.frames);
273 result.memoryBytes = job.bytes();
274 result.analysis = std::move(candidate);
275 result.status = OfflineStatus::Success;
276 }
277 catch (...)
278 {
279 result.status = detail::offlineExceptionStatus();
280 }
281 return result;
282 }
283
284 private:
285 struct MicroBin
286 {
287 double power = 0;
288 double peakPower = 0;
289 std::int64_t peakFrame = 0;
290 };
291
292 // The same refinement and body tracker serve full-band and high-band power.
293 // Peak samples always come from the original linked source, not the filter.
294 struct PowerView
295 {
296 std::span<const MicroBin> source;
297 std::span<const double> high;
298 PowerView(std::span<const MicroBin> full, std::span<const double> band = {}) noexcept
299 : source(full), high(band)
300 {
301 }
302 [[nodiscard]] std::size_t size() const noexcept { return source.size(); }
303 [[nodiscard]] double operator[](std::size_t i) const noexcept
304 {
305 return high.empty() ? source[i].power : high[i];
306 }
307 };
308
309 static void scanFeatures(OfflineAudioSource<T> &source, const OfflineAudioSpec &spec,
310 const Analysis &analysis, int microHop, MicroBin *micro,
311 double *highPower, bool *highEvidence,
312 FeatureFrame *features, detail::OfflineSession &job, int blockFrames)
313 {
314 const int size = analysis.frameSize_, hop = analysis.hop_;
315 auto ring = std::make_unique<T[]>(static_cast<std::size_t>(2 * size * spec.channels));
316 detail::OnsetSpectrum<T> spectrum;
317 detail::OnsetNovelty<T> novelty;
318 spectrum.prepare(size);
319 novelty.prepare(spec.sampleRate, size, true);
320 detail::OfflineBlock<T> scratch(job, spec, blockFrames);
321 Biquad<double> highPass;
322 std::unique_ptr<double[]> highGains;
323 if (highPower)
324 {
325 const auto coefficients = BiquadCoeffs::makeHighPass(spec.sampleRate, 800);
326 highPass.setCoeffsNow(coefficients);
327 highGains = std::make_unique<double[]>(static_cast<std::size_t>(size / 2 + 1));
328 for (int k = 0; k <= size / 2; ++k)
329 {
330 const double gain = coefficients.getMagnitude(
331 static_cast<double>(k) * spec.sampleRate / size, spec.sampleRate);
332 highGains[static_cast<std::size_t>(k)] = gain * gain;
333 }
334 }
335 OfflineFingerprint fingerprint;
336 int write = 0, inHop = 0;
337 std::size_t frame = 0;
338 auto push = [&](const std::array<T, 2> &values) {
339 std::array<const T *, 2> pointers{};
340 for (int c = 0; c < spec.channels; ++c)
341 {
342 T *channel = ring.get() + static_cast<std::size_t>(c * 2 * size);
343 channel[write] = channel[write + size] = values[static_cast<std::size_t>(c)];
344 }
345 if (++write == size)
346 write = 0;
347 if (++inHop < hop)
348 return;
349 inHop = 0;
350 for (int c = 0; c < spec.channels; ++c)
351 pointers[static_cast<std::size_t>(c)] = ring.get() + c * 2 * size + write;
352 if (!spectrum.computePooled({pointers.data(), spec.channels, size}))
354 const auto magnitudes = spectrum.magnitudes();
355 double windowPower = 0;
356 double lowPower = 0;
357 double filteredLow = 0, filteredHigh = 0;
358 for (std::size_t k = 0; k < magnitudes.size(); ++k)
359 {
360 const double magnitude = static_cast<double>(magnitudes[k]) / size;
361 const double power =
362 magnitude * magnitude * (k == 0 || k + 1 == magnitudes.size() ? 1 : 2);
363 windowPower += power;
364 if (static_cast<double>(k) * spec.sampleRate / size < 800)
365 {
366 lowPower += power;
367 if (highGains)
368 filteredLow += power * highGains[k];
369 }
370 else if (highGains)
371 filteredHigh += power * highGains[k];
372 }
373 const auto value = novelty.process(spectrum.magnitudes(), {},
375 if (!value.valid || !std::isfinite(value.value) || !std::isfinite(value.spectralFlux))
377 // A finite-order high-pass still passes some bass. Require a majority
378 // of its predicted spectral power above the split before using the
379 // band for timing; residual bass cycles alone are not new attacks.
380 if (highEvidence)
381 highEvidence[frame] = filteredHigh > 0 && filteredHigh >= filteredLow;
382 features[frame++] = {value.value, value.spectralFlux, value.registers, windowPower,
383 windowPower > 0 ? lowPower / windowPower : 0};
384 };
385 const double normalization = analysis.energy_.samplePeak();
386 for (std::int64_t first = 0; first < spec.frames;)
387 {
388 const int count =
389 static_cast<int>(std::min<std::int64_t>(scratch.frames(), spec.frames - first));
390 auto block = scratch.view(count);
391 detail::offlineRead(source, spec, first, block);
392 for (int f = 0; f < count; ++f)
393 {
394 std::array<T, 2> values{};
395 double power = 0;
396 double high = 0;
397 for (int c = 0; c < spec.channels; ++c)
398 {
399 const T raw = block.getChannel(c)[f];
400 if (!std::isfinite(raw))
402 detail::offlineHash(fingerprint, raw);
403 const double value = static_cast<double>(raw) / normalization;
404 if (!std::isfinite(value) || std::abs(value) > 1)
406 values[static_cast<std::size_t>(c)] = static_cast<T>(value);
407 power += value * value;
408 if (highPower)
409 {
410 const double filtered = highPass.processSample(value, c);
411 high += filtered * filtered;
412 }
413 }
414 power /= spec.channels;
415 const auto position = first + f;
416 auto &bin = micro[static_cast<std::size_t>(position / microHop)];
417 bin.power += power / microHop;
418 if (highPower)
419 highPower[static_cast<std::size_t>(position / microHop)] +=
420 high / spec.channels / microHop;
421 if (power > bin.peakPower)
422 {
423 bin.peakPower = power;
424 bin.peakFrame = position;
425 }
426 push(values);
427 }
428 first += count;
429 job.checkpoint(OfflinePhase::Analyze, first, spec.frames);
430 }
431 if (fingerprint != analysis.energy_.fingerprint())
433 for (int f = 0; f < size; ++f)
434 push({});
435 if (frame != analysis.featureCount_)
437 }
438
439 [[nodiscard]] static double meanPower(PowerView micro, std::int64_t begin,
440 std::int64_t end) noexcept
441 {
442 double sum = 0;
443 for (auto i = std::max<std::int64_t>(0, begin);
444 i < std::min<std::int64_t>(static_cast<std::int64_t>(micro.size()), end); ++i)
445 sum += micro[static_cast<std::size_t>(i)];
446 const double mean = end > begin ? sum / static_cast<double>(end - begin) : 0;
447 return micro.high.empty() ? std::min(1.0, mean) : mean;
448 }
449
450 [[nodiscard]] static bool refine(Event &event, std::int64_t reference,
451 PowerView micro, int microHop,
452 const OfflineAudioSpec &spec, bool bass) noexcept
453 {
454 const auto radius =
455 static_cast<std::int64_t>(std::lround((bass ? 0.045 : 0.025) * spec.sampleRate));
456 if (reference < -radius || reference >= spec.frames + radius)
457 return false;
458 const auto from = std::max<std::int64_t>(0, reference - radius) / microHop;
459 const auto to = std::min<std::int64_t>(spec.frames - 1, reference + radius) / microHop;
460 double best = 0, before = 0, after = 0;
461 std::int64_t selected = from;
462 for (auto i = from; i <= to; ++i)
463 {
464 const double left = meanPower(micro, i - 3, i);
465 const double right = meanPower(micro, i, i + 3);
466 if (right - left > best)
467 {
468 best = right - left;
469 before = left;
470 after = right;
471 selected = i;
472 }
473 }
474 if (!(best > 1e-12) || best < 0.12 * after)
475 return false;
476 if (bass)
477 {
478 // A bass waveform's second half-cycle can have a steeper slope than
479 // its actual attack. Use the low-register evidence already computed
480 // from the shared spectrum to locate the first sustained rise above the
481 // preceding body, rather than following that later half-cycle. Longer
482 // context averages the bass cycle; broadband attacks retain fine
483 // localization.
484 const double baseline = maximumPower(micro, from - 40, from);
485 double maximum = 0;
486 for (auto i = from; i <= to; ++i)
487 maximum = std::max(maximum, meanPower(micro, i, i + 3));
488 const double crossing = baseline + 0.08 * std::max(0.0, maximum - baseline);
489 bool found = false;
490 for (auto i = from; i <= to; ++i)
491 {
492 const double broadLeft = maximumPower(micro, i - 42, i - 2);
493 const double broadRight = maximumPower(micro, i, i + 40);
494 const double left = meanPower(micro, i - 3, i);
495 const double right = meanPower(micro, i, i + 3);
496 if (right > crossing && right > left && broadRight - broadLeft >= 0.4 * broadRight)
497 {
498 before = left;
499 after = right;
500 best = right - left;
501 selected = i;
502 found = true;
503 break;
504 }
505 }
506 if (!found)
507 return false;
508 }
509 if (!bass)
510 {
511 // A rising event can have its largest slope near its peak. Recover
512 // its actual restart when a preceding quiet interval is observable;
513 // do not move through sustained energy or a preceding event.
514 const double quiet = after * 1e-6;
515 const auto earliest = std::max<std::int64_t>(2, selected - 40);
516 for (auto i = selected - 1; i >= earliest; --i)
517 if (micro[static_cast<std::size_t>(i)] <= quiet &&
518 micro[static_cast<std::size_t>(i - 1)] <= quiet &&
519 micro[static_cast<std::size_t>(i - 2)] <= quiet)
520 {
521 auto onset = i + 1;
522 while (onset < selected && micro[static_cast<std::size_t>(onset)] <= quiet)
523 ++onset;
524 if (onset < selected)
525 {
526 selected = onset;
527 before = meanPower(micro, onset - 3, onset);
528 after = meanPower(micro, onset, onset + 3);
529 best = after - before;
530 }
531 break;
532 }
533 }
534 event.contrast = best / std::max(1e-30, before + after);
535 const double floor = before + 0.05 * best;
536 auto begin = selected;
537 while (begin > 0 && begin > selected - 5 &&
538 micro[static_cast<std::size_t>(begin - 1)] > floor)
539 --begin;
540 while (begin < to && micro[static_cast<std::size_t>(begin)] <= floor)
541 ++begin;
542 // A waveform half-cycle can rise sharply without a new attack. Check
543 // the enclosing energy step as well as the 3 ms localization gradient.
544 // This also rejects the spectral spread caused by cutting a sustained
545 // tone at the end of a file; zero padding must not invent an attack.
546 const double contextBefore = meanPower(micro, begin - 12, begin - 2);
547 const auto availableEnd = static_cast<std::int64_t>(micro.size());
548 const double contextAfter = meanPower(micro, begin, std::min(begin + 10, availableEnd));
549 if (contextAfter - contextBefore < 0.08 * contextAfter)
550 return false;
551 const double broadBefore = meanPower(micro, begin - 22, begin - 2);
552 const double broadAfter = meanPower(micro, begin, std::min(begin + 20, availableEnd));
553 if (broadAfter - broadBefore < 0.08 * broadAfter)
554 return false;
555 const auto &bin = micro.source[static_cast<std::size_t>(begin)];
556 event.begin = begin * microHop;
557 // Preserve the exact position of an isolated single-frame impulse.
558 if (micro.high.empty() && bin.peakPower > 0.95 * bin.power * microHop)
559 event.begin = bin.peakFrame;
560 event.peak = event.begin;
561 event.end = event.begin + 1;
562 return true;
563 }
564
565 [[nodiscard]] static double maximumPower(PowerView micro, std::int64_t begin,
566 std::int64_t end) noexcept
567 {
568 double maximum = 0;
569 for (auto i = begin; i < end; ++i)
570 maximum = std::max(maximum, meanPower(micro, i, i + 3));
571 return maximum;
572 }
573
574 static void followBody(Event &event, const Event *next, std::span<const MicroBin> micro,
575 std::span<const double> highPower,
576 int microHop, const OfflineAudioSpec &spec, double scale) noexcept
577 {
578 const auto begin = event.begin / microHop;
579 const auto maximum =
580 std::max<std::int64_t>(1, static_cast<std::int64_t>(0.25 * spec.sampleRate));
581 const auto limitFrame =
582 std::min(spec.frames, event.begin + std::min(maximum, spec.frames - event.begin));
583 const auto horizon = (next ? std::min(limitFrame, next->begin) : limitFrame);
584 const auto stop = std::max(begin + 1, (horizon - 1) / microHop + 1);
585 const auto tailBegin = std::max(begin, stop - 25);
586 const PowerView body(micro, event.bandRefined ? highPower : std::span<const double>{});
587 const double sustain = meanPower(body, tailBegin, stop);
588 double crest = 0;
589 auto crestBin = begin;
590 for (auto i = begin; i < std::min(stop, begin + 40); ++i)
591 {
592 const double power = meanPower(body, i - 1, i + 2);
593 if (power > crest)
594 {
595 crest = power;
596 crestBin = i;
597 }
598 }
599 auto end = stop;
600 if (crest > 1.25 * sustain)
601 {
602 const double floor = sustain + 0.1 * (crest - sustain);
603 for (auto i = crestBin + 1; i < stop; ++i)
604 if (meanPower(body, i - 1, i + 2) <= floor && meanPower(body, i, i + 3) <= floor)
605 {
606 end = i;
607 break;
608 }
609 }
610 else
611 end = std::min(stop, crestBin + 2);
612 event.endLimited = end == stop && horizon < spec.frames;
613 event.overlapsNext = event.endLimited && next && horizon == next->begin;
614 event.end = std::min(horizon, std::max(event.begin + 1, end * microHop));
615 event.sustainRms = std::sqrt(meanPower(micro, tailBegin, stop)) * scale;
616 double peak = 0;
617 for (auto i = begin; i < stop && i * microHop < event.end; ++i)
618 {
619 const auto &bin = micro[static_cast<std::size_t>(i)];
620 if (bin.peakPower > peak && bin.peakFrame >= event.begin && bin.peakFrame < event.end)
621 {
622 peak = bin.peakPower;
623 event.peak = bin.peakFrame;
624 }
625 }
626 event.peakAmplitude = std::sqrt(peak) * scale;
627 }
628
629 static void buildEvents(Analysis &analysis, std::span<const FeatureFrame> features,
630 std::span<const MicroBin> micro, std::span<const double> highPower,
631 std::span<const bool> highEvidence, int microHop, bool pulse,
632 detail::OfflineSession &job)
633 {
634 const auto spec = analysis.energy_.getSpec();
635 T maximum = T(0);
636 for (const auto &f : features)
637 maximum = std::max(maximum, pulse ? f.pulse : f.attack);
638 if (!(maximum > T(0)))
639 return;
640 const auto value = [&](std::size_t i) {
641 return (pulse ? features[i].pulse : features[i].attack) / maximum;
642 };
643 const auto average =
644 static_cast<std::size_t>(std::lround(0.1 * spec.sampleRate / analysis.hop_));
645 const auto gap = static_cast<std::size_t>(
646 std::lround((pulse ? 0.05 : 0.03) * spec.sampleRate / analysis.hop_));
647 const auto gapFrames =
648 static_cast<std::int64_t>(std::llround((pulse ? 0.05 : 0.03) * spec.sampleRate));
649 const typename detail::OnsetPeakPicker<T>::Windows windows{
650 pulse ? 1u : 6u, pulse ? 1u : 6u, average, average, gap > 0 ? gap - 1 : 0};
651 const auto offset = static_cast<std::int64_t>(std::lround(0.34 * analysis.frameSize_));
652 const auto visitSpectral = [&](auto emit) {
653 detail::OnsetPeakPicker<T> picker;
654 std::int64_t last = -1;
655 for (std::size_t i = 0; i < features.size(); ++i)
656 {
657 if ((i & 1023u) == 0)
658 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(i),
659 static_cast<std::int64_t>(features.size()));
660 // Preserve the separate pulse policy. An attack may add upper
661 // frequencies while a louder bass body loses total energy;
662 // source-domain refinement below decides whether it is real.
663 if (pulse && i > 0 && features[i].windowPower <= features[i - 1].windowPower)
664 continue;
665 // A padded spectrum can turn the file's ending into novelty.
666 // Events there require source-domain evidence, supplied below.
667 if (!pulse && analysis.featureCenter(i) + offset >= spec.frames)
668 continue;
669 if (!picker.accept(i, features.size(), value, windows, T(0.06)))
670 continue;
671 Event event;
672 // Source energy, not novelty energy: stopping a bass note spreads
673 // its novelty into high bands without changing the source register.
674 const bool bass = features[i].lowEnergyShare > 0.8;
675 if (!refine(event, analysis.featureCenter(i) + offset, micro, microHop, spec, bass))
676 {
677 if (pulse || highPower.empty() ||
678 !refine(event, analysis.featureCenter(i) + offset,
679 PowerView(micro, highPower), microHop, spec, false))
680 continue;
681 // The novelty window precedes the actual onset. Check the
682 // nearest window to the refined source position, not that
683 // earlier window dominated by the preceding bass body.
684 const auto evidence = static_cast<std::size_t>(std::clamp<std::int64_t>(
685 (event.begin + analysis.frameSize_ / 2 + analysis.hop_ / 2) /
686 analysis.hop_ - 1,
687 0, static_cast<std::int64_t>(features.size() - 1)));
688 if (!highEvidence[evidence])
689 continue;
690 event.bandRefined = true;
691 }
692 if (last >= 0 && event.begin - last < gapFrames)
693 continue;
694 event.strength =
695 static_cast<double>((pulse ? features[i].pulse : features[i].attack) / maximum);
696 emit(event);
697 last = event.begin;
698 }
699 };
700 const auto visit = [&](auto emit) {
701 if (pulse)
702 {
703 visitSpectral(emit);
704 return;
705 }
706 // A global novelty maximum can mask an isolated quiet attack, and
707 // the spectral peak window can merge closely spaced separate hits.
708 // Add source-energy restarts after at least 8 ms of local quiet.
709 // The 30 dB contrast requirement rejects ordinary waveform valleys;
710 // slow bass onsets remain the spectral/refinement path's job.
711 std::size_t nextBin = 0;
712 Event restart;
713 bool hasRestart = false;
714 const auto seekRestart = [&] {
715 hasRestart = false;
716 while (nextBin < micro.size())
717 {
718 const auto at = static_cast<std::int64_t>(nextBin++);
719 if (micro[static_cast<std::size_t>(at)].power <= 1e-12)
720 continue;
721 const auto &bin = micro[static_cast<std::size_t>(at)];
722 const bool impulse = bin.peakPower > .95 * bin.power * microHop;
723 // Missing left context is not eight milliseconds of silence.
724 if (at < 8 && !impulse)
725 continue;
726 double before = 0;
727 for (auto j = std::max<std::int64_t>(0, at - 8); j < at; ++j)
728 before = std::max(before, micro[static_cast<std::size_t>(j)].power);
729 const double after = meanPower(micro, at, at + 3);
730 if (before > after * .001)
731 continue;
732 restart = {};
733 restart.begin = bin.peakFrame;
734 restart.peak = restart.begin;
735 restart.end = restart.begin + 1;
736 restart.strength = std::sqrt(after);
737 restart.contrast = (after - before) / (after + before);
738 restart.energyRestart = true;
739 hasRestart = true;
740 return;
741 }
742 };
743 std::int64_t last = -1;
744 const auto sameAttack = static_cast<std::int64_t>(std::llround(.006 * spec.sampleRate));
745 const auto append = [&](Event event) {
746 if (last < 0 || event.begin - last >= gapFrames)
747 {
748 emit(event);
749 last = event.begin;
750 }
751 };
752 seekRestart();
753 visitSpectral([&](Event event) {
754 while (hasRestart && restart.begin < event.begin - sameAttack)
755 {
756 append(restart);
757 seekRestart();
758 }
759 if (hasRestart && restart.begin <= event.begin + sameAttack)
760 {
761 append(restart);
762 seekRestart();
763 }
764 else
765 append(event);
766 });
767 while (hasRestart)
768 {
769 append(restart);
770 seekRestart();
771 }
772 };
773 std::size_t count = 0;
774 visit([&](Event) { ++count; });
775 if (count == 0)
776 return;
777 auto events = job.allocate<Event>(count);
778 std::size_t at = 0;
779 visit([&](Event event) { events[at++] = event; });
780 for (std::size_t i = 0; i < count; ++i)
781 {
782 followBody(events[i], i + 1 < count ? &events[i + 1] : nullptr, micro, highPower,
783 microHop, spec, analysis.energy_.samplePeak());
784 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(i + 1),
785 static_cast<std::int64_t>(count));
786 }
787 if (pulse)
788 {
789 analysis.pulses_ = std::move(events);
790 analysis.pulseCount_ = count;
791 }
792 else
793 {
794 analysis.attacks_ = std::move(events);
795 analysis.attackCount_ = count;
796 }
797 }
798};
799
800} // namespace dspark
801
802#endif // DSPARK_HAS_OFFLINE
Rewindable, complete-file source with int64 positions and bounded blocks.
virtual OfflineAudioSpec getSpec() const noexcept=0
Returns format and provenance by value.
Read-only source-bound energy map; owns bins, never source samples.
Read-only analysis with source provenance; no owned audio samples. Threading: energy() is an owner-th...
const OfflineEnergyAnalyzer< T >::Analysis & energy() const noexcept
Borrows the energy map as an owner-thread reference view. Valid while this analysis remains alive and...
std::span< const FeatureFrame > features() const noexcept
std::int64_t featureCenter(std::size_t index) const noexcept
Spectral window center, before event localization/refinement.
std::span< const Event > attacks() const noexcept
Analysis(Analysis &&) noexcept=default
std::span< const Event > pulses() const noexcept
Source-bound attack and pulse maps for offline Dynamics.
Result analyze(OfflineAudioSource< T > &source, Options options={}, const OfflineJobOptions &jobOptions={}) const
Analyzes immutable mono/stereo audio at 8-384 kHz on the calling worker.
static constexpr std::uint32_t algorithmRevision
static std::size_t preparationBytes(int fftSize) noexcept
Setup payload allowance when prepare() reserves the bounded filterbank.
static int defaultFrameSize(double sampleRate) noexcept
static std::size_t preparationBytes(int fftSize) noexcept
Conservative setup payload allowance for a fresh power-of-two instance.
void offlineFail(OfflineStatus status)
void offlineRead(OfflineAudioSource< T > &source, const OfflineAudioSpec &expected, std::int64_t first, AudioBufferView< T > block)
void offlineValidateSpec(const OfflineAudioSpec &spec, int maximumChannels=16)
void offlineHash(OfflineFingerprint &state, T sample) noexcept
std::size_t offlineBytes(std::uint64_t count, std::size_t size)
OfflineJobOptions offlineRemaining(const OfflineJobOptions &job, std::size_t used)
OfflineStatus offlineExceptionStatus() noexcept
Main namespace for the DSPark framework.
bool offlineSucceeded(OfflineStatus status) noexcept
True for successful processing, including an explicit identity result.
OfflineStatus
Explicit completion or no-publication outcome of an offline job.
static BiquadCoeffs makeHighPass(double sampleRate, double freq, double Q=0.7071067811865476) noexcept
High-pass filter.
Definition Biquad.h:142
Resource and cooperative-cancellation controls for one worker operation.
A detected event, including its locally measured attack/body interval.
double contrast
Local power-rise contrast, not a probability.
std::int64_t end
Exclusive; begin <= peak < end <= source frames.
bool endLimited
Body transition not resolved before the search boundary.
bool bandRefined
Attack/body localized above 800 Hz under a lower-frequency bed.
bool energyRestart
Source-energy restart instead of a spectral candidate.
double strength
Normalized novelty, or normalized RMS for energy restarts.
double peakAmplitude
Linked pooled-channel peak in the event.
double sustainRms
Local body/noise reference after the event.
Distinct novelty functions and SuperFlux registers from one spectrum.
double lowEnergyShare
Fraction of that power below 800 Hz.
double windowPower
Normalized, Hann-weighted mean power by Parseval's identity.
std::size_t memoryBytes
Charged payload, including conservative FFT setup allowance.