diff --git a/README.md b/README.md index b24046f..ce3a432 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,17 @@ adjacent phase-table rows (≈ −12 dB per doubling of `L`, +12 dB per octave o signal frequency). Servo lock from a cold start takes ~1 s; a 0 → 300 ppm drift ramp at 10 ppm/s is tracked without unlocking. +The same structure holds at other deployment rates, e.g. 16 kHz for +reference-microphone processing — but `FilterSpec` band edges and +`ServoConfig` bandwidths are absolute Hz (the defaults assume ~48 kHz), so +scale both with the rate (balanced at 16 kHz: passband ≈ 6.67 kHz, stopband +≈ 9.33 kHz, servo bandwidths × 16/48). Measured that way +(`tests/test_asrc_quality_16k.cpp`), 16 kHz matches the 48 kHz +normalized-frequency structure: 136.6 dB at 333 Hz and 106.5 dB at 6.5 kHz, +within ~1 dB of the 48 kHz tones at the same f/fs. Interpolation noise +depends only on f/fs; group delay at the same tap count stays ~24 input +samples and therefore triples in milliseconds (1.5 ms vs 0.5 ms). + ## Platform support CI builds and tests every push on: diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1fc21ec..999e5f2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable(srt_tests test_servo.cpp test_asrc_lock.cpp test_asrc_quality.cpp + test_asrc_quality_16k.cpp test_fade.cpp test_latency.cpp test_multichannel.cpp) diff --git a/tests/bare_metal_main.cpp b/tests/bare_metal_main.cpp index abf1086..fd909db 100644 --- a/tests/bare_metal_main.cpp +++ b/tests/bare_metal_main.cpp @@ -13,7 +13,9 @@ int main() { // MultiChannelShort.* stays in: it is the only on-target coverage of the // N-channel deinterleave and the wide-MAC dotRow paths at N > 2. - ::testing::GTEST_FLAG(filter) = "-AsrcQuality.*:AsrcLock.*:Servo.*:Kaiser.*MeetsSpec:" + // "AsrcQuality*" (no dot) excludes every quality suite: in gtest filters + // '.' is a literal, so "AsrcQuality.*" would not cover AsrcQuality16k. + ::testing::GTEST_FLAG(filter) = "-AsrcQuality*:AsrcLock.*:Servo.*:Kaiser.*MeetsSpec:" "FixedPoint.AsrcQuality*:" "FixedPoint.FullScaleSineDoesNotWrapQ15:" "MultiChannel.*"; diff --git a/tests/test_asrc_quality_16k.cpp b/tests/test_asrc_quality_16k.cpp new file mode 100644 index 0000000..8a2fdb4 --- /dev/null +++ b/tests/test_asrc_quality_16k.cpp @@ -0,0 +1,118 @@ +// 16 kHz -> 16 kHz quality coverage (a real deployment rate, e.g. +// reference-microphone processing). Same methodology as +// test_asrc_quality.cpp, with two rate-specific twists, both applied +// test-side (the header presets and defaults are deliberately untouched): +// +// 1. FilterSpec band edges are absolute Hz and the presets assume ~48 kHz, +// so passbandHz/stopbandHz are scaled by 16/48. +// 2. ServoConfig bandwidths are absolute Hz too. The slip-sawtooth beat +// sits at ppm * fs = 3.2 Hz instead of 9.6 Hz, so with default servo +// settings the 3-pole quiet smoother rejects it (16/48)^3 ~ 28.6 dB +// less and the measurement becomes servo-FM-limited: measured ~32 dB +// below the 48 kHz figures at every tone, falling 6 dB/octave of +// signal frequency (the small-index FM sideband signature). Scaling +// the servo bandwidths by 16/48 keeps the loop identical in +// normalized (per-sample) terms and restores the 48 kHz structure. +#include +#include +#include +#include + +#include + +#include "srt/asrc.hpp" +#include "support/sine_analysis.hpp" +#include "support/two_clock_sim.hpp" + +namespace { + +constexpr double kFs = 16000.0; +constexpr double kEps = 200e-6; +constexpr double kAmp = 0.5; + +// balanced() with band edges scaled to 16 kHz: identical L/T (so the same +// normalized-frequency response and the same group delay in samples — which +// is 3x the milliseconds at one third the rate). +srt::FilterSpec balancedAt16k() { + return {.numPhases = 256, + .tapsPerPhase = 48, + .passbandHz = 20000.0 * 16.0 / 48.0, // ~6666.7 Hz + .stopbandHz = 28000.0 * 16.0 / 48.0, // ~9333.3 Hz + .stopbandAttenDb = 120.0}; +} + +// Resamples a sine across a +200 ppm clock offset (sample-synchronous +// transfer) and measures the residual after removing the fitted fundamental +// from the last second of output. Mirrors measureSnrDb in +// test_asrc_quality.cpp at fs = 16 kHz. +double measureSnrDb16k(const srt::FilterSpec& spec, double freqHz) { + srt::Config cfg; + cfg.sampleRateHz = kFs; + cfg.channels = 1; + cfg.filter = spec; + // Servo scaled with the rate (see file comment): same normalized loop + // as the 48 kHz defaults. + constexpr double r = 16.0 / 48.0; + cfg.servo.acquireBandwidthHz *= r; + cfg.servo.trackBandwidthHz *= r; + cfg.servo.quietBandwidthHz *= r; + cfg.servo.acquireSmootherHz *= r; + cfg.servo.trackSmootherHz *= r; + cfg.servo.quietSmootherHz *= r; + srt::AsyncSampleRateConverter asrc(cfg); + srt_test::TwoClockSim sim{.asrc = asrc, + .fsIn = kFs * (1.0 + kEps), + .fsOut = kFs, + .channels = 1, + .chunkIn = 1, + .chunkOut = 1}; + const double nuIn = freqHz / kFs; + sim.gen = [&](std::uint64_t i) { + return static_cast(kAmp * + std::sin(2.0 * std::numbers::pi * nuIn * static_cast(i))); + }; + std::vector tail; + tail.reserve(16000); + // Long run: the locked loop must fully forget the acquisition transient + // before the measurement window. The quiet loop is scaled to ~0.017 Hz, + // so the 48 kHz test's 40 s becomes 120 s here — the identical number + // of samples and of loop time constants (a 40 s run still sits ~15 dB + // above the settled residual at every tone). + const double total = 120.0; + sim.run(total, [&](const float* x, std::size_t frames, double t) { + if (t >= total - 1.0) + tail.insert(tail.end(), x, x + frames); + }); + EXPECT_EQ(asrc.status().underruns, 0u); + EXPECT_EQ(asrc.status().state, srt::State::Locked); + const double nuOutExpected = nuIn * (1.0 + kEps); + const auto fit = srt_test::fitSineTracked(tail, nuOutExpected); + EXPECT_NEAR(fit.amplitude, kAmp, 0.01); + // The tracked frequency must still match the true clock ratio closely. + EXPECT_NEAR(fit.freqNorm / nuOutExpected, 1.0, 2e-6); + const double snr = srt_test::snrDb(fit); + std::printf("[ measured ] %5.0f Hz, %zu phases: SNR %.1f dB\n", freqHz, spec.numPhases, snr); + return snr; +} + +// Thresholds sit ~4 dB under measured performance, the convention of +// test_asrc_quality.cpp. Measured (balanced-at-16k, +200 ppm): +// 333 Hz: 136.6 dB, 2 kHz: 121.9 dB, 4 kHz: 114.3 dB, 6.5 kHz: 106.5 dB. +// The interpolation residual depends on the normalized frequency f/fs and +// the tones sit at the same f/fs as the 48 kHz suite's 997 Hz/6 k/12 k/ +// 19.5 k, which measure 135.0/120.0/112.8/105.8 dB on the same host — +// matching within ~1 dB, as expected. +TEST(AsrcQuality16k, Balanced333Hz) { + EXPECT_GT(measureSnrDb16k(balancedAt16k(), 333.0), 132.0); +} +TEST(AsrcQuality16k, Balanced2kHz) { + EXPECT_GT(measureSnrDb16k(balancedAt16k(), 2000.0), 117.0); +} +TEST(AsrcQuality16k, Balanced4kHz) { + EXPECT_GT(measureSnrDb16k(balancedAt16k(), 4000.0), 110.0); +} +TEST(AsrcQuality16k, Balanced6_5kHz) { + EXPECT_GT(measureSnrDb16k(balancedAt16k(), 6500.0), 102.0); +} + +} // namespace