Skip to content

Commit 2edb04c

Browse files
author
Cristian Moresi
committed
[FIX] Tests: measure harmonic integrity with a calibrated probe
1 parent 18add62 commit 2edb04c

3 files changed

Lines changed: 303 additions & 16 deletions

File tree

Effects/PitchCorrector.h

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,22 +128,34 @@
128128
* settling times above are milliseconds; so a frame pinned in samples would
129129
* halve its own span every time the rate doubled and take the low register with
130130
* it. At a 21 ms span - which is what 2048 samples give at 96 kHz - the
131-
* harmonic-to-residual ratio of a corrected F2 falls to about 7 dB, from 23 dB
132-
* at 43 ms. Holding the span keeps every millisecond figure in this file true
133-
* at every supported rate, at the cost of latency growing with the rate in
134-
* samples while staying constant in time. Pass an explicit frame to prepare()
135-
* to override the policy; what that trade costs is measured below.
131+
* harmonic-to-residual ratio of a corrected F2 falls to 3.5-7.3 dB, from
132+
* 42-48 dB at 43 ms. Holding the span keeps every millisecond figure in this
133+
* file true at every supported rate, at the cost of latency growing with the
134+
* rate in samples while staying constant in time. Pass an explicit frame to
135+
* prepare() to override the policy; what that trade costs is measured below.
136136
*
137137
* At the automatic frame, both ends of that trade were measured before the span
138-
* was fixed. Halving it - a 21 ms span, whether by asking for 1024 at 48 kHz or
139-
* by leaving 2048 pinned at 96 kHz - halves every settling time and drops the
140-
* harmonic-to-residual ratio of a corrected vowel from about 22 dB to 7.5 dB at
141-
* F2 (87.31 Hz) and from 23 dB to 6.1 dB at A2 (110 Hz), with A3 (220 Hz)
142-
* untouched at about 21 dB: fifteen dB of harmonic structure, paid by exactly
143-
* the voices this effect is pointed at. Doubling it to an 85 ms span keeps the
144-
* low register but a one-semitone change no longer settles inside the
145-
* pipeline's latency (42 ms at 48 kHz). The 43 ms span is the only one in that
146-
* family which satisfies both ends.
138+
* was fixed. Every decibel below is read by one stated instrument - a four-term
139+
* Blackman-Harris DFT over a single un-padded 0.68 s segment, summing the
140+
* window's main lobe around each harmonic of the MEASURED fundamental against
141+
* everything else up to 20 kHz - which reads about 89 dB on a signal
142+
* constructed to have no residual at all, and returns planted ratios of 15 to
143+
* 40 dB within 0.4 dB. That headroom is what makes these figures reproducible:
144+
* a probe reports 1/M = 1/R + 1/F for a true ratio R and its own leakage floor
145+
* F, so one whose floor sat near 20 dB would report its own aperture here and
146+
* not the effect. Halving the span - a 21 ms span, whether by asking for 1024
147+
* at 48 kHz or by leaving 2048 pinned at 96 kHz - halves every settling time
148+
* and drops the harmonic-to-residual ratio of a corrected vowel from 42-48 dB
149+
* to 3.5-7.3 dB at F2 (87.31 Hz) and from 38-41 dB to 4.0-4.4 dB at A2
150+
* (110 Hz), while A3 (220 Hz) moves only from 47-51 dB to 40-42 dB: 34 to
151+
* 45 dB of harmonic structure at the low notes against 5 to 11 dB an octave
152+
* up, paid by exactly the voices this effect is pointed at. The low notes read
153+
* below A3 even at the full span, by 3 to 13 dB, and that is the resolution of
154+
* a 43 ms window rather than damage: at a fixed span 220 Hz gets about two and
155+
* a half times as many bins per harmonic spacing as 87 Hz. Doubling the span
156+
* to 85 ms keeps the low register but a one-semitone change no longer settles
157+
* inside the pipeline's latency (42 ms at 48 kHz). The 43 ms span is the only
158+
* one in that family which satisfies both ends.
147159
*
148160
* Latency: exactly the internal shifter's twice the frame - 4096 samples at
149161
* 44.1/48 kHz (85.3 ms at 48 kHz, 92.9 ms at 44.1 kHz), and about the same

