@@ -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
328503constexpr 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+
11431413DSPARK_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