mirror of
https://github.com/portapack-mayhem/mayhem-firmware.git
synced 2026-09-03 13:29:01 +00:00
Add external 2-Tone TX/RX Apps (#3128)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, 0100–9999)
|
||||
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__ */
|
||||
@@ -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 1–50 = 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__ */
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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__ */
|
||||
@@ -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__*/
|
||||
|
||||
@@ -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'};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user