|
HART
0.2.0
High level Audio Regression and Testing
|
Metrics are built-in functions that help you measure some common audio properties, and express custom checks, based on them, in a short and readable way.
They're mostly meant to be used when writing your own Matchers, especially light-weight function-based ones (see hart::MatcherFunction), although you may also use the same metric functions inside a full custom hart::Matcher subclass as well.
For example, if you want to check something like:
...a simple function-based matcher using metrics is often the cleanest way to express it.
If you're making a quick custom check, it is often enough to pass a lambda to expectTrue() or assertTrue(), or their inverted counterparts. HART supports function-based matchers with these signatures:
bool matcherFunction (const AudioBuffer<SampleType>& output)bool matcherFunction (const AudioBuffer<SampleType>& input, const AudioBuffer<SampleType>& output)Using built-in metrics in both of those forms will let you express some non-trivial matchers in a very human-readable form. For more details on making use of such matchers, refer to MatcherFunction and AudioTestBuilder documentation. Also, check respective sections in Testing Your DSP in HART.
Let's say you have a compressor, and you expect it to control the drum sample's dynamic range. You might want to express that the processed drum clip doesn't have a very sharp transient, but is not completely "slammed" at the same time. Crest factor is quite an appropriate metric to express that.
Or perhaps you don't have a specific crest factor value in mind, and merely want to express that your compressor reduces crest factor, as compared to its input:
This is the main idea behind metrics in HART: they give you small re-usable analysis building blocks, so your matcher logic can stay compact and expressive, and highly customizable at the same time.
Most metrics, such as crestFactor(), are fundamentally per-channel properties, i. e. for multi-channel signals, they're calculated individually per each channel. So, if you calculate crest factor on a 5-channel signal, and request to process all the channels (explicitly or implicitly), it will calculate a vector of 5 values, not a scalar. You can also request to calculate the metric only at a specific channel, or a channel subset. For example, to get a value for channel 4 (zero-based) you can do:
In this case, the crest factor will actually be calculated for only channel 4. Which is useful, as some metrics can be computationally expensive, and we don't want to waste CPU time on the channels we're not interested in.
For mono audio, you can simply do:
or, even simpler:
Last form involves implicit conversion to double, which isn't always appropriate (for example, it can confuse the compiler when used with macros like HART_EXPECT_FLOAT_EQ()), but use of explicit .get() is always safe. Each built-in metric returns a hart::MetricQuery object, that you can eventually convert to a scalar value.
The multi-channel version first calculates the metric per channel, and then hands those per-channel values to a reducer (we'll discuss reducers shortly):
So, if the output buffer has two channels, and the per-channel crest factors are { 3.2, 5.8 }, then the hart::max() reducer returns 5.8.
If you want to measure a specific metric only at specific channels, you can list those channels' indices in the optional third argument:
The order of the per-channel metrics handed to a reducer will obey the order of supplied channel numbers. In this case, if the reducer was hart::last(), it would return the crest factor of channel 1, because this channel was listed at the very end of the list.
Some metrics require pairs of channels, for example:
This means the metric will calculate two values:
and then grab the smallest of two values, as instructed by hart::min() reducer (yes, I'm mentioning reducers again - we'll get to them soon). So, you can describe pretty advanced routind this way. For symmetrical pars, like:
you can do this:
or simply:
So, last form is a shortcut for symmetrical routing. Not to be confused with:
...which results in a single pair - input, channel 2 vs output, channel 3.
In most cases, especially if you're working with mono or stereo channels, you can just skip the .ch() call, which will result in a default routing. Default routing is defined by each specific metric, and usually it's the one that makes the most sense for that specific metric.
A reducer is a small callable object that takes a range of values and produces a result from it.
If you're familiar with Python and Pandas, you may think of reducers as something akin to DataFrame.groupby() aggregations - such as max, mean or first. The idea is very similar: you have multiple measured values, and you want to aggregate them into something useful.
HART ships with a set of reducers such as:
hart::first() - return the first valuehart::last() - return the last valuehart::nth() - return n'th valuehart::min() / hart::max() - return the smallest or largest valuehart::mean() / hart::sum() - numeric reductionshart::percentile - return a specific percentile, like median, p99, p95 or any arbitrary numberhart::argmin() / hart::argmax() - return the index of the smallest or largest valuehart::collect() - return all values as an std::vectorhart::anyNaN() / hart::allNaN() - boolean checks over the set of per-channel valueshart::allFloatsEqual() - check whether all values are equal within a tolerancehart::allFloatsEqualToEachOther() - check whether all values are eqau to each other within a certain toleranceThe result does not have to be a single scalar. While that's the most common case, reducers can also return booleans or containers.
For example:
The first one answers "Are these channels close enough to each other?". The second one gives you the full set of values, in case you want to inspect or post-process them yourself. And you can, of course, make your own reducers, as will be discussed in one of the following sections.
In a lot of cases, you can also omit get() entirely, especially for mono signals. In this case, you'll just get the first value in a vector:
Note: Result of running a metric in that manner is typically double, but not always. Some metrics may return a different type of value, for example, int or size_t. And sometimes the result of .get (...) can be a bool or size_t - se the section about the Reducers later in this article.
Let's say your stereo compressor is expected to keep left and right channels reasonably matched in dynamics, when processing a sampled chord:
Or perhaps you want to make sure that none of the channels in a drum bus exceed some crest factor limit:
You are not limited to the stock reducers. Any callable that accepts two iterators and returns something useful can be used as a reducer.
For instance, perhaps you want to know whether the spread between the smallest and largest crest factors stays below 1 dB:
Then use it like this:
This is often enough for very use-case-specific checks. If you find yourself re-using the same reducer in many places, then it may be worth turning it into a named reducer type, similar to the built-in ones.
A lot of metrics calculate a value that can be represented by different unit. For example, samplePeak() can either be in decibels, or in a linear domain, and both units are equally useful. You can request a specific unid from the metric using a chained ch() call:
The requested unit is communicated to a specific metric, and each metric knows how to calculate itself in a subset of appropriate units. So, for example, samplePeak() knows it calculate decibels using a dB formula for ratio (voltage), and not power, but some other metric can decide to use power formula instead. You won't have to worry about it, but if you do, you can always refer to each metric's documentation.
Converting to a specific unit happens before the reducer, for example:
Unless all channels peak at the same value, valueA and ValueB will be different. In first case, values are converted to dB for each channel, and then mean value is calculated. If latter case, mean value is calculated in linear domain, and only then converted to dB. Mathematically, those are different types of averaging, and you have means to perform either of those.
Metrics are especially handy when:
If your check is simple and likely to be re-used across a few test cases, you might want to turn it into a dedicated custom Matcher, possibly with your custom implementation of the metric'a math under the hood. But for many practical cases, a metric plus a short lambda-based matcher is already the sweet spot.
Metrics are just one way to write custom checks. To learn more about using function-based matchers and custom DSP wrappers in general, see Testing Your DSP in HART.