Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions include/srt/asrc.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down
23 changes: 23 additions & 0 deletions include/srt/pi_servo.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions include/srt/polyphase_filter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 36 additions & 33 deletions tests/test_asrc_quality_16k.cpp
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <cmath>
#include <cstdio>
#include <numbers>
Expand All @@ -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),
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Loading