Add external 2-Tone TX/RX Apps (#3128)

This commit is contained in:
Ryan Harden
2026-04-07 12:32:41 -05:00
committed by GitHub
parent fba15c48e5
commit d5f94398ce
16 changed files with 2340 additions and 1 deletions
+5
View File
@@ -393,6 +393,11 @@ void set_moreserx_config(uint8_t mode) {
send_message(&message);
}
void set_tonedetect_config(uint8_t squelch, uint32_t ctcss_freq_x10) {
const ToneDetectConfigureMessage message{squelch, ctcss_freq_x10};
send_message(&message);
}
void set_morsetx_config(uint8_t mode, uint32_t tone, float fm_delta) {
const MorseTXConfigureMessage message{mode, tone, fm_delta};
send_message(&message);
+1
View File
@@ -108,6 +108,7 @@ void set_siggen_config(const uint32_t bw, const uint32_t shape, const uint32_t d
void set_spectrum_painter_config(const uint16_t width, const uint16_t height, bool update, int32_t bw);
void set_subghzd_config(uint8_t modulation, uint32_t sampling_rate);
void set_moreserx_config(uint8_t mode);
void set_tonedetect_config(uint8_t squelch, uint32_t ctcss_freq_x10 = 0);
void set_morsetx_config(uint8_t mode, uint32_t tone, float fm_delta);
void set_morsetx_key(bool key_down);
void set_wefax_config(uint8_t lpm, uint8_t ioc);
+10
View File
@@ -340,6 +340,14 @@ set(EXTCPPSRC
#p25_tx
external/p25_tx/main.cpp
external/p25_tx/ui_p25_tx.cpp
#two_tone_pager
external/two_tone_pager/main.cpp
external/two_tone_pager/ui_two_tone_pager.cpp
#two_tone_rx
external/two_tone_rx/main.cpp
external/two_tone_rx/ui_two_tone_rx.cpp
)
set(EXTAPPLIST
@@ -425,6 +433,8 @@ set(EXTAPPLIST
time_sink
kiss_tnc
p25_tx
two_tone_pager
two_tone_rx
)
# sdusb has type conflicts with PRALINE (HackRF Pro) - add only for non-PRALINE builds
+14
View File
@@ -106,6 +106,8 @@ MEMORY
ram_external_app_epirb_tx (rwx) : org = 0xAE010000, len = 32k
ram_external_app_fpv_detect (rwx) : org = 0xAE020000, len = 32k
ram_external_app_p25_tx (rwx) : org = 0xAE030000, len = 32k
ram_external_app_two_tone_pager (rwx) : org = 0xAE040000, len = 32k
ram_external_app_two_tone_rx (rwx) : org = 0xAE050000, len = 32k
}
SECTIONS
@@ -607,4 +609,16 @@ SECTIONS
KEEP(*(.external_app.app_p25_tx.application_information));
*(*ui*external_app*p25_tx*);
} > ram_external_app_p25_tx
.external_app_two_tone_pager : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_two_tone_pager.application_information));
*(*ui*external_app*two_tone_pager*);
} > ram_external_app_two_tone_pager
.external_app_two_tone_rx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_two_tone_rx.application_information));
*(*ui*external_app*two_tone_rx*);
} > ram_external_app_two_tone_rx
}
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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 "ui.hpp"
#include "ui_two_tone_pager.hpp"
#include "ui_navigation.hpp"
#include "external_app.hpp"
namespace ui::external_app::two_tone_pager {
void initialize_app(ui::NavigationView& nav) {
nav.push<TwoTonePagerView>();
}
} // namespace ui::external_app::two_tone_pager
extern "C" {
__attribute__((section(".external_app.app_two_tone_pager.application_information"), used)) application_information_t _application_information_two_tone_pager = {
/*.memory_location = */ (uint8_t*)0x00000000,
/*.externalAppEntry = */ ui::external_app::two_tone_pager::initialize_app,
/*.header_version = */ CURRENT_HEADER_VERSION,
/*.app_version = */ VERSION_MD5,
/*.app_name = */ "2-Tone TX",
/*.bitmap_data = */ {
// 16×16 pager icon — two-tone radio / pager device
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0xFC,
0x3F,
0xFE,
0x7F,
0x02,
0x40,
0xBA,
0x45,
0x02,
0x40,
0xFE,
0x7F,
0xFE,
0x7F,
0x92,
0x7C,
0x92,
0x7C,
0xFC,
0x3F,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
},
/*.icon_color = */ ui::Color::orange().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ -1,
// Uses the proc_tones baseband processor (same as Morse TX)
/*.m4_app_tag = portapack::spi_flash::image_tag_tones */ {'P', 'T', 'O', 'N'},
/*.m4_app_offset = */ 0x00000000, // filled at compile time
};
} // extern "C"
@@ -0,0 +1,616 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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 "ui_two_tone_pager.hpp"
#include "portapack.hpp"
#include "baseband_api.hpp"
#include "portapack_shared_memory.hpp"
#include "string_format.hpp"
#include "theme.hpp"
#include <algorithm>
#include <cstring>
using namespace portapack;
namespace ui::external_app::two_tone_pager {
// ---------------------------------------------------------------------------
// Tone tables — store only freq×10 values; names are generated at runtime
// to keep static data small and avoid const-char-pointer patching issues.
// ---------------------------------------------------------------------------
static const uint32_t MOTO_FREQS[45] = {
2885,
3047,
3217,
3396,
3586,
3786,
3998,
4221,
4457,
4705,
4968,
5246,
5539,
5848,
6174,
6519,
6883,
7268,
7674,
8102,
8555,
9032,
9537,
10073,
10642,
11225,
11247,
11534,
11852,
11885,
12178,
12514,
12555,
12858,
13258,
13576,
13950,
13996,
14768,
15579,
16430,
17325,
18262,
19245,
20275,
};
// Index 0 = None (0), indices 1-50 = standard CTCSS tones
static const uint32_t CTCSS_FREQS[51] = {
0,
670,
719,
744,
770,
797,
825,
854,
885,
915,
948,
974,
1000,
1035,
1072,
1109,
1148,
1188,
1230,
1273,
1318,
1365,
1413,
1462,
1500,
1514,
1567,
1598,
1622,
1655,
1679,
1713,
1738,
1773,
1799,
1835,
1862,
1899,
1928,
1966,
1995,
2035,
2065,
2107,
2181,
2257,
2291,
2336,
2418,
2503,
2541,
};
// Generate a display name from a freq×10 value ("288.5Hz", "None" for 0)
static std::string freq_name(uint32_t freq_x10) {
if (freq_x10 == 0) return "None";
return to_string_dec_uint(freq_x10 / 10) + "." +
to_string_dec_uint(freq_x10 % 10) + "Hz";
}
// Common timing profiles used in the field
struct TimingPreset {
uint32_t dur_a; // ms
uint32_t dur_b; // ms
uint32_t gap; // ms
};
static const TimingPreset TIMING_PRESETS[4] = {
{1000, 3000, 0}, // Moto Std
{700, 1000, 0}, // Short Alert
{2000, 1000, 0}, // Fire Std
{3000, 3000, 0}, // Long Alert
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Parse one unsigned decimal integer from *p, advancing p past the digits and
// an optional trailing comma.
static uint32_t parse_uint_field(const char*& p) {
uint32_t v = 0;
while (*p >= '0' && *p <= '9')
v = v * 10 + (*p++ - '0');
if (*p == ',')
++p;
return v;
}
// Phase-delta for the 32-bit sine-table accumulator used by proc_tones.
// delta = freq_hz * 2^32 / sample_rate
// Using freq_x10 to avoid floating-point: delta = (freq_x10 * 2^32) / (sample_rate * 10)
uint32_t TwoTonePagerView::tone_delta(uint32_t freq_x10) const {
if (freq_x10 == 0) return 0;
return static_cast<uint32_t>(
(static_cast<uint64_t>(freq_x10) << 32) /
(static_cast<uint64_t>(SAMPLE_RATE) * 10ULL));
}
uint32_t TwoTonePagerView::ms_to_samples(uint32_t ms) const {
return static_cast<uint32_t>(
(static_cast<uint64_t>(ms) * SAMPLE_RATE) / 1000ULL);
}
// ---------------------------------------------------------------------------
// Preset encode / decode
// ---------------------------------------------------------------------------
std::string TwoTonePagerView::encode_preset() const {
return to_string_dec_uint(ctcss_idx) + "," +
to_string_dec_uint(tone_a_idx) + "," +
to_string_dec_uint(tone_b_idx) + "," +
to_string_dec_uint(dur_a) + "," +
to_string_dec_uint(dur_b) + "," +
to_string_dec_uint(gap_ms) + "," +
to_string_dec_uint(custom_freq_a_hz) + "," +
to_string_dec_uint(custom_freq_b_hz);
}
void TwoTonePagerView::decode_preset(const std::string& s) {
const char* p = s.c_str();
uint32_t ci = parse_uint_field(p);
uint32_t ai = parse_uint_field(p);
uint32_t bi = parse_uint_field(p);
uint32_t da = parse_uint_field(p);
uint32_t db = parse_uint_field(p);
uint32_t gp = parse_uint_field(p);
uint32_t cfa = (*p != '\0') ? parse_uint_field(p) : 405;
uint32_t cfb = (*p != '\0') ? parse_uint_field(p) : 814;
ctcss_idx = std::min(ci, static_cast<uint32_t>(CTCSS_COUNT - 1));
tone_a_idx = std::min(ai, CUSTOM_TONE_IDX);
tone_b_idx = std::min(bi, CUSTOM_TONE_IDX);
dur_a = std::max(uint32_t{100}, std::min(da, uint32_t{9900}));
dur_b = std::max(uint32_t{100}, std::min(db, uint32_t{9900}));
gap_ms = std::min(gp, uint32_t{9900});
custom_freq_a_hz = std::max(uint32_t{100}, std::min(cfa, uint32_t{9999}));
custom_freq_b_hz = std::max(uint32_t{100}, std::min(cfb, uint32_t{9999}));
}
std::string& TwoTonePagerView::slot_ref(uint32_t slot) {
switch (slot) {
case 2:
return preset_2;
case 3:
return preset_3;
case 4:
return preset_4;
case 5:
return preset_5;
default:
return preset_1;
}
}
std::string& TwoTonePagerView::slot_name_ref(uint32_t slot) {
switch (slot) {
case 2:
return preset_name_2;
case 3:
return preset_name_3;
case 4:
return preset_name_4;
case 5:
return preset_name_5;
default:
return preset_name_1;
}
}
void TwoTonePagerView::update_slot_name_display() {
const auto& name = slot_name_ref(preset_slot);
std::string display = name.empty() ? "(none)" : name;
if (display.size() > 8) display = display.substr(0, 8);
text_slot_name.set(display);
}
void TwoTonePagerView::save_preset(uint32_t slot) {
slot_ref(slot) = encode_preset();
text_status.set("Saved to slot " + to_string_dec_uint(slot));
}
void TwoTonePagerView::load_preset(uint32_t slot) {
decode_preset(slot_ref(slot));
// Update all UI fields without firing their on_change callbacks
options_ctcss.set_selected_index(ctcss_idx, false);
options_tone_a.set_selected_index(tone_a_idx, false);
options_tone_b.set_selected_index(tone_b_idx, false);
field_dur_a.set_value(static_cast<int32_t>(dur_a), false);
field_dur_b.set_value(static_cast<int32_t>(dur_b), false);
field_gap.set_value(static_cast<int32_t>(gap_ms), false);
symfield_custom_a.set_value(custom_freq_a_hz);
symfield_custom_b.set_value(custom_freq_b_hz);
options_timing.set_selected_index(detect_timing_preset(), false);
update_tx_time();
update_slot_name_display();
text_status.set("Loaded slot " + to_string_dec_uint(slot));
}
// ---------------------------------------------------------------------------
// Timing presets
// ---------------------------------------------------------------------------
size_t TwoTonePagerView::detect_timing_preset() const {
for (size_t i = 0; i < 4; i++) {
if (TIMING_PRESETS[i].dur_a == dur_a &&
TIMING_PRESETS[i].dur_b == dur_b &&
TIMING_PRESETS[i].gap == gap_ms)
return i;
}
return 4; // "Custom"
}
void TwoTonePagerView::apply_timing_preset(size_t idx) {
if (idx >= 4) return; // "Custom" — do not override current values
dur_a = TIMING_PRESETS[idx].dur_a;
dur_b = TIMING_PRESETS[idx].dur_b;
gap_ms = TIMING_PRESETS[idx].gap;
field_dur_a.set_value(static_cast<int32_t>(dur_a), false);
field_dur_b.set_value(static_cast<int32_t>(dur_b), false);
field_gap.set_value(static_cast<int32_t>(gap_ms), false);
update_tx_time();
}
// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------
void TwoTonePagerView::update_tx_time() {
uint32_t total_ms = dur_a + gap_ms + dur_b;
std::string name_a = (tone_a_idx == CUSTOM_TONE_IDX)
? to_string_dec_uint(custom_freq_a_hz) + "Hz"
: freq_name(MOTO_FREQS[tone_a_idx]);
std::string name_b = (tone_b_idx == CUSTOM_TONE_IDX)
? to_string_dec_uint(custom_freq_b_hz) + "Hz"
: freq_name(MOTO_FREQS[tone_b_idx]);
text_time.set("TX time: " +
to_string_dec_uint(total_ms / 1000) + "." +
to_string_dec_uint((total_ms / 100) % 10) + "s" +
" A:" + name_a + " B:" + name_b);
}
// ---------------------------------------------------------------------------
// Transmission
// ---------------------------------------------------------------------------
bool TwoTonePagerView::start_tx() {
auto& td = shared_memory.bb_data.tones_data;
const uint32_t freq_a_x10 = (tone_a_idx == CUSTOM_TONE_IDX)
? custom_freq_a_hz * 10
: MOTO_FREQS[tone_a_idx];
const uint32_t freq_b_x10 = (tone_b_idx == CUSTOM_TONE_IDX)
? custom_freq_b_hz * 10
: MOTO_FREQS[tone_b_idx];
const uint32_t delta_a = tone_delta(freq_a_x10);
const uint32_t delta_b = tone_delta(freq_b_x10);
const uint32_t delta_c = tone_delta(CTCSS_FREQS[ctcss_idx]);
const uint32_t samp_a = ms_to_samples(dur_a);
const uint32_t samp_b = ms_to_samples(dur_b);
const uint32_t samp_g = ms_to_samples(gap_ms);
const bool with_ctcss = (ctcss_idx > 0);
// Clear tone defs we'll use
memset(&td, 0, sizeof(td));
uint8_t tone_count;
if (!with_ctcss) {
// Single-tone sequential mode
// tone_defs[0] = Tone A, tone_defs[1] = Tone B
td.tone_defs[0].delta = delta_a;
td.tone_defs[0].duration = samp_a;
td.tone_defs[1].delta = delta_b;
td.tone_defs[1].duration = samp_b;
if (gap_ms > 0) {
td.silence = samp_g;
td.message[0] = 0; // Tone A
td.message[1] = 255; // Silence (any index ≥ 32 triggers silence)
td.message[2] = 1; // Tone B
tone_count = 3;
} else {
td.message[0] = 0; // Tone A
td.message[1] = 1; // Tone B
tone_count = 2;
}
} else {
// Dual-tone mode: CTCSS mixed simultaneously with pager tones.
//
// proc_tones dual-tone indexing for digit n:
// main delta = tone_deltas[n * 2] = tone_defs[n*2].delta
// sub delta = tone_deltas[n*2 + 1] = tone_defs[n*2+1].delta
// duration = tone_durations[n] = tone_defs[n].duration
//
// digit 0 → Tone A (main) + CTCSS (sub), duration from tone_defs[0]
// digit 1 → Tone B (main) + CTCSS (sub), duration from tone_defs[1]
td.tone_defs[0].delta = delta_a; // digit 0 main
td.tone_defs[0].duration = samp_a;
td.tone_defs[1].delta = delta_c; // digit 0 sub (CTCSS); also digit 1 duration source
td.tone_defs[1].duration = samp_b;
td.tone_defs[2].delta = delta_b; // digit 1 main (Tone B)
td.tone_defs[2].duration = 0; // duration for digit 1 comes from tone_defs[1]
td.tone_defs[3].delta = delta_c; // digit 1 sub (CTCSS)
td.tone_defs[3].duration = 0;
if (gap_ms > 0) {
td.silence = samp_g;
td.message[0] = 0;
td.message[1] = 255;
td.message[2] = 1;
tone_count = 3;
} else {
td.message[0] = 0;
td.message[1] = 1;
tone_count = 2;
}
}
progressbar.set_max(tone_count);
progressbar.set_value(0);
transmitter_model.set_baseband_bandwidth(1'750'000);
transmitter_model.enable();
baseband::set_tones_config(
transmitter_model.channel_bandwidth(),
0, // no pre-silence
tone_count,
with_ctcss, // dual_tone flag
false); // no audio monitor output
return true;
}
void TwoTonePagerView::stop_tx() {
transmitter_model.disable();
baseband::kill_tone();
tx_view.set_transmitting(false);
text_status.set("Stopped.");
}
void TwoTonePagerView::on_tx_progress(uint32_t progress, bool done) {
if (done) {
transmitter_model.disable();
progressbar.set_value(0);
tx_view.set_transmitting(false);
text_status.set("Done.");
} else {
progressbar.set_value(progress);
}
}
// ---------------------------------------------------------------------------
// View lifecycle
// ---------------------------------------------------------------------------
void TwoTonePagerView::focus() {
options_tone_a.focus();
}
TwoTonePagerView::~TwoTonePagerView() {
transmitter_model.disable();
baseband::shutdown();
}
TwoTonePagerView::TwoTonePagerView(NavigationView& nav)
: nav_(nav) {
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
// Clamp restored settings to valid ranges before touching UI
ctcss_idx = std::min(ctcss_idx, static_cast<uint32_t>(CTCSS_COUNT - 1));
tone_a_idx = std::min(tone_a_idx, CUSTOM_TONE_IDX);
tone_b_idx = std::min(tone_b_idx, CUSTOM_TONE_IDX);
dur_a = std::max(uint32_t{100}, std::min(dur_a, uint32_t{9900}));
dur_b = std::max(uint32_t{100}, std::min(dur_b, uint32_t{9900}));
gap_ms = std::min(gap_ms, uint32_t{9900});
preset_slot = std::max(uint32_t{1}, std::min(preset_slot, uint32_t{5}));
custom_freq_a_hz = std::max(uint32_t{100}, std::min(custom_freq_a_hz, uint32_t{9999}));
custom_freq_b_hz = std::max(uint32_t{100}, std::min(custom_freq_b_hz, uint32_t{9999}));
// Build CTCSS options list
{
std::vector<std::pair<std::string, int32_t>> opts;
opts.reserve(CTCSS_COUNT);
for (size_t i = 0; i < CTCSS_COUNT; i++)
opts.push_back({freq_name(CTCSS_FREQS[i]), static_cast<int32_t>(i)});
options_ctcss.set_options(std::move(opts));
}
// Build Motorola tone options (same pool for both A and B); "Custom" appended
{
std::vector<std::pair<std::string, int32_t>> opts;
opts.reserve(MOTO_TONE_COUNT + 1);
for (size_t i = 0; i < MOTO_TONE_COUNT; i++)
opts.push_back({freq_name(MOTO_FREQS[i]), static_cast<int32_t>(i)});
opts.push_back({"Custom", static_cast<int32_t>(CUSTOM_TONE_IDX)});
options_tone_a.set_options(opts);
options_tone_b.set_options(std::move(opts));
}
add_children({&labels,
&options_ctcss,
&options_tone_a,
&options_tone_b,
&symfield_custom_a,
&symfield_custom_b,
&field_dur_a,
&field_dur_b,
&field_gap,
&options_timing,
&field_slot,
&button_save,
&button_load,
&text_slot_name,
&text_time,
&text_status,
&progressbar,
&tx_view});
// Restore saved indices into the options / number fields (no callbacks)
options_ctcss.set_selected_index(ctcss_idx, false);
options_tone_a.set_selected_index(tone_a_idx, false);
options_tone_b.set_selected_index(tone_b_idx, false);
symfield_custom_a.set_value(custom_freq_a_hz);
symfield_custom_b.set_value(custom_freq_b_hz);
field_dur_a.set_value(static_cast<int32_t>(dur_a), false);
field_dur_b.set_value(static_cast<int32_t>(dur_b), false);
field_gap.set_value(static_cast<int32_t>(gap_ms), false);
options_timing.set_selected_index(detect_timing_preset(), false);
field_slot.set_value(static_cast<int32_t>(preset_slot), false);
update_tx_time();
update_slot_name_display();
// --- Callbacks ---
options_ctcss.on_change = [this](size_t i, int32_t) {
ctcss_idx = static_cast<uint32_t>(i);
};
options_tone_a.on_change = [this](size_t i, int32_t) {
tone_a_idx = static_cast<uint32_t>(i);
update_tx_time();
};
options_tone_b.on_change = [this](size_t i, int32_t) {
tone_b_idx = static_cast<uint32_t>(i);
update_tx_time();
};
symfield_custom_a.on_change = [this](SymField&) {
uint32_t v = static_cast<uint32_t>(symfield_custom_a.to_integer());
custom_freq_a_hz = std::max(uint32_t{100}, std::min(v, uint32_t{9999}));
update_tx_time();
};
symfield_custom_b.on_change = [this](SymField&) {
uint32_t v = static_cast<uint32_t>(symfield_custom_b.to_integer());
custom_freq_b_hz = std::max(uint32_t{100}, std::min(v, uint32_t{9999}));
update_tx_time();
};
field_dur_a.on_change = [this](int32_t v) {
dur_a = static_cast<uint32_t>(v);
options_timing.set_selected_index(detect_timing_preset(), false);
update_tx_time();
};
field_dur_b.on_change = [this](int32_t v) {
dur_b = static_cast<uint32_t>(v);
options_timing.set_selected_index(detect_timing_preset(), false);
update_tx_time();
};
field_gap.on_change = [this](int32_t v) {
gap_ms = static_cast<uint32_t>(v);
options_timing.set_selected_index(detect_timing_preset(), false);
update_tx_time();
};
options_timing.on_change = [this](size_t i, int32_t) {
apply_timing_preset(i);
};
field_slot.on_change = [this](int32_t v) {
preset_slot = static_cast<uint32_t>(v);
update_slot_name_display();
};
button_save.on_select = [this](Button&) {
text_prompt(nav_, slot_name_ref(preset_slot), 12, ENTER_KEYBOARD_MODE_ALPHA,
[this](std::string&) {
save_preset(preset_slot);
update_slot_name_display();
});
};
button_load.on_select = [this](Button&) {
load_preset(preset_slot);
};
tx_view.on_edit_frequency = [this, &nav]() {
auto new_view = nav.push<FrequencyKeypadView>(transmitter_model.target_frequency());
new_view->on_changed = [this](rf::Frequency f) {
transmitter_model.set_target_frequency(f);
};
};
tx_view.on_start = [this]() {
if (start_tx()) {
tx_view.set_transmitting(true);
text_status.set("Transmitting...");
}
};
tx_view.on_stop = [this]() {
stop_tx();
};
}
} // namespace ui::external_app::two_tone_pager
@@ -0,0 +1,250 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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_TWO_TONE_PAGER_H__
#define __UI_TWO_TONE_PAGER_H__
#include "ui.hpp"
#include "ui_widget.hpp"
#include "ui_navigation.hpp"
#include "ui_transmitter.hpp"
#include "ui_textentry.hpp"
#include "app_settings.hpp"
#include "radio_state.hpp"
#include "portapack.hpp"
#include "message.hpp"
#include "string_format.hpp"
namespace ui::external_app::two_tone_pager {
class TwoTonePagerView : public View {
public:
TwoTonePagerView(NavigationView& nav);
~TwoTonePagerView();
TwoTonePagerView(const TwoTonePagerView&) = delete;
TwoTonePagerView(TwoTonePagerView&&) = delete;
TwoTonePagerView& operator=(const TwoTonePagerView&) = delete;
TwoTonePagerView& operator=(TwoTonePagerView&&) = delete;
void focus() override;
std::string title() const override { return "2-Tone TX"; };
private:
NavigationView& nav_;
static constexpr uint32_t SAMPLE_RATE = 1536000;
static constexpr size_t MOTO_TONE_COUNT = 45;
static constexpr size_t CTCSS_COUNT = 51;
static constexpr uint32_t CUSTOM_TONE_IDX = MOTO_TONE_COUNT; // sentinel: one past the table, use custom Hz field
TxRadioState radio_state_{
154280000ULL, // 154.280 MHz — common VHF paging frequency
1750000,
SAMPLE_RATE};
// Persisted settings
uint32_t ctcss_idx{0};
uint32_t tone_a_idx{8}; // 405.3 Hz
uint32_t tone_b_idx{24}; // 813.9 Hz
uint32_t dur_a{1000};
uint32_t dur_b{3000};
uint32_t gap_ms{0};
uint32_t preset_slot{1};
uint32_t custom_freq_a_hz{405}; // used when tone_a_idx == CUSTOM_TONE_IDX
uint32_t custom_freq_b_hz{814};
// Five preset slots — encoded tone/timing data and a user-chosen display name
std::string preset_1{"0,8,24,1000,3000,0"};
std::string preset_2{"0,8,24,1000,3000,0"};
std::string preset_3{"0,8,24,1000,3000,0"};
std::string preset_4{"0,8,24,1000,3000,0"};
std::string preset_5{"0,8,24,1000,3000,0"};
std::string preset_name_1{""};
std::string preset_name_2{""};
std::string preset_name_3{""};
std::string preset_name_4{""};
std::string preset_name_5{""};
app_settings::SettingsManager settings_{
"tx_twotone",
app_settings::Mode::TX,
{
{"ctcss"sv, &ctcss_idx},
{"tone_a"sv, &tone_a_idx},
{"tone_b"sv, &tone_b_idx},
{"dur_a"sv, &dur_a},
{"dur_b"sv, &dur_b},
{"gap"sv, &gap_ms},
{"slot"sv, &preset_slot},
{"custom_a"sv, &custom_freq_a_hz},
{"custom_b"sv, &custom_freq_b_hz},
{"preset1"sv, &preset_1},
{"preset2"sv, &preset_2},
{"preset3"sv, &preset_3},
{"preset4"sv, &preset_4},
{"preset5"sv, &preset_5},
{"pname1"sv, &preset_name_1},
{"pname2"sv, &preset_name_2},
{"pname3"sv, &preset_name_3},
{"pname4"sv, &preset_name_4},
{"pname5"sv, &preset_name_5},
}};
bool start_tx();
void stop_tx();
void on_tx_progress(uint32_t progress, bool done);
void apply_timing_preset(size_t idx);
void save_preset(uint32_t slot);
void load_preset(uint32_t slot);
std::string encode_preset() const;
void decode_preset(const std::string& s);
std::string& slot_ref(uint32_t slot);
std::string& slot_name_ref(uint32_t slot);
uint32_t tone_delta(uint32_t freq_x10) const;
uint32_t ms_to_samples(uint32_t ms) const;
size_t detect_timing_preset() const;
void update_tx_time();
void update_slot_name_display();
// --- Widgets ---
Labels labels{
{{0 * 8, 1 * 16}, "CTCSS:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 2 * 16}, "A:", Theme::getInstance()->fg_light->foreground},
{{13 * 8, 2 * 16}, "B:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 3 * 16}, "ADur:", Theme::getInstance()->fg_light->foreground},
{{9 * 8, 3 * 16}, "ms", Theme::getInstance()->fg_light->foreground},
{{12 * 8, 3 * 16}, "BDur:", Theme::getInstance()->fg_light->foreground},
{{21 * 8, 3 * 16}, "ms", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 4 * 16}, "Gap: ", Theme::getInstance()->fg_light->foreground},
{{9 * 8, 4 * 16}, "ms", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 5 * 16}, "Timing:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 6 * 16}, "Slot: ", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 9 * 16}, "AHz:", Theme::getInstance()->fg_light->foreground},
{{8 * 8, 9 * 16}, "Hz", Theme::getInstance()->fg_light->foreground},
{{13 * 8, 9 * 16}, "BHz:", Theme::getInstance()->fg_light->foreground},
{{21 * 8, 9 * 16}, "Hz", Theme::getInstance()->fg_light->foreground},
};
OptionsField options_ctcss{
{7 * 8, 1 * 16},
9,
{}};
OptionsField options_tone_a{
{3 * 8, 2 * 16},
9,
{}};
OptionsField options_tone_b{
{16 * 8, 2 * 16},
9,
{}};
NumberField field_dur_a{
{5 * 8, 3 * 16},
4,
{100, 9900},
100,
' '};
NumberField field_dur_b{
{17 * 8, 3 * 16},
4,
{100, 9900},
100,
' '};
// Digit-by-digit entry for custom tone frequency (integer Hz, 4 slots, 01009999)
SymField symfield_custom_a{
{4 * 8, 9 * 16},
4,
SymField::Type::Dec};
SymField symfield_custom_b{
{17 * 8, 9 * 16},
4,
SymField::Type::Dec};
NumberField field_gap{
{5 * 8, 4 * 16},
4,
{0, 9900},
50,
' '};
OptionsField options_timing{
{8 * 8, 5 * 16},
12,
{{"Moto Std", 0},
{"Short Alert", 1},
{"Fire Std", 2},
{"Long Alert", 3},
{"Custom", 4}}};
NumberField field_slot{
{7 * 8, 6 * 16},
1,
{1, 5},
1,
' '};
Button button_save{
{9 * 8, 6 * 16, 6 * 8, 20},
"Save"};
Button button_load{
{16 * 8, 6 * 16, 6 * 8, 20},
"Load"};
// Shows the name of the currently selected preset slot (up to 8 visible chars)
Text text_slot_name{
{22 * 8, 6 * 16, 8 * 8, 16},
""};
Text text_time{
{0, 7 * 16, 30 * 8, 16},
""};
Text text_status{
{0, 8 * 16, 30 * 8, 16},
""};
ProgressBar progressbar{
{2 * 8, 14 * 16, UI_POS_WIDTH_REMAINING(4), 16}};
TransmitterView tx_view{
(int16_t)UI_POS_Y_BOTTOM(4),
10000,
9};
MessageHandlerRegistration message_handler_tx_progress{
Message::ID::TXProgress,
[this](const Message* const p) {
const auto msg = *reinterpret_cast<const TXProgressMessage*>(p);
this->on_tx_progress(msg.progress, msg.done);
}};
};
} // namespace ui::external_app::two_tone_pager
#endif /* __UI_TWO_TONE_PAGER_H__ */
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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 "ui.hpp"
#include "ui_two_tone_rx.hpp"
#include "ui_navigation.hpp"
#include "external_app.hpp"
namespace ui::external_app::two_tone_rx {
void initialize_app(ui::NavigationView& nav) {
nav.push<TwoToneRxView>();
}
} // namespace ui::external_app::two_tone_rx
extern "C" {
__attribute__((section(".external_app.app_two_tone_rx.application_information"), used)) application_information_t _application_information_two_tone_rx = {
/*.memory_location = */ (uint8_t*)0x00000000,
/*.externalAppEntry = */ ui::external_app::two_tone_rx::initialize_app,
/*.header_version = */ CURRENT_HEADER_VERSION,
/*.app_version = */ VERSION_MD5,
/*.app_name = */ "2-Tone RX",
/*.bitmap_data = */ {
// 16×16 icon — pager device with receive arrow
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0xFC,
0x3F,
0xFE,
0x7F,
0x02,
0x40,
0xBA,
0x5D,
0x02,
0x40,
0xFE,
0x7F,
0xFE,
0x7F,
0x12,
0x48,
0x12,
0x48,
0xFC,
0x3F,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
},
/*.icon_color = */ ui::Color::yellow().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
// Uses the proc_tonedetect baseband processor
/*.m4_app_tag = portapack::spi_flash::image_tag_tonedetect */ {'P', 'T', 'N', 'E'},
/*.m4_app_offset = */ 0x00000000, // filled at compile time
};
} // extern "C"
@@ -0,0 +1,599 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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 "ui_two_tone_rx.hpp"
#include "audio.hpp"
#include "baseband_api.hpp"
#include "portapack.hpp"
#include "string_format.hpp"
using namespace portapack;
namespace ui::external_app::two_tone_rx {
static constexpr uint32_t DETECT_WINDOW_MS = 40;
static constexpr Coord screen_width_px = 240;
static constexpr Coord screen_height_px = 320;
static constexpr Coord tone_log_top = 4 * 16;
static constexpr Coord waterfall_top = 13 * 16;
static constexpr Coord waterfall_height = 6 * 16;
static constexpr Rect tone_log_rect{0, tone_log_top, 30 * 8, waterfall_top - tone_log_top};
// ---------------------------------------------------------------------------
// Tone tables
// ---------------------------------------------------------------------------
static const uint32_t MOTO_FREQS[45] = {
2885,
3047,
3217,
3396,
3586,
3786,
3998,
4221,
4457,
4705,
4968,
5246,
5539,
5848,
6174,
6519,
6883,
7268,
7674,
8102,
8555,
9032,
9537,
10073,
10642,
11225,
11247,
11534,
11852,
11885,
12178,
12514,
12555,
12858,
13258,
13576,
13950,
13996,
14768,
15579,
16430,
17325,
18262,
19245,
20275,
};
// Index 0 = None (0), indices 150 = standard CTCSS tones (freq × 10)
static const uint32_t CTCSS_FREQS[51] = {
0,
670,
719,
744,
770,
797,
825,
854,
885,
915,
948,
974,
1000,
1035,
1072,
1109,
1148,
1188,
1230,
1273,
1318,
1365,
1413,
1462,
1500,
1514,
1567,
1598,
1622,
1655,
1679,
1713,
1738,
1773,
1799,
1835,
1862,
1899,
1928,
1966,
1995,
2035,
2065,
2107,
2181,
2257,
2291,
2336,
2418,
2503,
2541,
};
static std::string ctcss_name(uint32_t freq_x10) {
if (freq_x10 == 0) return "None";
return to_string_dec_uint(freq_x10 / 10) + "." +
to_string_dec_uint(freq_x10 % 10) + "Hz";
}
// ---------------------------------------------------------------------------
// MOTO table helpers
// ---------------------------------------------------------------------------
static constexpr uint32_t MOTO_NONE = 255;
static constexpr uint32_t MOTO_TRANSITION_SNAP_HZ = 30; // stricter live transition tolerance
static constexpr uint32_t MOTO_FINAL_MATCH_HZ = 60; // looser phase-end/logging tolerance
static constexpr uint32_t MOTO_TRANSITION_DELTA_HZ = 25; // raw shift needed to confirm close-in A→B handoff
static uint32_t abs_diff_u32(uint32_t a, uint32_t b) {
return (a > b) ? (a - b) : (b - a);
}
// Return the nearest MOTO table index within match_hz, or MOTO_NONE.
static uint32_t moto_index(uint32_t freq_hz, uint32_t match_hz = MOTO_TRANSITION_SNAP_HZ) {
if (freq_hz == 0) return MOTO_NONE;
uint32_t best_idx = MOTO_NONE;
uint32_t best_diff = match_hz + 1;
for (size_t i = 0; i < 45; i++) {
const uint32_t hz = MOTO_FREQS[i] / 10;
const uint32_t diff = (freq_hz > hz) ? (freq_hz - hz) : (hz - freq_hz);
if (diff < best_diff) {
best_diff = diff;
best_idx = i;
}
}
return best_idx;
}
// Format a detected tone for display, snapping to nearest MOTO table entry.
static std::string format_tone(uint32_t freq_hz, uint32_t duration_ms) {
const uint32_t idx = moto_index(freq_hz, MOTO_FINAL_MATCH_HZ);
std::string freq_str;
if (idx != MOTO_NONE) {
const uint32_t matched_x10 = MOTO_FREQS[idx];
freq_str = to_string_dec_uint(matched_x10 / 10) + "." +
to_string_dec_uint(matched_x10 % 10) + "Hz";
} else {
freq_str = to_string_dec_uint(freq_hz) + "Hz";
}
return freq_str + " " + to_string_dec_uint(duration_ms) + "ms";
}
// ---------------------------------------------------------------------------
// Waterfall rect
// ---------------------------------------------------------------------------
static constexpr ui::Rect waterfall_rect{0, waterfall_top, screen_width_px, waterfall_height};
// ---------------------------------------------------------------------------
// TwoToneRxView
// ---------------------------------------------------------------------------
void TwoToneRxView::focus() {
button_startstop.focus();
}
void TwoToneRxView::on_hide() {
if (running_) stop_rx();
}
TwoToneRxView::TwoToneRxView(NavigationView& nav)
: nav_{nav} {
prior_antenna_bias_ = get_antenna_bias();
debug_file_.append(u"DEBUG/TWOTONERX.TXT");
debug_file_.write_entry("==== two_tone_rx session start ====");
add_children({
&field_frequency,
&field_rf_amp,
&field_lna,
&field_vga,
&rssi,
&field_volume,
&labels,
&options_ctcss,
&field_squelch,
&button_startstop,
&button_clear,
&text_status,
&check_bias_t,
&tone_log_view,
});
// Populate CTCSS options
using opt_t = std::pair<std::string, int32_t>;
options_ctcss.set_options([&]() {
std::vector<opt_t> opts;
opts.reserve(51);
for (size_t i = 0; i < 51; i++)
opts.push_back({ctcss_name(CTCSS_FREQS[i]), (int32_t)i});
return opts;
}());
options_ctcss.set_by_value((int32_t)ctcss_idx);
options_ctcss.on_change = [this](size_t, int32_t v) {
ctcss_idx = (uint32_t)v;
if (running_) baseband::set_tonedetect_config((uint8_t)squelch_val, CTCSS_FREQS[ctcss_idx]);
};
field_squelch.set_value((int32_t)squelch_val);
field_squelch.on_change = [this](int32_t v) {
squelch_val = (uint32_t)v;
if (running_) baseband::set_tonedetect_config((uint8_t)squelch_val, CTCSS_FREQS[ctcss_idx]);
};
button_startstop.on_select = [this](Button&) {
if (running_)
stop_rx();
else
start_rx();
};
button_clear.on_select = [this](Button&) {
tone_log_entries_.clear();
tone_log_view.set_dirty();
text_status.set("");
reset_detect_state();
};
check_bias_t.set_value(bias_t_enabled);
check_bias_t.on_select = [this](Checkbox&, bool v) {
bias_t_enabled = v;
apply_bias_t(running_);
};
field_frequency.set_step(12500);
tone_log_view.set_parent_rect(tone_log_rect);
}
TwoToneRxView::~TwoToneRxView() {
if (running_) stop_rx();
}
void TwoToneRxView::reset_detect_state() {
detect_state_ = DetectState::IDLE;
phase_window_count_ = 0;
phase_freq_accum_ = 0;
phase_valid_windows_ = 0;
phase_last_freq_ = 0;
phase_last_is_first_ = true;
t1_avg_freq_ = 0;
t1_window_count_ = 0;
t1_transition_candidate_windows_ = 0;
t2_zero_window_count_ = 0;
debug_trace_active_ = false;
debug_trace_id_ = 0;
}
void TwoToneRxView::start_rx() {
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
audio::set_rate(audio::Rate::Hz_24000);
audio::output::start();
receiver_model.set_hidden_offset(0);
receiver_model.set_sampling_rate(3072000);
receiver_model.set_baseband_bandwidth(1750000);
receiver_model.enable();
apply_bias_t(true);
baseband::set_tonedetect_config((uint8_t)squelch_val, CTCSS_FREQS[ctcss_idx]);
add_child(&waterfall);
waterfall.set_parent_rect(waterfall_rect);
running_ = true;
button_startstop.set_text("Stop");
text_status.set("");
}
void TwoToneRxView::stop_rx() {
remove_child(&waterfall);
apply_bias_t(false);
receiver_model.disable();
baseband::shutdown();
audio::output::stop();
running_ = false;
button_startstop.set_text("Start");
text_status.set("");
reset_detect_state();
}
void TwoToneRxView::apply_bias_t(bool active) {
if (active && bias_t_enabled) {
set_antenna_bias(true);
receiver_model.set_antenna_bias();
applied_bias_t_ = true;
return;
}
if (!applied_bias_t_) return;
set_antenna_bias(prior_antenna_bias_);
receiver_model.set_antenna_bias();
applied_bias_t_ = false;
}
void TwoToneRxView::debug_log(const std::string& line) {
const std::string trace_prefix = debug_trace_active_
? ("#" + to_string_dec_uint(debug_trace_id_) + " ")
: "";
const std::string entry = "[D] " + trace_prefix + line;
debug_file_.write_entry(entry);
}
void TwoToneRxView::debug_trace_begin(const std::string& line) {
if (!debug_trace_active_) {
debug_trace_id_ = ++debug_trace_counter_;
debug_trace_active_ = true;
}
debug_log(line);
}
void TwoToneRxView::finalize_detected_pair(uint32_t t2_avg, uint32_t t2_dur, const char* reason) {
const uint32_t t1_dur = t1_window_count_ * DETECT_WINDOW_MS;
const std::string suffix = std::string(" via ") + reason;
if (t1_avg_freq_ > 0 && t2_avg > 0 &&
t1_dur >= 500 && t2_dur >= 500 &&
moto_index(t1_avg_freq_, MOTO_FINAL_MATCH_HZ) != MOTO_NONE &&
moto_index(t2_avg, MOTO_FINAL_MATCH_HZ) != MOTO_NONE) {
debug_trace_begin("END ok T1=" + format_tone(t1_avg_freq_, t1_dur) +
" T2=" + format_tone(t2_avg, t2_dur) + suffix);
log_tone_pair(t1_avg_freq_, t1_dur, t2_avg, t2_dur);
} else {
debug_trace_begin("END drop T1=" + format_tone(t1_avg_freq_, t1_dur) +
" T2=" + format_tone(t2_avg, t2_dur) + suffix);
}
}
// ---------------------------------------------------------------------------
// on_tone_data — window-based two-tone detection state machine
//
// Every 40 ms the baseband sends tone_end=false with a raw frequency estimate
// for that window (freq_hz=0 if no stable QCII candidate).
// When the CTCSS gate closes it sends tone_end=true (freq_hz=0).
//
// First and last windows of each phase are discarded:
// - "First" is tracked with phase_last_is_first_.
// - "Last" is phase_last_freq_, never added to the accumulator while
// pending; discarded when the phase ends.
//
// T1→T2 transition: consecutive windows that snap to different MOTO table
// entries. The transition window becomes T2's first window.
//
// Final matching is done from the phase-average raw estimates collected here,
// not from the per-window nearest-table snap used for transition detection.
// ---------------------------------------------------------------------------
void TwoToneRxView::on_tone_data(const ToneDetectDataMessage* msg) {
if (!running_) return;
if (msg->tone_end) {
// Gate closed — finalize whatever phase we're in
if (detect_state_ == DetectState::T2_COLLECTING) {
// phase_last_freq_ is the last T2 window → discard it
uint32_t t2_avg = (phase_valid_windows_ > 0)
? (phase_freq_accum_ / phase_valid_windows_)
: phase_last_freq_;
uint32_t t2_dur = phase_window_count_ * DETECT_WINDOW_MS;
finalize_detected_pair(t2_avg, t2_dur, "tone_end");
} else if (debug_trace_active_) {
debug_log("END before T2");
}
reset_detect_state();
text_status.set("");
return;
}
// tone_end=false: this is a 40 ms measurement window
const uint32_t freq = msg->freq_hz;
if (freq == 0) {
if (detect_state_ == DetectState::T2_COLLECTING) {
const uint32_t t2_avg = (phase_valid_windows_ > 0)
? (phase_freq_accum_ / phase_valid_windows_)
: phase_last_freq_;
const uint32_t t2_dur = phase_window_count_ * DETECT_WINDOW_MS;
if (t2_avg > 0 && t2_dur >= 2000) {
finalize_detected_pair(t2_avg, t2_dur, "t2_break");
reset_detect_state();
text_status.set("");
return;
}
}
// A single weak/noisy dropout is tolerated during T2 so standard 3 s
// B tones are not lost to one marginal 40 ms estimate window.
if (detect_state_ == DetectState::T2_COLLECTING && t2_zero_window_count_ == 0) {
t2_zero_window_count_++;
if (debug_trace_active_) {
debug_log("T2 zero dropout");
}
text_status.set("T1:" + format_tone(t1_avg_freq_, t1_window_count_ * DETECT_WINDOW_MS) +
" T2 ?");
return;
}
// Otherwise, treat the missing estimate as a break in the sequence.
if (debug_trace_active_) {
debug_log("RESET zero");
}
reset_detect_state();
text_status.set("");
return;
}
t2_zero_window_count_ = 0;
switch (detect_state_) {
case DetectState::IDLE:
detect_state_ = DetectState::T1_COLLECTING;
phase_window_count_ = 1;
phase_freq_accum_ = 0;
phase_valid_windows_ = 0;
phase_last_freq_ = freq;
phase_last_is_first_ = true;
text_status.set("T1 ...");
break;
case DetectState::T1_COLLECTING: {
// Check for T1→T2 MOTO index transition.
// Skip if phase_last_freq_ is the first (noisy) window — it's
// marked for discard and must not trigger a false transition.
if (freq > 0 && phase_last_freq_ > 0 && !phase_last_is_first_) {
const uint32_t t1_ref_freq = (phase_valid_windows_ > 0)
? (phase_freq_accum_ / phase_valid_windows_)
: phase_last_freq_;
const uint32_t idx_ref = moto_index(t1_ref_freq, MOTO_FINAL_MATCH_HZ);
const uint32_t idx_last = moto_index(phase_last_freq_);
const uint32_t idx_cur = moto_index(freq);
const uint32_t idx_cur_final = moto_index(freq, MOTO_FINAL_MATCH_HZ);
bool transition_detected = false;
if (idx_last != MOTO_NONE && idx_cur != MOTO_NONE && idx_last != idx_cur) {
if (!debug_trace_active_) {
debug_trace_begin("T1 start " + format_tone(t1_ref_freq, phase_window_count_ * DETECT_WINDOW_MS));
}
debug_log("T1->T2 snap " + format_tone(phase_last_freq_, DETECT_WINDOW_MS) +
" -> " + format_tone(freq, DETECT_WINDOW_MS));
transition_detected = true;
} else if (idx_ref != MOTO_NONE &&
idx_cur_final != MOTO_NONE &&
idx_cur_final != idx_ref &&
abs_diff_u32(freq, t1_ref_freq) >= MOTO_TRANSITION_DELTA_HZ) {
t1_transition_candidate_windows_++;
if (debug_trace_active_) {
debug_log("T1 cand " + to_string_dec_uint(t1_transition_candidate_windows_) +
" ref=" + to_string_dec_uint(t1_ref_freq) +
" cur=" + to_string_dec_uint(freq));
}
transition_detected = (t1_transition_candidate_windows_ >= 2);
} else {
if (debug_trace_active_ && t1_transition_candidate_windows_ > 0) {
debug_log("T1 cand reset");
}
t1_transition_candidate_windows_ = 0;
}
if (transition_detected) {
// Transition detected.
// phase_last_freq_ is the last T1 window → discard.
t1_avg_freq_ = (phase_valid_windows_ > 0)
? (phase_freq_accum_ / phase_valid_windows_)
: phase_last_freq_;
t1_window_count_ = phase_window_count_;
// Start T2 with current window as first (also discarded)
detect_state_ = DetectState::T2_COLLECTING;
phase_window_count_ = 1;
phase_freq_accum_ = 0;
phase_valid_windows_ = 0;
phase_last_freq_ = freq;
phase_last_is_first_ = true;
t1_transition_candidate_windows_ = 0;
t2_zero_window_count_ = 0;
if (!debug_trace_active_) {
debug_trace_begin("T1 start " + format_tone(t1_avg_freq_, t1_window_count_ * DETECT_WINDOW_MS));
}
debug_log("T2 start " + format_tone(freq, DETECT_WINDOW_MS));
text_status.set("T1:" + format_tone(t1_avg_freq_, t1_window_count_ * DETECT_WINDOW_MS) + " T2...");
break;
}
}
// No transition: promote pending window into accumulator (unless first)
if (!phase_last_is_first_ && phase_last_freq_ > 0) {
phase_freq_accum_ += phase_last_freq_;
phase_valid_windows_++;
}
phase_last_freq_ = freq;
phase_last_is_first_ = false;
phase_window_count_++;
if (!debug_trace_active_ &&
phase_window_count_ >= 3 &&
moto_index(phase_last_freq_, MOTO_FINAL_MATCH_HZ) != MOTO_NONE) {
const uint32_t t1_ref_freq = (phase_valid_windows_ > 0)
? (phase_freq_accum_ / phase_valid_windows_)
: phase_last_freq_;
debug_trace_begin("T1 start " + format_tone(t1_ref_freq, phase_window_count_ * DETECT_WINDOW_MS));
}
text_status.set("T1 " + to_string_dec_uint(phase_window_count_ * DETECT_WINDOW_MS) + "ms...");
break;
}
case DetectState::T2_COLLECTING:
// Promote pending window into accumulator (unless first)
if (!phase_last_is_first_ && phase_last_freq_ > 0) {
phase_freq_accum_ += phase_last_freq_;
phase_valid_windows_++;
}
phase_last_freq_ = freq;
phase_last_is_first_ = false;
phase_window_count_++;
text_status.set("T1:" + format_tone(t1_avg_freq_, t1_window_count_ * DETECT_WINDOW_MS) +
" T2 " + to_string_dec_uint(phase_window_count_ * DETECT_WINDOW_MS) + "ms");
break;
}
}
void TwoToneRxView::log_tone_pair(uint32_t f1, uint32_t d1_ms, uint32_t f2, uint32_t d2_ms) {
// Keep the oldest entry at the top so newer pairs appear below it.
tone_log_entries_.push_back({++next_log_serial_, format_tone(f1, d1_ms) + " " + format_tone(f2, d2_ms)});
tone_log_view.set_dirty();
}
} // namespace ui::external_app::two_tone_rx
namespace ui {
template <>
void RecentEntriesTable<ui::external_app::two_tone_rx::TwoToneLogEntries>::draw(
const Entry& entry,
const Rect& target_rect,
Painter& painter,
const Style& style,
ui::RecentEntriesColumns&) {
std::string line = entry.line;
line.resize(target_rect.width() / 8, ' ');
painter.draw_string(target_rect.location(), style, line);
}
} // namespace ui
@@ -0,0 +1,214 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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_TWO_TONE_RX_H__
#define __UI_TWO_TONE_RX_H__
#include "ui.hpp"
#include "ui_navigation.hpp"
#include "ui_receiver.hpp"
#include "ui_freq_field.hpp"
#include "ui_spectrum.hpp"
#include "app_settings.hpp"
#include "radio_state.hpp"
#include "message.hpp"
#include "string_format.hpp"
#include "log_file.hpp"
#include "recent_entries.hpp"
namespace ui::external_app::two_tone_rx {
struct TwoToneLogEntry {
using Key = uint32_t;
static constexpr Key invalid_key = 0;
Key serial{};
std::string line{};
Key key() const { return serial; }
};
using TwoToneLogEntries = RecentEntries<TwoToneLogEntry>;
class TwoToneRxView : public View {
public:
TwoToneRxView(NavigationView& nav);
~TwoToneRxView();
TwoToneRxView(const TwoToneRxView&) = delete;
TwoToneRxView(TwoToneRxView&&) = delete;
TwoToneRxView& operator=(const TwoToneRxView&) = delete;
TwoToneRxView& operator=(TwoToneRxView&&) = delete;
void focus() override;
void on_hide() override;
std::string title() const override { return "2-Tone RX"; };
private:
NavigationView& nav_;
RxRadioState radio_state_{};
bool running_{false};
TwoToneLogEntries tone_log_entries_{};
uint32_t next_log_serial_{0};
uint32_t squelch_val{50};
uint32_t ctcss_idx{0};
bool bias_t_enabled{false};
bool prior_antenna_bias_{false};
bool applied_bias_t_{false};
LogFile debug_file_{};
app_settings::SettingsManager settings_{
"rx_twotone",
app_settings::Mode::RX,
{
{"tone_sq"sv, &squelch_val},
{"ctcss"sv, &ctcss_idx},
{"bias_t"sv, &bias_t_enabled},
}};
void start_rx();
void stop_rx();
void apply_bias_t(bool active);
void debug_log(const std::string& line);
void debug_trace_begin(const std::string& line);
void on_tone_data(const ToneDetectDataMessage* msg);
void log_tone_pair(uint32_t f1, uint32_t d1_ms, uint32_t f2, uint32_t d2_ms);
void finalize_detected_pair(uint32_t t2_avg, uint32_t t2_dur, const char* reason);
void reset_detect_state();
// ── Two-tone detection state machine ─────────────────────────────────────
//
// Each 40 ms measurement window from the baseband arrives as tone_end=false.
// tone_end=true signals that the CTCSS gate closed (end of transmission).
//
// Collection rule: discard first and last window of each phase.
// - "First" is always discarded (may contain carrier ramp-up).
// - "Last" is never added to the accumulator while pending; it is
// discarded when the next window confirms it is not the last, OR when
// the phase ends (tone_end=true or T1→T2 transition).
//
// Transition detection: two consecutive windows that snap to different MOTO
// table entries trigger T1→T2. The transition window becomes T2's first
// window (also discarded as T2's first).
enum class DetectState : uint8_t { IDLE,
T1_COLLECTING,
T2_COLLECTING };
DetectState detect_state_{DetectState::IDLE};
// Per-phase window collection
uint32_t phase_window_count_{0}; // total windows seen in this phase (including first)
uint32_t phase_freq_accum_{0}; // sum of non-first, non-last window frequencies
uint32_t phase_valid_windows_{0}; // count of windows in phase_freq_accum_
uint32_t phase_last_freq_{0}; // pending window (not yet added; discarded if last)
bool phase_last_is_first_{true}; // true when phase_last_freq_ is window 1 (discard)
// T1 result (stored at T1→T2 transition for use when pair is logged)
uint32_t t1_avg_freq_{0};
uint32_t t1_window_count_{0};
uint8_t t1_transition_candidate_windows_{0};
uint8_t t2_zero_window_count_{0};
bool debug_trace_active_{false};
uint32_t debug_trace_id_{0};
uint32_t debug_trace_counter_{0};
// ── Row 0: frequency + RF controls ───────────────────────────────────────
RxFrequencyField field_frequency{
{UI_POS_X(0), UI_POS_Y(0)},
nav_};
RFAmpField field_rf_amp{
{UI_POS_X(13), UI_POS_Y(0)}};
LNAGainField field_lna{
{UI_POS_X(15), UI_POS_Y(0)}};
VGAGainField field_vga{
{UI_POS_X(18), UI_POS_Y(0)}};
RSSI rssi{
{UI_POS_X(21), UI_POS_Y(0), UI_POS_WIDTH_REMAINING(24), 4}};
AudioVolumeField field_volume{
{UI_POS_X_RIGHT(2), UI_POS_Y(0)}};
// ── Row 1: CTCSS / Squelch / Start+Stop / Clear ──────────────────────────
Labels labels{
{{0 * 8, 1 * 16}, "CTCSS:", Theme::getInstance()->fg_light->foreground},
{{15 * 8, 1 * 16}, "Sq:", Theme::getInstance()->fg_light->foreground},
};
OptionsField options_ctcss{
{7 * 8, 1 * 16},
8,
{}};
NumberField field_squelch{
{19 * 8, 1 * 16},
2,
{0, 99},
1,
' '};
Button button_startstop{
{21 * 8, 1 * 16, 5 * 8, 18},
"Start"};
Button button_clear{
{26 * 8, 1 * 16, 4 * 8, 18},
"Clr"};
// ── Row 2: live status (active tone in progress) + Bias-T toggle ─────────
Text text_status{
{0, 2 * 16 + 4, 20 * 8, 16},
""};
Checkbox check_bias_t{
{20 * 8, 2 * 16 + 2},
9,
"Bias-T",
true};
ui::RecentEntriesColumns tone_log_columns{{
{"Tones", 0},
}};
ui::RecentEntriesTable<TwoToneLogEntries> tone_log_view{tone_log_entries_, tone_log_columns};
// ── Waterfall (rest of screen) ────────────────────────────────────────────
spectrum::WaterfallView waterfall{};
// ── Message handler ───────────────────────────────────────────────────────
MessageHandlerRegistration message_handler_tone{
Message::ID::ToneDetectData,
[this](const Message* const p) {
const auto* msg = reinterpret_cast<const ToneDetectDataMessage*>(p);
this->on_tone_data(msg);
}};
};
} // namespace ui::external_app::two_tone_rx
#endif /* __UI_TWO_TONE_RX_H__ */
+8
View File
@@ -683,6 +683,14 @@ set(MODE_CPPSRC
)
DeclareTargets(PSCD subcar)
### Tone Detect RX
set(MODE_CPPSRC
proc_tonedetect.cpp
)
DeclareTargets(PTNE tonedetect)
### Morse RX Decoder
set(MODE_CPPSRC
+332
View File
@@ -0,0 +1,332 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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_tonedetect.hpp"
#include "audio_dma.hpp"
#include "portapack_shared_memory.hpp"
#include "event_m4.hpp"
#include <cmath>
static constexpr float SAMPLE_RATE = 24000.0f;
static constexpr uint32_t WINDOW_SAMPLES = 960; // 40 ms at 24 kHz (30 execute() calls)
static constexpr uint32_t WINDOW_MS = (1000 * WINDOW_SAMPLES) / (uint32_t)SAMPLE_RATE;
static constexpr uint32_t AUDIO_BLOCK_SAMPLES = 32; // demod_fm emits 32 audio samples per execute()
static constexpr uint32_t SQUELCH_HOLD_BLOCKS =
(100 * (uint32_t)SAMPLE_RATE) / (1000 * AUDIO_BLOCK_SAMPLES); // 100 ms hold at execute() block rate
static constexpr float PI_F = 3.14159265f;
// Motorola/EIA QCII paging frequencies (×10 to avoid float in table)
static constexpr uint32_t MOTO_FREQS_X10[45] = {
2885,
3047,
3217,
3396,
3586,
3786,
3998,
4221,
4457,
4705,
4968,
5246,
5539,
5848,
6174,
6519,
6883,
7268,
7674,
8102,
8555,
9032,
9537,
10073,
10642,
11225,
11247,
11534,
11852,
11885,
12178,
12514,
12555,
12858,
13258,
13576,
13950,
13996,
14768,
15579,
16430,
17325,
18262,
19245,
20275,
};
// Minimum coherent Goertzel energy for MOTO tone detection.
// For amplitude A over WINDOW_SAMPLES: energy ≈ (A × N/2)² = (A × 240)².
// Threshold 1000 → requires A > ~13% of FM demod full scale (~650 Hz deviation).
// Set high enough that FM broadband noise (σ ≈ 0.3, expected per-bin energy ≈ 43)
// never triggers a false detection even when the carrier gate is briefly open.
static constexpr float MOTO_ENERGY_THRESHOLD = 1000.0f;
// Fixed carrier-detect threshold for the detection gate (independent of user squelch).
// FMSquelch returns true when HF noise is BELOW this value (= FM carrier present).
// 0.20 opens reliably on any clean FM carrier without false-opening on noise.
static constexpr float CARRIER_DETECT_THRESHOLD = 0.20f;
// Goertzel energy threshold for CTCSS detection.
// Pure CTCSS at 5% amplitude over 960 samples → energy ≈ (0.05 × 480)² = 576.
static constexpr float CTCSS_ENERGY_THRESHOLD = 30.0f;
void ToneDetectProcessor::configure(uint8_t squelch, uint32_t ctcss_f_x10) {
configured = false;
user_squelch_level = squelch;
ctcss_freq_x10 = ctcss_f_x10;
decim_0.configure(taps_11k0_decim_0.taps);
decim_1.configure(taps_11k0_decim_1.taps);
channel_filter.configure(taps_11k0_channel.taps, 2);
demod_fm.configure(24000, 5000);
audio_output.configure(false);
fm_squelch.set_threshold((float)user_squelch_level / 100.0f);
channel_spectrum.set_decimation_factor(1);
// Precompute Goertzel coefficient for CTCSS (if active).
// k = nearest DFT bin for the CTCSS frequency over WINDOW_SAMPLES.
if (ctcss_freq_x10 > 0) {
const float freq = (float)ctcss_freq_x10 / 10.0f;
const float k = roundf((float)WINDOW_SAMPLES * freq / SAMPLE_RATE);
const float omega = 2.0f * PI_F * k / (float)WINDOW_SAMPLES;
goertzel_coeff = 2.0f * cosf(omega);
} else {
goertzel_coeff = 0.0f;
}
// Precompute Goertzel coefficients for all 45 MOTO frequencies.
// Each filter is tuned to the EXACT MOTO frequency (not the nearest DFT bin center)
// so that every entry has a unique coefficient and maximum energy only at its
// specific frequency. This eliminates bin-sharing ambiguity and gives the best
// discrimination between close entries such as 1357.6 Hz and 1395.0 Hz.
for (size_t i = 0; i < 45; i++) {
const float freq = (float)MOTO_FREQS_X10[i] / 10.0f;
const float omega = 2.0f * PI_F * freq / SAMPLE_RATE;
moto_coeff[i] = 2.0f * cosf(omega);
moto_s1[i] = 0.0f;
moto_s2[i] = 0.0f;
}
// Fixed carrier-detect squelch — threshold never changes with user settings.
carrier_sq.set_threshold(CARRIER_DETECT_THRESHOLD);
// Reset all per-window state
goertzel_s1 = 0.0f;
goertzel_s2 = 0.0f;
window_sample_count = 0;
was_ctcss_detected = false;
tone_duration_windows = 0;
squelch_is_open = false;
squelch_hold = 0;
carrier_is_open = false;
carrier_hold = 0;
configured = true;
}
void ToneDetectProcessor::execute(const buffer_c8_t& buffer) {
if (!configured) return;
const auto decim_0_out = decim_0.execute(buffer, dst_buffer);
const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer);
// Feed IQ data into spectrum collector for the RF waterfall.
channel_spectrum.feed(decim_1_out, -5500, 5500, 3400);
const auto channel_out = channel_filter.execute(decim_1_out, dst_buffer);
auto audio_buf = demod_fm.execute(channel_out, audio_buffer);
// --- Audio muting squelch (user-adjustable level) ---
// FMSquelch returns true when HF noise is LOW (carrier present).
const bool fm_open = fm_squelch.execute(audio_buf);
if (fm_open) {
squelch_hold = SQUELCH_HOLD_BLOCKS;
squelch_is_open = true;
} else if (squelch_hold > 0) {
squelch_hold--;
} else {
squelch_is_open = false;
}
// --- Fixed carrier-detect gate (independent of user squelch level) ---
// carrier_sq always uses CARRIER_DETECT_THRESHOLD (0.20) regardless of squelch_val.
// This ensures the detection gate closes when the carrier disappears even when the
// user sets squelch=0 (always-open audio), preventing the state machine from
// accumulating noise windows or getting stuck between transmissions.
const bool carrier_raw = carrier_sq.execute(audio_buf);
if (carrier_raw) {
carrier_hold = SQUELCH_HOLD_BLOCKS;
carrier_is_open = true;
} else if (carrier_hold > 0) {
carrier_hold--;
} else {
carrier_is_open = false;
}
for (size_t i = 0; i < audio_buf.count; i++) {
const float s = audio_buf.p[i];
// Mute audio output when FM squelch is closed (does not affect Goertzel).
if (!squelch_is_open) audio_buf.p[i] = 0.0f;
// CTCSS Goertzel step (CTCSS mode only).
// Uses original unmuted sample so CTCSS energy is unaffected by audio muting.
if (ctcss_freq_x10 > 0) {
const float s0 = s + goertzel_coeff * goertzel_s1 - goertzel_s2;
goertzel_s2 = goertzel_s1;
goertzel_s1 = s0;
}
// MOTO frequency bank — Goertzel step for all 45 MOTO bins.
// Runs unconditionally so the full window always contributes to energy.
// Benefit over zero-crossing: coherent detection, no warm-up period,
// accurate on the very first window after the gate opens.
for (size_t j = 0; j < 45; j++) {
const float s0 = s + moto_coeff[j] * moto_s1[j] - moto_s2[j];
moto_s2[j] = moto_s1[j];
moto_s1[j] = s0;
}
// Window boundary: every WINDOW_SAMPLES samples = one 40 ms estimate window
if (++window_sample_count >= WINDOW_SAMPLES) {
window_sample_count = 0;
// --- Detection gate ---
// Carrier presence (carrier_is_open) is always required — without it,
// the FM demod outputs broadband noise that floods every Goertzel bin and
// triggers false detections regardless of the CTCSS threshold.
// CTCSS mode adds a second requirement: coherent CTCSS energy must also
// be present. This is the standard two-condition squelch used in real radios.
bool gate_open;
if (ctcss_freq_x10 > 0) {
const float power = goertzel_s1 * goertzel_s1 + goertzel_s2 * goertzel_s2 - goertzel_coeff * goertzel_s1 * goertzel_s2;
gate_open = carrier_is_open && (power > CTCSS_ENERGY_THRESHOLD);
goertzel_s1 = 0.0f;
goertzel_s2 = 0.0f;
} else {
gate_open = carrier_is_open;
}
// --- MOTO frequency identification via Goertzel energy ---
// Find the MOTO table entry with the highest coherent energy this window.
// Reset all states regardless of gate so each window starts fresh.
float energies[45]{};
uint32_t best_idx = 45; // 45 = sentinel (no match)
float best_energy = MOTO_ENERGY_THRESHOLD;
for (size_t j = 0; j < 45; j++) {
const float pwr = moto_s1[j] * moto_s1[j] + moto_s2[j] * moto_s2[j] - moto_coeff[j] * moto_s1[j] * moto_s2[j];
energies[j] = pwr;
moto_s1[j] = 0.0f;
moto_s2[j] = 0.0f;
if (pwr > best_energy) {
best_energy = pwr;
best_idx = j;
}
}
// Report a raw estimate from the local energy centroid around the best
// entry, and let the UI do the final table snap from the phase average.
uint32_t win_freq_hz = 0;
if (best_idx < 45) {
const size_t start = (best_idx > 0) ? (best_idx - 1) : best_idx;
const size_t end = (best_idx + 1 < 45) ? (best_idx + 1) : best_idx;
float weight_sum = 0.0f;
float weighted_freq_x10 = 0.0f;
for (size_t j = start; j <= end; j++) {
const float weight = energies[j] - MOTO_ENERGY_THRESHOLD;
if (weight > 0.0f) {
weight_sum += weight;
weighted_freq_x10 += weight * (float)MOTO_FREQS_X10[j];
}
}
if (weight_sum > 0.0f) {
win_freq_hz = (uint32_t)((weighted_freq_x10 / weight_sum) / 10.0f + 0.5f);
} else {
win_freq_hz = MOTO_FREQS_X10[best_idx] / 10;
}
}
if (gate_open) {
if (!was_ctcss_detected) {
tone_duration_windows = 0;
}
tone_duration_windows++;
was_ctcss_detected = true;
data_message.freq_hz = win_freq_hz;
data_message.duration_ms = tone_duration_windows * WINDOW_MS;
data_message.tone_end = false;
shared_memory.application_queue.push(data_message);
} else {
if (was_ctcss_detected) {
// Gate just closed — signal tone end to application
data_message.freq_hz = 0;
data_message.duration_ms = tone_duration_windows * WINDOW_MS;
data_message.tone_end = true;
shared_memory.application_queue.push(data_message);
tone_duration_windows = 0;
}
was_ctcss_detected = false;
}
}
}
audio_output.write(audio_buf);
}
void ToneDetectProcessor::on_message(const Message* const p) {
switch (p->id) {
case Message::ID::ToneDetectConfig: {
const auto& msg = *reinterpret_cast<const ToneDetectConfigureMessage*>(p);
configure(msg.squelch_level, msg.ctcss_freq_x10);
break;
}
case Message::ID::NBFMConfigure: {
const auto& msg = *reinterpret_cast<const NBFMConfigureMessage*>(p);
user_squelch_level = msg.squelch_level;
fm_squelch.set_threshold((float)user_squelch_level / 100.0f);
break;
}
case Message::ID::UpdateSpectrum:
case Message::ID::SpectrumStreamingConfig:
channel_spectrum.on_message(p);
break;
default:
break;
}
}
int main() {
audio::dma::init_audio_out();
EventDispatcher event_dispatcher{std::make_unique<ToneDetectProcessor>()};
event_dispatcher.run();
return 0;
}
+94
View File
@@ -0,0 +1,94 @@
/*
* Copyright (C) 2024 PortaPack Mayhem
*
* 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_TONEDETECT_H__
#define __PROC_TONEDETECT_H__
#include "baseband_processor.hpp"
#include "baseband_thread.hpp"
#include "message.hpp"
#include "dsp_decimate.hpp"
#include "dsp_demodulate.hpp"
#include "audio_output.hpp"
#include "dsp_fir_taps.hpp"
#include "rssi_thread.hpp"
#include "spectrum_collector.hpp"
#include "dsp_squelch.hpp"
class ToneDetectProcessor : public BasebandProcessor {
public:
ToneDetectProcessor() {}
void execute(const buffer_c8_t& buffer) override;
void on_message(const Message* const p) override;
private:
void configure(uint8_t squelch, uint32_t ctcss_freq_x10);
BasebandThread baseband_thread{3072000, this, baseband::Direction::Receive};
RSSIThread rssi_thread{};
std::array<complex16_t, 512> dst{};
const buffer_c16_t dst_buffer{dst.data(), dst.size()};
std::array<float, 32> audio{};
const buffer_f32_t audio_buffer{audio.data(), audio.size()};
dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{};
dsp::decimate::FIRC16xR16x32Decim8 decim_1{};
dsp::decimate::FIRAndDecimateComplex channel_filter{};
dsp::demodulate::FM demod_fm{};
AudioOutput audio_output{};
bool configured{false};
uint8_t user_squelch_level{0};
FMSquelch fm_squelch{}; // user-adjustable: drives audio muting only
FMSquelch carrier_sq{}; // fixed threshold: drives detection gate
// Audio muting squelch state (user-adjustable level, has hold for smooth audio)
bool squelch_is_open{false};
uint32_t squelch_hold{0};
// Carrier-detect gate state (fixed 0.20 threshold, separate from audio squelch)
bool carrier_is_open{false};
uint32_t carrier_hold{0};
// CTCSS gate via Goertzel algorithm (per 40 ms window)
uint32_t ctcss_freq_x10{0}; // 0 = None → fall back to FMSquelch gate
float goertzel_coeff{0.0f}; // 2 * cos(2π * k / WINDOW_SAMPLES)
float goertzel_s1{0.0f};
float goertzel_s2{0.0f};
// 40 ms measurement window
uint32_t window_sample_count{0};
bool was_ctcss_detected{false};
uint32_t tone_duration_windows{0};
// MOTO frequency bank — one Goertzel filter per MOTO table entry.
// States reset each window; coefficients set once in configure().
float moto_coeff[45]{};
float moto_s1[45]{};
float moto_s2[45]{};
ToneDetectDataMessage data_message{};
SpectrumCollector channel_spectrum{};
};
#endif /* __PROC_TONEDETECT_H__ */
+23
View File
@@ -158,6 +158,8 @@ class Message {
TimeSinkConfig = 100,
EPIRBTXData = 101,
P25TxConfigure = 102,
ToneDetectData = 103,
ToneDetectConfig = 104,
MAX
};
@@ -1861,4 +1863,25 @@ class NotificationDataMessage : public Message {
uint16_t timeout = 10000;
};
// Sent M0→M4: a tone detection event (tone ended, or periodic update while active)
class ToneDetectDataMessage : public Message {
public:
constexpr ToneDetectDataMessage()
: Message{ID::ToneDetectData} {}
uint32_t freq_hz{0}; // Dominant audio frequency in Hz (0 = silence)
uint32_t duration_ms{0}; // How long the tone lasted in milliseconds
bool tone_end{false}; // True = tone just ended; False = tone still active (periodic update)
};
// Sent M4→M0: configure the tone detector
class ToneDetectConfigureMessage : public Message {
public:
constexpr ToneDetectConfigureMessage(uint8_t squelch = 0, uint32_t ctcss_freq_x10 = 0)
: Message{ID::ToneDetectConfig},
squelch_level{squelch},
ctcss_freq_x10{ctcss_freq_x10} {}
uint8_t squelch_level{0};
uint32_t ctcss_freq_x10{0}; // CTCSS frequency × 10 (e.g. 1000 = 100.0 Hz); 0 = None
};
#endif /*__MESSAGE_H__*/
+1
View File
@@ -131,6 +131,7 @@ constexpr image_tag_t image_tag_morse{'P', 'M', 'R', 'S'};
constexpr image_tag_t image_tag_morsetx{'P', 'M', 'R', 'T'};
constexpr image_tag_t image_tag_rttyrx{'P', 'R', 'T', 'R'};
constexpr image_tag_t image_tag_rttytx{'P', 'R', 'T', 'T'};
constexpr image_tag_t image_tag_tonedetect{'P', 'T', 'N', 'E'};
constexpr image_tag_t image_tag_noop{'P', 'N', 'O', 'P'};
+1 -1
View File
@@ -24,4 +24,4 @@
# external app address ranges below must match those in linker file "external.ld"
maximum_application_size = 32*1024
external_apps_address_start = 0xADB00000
external_apps_address_end = 0xAE020000
external_apps_address_end = 0xAE060000