docs/cookbook.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,12 @@ a sample count: 2048 samples at 44.1/48 kHz, 4096 at 88.2/96 kHz, 8192 at
632632
with it, staying near 85 ms in time. Pass a frame to `prepare()` if you want
633633
a different trade — a shorter one settles faster and stops resolving the
634634
harmonics of a bass voice, which is the whole reason the default is what it
635-
is.
635+
is. Halving the default span costs a corrected F2 most of its harmonic
636+
structure: its harmonic-to-residual ratio falls from 42-48 dB to 3.5-7.3 dB,
637+
and A2's from 38-41 dB to 4.0-4.4 dB, while A3 an octave up moves only from
638+
47-51 dB to 40-42 dB. Those decibels are read by a four-term Blackman-Harris
639+
DFT over one un-padded 0.68 s segment, which reads about 89 dB on a signal
640+
built to have no residual at all, so it has the headroom to see them.
636641

637642
The scale mask is a 12-bit `harmony::NoteSet` (bit k = k semitones above the
638643
root), so anything in `harmony::allScales` works, as does a mask you build

tests/TestPitchCorrector.cpp

Lines changed: 271 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,181 @@ std::vector<double> vowelTone(double f0, double sampleRate, int n)
323323
return out;
324324
}
325325

326+
// -- Harmonic integrity: the stimulus, and an instrument that proves itself ---
327+
//
328+
// A harmonic-to-residual ratio in decibels is a property of the instrument that
329+
// reads it as much as of the audio it reads. A probe counts whatever its window
330+
// leaks outside the capture band as residual, even on a signal that has none,
331+
// so a probe with a true ratio R and its own leakage floor F reports
332+
// 1/M = 1/R + 1/F: near its floor it is measuring itself and not the product,
333+
// and a threshold quoted without its instrument is not a threshold but a
334+
// reading. The two calibrations below are therefore run before any number from
335+
// this instrument is allowed to decide anything.
336+
337+
// Additive source-filter vowel. Because it is a sum of exact harmonics of f0,
338+
// its residual is zero by construction, which is what lets the same signal
339+
// serve as the instrument's calibration reference. The harmonic phases come
340+
// from a stated integer recurrence rather than from a standard-library
341+
// distribution, whose mapping onto a real interval is not specified across
342+
// implementations, so this stimulus is identical on every platform.
343+
std::vector<double> vowelStimulus(double f0, double sampleRate, int n)
344+
{
345+
const double formantHz[5] = { 730.0, 1090.0, 2440.0, 3400.0, 4500.0 };
346+
const double bandwidthHz[5] = { 90.0, 110.0, 140.0, 180.0, 220.0 };
347+
const double gain[5] = { 1.0, 0.7, 0.4, 0.2, 0.1 };
348+
349+
std::vector<double> out(static_cast<std::size_t>(n), 0.0);
350+
const int harmonics = static_cast<int>(
351+
std::floor(std::min(8000.0, 0.45 * sampleRate) / f0));
352+
std::uint32_t state = 12345u;
353+
for (int k = 1; k <= harmonics; ++k)
354+
{
355+
const double f = static_cast<double>(k) * f0;
356+
double envelope = 0.0;
357+
for (int j = 0; j < 5; ++j)
358+
{
359+
const double detune = f * f - formantHz[j] * formantHz[j];
360+
const double magnitude = std::sqrt(
361+
detune * detune + 4.0 * f * f * bandwidthHz[j] * bandwidthHz[j]);
362+
envelope += gain[j] * formantHz[j] * bandwidthHz[j]
363+
/ std::max(magnitude, 1.0e-9);
364+
}
365+
const double amplitude = envelope / (1.0 + (f / 300.0) * (f / 300.0));
366+
state = 1664525u * state + 1013904223u;
367+
const double phase = twoPi<double> * static_cast<double>(state) / 4294967296.0;
368+
const double omega = twoPi<double> * f / sampleRate;
369+
for (int i = 0; i < n; ++i)
370+
out[static_cast<std::size_t>(i)] +=
371+
amplitude * std::sin(omega * static_cast<double>(i) + phase);
372+
}
373+
double peak = 0.0;
374+
for (double v : out) peak = std::max(peak, std::abs(v));
375+
if (peak > 0.0) for (double& v : out) v *= 0.5 / peak;
376+
return out;
377+
}
378+
379+
// The analysis segment: the largest power of two whose duration does not exceed
380+
// 0.75 s. Stated in TIME, so the resolution in Hz is the same at every rate; a
381+
// rule that rounds UP to a power of two gives two adjacent supported rates a
382+
// factor of two in resolution and makes them disagree about the same audio.
383+
int analysisSegment(double sampleRate)
384+
{
385+
int n = 1;
386+
while (2.0 * static_cast<double>(n) <= 0.75 * sampleRate) n <<= 1;
387+
return n;
388+
}
389+
390+
// Harmonic-to-residual ratio in dB over one segment: one window over the whole
391+
// segment and one DFT of exactly that many points, with NO zero padding, which
392+
// would widen the window's main lobe in bins while the capture stayed where it
393+
// was. The capture is four bins around each harmonic because the four-term
394+
// Blackman-Harris window nulls at four bins: capturing less than its main lobe
395+
// counts the skirt as residual for every harmonic and drops the instrument's
396+
// own floor from 89 dB to 45 dB, which is below the values it has to judge.
397+
// `f0` is the MEASURED fundamental of this segment, never the nominal target:
398+
// on a nominal grid a pitch error walks the grid off the harmonics and this
399+
// measurement double-counts a failure the accuracy tests already own.
400+
double harmonicResidualDb(const std::vector<double>& x, double sampleRate, double f0)
401+
{
402+
const int n = static_cast<int>(x.size());
403+
if (n <= 0 || (n & (n - 1)) != 0 || f0 <= 0.0) return -300.0;
404+
std::vector<std::complex<double>> spectrum(static_cast<std::size_t>(n));
405+
for (int i = 0; i < n; ++i)
406+
{
407+
const double t = static_cast<double>(i) / static_cast<double>(n);
408+
const double w = 0.35875 - 0.48829 * std::cos(twoPi<double> * t)
409+
+ 0.14128 * std::cos(2.0 * twoPi<double> * t)
410+
- 0.01168 * std::cos(3.0 * twoPi<double> * t);
411+
spectrum[static_cast<std::size_t>(i)] = { x[static_cast<std::size_t>(i)] * w, 0.0 };
412+
}
413+
fftRadix2(spectrum);
414+
415+
const double binHz = sampleRate / static_cast<double>(n);
416+
const int lowest = 2; // DC leakage, out of BOTH sums
417+
const int top = std::min(n / 2, static_cast<int>(
418+
std::floor(std::min(20000.0, 0.45 * sampleRate) / binHz)));
419+
std::vector<char> isHarmonic(static_cast<std::size_t>(top) + 1, 0);
420+
for (int k = 1; static_cast<double>(k) * f0 <= 8000.0; ++k)
421+
{
422+
const double centre = static_cast<double>(k) * f0 / binHz;
423+
const int lo = static_cast<int>(std::ceil(centre - 4.0));
424+
const int hi = static_cast<int>(std::floor(centre + 4.0));
425+
for (int b = std::max(lo, lowest); b <= std::min(hi, top); ++b)
426+
isHarmonic[static_cast<std::size_t>(b)] = 1;
427+
}
428+
double harmonic = 0.0, residual = 0.0;
429+
for (int b = lowest; b <= top; ++b)
430+
{
431+
const double power = std::norm(spectrum[static_cast<std::size_t>(b)]);
432+
if (isHarmonic[static_cast<std::size_t>(b)]) harmonic += power;
433+
else residual += power;
434+
}
435+
if (harmonic <= 0.0) return -300.0;
436+
return 10.0 * std::log10(harmonic / std::max(residual, 1.0e-300));
437+
}
438+
439+
// `clean` plus white Gaussian noise band-limited to exactly the bins the
440+
// instrument sums as residual, scaled so the in-band ratio of the sum is
441+
// `snrDb`. Band-limiting is the point: noise spread to Nyquist, read by a probe
442+
// whose residual band stops short of it, measures a bookkeeping mismatch
443+
// instead of the probe. The deviates come from a stated recurrence rather than
444+
// from <random>, whose engines' mapping onto doubles is implementation-defined.
445+
std::vector<double> plantNoise(const std::vector<double>& clean, double sampleRate,
446+
double snrDb, std::uint64_t seed)
447+
{
448+
const int n = static_cast<int>(clean.size());
449+
if (n <= 0 || (n & (n - 1)) != 0) return clean;
450+
451+
std::uint64_t state = seed;
452+
auto uniform = [&state]() {
453+
state += 0x9E3779B97F4A7C15ull;
454+
std::uint64_t z = state;
455+
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
456+
z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
457+
z ^= z >> 31;
458+
return (static_cast<double>(z >> 11) + 0.5) * (1.0 / 9007199254740992.0);
459+
};
460+
std::vector<std::complex<double>> spectrum(static_cast<std::size_t>(n));
461+
for (int i = 0; i < n; i += 2)
462+
{
463+
const double radius = std::sqrt(-2.0 * std::log(uniform()));
464+
const double angle = twoPi<double> * uniform();
465+
spectrum[static_cast<std::size_t>(i)] = { radius * std::cos(angle), 0.0 };
466+
if (i + 1 < n)
467+
spectrum[static_cast<std::size_t>(i + 1)] = { radius * std::sin(angle), 0.0 };
468+
}
469+
fftRadix2(spectrum);
470+
471+
const double binHz = sampleRate / static_cast<double>(n);
472+
const int top = std::min(n / 2, static_cast<int>(
473+
std::floor(std::min(20000.0, 0.45 * sampleRate) / binHz)));
474+
for (int b = 0; b <= n / 2; ++b)
475+
{
476+
if (b >= 2 && b <= top) continue;
477+
spectrum[static_cast<std::size_t>(b)] = { 0.0, 0.0 };
478+
if (b > 0 && b < n / 2) spectrum[static_cast<std::size_t>(n - b)] = { 0.0, 0.0 };
479+
}
480+
for (auto& value : spectrum) value = std::conj(value); // inverse through
481+
fftRadix2(spectrum); // the forward one
482+
483+
double noisePower = 0.0, signalPower = 0.0;
484+
std::vector<double> noise(static_cast<std::size_t>(n), 0.0);
485+
for (int i = 0; i < n; ++i)
486+
{
487+
noise[static_cast<std::size_t>(i)] =
488+
spectrum[static_cast<std::size_t>(i)].real() / static_cast<double>(n);
489+
noisePower += noise[static_cast<std::size_t>(i)] * noise[static_cast<std::size_t>(i)];
490+
signalPower += clean[static_cast<std::size_t>(i)] * clean[static_cast<std::size_t>(i)];
491+
}
492+
if (noisePower <= 0.0) return clean;
493+
const double scale = std::sqrt(signalPower * std::pow(10.0, -snrDb / 10.0) / noisePower);
494+
std::vector<double> out(static_cast<std::size_t>(n), 0.0);
495+
for (int i = 0; i < n; ++i)
496+
out[static_cast<std::size_t>(i)] = clean[static_cast<std::size_t>(i)]
497+
+ scale * noise[static_cast<std::size_t>(i)];
498+
return out;
499+
}
500+
326501
// -- Corrector driver ---------------------------------------------------------
327502

