HART  0.2.0
High level Audio Regression and Testing
Loading...
Searching...
No Matches
hart_snra.hpp
Go to the documentation of this file.
1#pragma once
2
3#include <memory> // make_shared()
4
7#include "metrics/hart_metric_query.hpp"
8#include "metrics/hart_metrics_common.hpp" // ChannelSubsets
9#include "hart_slice.hpp"
10#include "metrics/hart_snr.hpp"
11#include "hart_utils.hpp" // nan(), floatsEqual(), floatsNotEqual()
12#include "hart_units.hpp" // Unit
13
14namespace hart
15{
16
17/// @brief Calculates signal-to-aliasing-noise ratio (a.k.a. SNRA, SNRa or sometimes SANR)
18/// @details SNRa estimates the amount of aliasing introduced by an audio
19/// processing algorithm by comparing its output at the native sample rate
20/// against a higher-sample-rate render.
21///
22/// SNRa is particularly suitable for non-linear DSP such as waveshapers,
23/// saturation, clipping, nonlinear filters, limiters, nonlinear circuit
24/// models, and anti-aliased waveform generators. It may be unsuitable for
25/// algorithms whose behavior changes materially with sample rate, such as
26/// fixed-rate processing engines or some fixed-size STFT-based algorithms.
27///
28/// This metric implies a specific experiment:
29/// 1. You render audio through your DSP at native sample rate. This will be
30/// considered as "signal + aliasing noise", and amount of aliasing will be
31/// evaluated in this piece of audio.
32/// 2. Render audio through your DSP at the same starting state, and same
33/// duration of audio (expressed in seconds, not frames), but at higher sample
34/// rate. Due to high sample rate, this will be consiudered as "ideal signal
35/// with no aliasing", assuming selected sample rate is high enough to make
36/// aliasing negligible.
37/// 3. Pass the `AudioBuffer`'s to this metric. They're expected to have
38/// matching lengths in seconds, but different sample rates - all resampling
39/// will be done by this metric internally.
40///
41/// You may pick the exact high sample rate value yourself.
42/// This sample rate doesn't have to be an integer multiple o the "native"
43/// sample rate, although typically you might still use something like 4x or
44/// 8x of native sample rate. Obviously, the higher the better.
45///
46/// Keep in mind that "high sample rate" refers to a "host" sample rate, and
47/// doesn't take into account any internal oversampling your DSP testee might
48/// have. If your DSP has internal oversampling, keep it on in both renders at
49/// the same setting. So, if your DSP has internal 4x OS, and native SR is
50/// 44.1 kHz, you keep internal oversampling on, and do a first render at
51/// 44.1 kHz, and then a second render at, say, 196 kHz (which, assuming your
52/// DSP does 4x OS internally, will result in a whopping 784 kHz internally,
53/// but HART renders everuthing offline, so you don't have to worry about
54/// potential non-realtime performance here).
55///
56/// The reference (high SR) buffer is internally resampled to the native sample
57/// rate. Aliasing noise is then defined as the sample-by-sample difference
58/// betweenthe native-rate output and this resampled reference.
59///
60/// SNRa is calculated as:
61/// @f[
62/// \mathrm{SNR_a}
63/// =
64/// \frac
65/// {\sum_{n=0}^{N-1} r[n]^2}
66/// {\sum_{n=0}^{N-1} \left(x[n] - r[n]\right)^2}
67/// @f]
68///
69/// (SNRa = sum(r[n] ** 2) / sum((x[n] - r[n]) ** 2)),
70///
71/// where `x[n]` is a sample from the output rendered at the native sample rate,
72/// `r[n]` is the corresponding sample from the higher-sample-rate reference
73/// after it has been resampled to the native sample rate, and `N` is the number
74/// of frames being analyzed.
75///
76/// Higher values indicate less aliasing, so higher is better. A perfectly
77/// matching native-rate and reference signal produces positive infinity, and
78/// this metric will return `+inf`.
79///
80/// Can be requested as an energy ratio or decibels. Supports `Unit::ratio`,
81/// `Unit::native` (default, same as `Unit::ratio`), and `Unit::dB`.
82/// Values in decibels are calculated as a power ratio:
83///
84/// @f[
85/// \mathrm{SNR_{a}},dB = 10 \log_{10}\left(\mathrm{SNR_a}\right)
86/// @f]
87///
88/// (SNRa_dB = 10 * log10(SNRa)).
89///
90/// As mentioned above, this metric assumes that the two buffers are equivalent
91/// renders of the same algorithm and state, with the reference buffer rendered
92/// at a higher sample rate. In particular:
93///
94/// - Changing the sample rate must preserve the intended behavior of the
95/// algorithm: i.e. all the timings, frequencies etc. must retain their
96/// real-world values in their physical units, taking sample rate into account.
97/// Same goes fot FFT sizes.
98/// - The higher sample rate must actually propagate into the processing being
99/// evaluated rather than being internally converted back to a fixed rate.
100/// E.g. ML-based DSP models runing at fixed SR internally do not qualify.
101/// - Both renders must begin from equivalent state and receive equivalent
102/// control and modulation trajectories.
103/// - Stochastic processing must be deterministic between the two renders, or
104/// otherwise reproduce an equivalent stochastic trajectory.
105/// - If your DSP algorithm involves any internal oversampling, make sure to
106/// keep it on in both renders, and at the same setting.
107///
108/// The reference audio resampling operation may introduce boundary transients
109/// at the beginning and end of the buffer. For accurate measurements, render
110/// guard regions around the desired analysis interval, and use `MetricQuery::at()`
111/// with an appropriate `hart::Slice` to measure only an interior, settled slice
112/// of audio.
113///
114/// See `tests/test_metrics.cpp`, in particular the
115/// "Metrics - SNRa - Distortion effect with aliasing" and
116/// "Metrics - SNRa - Clean effect with no aliasing" tests, for complete
117/// experiment examples.
118///
119/// @tparam SampleType Type of audio buffers' values, typically `float` or `double`
120/// @param estimatedBufferAtNativeSR Output rendered at the native sample rate.
121/// This will be considered to be a "signal + aliasing noise" when estimating SNRa.
122/// @param referenceBufferAtHighSR Equivalent reference output rendered at a
123/// higher sample rate. This audio buffer will be considered to be an ideal
124/// signal with no aliasing ("signal") when estimating SNRa. This signal should
125/// be captured at a higher sample rate that `estimatedBufferAtNativeSR`, and will
126/// be internally resampled. Also, note that "high sample rate" refers to a "host"
127/// sample rate, and doesn't take into account any internal oversampling your DSP
128/// testee might have.
129/// @return Chainable `MetricQuery` object which calculates SNRa as a linear
130/// energy ratio or in decibels. May return `NaN` or `+inf`.
131/// @ingroup Metrics
132template <typename SampleType>
133MetricQuery<double> snra (const AudioBuffer<SampleType>& estimatedBufferAtNativeSR, const AudioBuffer<SampleType>& referenceBufferAtHighSR)
134{
135 if (! estimatedBufferAtNativeSR.hasSampleRate() || estimatedBufferAtNativeSR.getSampleRateHz() < 0.0 || floatsEqual (estimatedBufferAtNativeSR.getSampleRateHz(), 0.0))
136 HART_THROW_OR_RETURN (SampleRateError, "bufferAtNativeSR must have a valid sample rate", nan<double>());
137
138 if (! referenceBufferAtHighSR.hasSampleRate() || referenceBufferAtHighSR.getSampleRateHz() < 0.0 || floatsEqual (referenceBufferAtHighSR.getSampleRateHz(), 0.0))
139 HART_THROW_OR_RETURN (SampleRateError, "referenceBufferAtHighSR must have a valid sample rate", nan<double>());
140
141 if (referenceBufferAtHighSR.getSampleRateHz() < estimatedBufferAtNativeSR.getSampleRateHz() || floatsEqual (referenceBufferAtHighSR.getSampleRateHz(), estimatedBufferAtNativeSR.getSampleRateHz()))
142 HART_THROW_OR_RETURN (SampleRateError, "bufferAtHighSR must have sample rate higher than bufferAtNativeSR for proper SANR estimation", nan<double>());
143
144 if (floatsNotEqual (estimatedBufferAtNativeSR.getLengthSeconds(), referenceBufferAtHighSR.getLengthSeconds()))
145 HART_THROW_OR_RETURN (SizeError, "Both audio buffers must have same length in seconds", nan<double>());
146
147 // This one is perhaps a bit too strict, but still justified
148 if (estimatedBufferAtNativeSR.getNumChannels() != referenceBufferAtHighSR.getNumChannels())
149 HART_THROW_OR_RETURN (ChannelLayoutError, "Both buffers have to be rendered with same number of channels to ensure identical DSP state", nan<double>());
150
151 const double nativeSampleRateHz = estimatedBufferAtNativeSR.getSampleRateHz();
152 const std::shared_ptr<AudioBuffer<SampleType>> referenceBufferAtNativeSR =
153 std::make_shared<AudioBuffer<SampleType>> (referenceBufferAtHighSR.resample (nativeSampleRateHz));
154
155 // Those two may probably differ by a frame or so, so it's okay to loosen this check once it trips over a legit case
156 hassert (estimatedBufferAtNativeSR.getNumFrames() == referenceBufferAtNativeSR->getNumFrames());
157
158 // A practical case with mismatched channel numbers is highly unlikely, so channel number match is ensured above
159 hassert (estimatedBufferAtNativeSR.getNumChannels() == referenceBufferAtNativeSR->getNumChannels());
160
161 MetricQuery<double>::SingleChannelMetricEvaluator evaluator =
162 [&estimatedBufferAtNativeSR, referenceBufferAtNativeSR]
163 (size_t channel, Slice slice, Unit requestedUnit)
164 -> double
165 {
166 hassert (referenceBufferAtNativeSR != nullptr);
167
168 // Those two may probably differ by a frame or so, so it's okay to loosen this check once it trips over a legit case
169 hassert (estimatedBufferAtNativeSR.getNumFrames() == referenceBufferAtNativeSR->getNumFrames());
170
171 // A practical case with mismatched channel numbers is highly unlikely, so channel number match is ensured above
172 hassert (estimatedBufferAtNativeSR.getNumChannels() == referenceBufferAtNativeSR->getNumChannels());
173
174 if (channel >= estimatedBufferAtNativeSR.getNumChannels())
175 HART_THROW_OR_RETURN (hart::IndexError, "Channel index is out of bounds", nan<double>());
176
177 if (slice.isEmpty())
178 return nan<double>();
179
180 // We'll allow non-full slices, just to be consistent with the reso of the metrics
181 const auto sliceFrameIndices = estimatedBufferAtNativeSR.getFrameIndices (slice);
182 const size_t sliceStart = sliceFrameIndices.first;
183 const size_t sliceStop = sliceFrameIndices.second;
184 hassert (sliceStop > sliceStart);
185 hassert (sliceStop <= estimatedBufferAtNativeSR.getNumFrames());
186
187 const size_t numFrames = sliceStop - sliceStart;
188 hassert (numFrames != 0);
189
190 AccurateSum<double> signalEnergy;
191 AccurateSum<double> aliasingNoiseEnergy;
192
193 const SampleType* referenceChannelData = (*referenceBufferAtNativeSR)[channel] + sliceStart;
194 const SampleType* estimatedChannelData = estimatedBufferAtNativeSR[channel] + sliceStart;
195
196 for (size_t frame = 0; frame < numFrames; ++frame)
197 {
198 const double x = static_cast<double> (referenceChannelData[frame]);
199 const double y = static_cast<double> (estimatedChannelData[frame]);
200 const double noise = x - y;
201
202 signalEnergy += x * x;
203 aliasingNoiseEnergy += noise * noise;
204 }
205
206 if (floatsEqual<double> (signalEnergy, 0.0))
207 return nan<double>();
208
209 if (floatsEqual<double> (aliasingNoiseEnergy, 0.0))
210 return inf; // Congrats - no noise at all!
211
212 const double sanrRatio = signalEnergy.getValue() / aliasingNoiseEnergy.getValue();
213
214 switch (requestedUnit)
215 {
216 case Unit::native:
217 case Unit::ratio: return sanrRatio;
218
219 case Unit::dB: return hart::powerToDecibels (sanrRatio);
220
221 default: HART_THROW_OR_RETURN (hart::UnitError, "Unsupported unit", nan<double>());
222 }
223 };
224
225 hassert (estimatedBufferAtNativeSR.getNumChannels() == referenceBufferAtHighSR.getNumChannels());
226 const size_t numChannels = estimatedBufferAtNativeSR.getNumChannels();
227 return MetricQuery<double> (
228 std::move (evaluator),
229 numChannels,
231 );
232}
233
234} // namespace hart
Implements Kahan algorithm for floating point accumulations.
SampleType getValue() const
AccurateSum & operator+=(SampleType value)
Adds a value to a sum, tracking the potential floating point error.
Thrown when a numbers of channels is mismatched.
Thrown when a container index is out of range.
Manages the metrics calculations.
MetricQuery(SingleChannelMetricEvaluator evaluator, size_t totalNumChannels, std::vector< size_t > &&defaultChannelsToProcess)
Create a metric query object for a metric that operates on one channel at a time.
Thrown when sample rate is mismatched or invalid.
Thrown when an unexpected container size is encountered.
Thrown when some metric is requested to return a value in an unsupported unit.
#define hassert(condition)
Triggers a HartAssertException if the condition is false
#define HART_THROW_OR_RETURN(ExceptionType, message, returnValue)
Throws an exception if HART_DO_NOT_THROW_EXCEPTIONS is set, prints a message and returns a specified ...
MetricQuery< double > snra(const AudioBuffer< SampleType > &estimatedBufferAtNativeSR, const AudioBuffer< SampleType > &referenceBufferAtHighSR)
Calculates signal-to-aliasing-noise ratio (a.k.a. SNRA, SNRa or sometimes SANR)
FloatType nan()
Returns a quiet NaN value for the given floating-point type.
constexpr double inf
Infinity.
static SampleType powerToDecibels(SampleType valueLinear)
Converts linear value (power) to dB.
static SampleType floatsEqual(SampleType a, SampleType b, SampleType epsilon=(SampleType) 1e-8)
Compares two floating point numbers within a given tolerance.
Unit
Represents a physical unit.
@ dB
Value of something in decibels. Can represent voltage, power, or a domain-specific unit like "LUFS" o...
@ ratio
Generic ratio.
@ native
Default (native) unit of whatever returns some value.
Helpers to generate common default channel subsets.
static std::vector< size_t > allChannels(size_t numChannels)
Represents a slice of analysis data.
bool isEmpty() const