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;
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 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()) {
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
auto& entry = db_->get_beacon(index);
char buffer[64];
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.
File diff suppressed because it is too large Load Diff
+323 -323
View File
@@ -1,324 +1,324 @@
/*
* Copyright (C) 2024 EPIRB Decoder Implementation
* Copyright (C) 2026 Frederic BORRY - ADRASEC 31
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#ifndef __UI_EPIRB_RX_H__
#define __UI_EPIRB_RX_H__
#include "app_settings.hpp"
#include "radio_state.hpp"
#include "ui_widget.hpp"
#include "ui_navigation.hpp"
#include "ui_receiver.hpp"
#include "ui_geomap.hpp"
// Specan is disable to keep application size below the 32k limit
// #define SPECAN
// Comment to disable timout reset on select and save approx 200 bytes of flash
#ifndef PRALINE
// Application does not fit on Praline with RESET_TIMER enabled
#define RESET_TIMER
#endif
// Comment to disable squelch control
#define SQUELCH
// Comment to disable beacon selection by encoder on detail tab
#define DETAIL_TAB_BEACON_SEL
// #define LOGGER
#ifdef SPECAN
#include "ui_spectrum.hpp"
#endif
#include "ui_tabview.hpp"
#include "ui_qrcode.hpp"
#include "event_m0.hpp"
#include "message.hpp"
#include "log_file.hpp"
#include "baseband_packet.hpp"
#include "audio.hpp"
#include "beacon.hpp"
#include "beacon_db.hpp"
#include "ui_beaconlist.hpp"
#include "resources.hpp"
namespace ui::external_app::epirb_rx {
/**
* Status of a packet
*/
enum class PacketStatus : uint8_t {
Valid = 0,
Corrected = 1,
Error = 2
};
// Position of tabs in tab view
#define EPIRB_TAB_POS_Y (UI_POS_Y(4) + 3 * 8)
// Height of tabs in tab view
#define EPIRB_TAB_HEIGHT (screen_height - EPIRB_TAB_POS_Y - UI_POS_HEIGHT(1))
#ifdef LOGGER
class EPIRBLogger {
public:
Optional<File::Error> append(const std::filesystem::path& filename) {
return log_file.append(filename);
}
void on_packet(Beacon& beacon);
private:
LogFile log_file{};
};
#endif
/**
* Dedicated TextArea component used to optimize application code size
*/
class TextArea : public Widget {
public:
TextArea(Rect parent_rect);
#ifdef RESET_TIMER
std::function<void(TextArea&)> on_select{};
bool on_key(const KeyEvent key) override;
#endif
void set_content(std::string_view value);
void paint(Painter& painter) override;
private:
std::string content{};
};
// Forward declaration
class EPIRBAppView;
/**
* View for beacon detail tab
*/
class EPIRBDetailView : public View {
public:
EPIRBDetailView(Rect parent_rect, EPIRBAppView& parent);
void set_beacon(Beacon& beacon);
#ifdef DETAIL_TAB_BEACON_SEL
bool on_encoder(EncoderEvent delta) override;
#endif
private:
TextArea text_beacon{{UI_POS_X(0), UI_POS_Y(0), UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT}};
EPIRBAppView& parent_app;
};
#define EPIRB_RX_DEFAULT_LATITUDE 43.604f
#define EPIRB_RX_DEFAULT_LONGITUDE 1.458f
/**
* View for beacon map tab
*/
class EPIRBMapView : public View {
public:
EPIRBMapView(Rect parent_rect);
void paint(Painter& painter) override;
void on_show() override;
void set_main_marker(const std::string& label, float lat, float lon);
void clear_markers();
void add_marker(GeoMarker& marker);
void hide_map(bool hide);
void repaint();
private:
GeoMap geomap{{0, 0, UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT}};
float lat_{EPIRB_RX_DEFAULT_LATITUDE};
float lon_{EPIRB_RX_DEFAULT_LONGITUDE};
bool map_hidden{true};
};
#define QR_WIDTH 126
#define QR_HEIGHT 127
/**
* Vieaw for Beacon QR tab
*/
class EPRIBQRView : public View {
public:
EPRIBQRView(Rect parent_rect);
EPRIBQRView(const EPRIBQRView&) = delete;
EPRIBQRView& operator=(const EPRIBQRView&) = delete;
void set_beacon(Beacon* beacon);
void update_qr();
void update_display();
private:
bool show_map{true};
Beacon* current_beacon{nullptr};
char qr_url[128];
OptionsField options_qr{
{UI_POS_X(5), UI_POS_Y(1)},
6,
{{"Map", 0},
{"Detail", 1}}};
QRCodeImage qr_code{
{UI_POS_MAXWIDTH - QR_WIDTH - UI_POS_X(1), UI_POS_Y(1), QR_WIDTH, QR_HEIGHT}};
TextArea text_data{{UI_POS_X(0), UI_POS_Y(1), UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT - UI_POS_Y(1)}};
};
#ifdef SPECAN
class EPIRBRxView : public spectrum::WaterfallView {
public:
EPIRBRxView(EPIRBAppView& parent, Rect parent_rect);
void on_show() override;
void on_hide() override;
private:
EPIRBAppView& app_view;
};
#endif
class EPIRBAppView final : public ui::View {
public:
EPIRBAppView(ui::NavigationView& nav);
~EPIRBAppView();
void focus() override;
void refresh();
// Message to configure rx baseband
EPIRBRXConfig epirb_rx_config_message{};
void send_config();
// Beacons database
BeaconDB beacon_db{};
// Update display when beacon selection changed0
void on_beacon_change();
std::string title() const override { return "EPIRB RX"; }
private:
uint8_t squelch{50};
// The delay between each frame
uint32_t countdown{50};
app_settings::SettingsManager settings_{
"rx_epirb",
app_settings::Mode::RX,
{
{"epirb_squelch"sv, &squelch},
{"countdown"sv, &countdown},
}};
ui::NavigationView& nav_;
#ifdef LOGGER
std::unique_ptr<EPIRBLogger> logger{};
#endif
OptionsField options_frequency{
{UI_POS_X(0), UI_POS_Y(0)},
7,
{}};
ui::RFAmpField field_rf_amp{
{UI_POS_X(8), UI_POS_Y(0)}};
ui::LNAGainField field_lna{
{UI_POS_X(10), UI_POS_Y(0)}};
ui::VGAGainField field_vga{
{UI_POS_X(13), UI_POS_Y(0)}};
ui::RSSI rssi{
{UI_POS_X(16), UI_POS_Y(0), UI_POS_WIDTH_REMAINING(22), 4}};
ui::Channel channel{
{UI_POS_X(16), UI_POS_Y(0) + 5, UI_POS_WIDTH_REMAINING(22), 4}};
// ui::Audio audio{
// {UI_POS_X(16), UI_POS_Y(0) + 10, UI_POS_WIDTH_REMAINING(22), 4}};
ui::AudioVolumeField field_volume{
{UI_POS_WIDTH_REMAINING(2), UI_POS_Y(0)}};
#ifdef SQUELCH
NumberField field_squelch{
{UI_POS_WIDTH_REMAINING(5), UI_POS_Y(0)},
2,
{0, 99},
1,
' '};
#endif
// Status display
TextArea text_status{{UI_POS_X(0), UI_POS_Y(1), UI_POS_MAXWIDTH, UI_POS_HEIGHT(3)}};
TextArea text_timeout{
{UI_POS_X(13), UI_POS_Y(1), UI_POS_WIDTH(3), UI_POS_HEIGHT(1)}};
SignalToken signal_token_tick_second{};
// Timeout string
int16_t timeout{0};
// Tab View
Rect view_rect = {0, EPIRB_TAB_POS_Y, UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT};
BeaconUIList view_list{view_rect};
EPIRBDetailView view_detail{view_rect, (*this)};
EPIRBMapView view_map{view_rect};
#ifdef SPECAN
EPIRBRxView view_rx{*this, view_rect};
#endif
EPRIBQRView view_qr{view_rect};
TabView tab_view{
{"List", Theme::getInstance()->fg_cyan->foreground, &view_list},
{"Detail", Theme::getInstance()->fg_green->foreground, &view_detail},
{"Map", Theme::getInstance()->fg_yellow->foreground, &view_map},
#ifdef SPECAN
{"RX", Theme::getInstance()->fg_orange->foreground, &view_rx},
#endif
{"QR", Theme::getInstance()->fg_orange->foreground, &view_qr}};
uint16_t beacons_received = 0;
uint16_t packets_valid = 0;
uint16_t packets_corrected = 0;
uint16_t packets_error = 0;
MessageHandlerRegistration message_handler_packet{
Message::ID::EPIRBPacket,
[this](Message* const p) { on_packet(p); }};
static void decode_packet(const baseband::Packet& packet, Beacon& beacon);
void on_packet(Message* const p);
void update_map();
void on_tick_second();
void update_display();
};
} // namespace ui::external_app::epirb_rx
/*
* Copyright (C) 2024 EPIRB Decoder Implementation
* Copyright (C) 2026 Frederic BORRY - ADRASEC 31
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#ifndef __UI_EPIRB_RX_H__
#define __UI_EPIRB_RX_H__
#include "app_settings.hpp"
#include "radio_state.hpp"
#include "ui_widget.hpp"
#include "ui_navigation.hpp"
#include "ui_receiver.hpp"
#include "ui_geomap.hpp"
// Specan is disable to keep application size below the 32k limit
// #define SPECAN
// Comment to disable timout reset on select and save approx 200 bytes of flash
#ifndef PRALINE
// Application does not fit on Praline with RESET_TIMER enabled
#define RESET_TIMER
#endif
// Comment to disable squelch control
#define SQUELCH
// Comment to disable beacon selection by encoder on detail tab
#define DETAIL_TAB_BEACON_SEL
// #define LOGGER
#ifdef SPECAN
#include "ui_spectrum.hpp"
#endif
#include "ui_tabview.hpp"
#include "ui_qrcode.hpp"
#include "event_m0.hpp"
#include "message.hpp"
#include "log_file.hpp"
#include "baseband_packet.hpp"
#include "audio.hpp"
#include "beacon.hpp"
#include "beacon_db.hpp"
#include "ui_beaconlist.hpp"
#include "resources.hpp"
namespace ui::external_app::epirb_rx {
/**
* Status of a packet
*/
enum class PacketStatus : uint8_t {
Valid = 0,
Corrected = 1,
Error = 2
};
// Position of tabs in tab view
#define EPIRB_TAB_POS_Y (UI_POS_Y(4) + 3 * 8)
// Height of tabs in tab view
#define EPIRB_TAB_HEIGHT (screen_height - EPIRB_TAB_POS_Y - UI_POS_HEIGHT(1))
#ifdef LOGGER
class EPIRBLogger {
public:
Optional<File::Error> append(const std::filesystem::path& filename) {
return log_file.append(filename);
}
void on_packet(Beacon& beacon);
private:
LogFile log_file{};
};
#endif
/**
* Dedicated TextArea component used to optimize application code size
*/
class TextArea : public Widget {
public:
TextArea(Rect parent_rect);
#ifdef RESET_TIMER
std::function<void(TextArea&)> on_select{};
bool on_key(const KeyEvent key) override;
#endif
void set_content(std::string_view value);
void paint(Painter& painter) override;
private:
std::string content{};
};
// Forward declaration
class EPIRBAppView;
/**
* View for beacon detail tab
*/
class EPIRBDetailView : public View {
public:
EPIRBDetailView(Rect parent_rect, EPIRBAppView& parent);
void set_beacon(Beacon& beacon);
#ifdef DETAIL_TAB_BEACON_SEL
bool on_encoder(EncoderEvent delta) override;
#endif
private:
TextArea text_beacon{{UI_POS_X(0), UI_POS_Y(0), UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT}};
EPIRBAppView& parent_app;
};
#define EPIRB_RX_DEFAULT_LATITUDE 43.604f
#define EPIRB_RX_DEFAULT_LONGITUDE 1.458f
/**
* View for beacon map tab
*/
class EPIRBMapView : public View {
public:
EPIRBMapView(Rect parent_rect);
void paint(Painter& painter) override;
void on_show() override;
void set_main_marker(const std::string& label, float lat, float lon);
void clear_markers();
void add_marker(GeoMarker& marker);
void hide_map(bool hide);
void repaint();
private:
GeoMap geomap{{0, 0, UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT}};
float lat_{EPIRB_RX_DEFAULT_LATITUDE};
float lon_{EPIRB_RX_DEFAULT_LONGITUDE};
bool map_hidden{true};
};
#define QR_WIDTH 126
#define QR_HEIGHT 127
/**
* Vieaw for Beacon QR tab
*/
class EPRIBQRView : public View {
public:
EPRIBQRView(Rect parent_rect);
EPRIBQRView(const EPRIBQRView&) = delete;
EPRIBQRView& operator=(const EPRIBQRView&) = delete;
void set_beacon(Beacon* beacon);
void update_qr();
void update_display();
private:
bool show_map{true};
Beacon* current_beacon{nullptr};
char qr_url[128];
OptionsField options_qr{
{UI_POS_X(5), UI_POS_Y(1)},
6,
{{"Map", 0},
{"Detail", 1}}};
QRCodeImage qr_code{
{UI_POS_MAXWIDTH - QR_WIDTH - UI_POS_X(1), UI_POS_Y(1), QR_WIDTH, QR_HEIGHT}};
TextArea text_data{{UI_POS_X(0), UI_POS_Y(1), UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT - UI_POS_Y(1)}};
};
#ifdef SPECAN
class EPIRBRxView : public spectrum::WaterfallView {
public:
EPIRBRxView(EPIRBAppView& parent, Rect parent_rect);
void on_show() override;
void on_hide() override;
private:
EPIRBAppView& app_view;
};
#endif
class EPIRBAppView final : public ui::View {
public:
EPIRBAppView(ui::NavigationView& nav);
~EPIRBAppView();
void focus() override;
void refresh();
// Message to configure rx baseband
EPIRBRXConfig epirb_rx_config_message{};
void send_config();
// Beacons database
BeaconDB beacon_db{};
// Update display when beacon selection changed0
void on_beacon_change();
std::string title() const override { return "EPIRB RX"; }
private:
uint8_t squelch{50};
// The delay between each frame
uint32_t countdown{50};
app_settings::SettingsManager settings_{
"rx_epirb",
app_settings::Mode::RX,
{
{"epirb_squelch"sv, &squelch},
{"countdown"sv, &countdown},
}};
ui::NavigationView& nav_;
#ifdef LOGGER
std::unique_ptr<EPIRBLogger> logger{};
#endif
OptionsField options_frequency{
{UI_POS_X(0), UI_POS_Y(0)},
7,
{}};
ui::RFAmpField field_rf_amp{
{UI_POS_X(8), UI_POS_Y(0)}};
ui::LNAGainField field_lna{
{UI_POS_X(10), UI_POS_Y(0)}};
ui::VGAGainField field_vga{
{UI_POS_X(13), UI_POS_Y(0)}};
ui::RSSI rssi{
{UI_POS_X(16), UI_POS_Y(0), UI_POS_WIDTH_REMAINING(22), 4}};
ui::Channel channel{
{UI_POS_X(16), UI_POS_Y(0) + 5, UI_POS_WIDTH_REMAINING(22), 4}};
// ui::Audio audio{
// {UI_POS_X(16), UI_POS_Y(0) + 10, UI_POS_WIDTH_REMAINING(22), 4}};
ui::AudioVolumeField field_volume{
{UI_POS_WIDTH_REMAINING(2), UI_POS_Y(0)}};
#ifdef SQUELCH
NumberField field_squelch{
{UI_POS_WIDTH_REMAINING(5), UI_POS_Y(0)},
2,
{0, 99},
1,
' '};
#endif
// Status display
TextArea text_status{{UI_POS_X(0), UI_POS_Y(1), UI_POS_MAXWIDTH, UI_POS_HEIGHT(3)}};
TextArea text_timeout{
{UI_POS_X(13), UI_POS_Y(1), UI_POS_WIDTH(3), UI_POS_HEIGHT(1)}};
SignalToken signal_token_tick_second{};
// Timeout string
int16_t timeout{0};
// Tab View
Rect view_rect = {0, EPIRB_TAB_POS_Y, UI_POS_MAXWIDTH, EPIRB_TAB_HEIGHT};
BeaconUIList view_list{view_rect};
EPIRBDetailView view_detail{view_rect, (*this)};
EPIRBMapView view_map{view_rect};
#ifdef SPECAN
EPIRBRxView view_rx{*this, view_rect};
#endif
EPRIBQRView view_qr{view_rect};
TabView tab_view{
{"List", Theme::getInstance()->fg_cyan->foreground, &view_list},
{"Detail", Theme::getInstance()->fg_green->foreground, &view_detail},
{"Map", Theme::getInstance()->fg_yellow->foreground, &view_map},
#ifdef SPECAN
{"RX", Theme::getInstance()->fg_orange->foreground, &view_rx},
#endif
{"QR", Theme::getInstance()->fg_orange->foreground, &view_qr}};
uint16_t beacons_received = 0;
uint16_t packets_valid = 0;
uint16_t packets_corrected = 0;
uint16_t packets_error = 0;
MessageHandlerRegistration message_handler_packet{
Message::ID::EPIRBPacket,
[this](Message* const p) { on_packet(p); }};
static void decode_packet(const baseband::Packet& packet, Beacon& beacon);
void on_packet(Message* const p);
void update_map();
void on_tick_second();
void update_display();
};
} // namespace ui::external_app::epirb_rx
#endif // __UI_EPIRB_RX_H__
+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 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()};
painter.fill_rectangle(
r0,
Theme::getInstance()->fg_blue->foreground);
bar_style->foreground);
const Rect r1{r.left() + x_max, r.top(), 1, r.height()};
painter.fill_rectangle(
+8
View File
@@ -44,8 +44,16 @@ class Channel : public Widget {
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:
int32_t max_db_;
int32_t overload_threshold_{1};
MessageHandlerRegistration message_handler_stats{
Message::ID::ChannelStatistics,
+338 -272
View File
@@ -1,272 +1,338 @@
/*
* Copyright (C) 2024 EPIRB Receiver Implementation
* Copyright (C) 2026 Frederic BORRY - ADRASEC 31
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#include "proc_epirb.hpp"
#include "portapack_shared_memory.hpp"
#include "dsp_fir_taps.hpp"
#include "audio_dma.hpp"
#include "event_m4.hpp"
#include <ch.h>
EPIRBProcessor::EPIRBProcessor() {
// Configure the decimation filters for narrowband EPIRB signal
decim_0.configure(taps_11k0_decim_0.taps);
decim_1.configure(taps_11k0_decim_1.taps);
// Configure channel filter for audio filtering
channel_filter.configure(taps_11k0_channel.taps, 2);
// Configure demodulation for audio output
demod.configure(SAMPLE_RATE, 5000);
// Configure audio output (+squelch level)
configure_audio();
#ifdef SPECAN
channel_spectrum.set_decimation_factor(1);
#endif
baseband_thread.start();
}
void EPIRBProcessor::configure_audio() {
// UI sends an squelch value ranging from 0 to 99, 0 disables squelch, dividing UI value by 40 gives a valid UI threashold around 50
audio_output.configure(audio_24k_hpf_300hz_config, audio_24k_deemph_300_6_config, ((float)squelch_level) / 40.0f);
}
float EPIRBProcessor::get_phase_diff(const complex16_t& sample0, const complex16_t& sample1) {
// Calculate the phase difference between two samples
float dI = sample1.real() * sample0.real() + sample1.imag() * sample0.imag();
float dQ = sample1.imag() * sample0.real() - sample1.real() * sample0.imag();
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;
}
bool EPIRBProcessor::filtered_rise_detect(bool condition) {
bool result = false;
if (condition) {
// If rise condition is matched, filter peaks that last less than 3 samples
rise_detection_count++;
if (rise_detection_count >= RISE_FILTER_SAMPLES) {
result = true;
rise_detection_count = 0;
}
} else {
rise_detection_count = 0;
}
return result;
}
void EPIRBProcessor::execute(const buffer_c8_t& buffer) {
// First decimation stage: 3.072000 MHz / 8 -> 384 kHz
const auto decim_0_out = decim_0.execute(buffer, dst_buffer);
// Second decimation stage: 384 kHz / 8 -> 48 kHz
const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer);
// We use decim1 output as decimator output
const auto decimator_out = decim_1_out;
#ifdef SPECAN
// Feed IQ data into spectrum collector for the RF waterfall.
if (spectrum_on) channel_spectrum.feed(decim_1_out, -5500, 5500, 3400);
#endif
feed_channel_stats(decimator_out);
// if (audio_on) {
// Channel filter for audio out
const auto channel_out = channel_filter.execute(decim_1_out, dst_buffer);
auto audio = demod.execute(channel_out, audio_buffer);
audio_output.write(audio);
//}
// Process each decimated sample through state machine
for (size_t i = 0; i < decimator_out.count; i++) {
// Track sample count since last symbol and since begining of the frame
sample_count++;
frame_sample_count++;
// Compute phase delta since last sample
float phase_delta = get_phase_diff(last_sample, decimator_out.p[i]);
last_sample = decimator_out.p[i];
// 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_buffer[pahse_delta_index] = phase_delta;
phase_delta_acc += phase_delta_buffer[pahse_delta_index];
pahse_delta_index = (pahse_delta_index + 1) % PHASE_DELTA_ACC_SIZE;
// Use accumulated delta
phase_delta = phase_delta_acc;
// State machine for COSPAS frame detection
switch (current_state) {
case IDLE:
// We are waiting for a 160ms empty carrier => phase shouls be stable during this period
// We accept a 0.6 phase shift since phase may drift durring carrier if carrier frequency is not alligned with tuner frequency
if (filtered_rise_detect(phase_delta >= 0.6f)) {
stability_counter = 0;
} else {
stability_counter++;
if (stability_counter > CARRIER_SAMPLES_THRESHOLD) {
// Carrier has been stable long enought, go to locked state
current_state = CARRIER_LOCKED;
frame_sample_count = 0;
}
}
break;
case CARRIER_LOCKED:
// Carrier is locked, we now wait for a phase 1.1 rad phase jump corresponding to the befining of the frame
// Let's use a 0.7 phase jump threshold
if (filtered_rise_detect(phase_delta >= 0.7f)) {
// Jump detected, frame starts now
frame_sample_count = 0;
// Go to data sync state
current_state = DATA_SYNC;
// Frame should always start with a positive phase shift
last_phase_positive = true;
// And a 1 value
last_bit = true;
} else if (frame_sample_count > CARRIER_MAX_SAMPLES) {
// We missed sync pattern
frame_end();
}
break;
case DATA_SYNC: {
float abs_phase_delta = fabsf(phase_delta);
if (abs_phase_delta >= 1.6f) {
// Phase should jump from 1.1 rad to -1.1 rad or the other way around
// Absolute phase jump is expected to be 2.2 rad
// Phase jump is either positive or negative
bool phase_positive = (phase_delta >= 0.0f);
if (phase_positive != last_phase_positive) {
// Phase jumped to the opposit direction of last jump
last_phase_positive = phase_positive;
bool cur_bit;
// Phase change => how long since last change ?
if ((frame_sample_count >= (SAMPLES_PER_SYMBOL - SAMPLES_MARGIN)) && (frame_sample_count <= (SAMPLES_PER_SYMBOL + SAMPLES_MARGIN))) {
// Frame start
if (!phase_positive) {
// Symbol detection is made on falling edge
cur_bit = true;
} else {
// Ignore rising edge
continue;
}
} else if (sample_count > (SAMPLES_PER_SYMBOL * 2 + SAMPLES_MARGIN)) {
// We missed something...
// Let's keep same value for current bit
cur_bit = last_bit;
} else if (sample_count >= (SAMPLES_PER_SYMBOL * 2 - SAMPLES_MARGIN)) {
// 2 symbols since last change => bit value changes
cur_bit = !last_bit;
} 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
if ((phase_positive && last_bit) || (!phase_positive && !last_bit)) {
sample_count = 0;
// Ignore rising edge if current value is 1 and falling edge if current value is 0 and move to next symbol
continue;
}
// Same value on falling/rising edge
cur_bit = last_bit;
} else {
// Filter the rest
continue;
}
// Store new bit and move to next symbol
sample_count = 0;
packet_builder.execute(cur_bit);
last_bit = cur_bit;
}
}
if (frame_sample_count > FRAME_MAX_SAMPLES) {
// End of frame
current_state = POST_FRAME;
packet_builder.flush();
}
} break;
case POST_FRAME:
if (frame_sample_count > CARRIER_MAX_SAMPLES) {
// End of carrier
frame_end();
}
default:
break;
}
}
}
void EPIRBProcessor::frame_end() {
sample_count = 0;
frame_sample_count = 0;
stability_counter = 0;
last_phase_positive = false;
last_bit = false;
current_state = IDLE;
packet_builder.reset_state();
}
void EPIRBProcessor::payload_handler(const baseband::Packet& packet) {
// EPIRB packet received: create and send EPIRB packet message to application layer
const EPIRBPacketMessage message{packet};
shared_memory.application_queue.push(message);
}
void EPIRBProcessor::on_message(const Message* const msg) {
// Configure the processor
switch (msg->id) {
#ifdef SPECAN
case Message::ID::UpdateSpectrum:
case Message::ID::SpectrumStreamingConfig:
channel_spectrum.on_message(msg);
break;
#endif
case Message::ID::EPIRBRXConfig: {
const EPIRBRXConfig message = *reinterpret_cast<const EPIRBRXConfig*>(msg);
// audio_on = message.audio_on;
#ifdef SPECAN
spectrum_on = message.spectrum_on;
#endif
if (message.squelch != squelch_level) {
// Update squelch config
squelch_level = message.squelch;
configure_audio();
}
} break;
default:
break;
}
}
int main() {
audio::dma::init_audio_out();
EventDispatcher event_dispatcher{std::make_unique<EPIRBProcessor>()};
event_dispatcher.run();
return 0;
}
/*
* Copyright (C) 2024 EPIRB Receiver Implementation
* Copyright (C) 2026 Frederic BORRY - ADRASEC 31
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#include "proc_epirb.hpp"
#include "portapack_shared_memory.hpp"
#include "dsp_fir_taps.hpp"
#include "audio_dma.hpp"
#include "event_m4.hpp"
#include <algorithm>
#include <cmath>
#include <ch.h>
EPIRBProcessor::EPIRBProcessor() {
// Configure the decimation filters for narrowband EPIRB signal
decim_0.configure(taps_11k0_decim_0.taps);
decim_1.configure(taps_11k0_decim_1.taps);
// Configure channel filter for audio filtering
channel_filter.configure(taps_11k0_channel.taps, 2);
// Configure demodulation for audio output
demod.configure(SAMPLE_RATE, 5000);
// Configure audio output (+squelch level)
configure_audio();
#ifdef SPECAN
channel_spectrum.set_decimation_factor(1);
#endif
baseband_thread.start();
}
void EPIRBProcessor::configure_audio() {
// UI sends an squelch value ranging from 0 to 99, 0 disables squelch, dividing UI value by 40 gives a valid UI threashold around 50
audio_output.configure(audio_24k_hpf_300hz_config, audio_24k_deemph_300_6_config, ((float)squelch_level) / 40.0f);
}
float EPIRBProcessor::get_phase_diff(const complex16_t& sample0, const complex16_t& sample1) {
// Calculate the phase difference between two samples
float dI = sample1.real() * sample0.real() + sample1.imag() * sample0.imag();
float dQ = sample1.imag() * sample0.real() - sample1.real() * sample0.imag();
float phase_diff = atan2f(dQ, dI);
return phase_diff;
}
bool EPIRBProcessor::filtered_rise_detect(bool condition) {
bool result = false;
if (condition) {
// If rise condition is matched, filter peaks that last less than 3 samples
rise_detection_count++;
if (rise_detection_count >= RISE_FILTER_SAMPLES) {
result = true;
rise_detection_count = 0;
}
} else {
rise_detection_count = 0;
}
return result;
}
void EPIRBProcessor::execute(const buffer_c8_t& buffer) {
// First decimation stage: 3.072000 MHz / 8 -> 384 kHz
const auto decim_0_out = decim_0.execute(buffer, dst_buffer);
// Second decimation stage: 384 kHz / 8 -> 48 kHz
const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer);
// We use decim1 output as decimator output
const auto decimator_out = decim_1_out;
#ifdef SPECAN
// Feed IQ data into spectrum collector for the RF waterfall.
if (spectrum_on) channel_spectrum.feed(decim_1_out, -5500, 5500, 3400);
#endif
feed_channel_stats(decimator_out);
// if (audio_on) {
// Channel filter for audio out
const auto channel_out = channel_filter.execute(decim_1_out, dst_buffer);
auto audio = demod.execute(channel_out, audio_buffer);
audio_output.write(audio);
//}
// Process each decimated sample through state machine
for (size_t i = 0; i < decimator_out.count; i++) {
// Track sample count since last symbol and since begining of the frame
sample_count++;
frame_sample_count++;
// Compute phase delta since last sample
float phase_delta = get_phase_diff(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
phase_delta_acc -= phase_delta_buffer[phase_delta_index];
phase_delta_buffer[phase_delta_index] = phase_delta;
phase_delta_acc += phase_delta_buffer[phase_delta_index];
phase_delta_index = (phase_delta_index + 1) % PHASE_DELTA_ACC_SIZE;
// Use accumulated delta
phase_delta = phase_delta_acc;
// State machine for COSPAS frame detection
switch (current_state) {
case IDLE: {
// Continuously pull the AFC estimate toward the mean per-sample
// rotation so the accumulator self-centers for any offset up to
// 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;
// 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 {
stability_counter++;
// Check both phase stability AND AFC convergence before transitioning
if (stability_counter > CARRIER_SAMPLES_THRESHOLD) {
float afc_variance = (afc_convergence_n > 1) ? afc_m2 / (afc_convergence_n - 1) : 0.0f;
if (afc_variance < AFC_CONVERGENCE_THRESHOLD) {
// 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;
case CARRIER_LOCKED:
// 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
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
frame_sample_count = 0;
// Go to data sync state
current_state = DATA_SYNC;
// Frame should always start with a positive phase shift
last_phase_positive = true;
// And a 1 value
last_bit = true;
} else if (frame_sample_count > CARRIER_MAX_SAMPLES) {
// We missed sync pattern
frame_end();
}
break;
case DATA_SYNC: {
float abs_phase_delta = fabsf(phase_delta);
if (abs_phase_delta >= 1.6f) {
// Phase should jump from 1.1 rad to -1.1 rad or the other way around
// Absolute phase jump is expected to be 2.2 rad
// Phase jump is either positive or negative
bool phase_positive = (phase_delta >= 0.0f);
if (phase_positive != last_phase_positive) {
// Phase jumped to the opposite direction of last jump
last_phase_positive = phase_positive;
bool cur_bit;
// Phase change => how long since last change ?
if ((frame_sample_count >= (SAMPLES_PER_SYMBOL - SAMPLES_MARGIN)) && (frame_sample_count <= (SAMPLES_PER_SYMBOL + SAMPLES_MARGIN))) {
// Frame start
if (!phase_positive) {
// Symbol detection is made on falling edge
cur_bit = true;
} else {
// Ignore rising edge
continue;
}
} else if (sample_count > (SAMPLES_PER_SYMBOL * 2 + SAMPLES_MARGIN)) {
// We missed something...
// Let's keep same value for current bit
cur_bit = last_bit;
} else if (sample_count >= (SAMPLES_PER_SYMBOL * 2 - SAMPLES_MARGIN)) {
// 2 symbols since last change => bit value changes
cur_bit = !last_bit;
} else if ((sample_count >= (SAMPLES_PER_SYMBOL - SAMPLES_MARGIN)) && (sample_count <= (SAMPLES_PER_SYMBOL + SAMPLES_MARGIN))) {
// Phase change occurred in first half bit => we keep the same value
if ((phase_positive && last_bit) || (!phase_positive && !last_bit)) {
sample_count = 0;
// Ignore rising edge if current value is 1 and falling edge if current value is 0 and move to next symbol
continue;
}
// Same value on falling/rising edge
cur_bit = last_bit;
} else {
// Filter the rest
continue;
}
// Store new bit and move to next symbol
sample_count = 0;
packet_builder.execute(cur_bit);
last_bit = cur_bit;
}
}
if (frame_sample_count > FRAME_MAX_SAMPLES) {
// End of frame
current_state = POST_FRAME;
packet_builder.flush();
}
} break;
case POST_FRAME:
if (frame_sample_count > CARRIER_MAX_SAMPLES) {
// End of carrier
frame_end();
}
default:
break;
}
}
}
void EPIRBProcessor::frame_end() {
sample_count = 0;
frame_sample_count = 0;
stability_counter = 0;
last_phase_positive = false;
last_bit = false;
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();
}
void EPIRBProcessor::payload_handler(const baseband::Packet& packet) {
// EPIRB packet received: create and send EPIRB packet message to application layer
const EPIRBPacketMessage message{packet};
shared_memory.application_queue.push(message);
}
void EPIRBProcessor::on_message(const Message* const msg) {
// Configure the processor
switch (msg->id) {
#ifdef SPECAN
case Message::ID::UpdateSpectrum:
case Message::ID::SpectrumStreamingConfig:
channel_spectrum.on_message(msg);
break;
#endif
case Message::ID::EPIRBRXConfig: {
const EPIRBRXConfig message = *reinterpret_cast<const EPIRBRXConfig*>(msg);
// audio_on = message.audio_on;
#ifdef SPECAN
spectrum_on = message.spectrum_on;
#endif
if (message.squelch != squelch_level) {
// Update squelch config
squelch_level = message.squelch;
configure_audio();
}
} break;
default:
break;
}
}
int main() {
audio::dma::init_audio_out();
EventDispatcher event_dispatcher{std::make_unique<EPIRBProcessor>()};
event_dispatcher.run();
return 0;
}
+296 -268
View File
@@ -1,268 +1,296 @@
/*
* Copyright (C) 2024 EPIRB Receiver Implementation
* Copyright (C) 2026 Frederic BORRY - ADRASEC 31
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#ifndef __PROC_EPIRB_H__
#define __PROC_EPIRB_H__
#include <cstdint>
#include <cstddef>
#include <array>
#include <complex>
#include "baseband_processor.hpp"
#include "baseband_thread.hpp"
#include "rssi_thread.hpp"
#include "channel_decimator.hpp"
#include "matched_filter.hpp"
#include "packet_builder.hpp"
#include "baseband_packet.hpp"
#include "message.hpp"
#include "buffer.hpp"
// Specan is disable to keep application size below the 32k limit
// #define SPECAN
#ifdef SPECAN
#include "spectrum_collector.hpp"
#endif
#include "audio_output.hpp"
#include "dsp_demodulate.hpp"
// Forward declarations for types only used as pointers/references
class Message;
namespace baseband {
class Packet;
}
// COSPAS / SARSAT 406 frame constants
// Size of preamble (bits)
#define COSPAS_PREAMBLE_SIZE 24
// Size of long frame (bits)
#define COSPAS_LONG_FRAME_SIZE 144
// Siz of short frame (bits)
#define COSPAS_SHORT_FRAME_SIZE 112
// Preamble for real frames
#define COSPAS_REAL_PREAMBLE 0b1111'1111'1111'1110'0010'1111
// Preable for test frames
#define COSPAS_TEST_PREAMBLE 0b1111'1111'1111'1110'1101'0000
// Dedicated EPIRB PacketBuilder
// Usees diedicated preamble detection logic to find both real and test frames
// Also as a dedicated packet size detection based on frame's size bit
class EPIRBPacketBuilder {
public:
using EPIRBHandler = void (*)(void* context, const baseband::Packet& packet);
EPIRBPacketBuilder(
void* context,
EPIRBHandler handler)
: context(context),
handler(handler) {
}
void execute(
const uint_fast8_t symbol) {
bit_history.add(symbol);
switch (state) {
case State::Preamble: {
// Detect both real and test fram preambles
bool is_real = real_sync_matcher(bit_history, packet.size());
bool is_test = test_sync_matcher(bit_history, packet.size());
if (is_real || is_test) {
// Append preamble to the begining of the packet
uint64_t preamble = is_real ? COSPAS_REAL_PREAMBLE : COSPAS_TEST_PREAMBLE;
for (int8_t i = (COSPAS_PREAMBLE_SIZE - 1); i >= 0; i--) {
packet.add((preamble >> i) & 0x1);
}
state = State::Format;
}
} break;
case State::Format:
packet.add(symbol);
// 144 bits for long frames and 112 for short frames
size = symbol ? COSPAS_LONG_FRAME_SIZE : COSPAS_SHORT_FRAME_SIZE;
state = State::Payload;
break;
case State::Payload:
packet.add(symbol);
if (packet.size() >= size) {
flush();
} else {
if (packet.size() >= packet.capacity()) {
reset_state();
}
}
break;
default:
reset_state();
break;
}
}
void flush() {
// Timestamp is not set here to save some app space (not used on ui side)
// packet.set_timestamp(Timestamp::now());
if (handler) handler(context, packet);
reset_state();
}
void reset_state() {
packet.clear();
bit_history = BitHistory();
state = State::Preamble;
}
private:
enum State {
Preamble,
Format,
Payload,
};
BitHistory bit_history{};
BitPattern real_sync_matcher{COSPAS_REAL_PREAMBLE, COSPAS_PREAMBLE_SIZE};
BitPattern test_sync_matcher{COSPAS_TEST_PREAMBLE, COSPAS_PREAMBLE_SIZE};
void* context;
EPIRBHandler handler;
uint8_t size{0};
State state{State::Preamble};
baseband::Packet packet{};
};
class EPIRBProcessor : public BasebandProcessor {
public:
EPIRBProcessor();
void execute(const buffer_c8_t& buffer) override;
void on_message(const Message* const message) override;
private:
// Baseband frequency is set to 3,072,000 samples / sec
static constexpr uint32_t BASEBAND_SAMPLE_RATE = 3072000;
static constexpr uint32_t SAMPLE_RATE = BASEBAND_SAMPLE_RATE / 8 / 8; // We use to decimators with factor 8 each
static constexpr uint32_t SYMBOL_RATE = 800; // 400 bps + Manchester (2 1/2 bits per symbol) => 800
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_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 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_MAX_SAMPLES = 0.900f * SAMPLE_RATE; // Carrier + frame lasts 160ms + 520ms + 100ms post carrier = 880ms
static constexpr size_t FRAME_MAX_SAMPLES = SAMPLES_PER_BIT * (144 * 1.1f); // Frame max length (add 1% error margin)
AudioOutput audio_output{};
// Config
uint8_t squelch_level{50};
// Audio on/off logic is disabled to save app space
// bool audio_on{true};
#ifdef SPECAN
bool spectrum_on{false};
#endif
std::array<float, 32> audio{};
const buffer_f32_t audio_buffer{
audio.data(),
audio.size()};
// Last received bit (for manchester deconding)
bool last_bit = false;
// Sample count since last symbol
uint16_t sample_count{0};
// Sample count since frame start
uint16_t frame_sample_count{0};
// True if last phase shift was positive, false otherwise
bool last_phase_positive = false;
// Counter used for peak filtering
uint16_t rise_detection_count{0};
// Frame detection state machine states
enum State { IDLE,
CARRIER_LOCKED,
DATA_SYNC,
POST_FRAME };
// Current state for frame detection state machine
State current_state = IDLE;
// Carrier detection counter
uint32_t stability_counter = 0;
// Phase delta accumulator (6 samples)
static constexpr size_t PHASE_DELTA_ACC_SIZE = SAMPLES_ACCUMUMLATOR;
float phase_delta_buffer[PHASE_DELTA_ACC_SIZE] = {0.0f};
size_t pahse_delta_index = 0;
float phase_delta_acc = 0.0f;
std::array<complex16_t, 512> dst{};
const buffer_c16_t dst_buffer{
dst.data(),
dst.size()};
// Decimation chain for 406 MHz EPIRB signal processing
dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{};
dsp::decimate::FIRC16xR16x32Decim8 decim_1{};
// Audio filtering
dsp::decimate::FIRAndDecimateComplex channel_filter{};
// Audio demodulation
dsp::demodulate::FM demod{};
#ifdef SPECAN
SpectrumCollector channel_spectrum{};
#endif
// Store last stample for phase delta calculation
complex16_t last_sample{};
// EPIRB packet structure:
// - Sync pattern: 111111111111111 (15 bits)
// - Frame sync: 000101111(real) / 011010000(test) (9 bits)
// - Data: 120 bits (long frame) / // bits (short frame)
// - BCH error correction: 10 bits
// Total: 144 bits (long frame) / 112 bits (short frame)
EPIRBPacketBuilder packet_builder{
this,
[](void* ctx, const baseband::Packet& p) {
static_cast<EPIRBProcessor*>(ctx)->payload_handler(p);
}};
void payload_handler(const baseband::Packet& packet);
// Compute phase diff between two samples
float get_phase_diff(const complex16_t& sample0, const complex16_t& sample1);
// End current frame
void frame_end();
// Rise detection with peak filtering
bool filtered_rise_detect(bool condition);
// Configure audio processing
void configure_audio();
/* NB: Threads should be the last members in the class definition. */
BasebandThread baseband_thread{
BASEBAND_SAMPLE_RATE, this, baseband::Direction::Receive, /*auto_start*/ false};
RSSIThread rssi_thread{};
};
#endif /*__PROC_EPIRB_H__*/
/*
* Copyright (C) 2024 EPIRB Receiver Implementation
* Copyright (C) 2026 Frederic BORRY - ADRASEC 31
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#ifndef __PROC_EPIRB_H__
#define __PROC_EPIRB_H__
#include <cstdint>
#include <cstddef>
#include <array>
#include <complex>
#include "baseband_processor.hpp"
#include "baseband_thread.hpp"
#include "rssi_thread.hpp"
#include "channel_decimator.hpp"
#include "matched_filter.hpp"
#include "packet_builder.hpp"
#include "baseband_packet.hpp"
#include "message.hpp"
#include "buffer.hpp"
// Specan is disable to keep application size below the 32k limit
// #define SPECAN
#ifdef SPECAN
#include "spectrum_collector.hpp"
#endif
#include "audio_output.hpp"
#include "dsp_demodulate.hpp"
// Forward declarations for types only used as pointers/references
class Message;
namespace baseband {
class Packet;
}
// COSPAS / SARSAT 406 frame constants
// Size of preamble (bits)
#define COSPAS_PREAMBLE_SIZE 24
// Size of long frame (bits)
#define COSPAS_LONG_FRAME_SIZE 144
// Size of short frame (bits)
#define COSPAS_SHORT_FRAME_SIZE 112
// Preamble for real frames
#define COSPAS_REAL_PREAMBLE 0b1111'1111'1111'1110'0010'1111
// Preamble for test frames
#define COSPAS_TEST_PREAMBLE 0b1111'1111'1111'1110'1101'0000
// Dedicated EPIRB PacketBuilder
// 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
class EPIRBPacketBuilder {
public:
using EPIRBHandler = void (*)(void* context, const baseband::Packet& packet);
EPIRBPacketBuilder(
void* context,
EPIRBHandler handler)
: context(context),
handler(handler) {
}
void execute(
const uint_fast8_t symbol) {
bit_history.add(symbol);
switch (state) {
case State::Preamble: {
// Detect both real and test fram preambles
bool is_real = real_sync_matcher(bit_history, packet.size());
bool is_test = test_sync_matcher(bit_history, packet.size());
if (is_real || is_test) {
// Append preamble to the begining of the packet
uint64_t preamble = is_real ? COSPAS_REAL_PREAMBLE : COSPAS_TEST_PREAMBLE;
for (int8_t i = (COSPAS_PREAMBLE_SIZE - 1); i >= 0; i--) {
packet.add((preamble >> i) & 0x1);
}
state = State::Format;
}
} break;
case State::Format:
packet.add(symbol);
// 144 bits for long frames and 112 for short frames
size = symbol ? COSPAS_LONG_FRAME_SIZE : COSPAS_SHORT_FRAME_SIZE;
state = State::Payload;
break;
case State::Payload:
packet.add(symbol);
if (packet.size() >= size) {
flush();
} else {
if (packet.size() >= packet.capacity()) {
reset_state();
}
}
break;
default:
reset_state();
break;
}
}
void flush() {
// Timestamp is not set here to save some app space (not used on ui side)
// packet.set_timestamp(Timestamp::now());
if (handler) handler(context, packet);
reset_state();
}
void reset_state() {
packet.clear();
bit_history = BitHistory();
state = State::Preamble;
}
private:
enum State {
Preamble,
Format,
Payload,
};
BitHistory bit_history{};
BitPattern real_sync_matcher{COSPAS_REAL_PREAMBLE, COSPAS_PREAMBLE_SIZE};
BitPattern test_sync_matcher{COSPAS_TEST_PREAMBLE, COSPAS_PREAMBLE_SIZE};
void* context;
EPIRBHandler handler;
uint8_t size{0};
State state{State::Preamble};
baseband::Packet packet{};
};
class EPIRBProcessor : public BasebandProcessor {
public:
EPIRBProcessor();
void execute(const buffer_c8_t& buffer) override;
void on_message(const Message* const message) override;
private:
// Baseband frequency is set to 3,072,000 samples / sec
static constexpr uint32_t BASEBAND_SAMPLE_RATE = 3072000;
static constexpr uint32_t SAMPLE_RATE = BASEBAND_SAMPLE_RATE / 8 / 8; // We use to decimators with factor 8 each
static constexpr uint32_t SYMBOL_RATE = 800; // 400 bps + Manchester (2 1/2 bits per symbol) => 800
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_MARGIN = SAMPLES_PER_SYMBOL / 3; // = Allow 20 sample drift
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 CARRIER_SAMPLES_THRESHOLD = 0.080f * SAMPLE_RATE; // Carrier before frame lasts 160ms, require at least 80ms
static constexpr size_t CARRIER_MAX_SAMPLES = 0.900f * SAMPLE_RATE; // Carrier + frame lasts 160ms + 520ms + 100ms post carrier = 880ms
static constexpr size_t FRAME_MAX_SAMPLES = SAMPLES_PER_BIT * (144 * 1.1f); // Frame max length (add 1% error margin)
AudioOutput audio_output{};
// Config
uint8_t squelch_level{50};
// Audio on/off logic is disabled to save app space
// bool audio_on{true};
#ifdef SPECAN
bool spectrum_on{false};
#endif
std::array<float, 32> audio{};
const buffer_f32_t audio_buffer{
audio.data(),
audio.size()};
// Last received bit (for manchester deconding)
bool last_bit = false;
// Sample count since last symbol
uint16_t sample_count{0};
// Sample count since frame start
uint16_t frame_sample_count{0};
// True if last phase shift was positive, false otherwise
bool last_phase_positive = false;
// Counter used for peak filtering
uint16_t rise_detection_count{0};
// Frame detection state machine states
enum State { IDLE,
CARRIER_LOCKED,
DATA_SYNC,
POST_FRAME };
// Current state for frame detection state machine
State current_state = IDLE;
// Carrier detection counter
uint32_t stability_counter = 0;
// Phase delta accumulator (12 samples)
static constexpr size_t PHASE_DELTA_ACC_SIZE = SAMPLES_PER_SYMBOL / 5; // 12 samples
float phase_delta_buffer[PHASE_DELTA_ACC_SIZE] = {0.0f};
size_t phase_delta_index = 0;
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{};
const buffer_c16_t dst_buffer{
dst.data(),
dst.size()};
// Decimation chain for 406 MHz EPIRB signal processing
dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{};
dsp::decimate::FIRC16xR16x32Decim8 decim_1{};
// Audio filtering
dsp::decimate::FIRAndDecimateComplex channel_filter{};
// Audio demodulation
dsp::demodulate::FM demod{};
#ifdef SPECAN
SpectrumCollector channel_spectrum{};
#endif
// Store last stample for phase delta calculation
complex16_t last_sample{};
// EPIRB packet structure:
// - Sync pattern: 111111111111111 (15 bits)
// - Frame sync: 000101111(real) / 011010000(test) (9 bits)
// - Data: 120 bits (long frame) / // bits (short frame)
// - BCH error correction: 10 bits
// Total: 144 bits (long frame) / 112 bits (short frame)
EPIRBPacketBuilder packet_builder{
this,
[](void* ctx, const baseband::Packet& p) {
static_cast<EPIRBProcessor*>(ctx)->payload_handler(p);
}};
void payload_handler(const baseband::Packet& packet);
// Compute phase diff between two samples
float get_phase_diff(const complex16_t& sample0, const complex16_t& sample1);
// End current frame
void frame_end();
// Rise detection with peak filtering
bool filtered_rise_detect(bool condition);
// Configure audio processing
void configure_audio();
/* NB: Threads should be the last members in the class definition. */
BasebandThread baseband_thread{
BASEBAND_SAMPLE_RATE, this, baseband::Direction::Receive, /*auto_start*/ false};
RSSIThread rssi_thread{};
};
#endif /*__PROC_EPIRB_H__*/
@@ -21,6 +21,7 @@
#include "doctest.h"
#include "convert.hpp"
#include <cstdint>
#include <string>
#include <string_view>