From 71ec6a485cb83b14a4d055d8b1aab5e899d74cc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:40:38 +0000 Subject: [PATCH] Config::forSampleRate: rate-scaled defaults for non-48 kHz deployments FilterSpec::scaledTo and ServoConfig::scaledTo rescale the absolute-Hz band edges, loop bandwidths and smoother corners from the 48 kHz design rate (hold times scale inversely, keeping promotion gates at the same loop-time-constant count); Config::forSampleRate composes them. The 16 kHz suite now runs through the factory and doubles as its behavioral regression test: measured 136.6/122.0/114.3/106.5 dB - identical to the hand-scaled originals within 0.1 dB. New fast unit test pins the scaling rule field by field. Full suite 51/51. https://claude.ai/code/session_01HuAFfoeD5a5Xe5aGNA16M9 --- README.md | 9 +++-- include/srt/asrc.hpp | 22 ++++++++++ include/srt/pi_servo.hpp | 23 +++++++++++ include/srt/polyphase_filter.hpp | 15 +++++++ tests/test_asrc_quality_16k.cpp | 69 +++++++++++++++++--------------- 5 files changed, 102 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a7847d0..6596d53 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,12 @@ 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 +`ServoConfig` bandwidths are absolute Hz designed for ~48 kHz, and running +another rate with unscaled defaults silently costs quality (measured: +~32 dB at 16 kHz). Start any non-48 kHz deployment from +`srt::Config::forSampleRate(rateHz)`, which rescales both (plus the servo +hold times); `FilterSpec::scaledTo` / `ServoConfig::scaledTo` exist for +custom presets. Measured through that factory (`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 diff --git a/include/srt/asrc.hpp b/include/srt/asrc.hpp index 334d4eb..57206ca 100644 --- a/include/srt/asrc.hpp +++ b/include/srt/asrc.hpp @@ -26,6 +26,28 @@ struct Config { std::size_t fifoFrames = 0; ///< ring capacity; 0 => automatic FilterSpec filter{}; ServoConfig servo{}; + + /// Defaults adapted to a nominal rate other than 48 kHz. The filter + /// band edges and servo bandwidths are absolute Hz designed for 48 kHz; + /// running another rate with unscaled defaults silently costs quality + /// (measured: ~32 dB at 16 kHz, because the slip beat ppm * fs drops + /// below the servo smoothers' rejection). This factory rescales both — + /// see FilterSpec::scaledTo and ServoConfig::scaledTo — and is the + /// recommended starting point for any non-48 kHz deployment: + /// + /// srt::Config cfg = srt::Config::forSampleRate(16000.0); + /// cfg.channels = ...; // then adjust as usual + /// + /// Frame-denominated fields (targetLatencyFrames, fifoFrames) are + /// rate-invariant in frames and left alone; note their duration in + /// milliseconds scales inversely with the rate. + static Config forSampleRate(double sampleRateHz) noexcept { + Config c; + c.sampleRateHz = sampleRateHz; + c.filter = c.filter.scaledTo(sampleRateHz); + c.servo = c.servo.scaledTo(sampleRateHz); + return c; + } }; /// Converter state as seen by status(). diff --git a/include/srt/pi_servo.hpp b/include/srt/pi_servo.hpp index 4225d35..0c738f6 100644 --- a/include/srt/pi_servo.hpp +++ b/include/srt/pi_servo.hpp @@ -64,6 +64,29 @@ struct ServoConfig { double quietHoldSeconds = 2.0; ///< cascade-|e| hold => track -> quiet double unlockThresholdFrames = 24.0; ///< |e| above this => demote a stage double maxDeviationPpm = 1000.0; ///< epsHat clamp = +/- 1.5x this + + /// This config rescaled from the 48 kHz design rate to sampleRateHz: + /// the loop bandwidths and error-smoother corners are absolute Hz and + /// must track the rate, or the slip-sawtooth beat (ppm * fs) walks out + /// from under the smoothers — measured as a ~32 dB quality loss at + /// 16 kHz with unscaled defaults. Hold times scale inversely so the + /// promotion gates wait the same number of loop time constants. + /// Frame-denominated thresholds and ppm limits are rate-invariant and + /// stay put. See Config::forSampleRate. + ServoConfig scaledTo(double sampleRateHz) const noexcept { + constexpr double kDesignRateHz = 48000.0; + const double r = sampleRateHz / kDesignRateHz; + ServoConfig s = *this; + s.acquireBandwidthHz *= r; + s.trackBandwidthHz *= r; + s.quietBandwidthHz *= r; + s.acquireSmootherHz *= r; + s.trackSmootherHz *= r; + s.quietSmootherHz *= r; + s.lockHoldSeconds /= r; + s.quietHoldSeconds /= r; + return s; + } }; /// PI loop filter + three-stage lock-state machine. Pure double-precision diff --git a/include/srt/polyphase_filter.hpp b/include/srt/polyphase_filter.hpp index 390a474..aa9f5ec 100644 --- a/include/srt/polyphase_filter.hpp +++ b/include/srt/polyphase_filter.hpp @@ -94,6 +94,21 @@ struct FilterSpec { .stopbandHz = 26000.0, .stopbandAttenDb = 140.0}; } + + /// This spec with the band edges rescaled from the 48 kHz design rate + /// to sampleRateHz. The presets' passband/stopband are absolute Hz + /// chosen for ~48 kHz operation; at other rates the same L/T with + /// proportional band edges gives the identical normalized-frequency + /// response (and group delay in samples — i.e. more milliseconds at + /// lower rates). See also ServoConfig::scaledTo and + /// Config::forSampleRate, which a 16 kHz deployment wants as a set. + FilterSpec scaledTo(double sampleRateHz) const noexcept { + constexpr double kDesignRateHz = 48000.0; + FilterSpec s = *this; + s.passbandHz *= sampleRateHz / kDesignRateHz; + s.stopbandHz *= sampleRateHz / kDesignRateHz; + return s; + } }; /// Immutable polyphase coefficient table designed at construction. diff --git a/tests/test_asrc_quality_16k.cpp b/tests/test_asrc_quality_16k.cpp index 8a2fdb4..fbfb9bc 100644 --- a/tests/test_asrc_quality_16k.cpp +++ b/tests/test_asrc_quality_16k.cpp @@ -1,10 +1,10 @@ // 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): +// test_asrc_quality.cpp, configured through Config::forSampleRate — the +// rate-scaling rule this suite originally established by hand: // // 1. FilterSpec band edges are absolute Hz and the presets assume ~48 kHz, -// so passbandHz/stopbandHz are scaled by 16/48. +// so passbandHz/stopbandHz must scale with the rate. // 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 @@ -13,6 +13,10 @@ // 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. +// +// This suite doubles as the regression test for Config::forSampleRate +// itself (including its inverse hold-time scaling, which the hand-scaled +// original did not apply — re-measured identical within noise). #include #include #include @@ -30,35 +34,15 @@ 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; +// test_asrc_quality.cpp at fs = 16 kHz, with all rate adaptation coming +// from Config::forSampleRate (filter band edges, servo bandwidths and +// hold times). +double measureSnrDb16k(double freqHz) { + srt::Config cfg = srt::Config::forSampleRate(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), @@ -91,7 +75,7 @@ double measureSnrDb16k(const srt::FilterSpec& spec, double freqHz) { // 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); + std::printf("[ measured ] %5.0f Hz: SNR %.1f dB\n", freqHz, snr); return snr; } @@ -102,17 +86,36 @@ double measureSnrDb16k(const srt::FilterSpec& spec, double freqHz) { // 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. +// Fast deterministic check of the scaling rule itself (the sims below are +// the behavioral validation). +TEST(AsrcQuality16k, ForSampleRateScalesHzFieldsOnly) { + const srt::Config c = srt::Config::forSampleRate(16000.0); + const srt::Config d; // 48 kHz defaults + const double r = 16000.0 / 48000.0; + EXPECT_DOUBLE_EQ(c.sampleRateHz, 16000.0); + EXPECT_DOUBLE_EQ(c.filter.passbandHz, d.filter.passbandHz * r); + EXPECT_DOUBLE_EQ(c.filter.stopbandHz, d.filter.stopbandHz * r); + EXPECT_EQ(c.filter.numPhases, d.filter.numPhases); + EXPECT_EQ(c.filter.tapsPerPhase, d.filter.tapsPerPhase); + EXPECT_DOUBLE_EQ(c.servo.quietBandwidthHz, d.servo.quietBandwidthHz * r); + EXPECT_DOUBLE_EQ(c.servo.acquireSmootherHz, d.servo.acquireSmootherHz * r); + EXPECT_DOUBLE_EQ(c.servo.quietHoldSeconds, d.servo.quietHoldSeconds / r); + EXPECT_DOUBLE_EQ(c.servo.lockThresholdFrames, d.servo.lockThresholdFrames); + EXPECT_DOUBLE_EQ(c.servo.maxDeviationPpm, d.servo.maxDeviationPpm); + EXPECT_EQ(c.targetLatencyFrames, d.targetLatencyFrames); +} + TEST(AsrcQuality16k, Balanced333Hz) { - EXPECT_GT(measureSnrDb16k(balancedAt16k(), 333.0), 132.0); + EXPECT_GT(measureSnrDb16k(333.0), 132.0); } TEST(AsrcQuality16k, Balanced2kHz) { - EXPECT_GT(measureSnrDb16k(balancedAt16k(), 2000.0), 117.0); + EXPECT_GT(measureSnrDb16k(2000.0), 117.0); } TEST(AsrcQuality16k, Balanced4kHz) { - EXPECT_GT(measureSnrDb16k(balancedAt16k(), 4000.0), 110.0); + EXPECT_GT(measureSnrDb16k(4000.0), 110.0); } TEST(AsrcQuality16k, Balanced6_5kHz) { - EXPECT_GT(measureSnrDb16k(balancedAt16k(), 6500.0), 102.0); + EXPECT_GT(measureSnrDb16k(6500.0), 102.0); } } // namespace