Skip to content

Commit f2e74a4

Browse files
feat(analysis): accept the bin phasor in GoertzelAlgorithm (#360)
A caller can supply e^{jω} of the bin instead of the bin index. The resonator needs only cos ω and sin ω, evaluated once; on a Cortex-M4 the software sinf/cosf behind math::Sin/math::Cos pull in several kilobytes of argument reduction, which a caller with its own trigonometry can now keep out of the image. The bin-index constructor delegates to the new one and evaluates the cosine once instead of twice. Claude-Session: https://claude.ai/code/session_01SeegZiU2Up4ZYQBwnfrLeP Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f6d717 commit f2e74a4

3 files changed

Lines changed: 46 additions & 3 deletions

File tree

‎doc/analysis/GoertzelAlgorithm.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ The DC bin ($k = 0$) and the Nyquist bin ($k = N/2$) are purely real; their imag
9090

9191
The magnitude-squared form $s_1^2 + s_2^2 - c\,s_1 s_2$ avoids the square root when only power detection (threshold comparison) is needed, saving a transcendental call on platforms without hardware `sqrt`.
9292

93+
The recurrence and the final step need only $\cos\omega$ and $\sin\omega$ of the bin, $\omega = 2\pi k/N$, evaluated once per configuration. A caller that already evaluates them by other means — a sine table refined by a short series, or an exact value for a known angle — can supply the unit bin phasor $e^{j\omega}$ directly. On targets whose sine and cosine come from a software math library, that keeps the library's general argument-reduction code, several kilobytes on a Cortex-M4, out of the image. The phasor's accuracy sets the resonator frequency: an error $\varepsilon$ in $\cos\omega$ detunes it by about $\varepsilon / \sin\omega$ radians per sample, which over a block of $N$ samples rotates the result by about $N\varepsilon / (2\sin\omega)$.
94+
9395
When several tones must be tracked simultaneously, one independent Goertzel instance per tone runs in $O(BN)$ total with $O(B)$ state — still far cheaper than an FFT for small $B$.
9496

9597
A sliding Goertzel variant updates one sample at a time by subtracting the oldest sample, giving a continuous output at the cost of maintaining an input ring buffer; this trades $O(1)$ memory for $O(N)$ memory and is outside the scope of the no-heap constraint here.

‎numerical/analysis/GoertzelAlgorithm.hpp‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ namespace analysis
1919
public:
2020
GoertzelAlgorithm(std::size_t k, std::size_t blockLength);
2121
GoertzelAlgorithm(T targetHz, T sampleHz, std::size_t blockLength);
22+
GoertzelAlgorithm(math::Complex<T> binPhasor, std::size_t blockLength);
2223

2324
[[nodiscard]] static std::optional<GoertzelAlgorithm> TryCreate(std::size_t k, std::size_t blockLength);
2425

@@ -31,6 +32,8 @@ namespace analysis
3132
static T Coefficient(std::size_t k, std::size_t blockLength);
3233

3334
private:
35+
static math::Complex<T> BinPhasor(std::size_t k, std::size_t blockLength);
36+
3437
T coeff;
3538
T cosine;
3639
T sine;
@@ -51,9 +54,14 @@ namespace analysis
5154

5255
template<typename T>
5356
GoertzelAlgorithm<T>::GoertzelAlgorithm(std::size_t k, std::size_t blockLength)
54-
: coeff{ T{ 2 } * math::Cos(T{ 2 } * std::numbers::pi_v<T> * static_cast<T>(k) / static_cast<T>(blockLength)) }
55-
, cosine{ math::Cos(T{ 2 } * std::numbers::pi_v<T> * static_cast<T>(k) / static_cast<T>(blockLength)) }
56-
, sine{ math::Sin(T{ 2 } * std::numbers::pi_v<T> * static_cast<T>(k) / static_cast<T>(blockLength)) }
57+
: GoertzelAlgorithm(BinPhasor(k, blockLength), blockLength)
58+
{}
59+
60+
template<typename T>
61+
GoertzelAlgorithm<T>::GoertzelAlgorithm(math::Complex<T> binPhasor, std::size_t blockLength)
62+
: coeff{ T{ 2 } * binPhasor.Real() }
63+
, cosine{ binPhasor.Real() }
64+
, sine{ binPhasor.Imaginary() }
5765
, blockSize{ blockLength }
5866
{
5967
really_assert(blockLength > 0);
@@ -106,6 +114,13 @@ namespace analysis
106114
sampleCount = 0;
107115
}
108116

117+
template<typename T>
118+
math::Complex<T> GoertzelAlgorithm<T>::BinPhasor(std::size_t k, std::size_t blockLength)
119+
{
120+
const T angle{ T{ 2 } * std::numbers::pi_v<T> * static_cast<T>(k) / static_cast<T>(blockLength) };
121+
return math::Complex<T>{ math::Cos(angle), math::Sin(angle) };
122+
}
123+
109124
template<typename T>
110125
T GoertzelAlgorithm<T>::Coefficient(std::size_t k, std::size_t blockLength)
111126
{

‎numerical/analysis/test/TestGoertzelAlgorithm.cpp‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,32 @@ TEST_F(TestGoertzelAlgorithm, result_matches_direct_dft_bin)
9999
EXPECT_NEAR(result.Imaginary(), refImag, 1e-3f);
100100
}
101101

102+
TEST_F(TestGoertzelAlgorithm, supplied_bin_phasor_matches_direct_dft_bin)
103+
{
104+
constexpr std::size_t k{ 3 };
105+
const float binAngle{ 2.0f * std::numbers::pi_v<float> * static_cast<float>(k) / static_cast<float>(N) };
106+
analysis::GoertzelAlgorithm<float> goertzel{ math::Complex<float>{ std::cos(binAngle), std::sin(binAngle) }, N };
107+
108+
std::array<float, N> signal{};
109+
for (std::size_t n = 0; n < N; ++n)
110+
signal[n] = std::sin(binAngle * static_cast<float>(n)) + 0.5f;
111+
112+
for (float s : signal)
113+
goertzel.Push(s);
114+
115+
float refReal{ 0.0f };
116+
float refImag{ 0.0f };
117+
for (std::size_t n = 0; n < N; ++n)
118+
{
119+
refReal += signal[n] * std::cos(binAngle * static_cast<float>(n));
120+
refImag -= signal[n] * std::sin(binAngle * static_cast<float>(n));
121+
}
122+
123+
math::Complex<float> result{ goertzel.Result() };
124+
EXPECT_NEAR(result.Real(), refReal, 1e-3f);
125+
EXPECT_NEAR(result.Imaginary(), refImag, 1e-3f);
126+
}
127+
102128
TEST_F(TestGoertzelAlgorithm, magnitude_squared_equals_result_norm_squared)
103129
{
104130
constexpr std::size_t k{ 2 };

0 commit comments

Comments
 (0)