Epirb afc wide capture (#3213)

* EPIRB RX: widen AFC capture range to +/-5 kHz

The AFC estimate was only applied after carrier lock, so carrier
acquisition ran on raw phase deltas and could only tolerate offsets of
a few hundred Hz before the 0.6/0.7/1.6 rad detection thresholds (on
the 12-sample accumulator) broke down.

Track the carrier offset continuously in the IDLE state with a
first-order loop (AFC_TRACK_ALPHA), so the de-biased accumulator
self-centers for any offset within the discriminator Nyquist (~24 kHz)
*before* the thresholds run. ALPHA = 0.005 pulls a +/-5 kHz offset
under the 0.6 rad lock threshold in ~11 ms, well inside the 160 ms
preamble / 80 ms stability window. The IDLE rise-detect threshold is
also made symmetric (fabsf) now that the bias is removed.

Also add the missing <cstdint> include to test_convert.cpp so the
application_test suite compiles under the current toolchain.

Verified: baseband_epirb_rx.elf builds (flash 53%, RAM 12%) and
baseband_test passes.
This commit is contained in:
Arne Luehrs
2026-06-11 11:01:54 +02:00
committed by GitHub
parent 6b5955e0b5
commit 569bcaad22
8 changed files with 1599 additions and 1479 deletions
+12 -15
View File
@@ -41,29 +41,26 @@ void BeaconUIList::paint(Painter& painter) {
auto base_style = Theme::getInstance()->bg_darkest; auto base_style = Theme::getInstance()->bg_darkest;
for (auto offset = 0u; offset < BEACON_HISTORY_SIZE; ++offset) { for (auto offset = 0u; offset < BEACON_HISTORY_SIZE; ++offset) {
// The whole frame needs to be cleared so every line 'slot'
// is redrawn even when `text` just left empty.
auto text = std::string{};
auto index = start_index_ + offset; auto index = start_index_ + offset;
auto line_position = rect.location() + Point{0, 1 + (int)offset * char_height};
auto is_selected = offset == selected_index_;
auto style = base_style;
if (index < db_->size()) { if (index < db_->size()) {
auto line_position = rect.location() + Point{0, 1 + (int)offset * char_height};
auto is_selected = (offset == selected_index_);
auto style = base_style;
// Get beacon entry and format it's summary // Get beacon entry and format it's summary
auto& entry = db_->get_beacon(index); auto& entry = db_->get_beacon(index);
char buffer[64]; char buffer[64];
entry.formatSummary(buffer, true); entry.formatSummary(buffer, true);
text = std::string(buffer);
if (index == db_->get_current_beacon_index())
// If this is the currently displayed beacon change color
style = Theme::getInstance()->bg_medium;
// Draw entry line using stack buffer directly to avoid heap allocation
painter.draw_string(
line_position, (is_selected ? style->invert() : *style), buffer);
} }
if (index == db_->get_current_beacon_index())
// If this is the currently displayed beacon change color
style = Theme::getInstance()->bg_medium;
// Draw entry line
painter.draw_string(
line_position, (is_selected ? style->invert() : *style), text);
} }
// Draw a bounding rectangle when focused. // Draw a bounding rectangle when focused.
+26 -10
View File
@@ -39,7 +39,7 @@ namespace ui::external_app::epirb_rx {
// URL templates // URL templates
#define MAPS_URL_TEMPLATE "https://www.google.com/maps/search/?api=1&query=%s%%2C%s" #define MAPS_URL_TEMPLATE "https://www.google.com/maps/search/?api=1&query=%s%%2C%s"
#define BEACON_URL_TEMPALTE "https://decoder2.herokuapp.com/decoded/" #define BEACON_URL_TEMPLATE "https://decoder2.herokuapp.com/decoded/"
#ifndef DISABLE_COUNTRY_CACHE #ifndef DISABLE_COUNTRY_CACHE
int CountryManager::cache_count = 0; int CountryManager::cache_count = 0;
@@ -71,15 +71,24 @@ void TextArea::paint(Painter& painter) {
const Style& s = has_focus() ? style().invert() : style(); const Style& s = has_focus() ? style().invert() : style();
painter.fill_rectangle(rect, s.background); painter.fill_rectangle(rect, s.background);
// We use \t as line separator since \n is used in STR_COLOR_GREEN
auto rows = split_string(content, '\t');
const int line_height = s.font.line_height(); const int line_height = s.font.line_height();
size_t line_idx = 0; int line_idx = 0;
for (auto row : rows) {
painter.draw_string(rect.location() + Point(0, line_idx * line_height), s, row); // Efficiently draw lines separated by \t without heap allocations
std::string_view sv{content};
size_t start = 0;
size_t end;
while ((end = sv.find('\t', start)) != std::string_view::npos) {
painter.draw_string(rect.location() + Point(0, line_idx * line_height), s, sv.substr(start, end - start));
start = end + 1;
line_idx++; line_idx++;
} }
if (start < sv.length()) {
painter.draw_string(rect.location() + Point(0, line_idx * line_height), s, sv.substr(start));
}
} }
void TextArea::set_content(std::string_view value) { void TextArea::set_content(std::string_view value) {
@@ -294,7 +303,7 @@ void EPRIBQRView::set_beacon(Beacon* beacon) {
} }
void EPRIBQRView::update_display() { void EPRIBQRView::update_display() {
// Update data => we use a single TextArea component for code size optimizaton // Update data => we use a single TextArea component for code size optimization
char buffer[128]; char buffer[128];
char* buffer_pointer = buffer; char* buffer_pointer = buffer;
buffer_pointer += sprintf(buffer_pointer, "%sQR:%s\t\t\t\t\t\t\t\t", STR_COLOR_CYAN, STR_COLOR_WHITE); buffer_pointer += sprintf(buffer_pointer, "%sQR:%s\t\t\t\t\t\t\t\t", STR_COLOR_CYAN, STR_COLOR_WHITE);
@@ -320,7 +329,7 @@ void EPRIBQRView::update_qr() {
if (show_map) { if (show_map) {
// Map is selected // Map is selected
if (!current_beacon->location.isUnknown()) { if (!current_beacon->location.isUnknown()) {
// Loation is known => actually draw QR // Location is known => actually draw QR
current_beacon->location.formatFloatLocation(qr_url, MAPS_URL_TEMPLATE); current_beacon->location.formatFloatLocation(qr_url, MAPS_URL_TEMPLATE);
show_qr = true; show_qr = true;
} }
@@ -328,7 +337,7 @@ void EPRIBQRView::update_qr() {
// Detail is selected // Detail is selected
char* buffer_pointer = qr_url; char* buffer_pointer = qr_url;
// Send to heroku decoder // Send to heroku decoder
buffer_pointer += sprintf(qr_url, BEACON_URL_TEMPALTE); buffer_pointer += sprintf(qr_url, BEACON_URL_TEMPLATE);
current_beacon->hexString(buffer_pointer, false); current_beacon->hexString(buffer_pointer, false);
show_qr = true; show_qr = true;
} }
@@ -402,7 +411,7 @@ EPIRBAppView::EPIRBAppView(ui::NavigationView& nav)
options_frequency.on_change = [this](size_t, ui::OptionsField::value_t v) { options_frequency.on_change = [this](size_t, ui::OptionsField::value_t v) {
receiver_model.set_target_frequency(v); receiver_model.set_target_frequency(v);
}; };
// Restore frequency from preferencies // Restore frequency from preferences
options_frequency.set_by_value(receiver_model.target_frequency()); options_frequency.set_by_value(receiver_model.target_frequency());
// Tick second timer // Tick second timer
@@ -456,6 +465,13 @@ EPIRBAppView::EPIRBAppView(ui::NavigationView& nav)
audio::set_rate(audio::Rate::Hz_24000); audio::set_rate(audio::Rate::Hz_24000);
audio::output::start(); audio::output::start();
// Tint the channel-power bar red when the front-end overloads (ADC near
// full scale). Clipping distorts the constant-envelope carrier and the
// +/-1.1 rad biphase jumps the decoder relies on, so this cues the user to
// reduce RF-amp/LNA/VGA gain. -3 dBFS is a heuristic on the post-decimation
// level (not a literal ADC clip count); tune if it trips too early/late.
channel.set_overload_threshold(-3);
update_display(); update_display();
#ifdef LOGGER #ifdef LOGGER
+5 -1
View File
@@ -36,10 +36,14 @@ void Channel::paint(Painter& painter) {
const range_t<int> x_max_range{0, r.width() - 1}; const range_t<int> x_max_range{0, r.width() - 1};
const auto x_max = x_max_range.clip((max_db_ - db_min) * r.width() / db_delta); const auto x_max = x_max_range.clip((max_db_ - db_min) * r.width() / db_delta);
const auto bar_style = (max_db_ >= overload_threshold_)
? Theme::getInstance()->fg_red
: Theme::getInstance()->fg_blue;
const Rect r0{r.left(), r.top(), x_max, r.height()}; const Rect r0{r.left(), r.top(), x_max, r.height()};
painter.fill_rectangle( painter.fill_rectangle(
r0, r0,
Theme::getInstance()->fg_blue->foreground); bar_style->foreground);
const Rect r1{r.left() + x_max, r.top(), 1, r.height()}; const Rect r1{r.left() + x_max, r.top(), 1, r.height()};
painter.fill_rectangle( painter.fill_rectangle(
+8
View File
@@ -44,8 +44,16 @@ class Channel : public Widget {
void paint(Painter& painter) override; void paint(Painter& painter) override;
// Opt-in receiver-overload tint: when the channel power (peak IQ magnitude
// in dBFS, 0 = full scale) reaches this threshold the bar is drawn red
// instead of blue, flagging that the analog gain is too high and the ADC is
// clipping. Default is disabled (threshold above the 0 dBFS ceiling) so
// existing users are unaffected.
void set_overload_threshold(int32_t db) { overload_threshold_ = db; }
private: private:
int32_t max_db_; int32_t max_db_;
int32_t overload_threshold_{1};
MessageHandlerRegistration message_handler_stats{ MessageHandlerRegistration message_handler_stats{
Message::ID::ChannelStatistics, Message::ID::ChannelStatistics,
+85 -19
View File
@@ -29,8 +29,11 @@
#include "audio_dma.hpp" #include "audio_dma.hpp"
#include "event_m4.hpp" #include "event_m4.hpp"
#include <ch.h>
#include <algorithm>
#include <cmath>
#include <ch.h>
EPIRBProcessor::EPIRBProcessor() { EPIRBProcessor::EPIRBProcessor() {
// Configure the decimation filters for narrowband EPIRB signal // Configure the decimation filters for narrowband EPIRB signal
decim_0.configure(taps_11k0_decim_0.taps); decim_0.configure(taps_11k0_decim_0.taps);
@@ -57,9 +60,6 @@ float EPIRBProcessor::get_phase_diff(const complex16_t& sample0, const complex16
float dI = sample1.real() * sample0.real() + sample1.imag() * sample0.imag(); float dI = sample1.real() * sample0.real() + sample1.imag() * sample0.imag();
float dQ = sample1.imag() * sample0.real() - sample1.real() * sample0.imag(); float dQ = sample1.imag() * sample0.real() - sample1.real() * sample0.imag();
float phase_diff = atan2f(dQ, dI); float phase_diff = atan2f(dQ, dI);
// Prevent phase diff from wrapping around
if (phase_diff > M_PI) phase_diff -= 2.0f * M_PI;
if (phase_diff < -M_PI) phase_diff += 2.0f * M_PI;
return phase_diff; return phase_diff;
} }
@@ -110,36 +110,94 @@ void EPIRBProcessor::execute(const buffer_c8_t& buffer) {
float phase_delta = get_phase_diff(last_sample, decimator_out.p[i]); float phase_delta = get_phase_diff(last_sample, decimator_out.p[i]);
last_sample = decimator_out.p[i]; last_sample = decimator_out.p[i];
// AFC: remove the estimated carrier frequency offset from the raw delta
// before any further processing. Done on the per-sample value so the
// 12-sample accumulator below tracks it naturally.
phase_delta -= freq_offset_est;
// Keep the (de-biased) per-sample delta for AFC averaging over the carrier.
const float sample_phase_delta = phase_delta;
// Let's sum phase delta over a 12 sample window to get the full phase jump // Let's sum phase delta over a 12 sample window to get the full phase jump
phase_delta_acc -= phase_delta_buffer[pahse_delta_index]; phase_delta_acc -= phase_delta_buffer[phase_delta_index];
phase_delta_buffer[pahse_delta_index] = phase_delta; phase_delta_buffer[phase_delta_index] = phase_delta;
phase_delta_acc += phase_delta_buffer[pahse_delta_index]; phase_delta_acc += phase_delta_buffer[phase_delta_index];
pahse_delta_index = (pahse_delta_index + 1) % PHASE_DELTA_ACC_SIZE; phase_delta_index = (phase_delta_index + 1) % PHASE_DELTA_ACC_SIZE;
// Use accumulated delta // Use accumulated delta
phase_delta = phase_delta_acc; phase_delta = phase_delta_acc;
// State machine for COSPAS frame detection // State machine for COSPAS frame detection
switch (current_state) { switch (current_state) {
case IDLE: case IDLE: {
// We are waiting for a 160ms empty carrier => phase shouls be stable during this period // Continuously pull the AFC estimate toward the mean per-sample
// We accept a 0.6 phase shift since phase may drift durring carrier if carrier frequency is not alligned with tuner frequency // rotation so the accumulator self-centers for any offset up to
if (filtered_rise_detect(phase_delta >= 0.6f)) { // the discriminator Nyquist (~+/-24 kHz). On noise the de-biased
// deltas average to ~0, so the estimate stays put; on a real
// carrier it converges within a few ms and the thresholds below
// then see a de-biased signal regardless of the actual offset.
// Only update AFC when the per-sample phase delta is small
// (large jumps indicate noise or transient, which would cause
// a random-walk drift if used for AFC updates).
if (fabsf(sample_phase_delta) <= AFC_UPDATE_PHASE_MAX) {
freq_offset_est += AFC_TRACK_ALPHA * sample_phase_delta;
// Bounds checking: limit to ±5 kHz (~0.654 rad/sample at 48 kHz)
freq_offset_est = std::clamp(freq_offset_est, -0.654f, 0.654f);
// Track AFC convergence using Welford's online algorithm
afc_convergence_n++;
float delta = freq_offset_est - afc_mean;
afc_mean += delta / afc_convergence_n;
float delta2 = freq_offset_est - afc_mean;
afc_m2 += delta * delta2;
}
// We are waiting for a 160ms empty carrier => phase should be stable during this period
// Use a symmetric threshold: once AFC has removed the bias a stable
// carrier sits near 0, so both positive and negative excursions of
// the accumulated delta indicate the carrier is not yet stable.
if (filtered_rise_detect(fabsf(phase_delta) >= 0.6f)) {
stability_counter = 0; stability_counter = 0;
// Reset convergence tracking when the carrier is not stable,
// so variance is measured only over the current stable window.
afc_mean = 0.0f;
afc_m2 = 0.0f;
afc_convergence_n = 0;
} else { } else {
stability_counter++; stability_counter++;
// Check both phase stability AND AFC convergence before transitioning
if (stability_counter > CARRIER_SAMPLES_THRESHOLD) { if (stability_counter > CARRIER_SAMPLES_THRESHOLD) {
// Carrier has been stable long enought, go to locked state float afc_variance = (afc_convergence_n > 1) ? afc_m2 / (afc_convergence_n - 1) : 0.0f;
current_state = CARRIER_LOCKED; if (afc_variance < AFC_CONVERGENCE_THRESHOLD) {
frame_sample_count = 0; // Both phase and AFC have converged, go to locked state
current_state = CARRIER_LOCKED;
// Reset carrier accumulators so the latched update uses
// only the residual measured while in the locked window
carrier_phase_sum = 0.0f;
carrier_phase_n = 0;
frame_sample_count = 0;
}
} }
} }
break; } break;
case CARRIER_LOCKED: case CARRIER_LOCKED:
// Carrier is locked, we now wait for a phase 1.1 rad phase jump corresponding to the befining of the frame // Carrier is locked: this is the clean unmodulated carrier window.
// Average the per-sample phase delta here to estimate the residual
// frequency offset (rad/sample) used for AFC.
carrier_phase_sum += sample_phase_delta;
carrier_phase_n++;
// Carrier is locked, we now wait for a phase 1.1 rad phase jump corresponding to the beginning of the frame
// Let's use a 0.7 phase jump threshold // Let's use a 0.7 phase jump threshold
if (filtered_rise_detect(phase_delta >= 0.7f)) { if (filtered_rise_detect(phase_delta >= 0.7f)) {
// Latch the AFC estimate from the carrier we just measured so it
// applies to the data burst that starts now. Accumulate so the
// residual is folded into any prior estimate.
if (carrier_phase_n > 0) {
freq_offset_est += carrier_phase_sum / carrier_phase_n;
// Bounds checking: limit to ±5 kHz (~0.654 rad/sample at 48 kHz)
freq_offset_est = std::clamp(freq_offset_est, -0.654f, 0.654f);
}
// Jump detected, frame starts now // Jump detected, frame starts now
frame_sample_count = 0; frame_sample_count = 0;
// Go to data sync state // Go to data sync state
@@ -164,7 +222,7 @@ void EPIRBProcessor::execute(const buffer_c8_t& buffer) {
bool phase_positive = (phase_delta >= 0.0f); bool phase_positive = (phase_delta >= 0.0f);
if (phase_positive != last_phase_positive) { if (phase_positive != last_phase_positive) {
// Phase jumped to the opposit direction of last jump // Phase jumped to the opposite direction of last jump
last_phase_positive = phase_positive; last_phase_positive = phase_positive;
bool cur_bit; bool cur_bit;
// Phase change => how long since last change ? // Phase change => how long since last change ?
@@ -185,7 +243,7 @@ void EPIRBProcessor::execute(const buffer_c8_t& buffer) {
// 2 symbols since last change => bit value changes // 2 symbols since last change => bit value changes
cur_bit = !last_bit; cur_bit = !last_bit;
} else if ((sample_count >= (SAMPLES_PER_SYMBOL - SAMPLES_MARGIN)) && (sample_count <= (SAMPLES_PER_SYMBOL + SAMPLES_MARGIN))) { } else if ((sample_count >= (SAMPLES_PER_SYMBOL - SAMPLES_MARGIN)) && (sample_count <= (SAMPLES_PER_SYMBOL + SAMPLES_MARGIN))) {
// Phase change occured in first half bit => we keep the same value // Phase change occurred in first half bit => we keep the same value
if ((phase_positive && last_bit) || (!phase_positive && !last_bit)) { if ((phase_positive && last_bit) || (!phase_positive && !last_bit)) {
sample_count = 0; sample_count = 0;
// Ignore rising edge if current value is 1 and falling edge if current value is 0 and move to next symbol // Ignore rising edge if current value is 1 and falling edge if current value is 0 and move to next symbol
@@ -227,6 +285,14 @@ void EPIRBProcessor::frame_end() {
last_phase_positive = false; last_phase_positive = false;
last_bit = false; last_bit = false;
current_state = IDLE; current_state = IDLE;
// Reset AFC so the next burst is re-estimated from its own carrier preamble.
freq_offset_est = 0.0f;
carrier_phase_sum = 0.0f;
carrier_phase_n = 0;
// Reset AFC convergence tracking for next frame
afc_mean = 0.0f;
afc_m2 = 0.0f;
afc_convergence_n = 0;
packet_builder.reset_state(); packet_builder.reset_state();
} }
+35 -7
View File
@@ -59,15 +59,15 @@ class Packet;
#define COSPAS_PREAMBLE_SIZE 24 #define COSPAS_PREAMBLE_SIZE 24
// Size of long frame (bits) // Size of long frame (bits)
#define COSPAS_LONG_FRAME_SIZE 144 #define COSPAS_LONG_FRAME_SIZE 144
// Siz of short frame (bits) // Size of short frame (bits)
#define COSPAS_SHORT_FRAME_SIZE 112 #define COSPAS_SHORT_FRAME_SIZE 112
// Preamble for real frames // Preamble for real frames
#define COSPAS_REAL_PREAMBLE 0b1111'1111'1111'1110'0010'1111 #define COSPAS_REAL_PREAMBLE 0b1111'1111'1111'1110'0010'1111
// Preable for test frames // Preamble for test frames
#define COSPAS_TEST_PREAMBLE 0b1111'1111'1111'1110'1101'0000 #define COSPAS_TEST_PREAMBLE 0b1111'1111'1111'1110'1101'0000
// Dedicated EPIRB PacketBuilder // Dedicated EPIRB PacketBuilder
// Usees diedicated preamble detection logic to find both real and test frames // Uses dedicated preamble detection logic to find both real and test frames
// Also as a dedicated packet size detection based on frame's size bit // Also as a dedicated packet size detection based on frame's size bit
class EPIRBPacketBuilder { class EPIRBPacketBuilder {
public: public:
@@ -170,7 +170,7 @@ class EPIRBProcessor : public BasebandProcessor {
static constexpr size_t SAMPLES_PER_SYMBOL = SAMPLE_RATE / SYMBOL_RATE; // = 60 samples per symbol static constexpr size_t SAMPLES_PER_SYMBOL = SAMPLE_RATE / SYMBOL_RATE; // = 60 samples per symbol
static constexpr size_t SAMPLES_PER_BIT = SAMPLES_PER_SYMBOL * 2; // = 120 samples per bit static constexpr size_t SAMPLES_PER_BIT = SAMPLES_PER_SYMBOL * 2; // = 120 samples per bit
static constexpr size_t SAMPLES_MARGIN = SAMPLES_PER_SYMBOL / 3; // = Allow 20 sample drift static constexpr size_t SAMPLES_MARGIN = SAMPLES_PER_SYMBOL / 3; // = Allow 20 sample drift
static constexpr size_t SAMPLES_ACCUMUMLATOR = SAMPLES_PER_SYMBOL / 5; // Accumulate phase change across 12 samples static constexpr size_t SAMPLES_ACCUMULATOR = SAMPLES_PER_SYMBOL / 5; // Accumulate phase change across 12 samples
static constexpr size_t RISE_FILTER_SAMPLES = SAMPLES_PER_SYMBOL / 20; // Filter peaks of less than 3 samples static constexpr size_t RISE_FILTER_SAMPLES = SAMPLES_PER_SYMBOL / 20; // Filter peaks of less than 3 samples
static constexpr size_t CARRIER_SAMPLES_THRESHOLD = 0.080f * SAMPLE_RATE; // Carrier before frame lasts 160ms, require at least 80ms static constexpr size_t CARRIER_SAMPLES_THRESHOLD = 0.080f * SAMPLE_RATE; // Carrier before frame lasts 160ms, require at least 80ms
@@ -213,12 +213,40 @@ class EPIRBProcessor : public BasebandProcessor {
// Carrier detection counter // Carrier detection counter
uint32_t stability_counter = 0; uint32_t stability_counter = 0;
// Phase delta accumulator (6 samples) // Phase delta accumulator (12 samples)
static constexpr size_t PHASE_DELTA_ACC_SIZE = SAMPLES_ACCUMUMLATOR; static constexpr size_t PHASE_DELTA_ACC_SIZE = SAMPLES_PER_SYMBOL / 5; // 12 samples
float phase_delta_buffer[PHASE_DELTA_ACC_SIZE] = {0.0f}; float phase_delta_buffer[PHASE_DELTA_ACC_SIZE] = {0.0f};
size_t pahse_delta_index = 0; size_t phase_delta_index = 0;
float phase_delta_acc = 0.0f; float phase_delta_acc = 0.0f;
// Automatic Frequency Control (AFC)
// A residual carrier frequency offset between the tuner and the beacon shows
// up as a constant per-sample phase rotation. We measure its mean over the
// unmodulated carrier preamble and subtract it from every raw phase delta so
// the carrier-stability detection and the +/-2.2 rad data jumps stay centered.
// Current estimate (rad/sample), removed from each raw phase delta.
float freq_offset_est = 0.0f;
// Carrier-tracking loop gain. Applied per sample in IDLE so the estimate
// pulls in any offset within the discriminator's +/-SAMPLE_RATE/2 (~24 kHz)
// range *before* the carrier-detection thresholds run. First-order loop with
// time constant ~1/ALPHA samples (= 200 samples ~ 4 ms at 48 kHz). Tuned so a
// +/-5 kHz offset (0.654 rad/sample) decays the 12-sample accumulator below
// the 0.6 rad lock threshold in ~11 ms (~13 ms at 7 kHz) -- well inside the
// 160 ms preamble / 80 ms stability window, even if reception starts partway
// through the carrier -- while keeping added acquisition jitter negligible.
static constexpr float AFC_TRACK_ALPHA = 0.005f;
// AFC update gating: ignore large per-sample phase jumps (likely noise)
static constexpr float AFC_UPDATE_PHASE_MAX = 0.8f; // rad/sample
// AFC Convergence detection: track variance of AFC estimate to ensure it has stabilized
// before transitioning from IDLE to CARRIER_LOCKED state
static constexpr float AFC_CONVERGENCE_THRESHOLD = 0.001f; // Max variance for convergence
float afc_mean = 0.0f; // Running mean of AFC estimate
float afc_m2 = 0.0f; // Sum of squared differences (Welford's algorithm)
uint32_t afc_convergence_n = 0; // Sample count for AFC convergence calculation
// Running mean of the raw phase delta while a stable carrier is present.
float carrier_phase_sum = 0.0f;
uint32_t carrier_phase_n = 0;
std::array<complex16_t, 512> dst{}; std::array<complex16_t, 512> dst{};
const buffer_c16_t dst_buffer{ const buffer_c16_t dst_buffer{
dst.data(), dst.data(),
@@ -21,6 +21,7 @@
#include "doctest.h" #include "doctest.h"
#include "convert.hpp" #include "convert.hpp"
#include <cstdint>
#include <string> #include <string>
#include <string_view> #include <string_view>