328503
constexpr std::uint16_t kChromatic = 0x0FFF;
@@ -340,6 +515,7 @@ struct Run
340515
double retuneMs = 0.0;
341516
bool formant = false;
342517
int channels = 1;
518+
int frame = 0; ///< 0 = the rate's automatic analysis frame
343519
int scaleAt = -1; ///< absolute sample of a second setScale
344520
std::uint16_t secondMask = 0;
345521
int secondRoot = 0;
@@ -365,7 +541,7 @@ std::vector<std::vector<T>> renderAll(const std::vector<double>& mono, const Run
365541
corrector.setScale(run.mask, run.root);
366542
corrector.setRetuneSpeedMs(static_cast<T>(run.retuneMs));
367543
corrector.setFormantPreserve(run.formant);
368-
corrector.prepare(spec);
544+
corrector.prepare(spec, run.frame);
369545

370546
int position = 0;
371547
while (position < n)
@@ -1140,6 +1316,100 @@ DSPARK_TEST(PitchCorrector_holds_its_analysis_span_across_sample_rates)
11401316
EXPECT_EQ(tiny.getFrameSize(), 256);
11411317
}
11421318

1319+
DSPARK_TEST(PitchCorrector_keeps_the_low_register_resolved_at_its_analysis_span)
1320+
{
1321+
// What the analysis span is FOR, measured rather than asserted. A bass
1322+
// voice's harmonics are resolvable only in a long enough span, so this
1323+
// reads the harmonic-to-residual ratio of a corrected vowel at F2, A2 and
1324+
// A3 and requires 25 dB; and it reads the same thing again with the frame
1325+
// pinned to half the policy span, where the low notes must collapse. The
1326+
// control is expressed as half of whatever the policy chooses at this rate,
1327+
// never as a sample count: at a high rate a fixed count is a quarter of the
1328+
// span and would be testing something else. A3 is measured for the same
1329+
// floor as a guard against a global collapse, but it is NOT required to
1330+
// fail at half the span, and it does not - which is exactly why it takes a
1331+
// bass voice to pin the frame from below.
1332+
const double fs = 48000.0;
1333+
const int segment = analysisSegment(fs);
1334+
const int n = static_cast<int>(2.6 * fs);
1335+
const int offset = static_cast<int>(0.9 * fs);
1336+
1337+
AudioSpec spec;
1338+
spec.sampleRate = fs;
1339+
spec.maxBlockSize = 512;
1340+
spec.numChannels = 1;
1341+
PitchCorrector<float> policy;
1342+
policy.prepare(spec);
1343+
const int policyFrame = policy.getFrameSize();
1344+
const int policyLatency = policy.getLatency();
1345+
PitchCorrector<float> halved;
1346+
halved.prepare(spec, policyFrame / 2);
1347+
const int halvedLatency = halved.getLatency();
1348+
EXPECT_EQ(halved.getFrameSize(), policyFrame / 2);
1349+
1350+
struct Note { double f0; int midi; };
1351+
const Note notes[3] = { { 87.31, 41 }, { 110.0, 45 }, { 220.0, 57 } };
1352+
1353+
for (int index = 0; index < 3; ++index)
1354+
{
1355+
const auto stimulus = vowelStimulus(notes[index].f0, fs, n);
1356+
const double target = notes[index].f0 * std::exp2(1.0 / 12.0);
1357+
1358+
Run run;
1359+
run.sampleRate = fs;
1360+
run.block = 512;
1361+
run.mask = noteMask(notes[index].midi + 1); // forces exactly +1 st
1362+
const auto corrected = render(stimulus, run);
1363+
const auto tail = slice(corrected, policyLatency + offset, segment);
1364+
const double measured = estimateF0(tail, fs, 40.0, 700.0);
1365+
// The grid sits on the measured f0, so it has to BE the corrected note:
1366+
// otherwise this measurement would be re-reporting a tuning error.
1367+
EXPECT_LT(std::abs(centsBetween(measured, target)), 10.0);
1368+
const double reading = harmonicResidualDb(tail, fs, measured);
1369+
1370+
// The instrument, pointed at the same stimulus synthesized at the
1371+
// corrected pitch and never passed through the class: that signal's
1372+
// residual is zero by construction, so whatever it reports there is its
1373+
// own leakage floor. Ten decibels of headroom over the value it is
1374+
// about to judge bounds that value's underestimate at 0.41 dB.
1375+
const auto reference = vowelStimulus(target, fs, n);
1376+
const auto referenceTail = slice(reference, offset, segment);
1377+
const double referenceF0 = estimateF0(referenceTail, fs, 40.0, 700.0);
1378+
const double instrumentFloor = harmonicResidualDb(referenceTail, fs, referenceF0);
1379+
EXPECT_GT(instrumentFloor - reading, 10.0);
1380+
1381+
if (index == 0)
1382+
{
1383+
// And it returns an answer planted in advance. A capture narrower
1384+
// than the window's main lobe fails this by leakage, a much wider
1385+
// one by absorbing the planted noise, so the one check closes both
1386+
// ends of the instrument space.
1387+
for (double planted : { 15.0, 20.0, 25.0, 30.0, 35.0, 40.0 })
1388+
{
1389+
const auto noisy = plantNoise(referenceTail, fs, planted, 20260816u);
1390+
const double noisyF0 = estimateF0(noisy, fs, 40.0, 700.0);
1391+
EXPECT_LT(std::abs(harmonicResidualDb(noisy, fs, noisyF0) - planted), 1.0);
1392+
}
1393+
}
1394+
1395+
EXPECT_GT(reading, 25.0);
1396+
1397+
if (index < 2)
1398+
{
1399+
Run control = run;
1400+
control.frame = policyFrame / 2;
1401+
const auto collapsed = render(stimulus, control);
1402+
const auto controlTail = slice(collapsed, halvedLatency + offset, segment);
1403+
const double controlF0 = estimateF0(controlTail, fs, 40.0, 700.0);
1404+
const double controlDb = harmonicResidualDb(controlTail, fs, controlF0);
1405+
// The control must be shown firing: a control that passes the floor
1406+
// proves nothing about what the span buys.
1407+
EXPECT_LT(controlDb, 25.0);
1408+
EXPECT_GT(reading - controlDb, 15.0);
1409+
}
1410+
}
1411+
}
1412+
11431413
DSPARK_TEST(PitchCorrector_corrects_accurately_at_every_supported_rate)
11441414
{
11451415
// Criterion 1 at the professional session rates, not only at 48 kHz: a low

0 commit comments

Comments
 (0)