diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index 42a172e7b..0a0bc899b 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -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); diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index 95a6b6913..69a684a0a 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -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); diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index dfa89efc4..c8dc21a2b 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -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 diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index cd61633c5..0bd3c2b8c 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -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 } diff --git a/firmware/application/external/two_tone_pager/main.cpp b/firmware/application/external/two_tone_pager/main.cpp new file mode 100644 index 000000000..dbba7ea18 --- /dev/null +++ b/firmware/application/external/two_tone_pager/main.cpp @@ -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(); +} +} // 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" diff --git a/firmware/application/external/two_tone_pager/ui_two_tone_pager.cpp b/firmware/application/external/two_tone_pager/ui_two_tone_pager.cpp new file mode 100644 index 000000000..d1c44063c --- /dev/null +++ b/firmware/application/external/two_tone_pager/ui_two_tone_pager.cpp @@ -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 +#include + +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( + (static_cast(freq_x10) << 32) / + (static_cast(SAMPLE_RATE) * 10ULL)); +} + +uint32_t TwoTonePagerView::ms_to_samples(uint32_t ms) const { + return static_cast( + (static_cast(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(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(dur_a), false); + field_dur_b.set_value(static_cast(dur_b), false); + field_gap.set_value(static_cast(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(dur_a), false); + field_dur_b.set_value(static_cast(dur_b), false); + field_gap.set_value(static_cast(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(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> opts; + opts.reserve(CTCSS_COUNT); + for (size_t i = 0; i < CTCSS_COUNT; i++) + opts.push_back({freq_name(CTCSS_FREQS[i]), static_cast(i)}); + options_ctcss.set_options(std::move(opts)); + } + + // Build Motorola tone options (same pool for both A and B); "Custom" appended + { + std::vector> 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(i)}); + opts.push_back({"Custom", static_cast(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(dur_a), false); + field_dur_b.set_value(static_cast(dur_b), false); + field_gap.set_value(static_cast(gap_ms), false); + options_timing.set_selected_index(detect_timing_preset(), false); + field_slot.set_value(static_cast(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(i); + }; + + options_tone_a.on_change = [this](size_t i, int32_t) { + tone_a_idx = static_cast(i); + update_tx_time(); + }; + + options_tone_b.on_change = [this](size_t i, int32_t) { + tone_b_idx = static_cast(i); + update_tx_time(); + }; + + symfield_custom_a.on_change = [this](SymField&) { + uint32_t v = static_cast(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(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(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(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(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(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(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 diff --git a/firmware/application/external/two_tone_pager/ui_two_tone_pager.hpp b/firmware/application/external/two_tone_pager/ui_two_tone_pager.hpp new file mode 100644 index 000000000..e1b1a06d0 --- /dev/null +++ b/firmware/application/external/two_tone_pager/ui_two_tone_pager.hpp @@ -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(p); + this->on_tx_progress(msg.progress, msg.done); + }}; +}; + +} // namespace ui::external_app::two_tone_pager + +#endif /* __UI_TWO_TONE_PAGER_H__ */ diff --git a/firmware/application/external/two_tone_rx/main.cpp b/firmware/application/external/two_tone_rx/main.cpp new file mode 100644 index 000000000..ed383bb22 --- /dev/null +++ b/firmware/application/external/two_tone_rx/main.cpp @@ -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(); +} +} // 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" diff --git a/firmware/application/external/two_tone_rx/ui_two_tone_rx.cpp b/firmware/application/external/two_tone_rx/ui_two_tone_rx.cpp new file mode 100644 index 000000000..c30496e91 --- /dev/null +++ b/firmware/application/external/two_tone_rx/ui_two_tone_rx.cpp @@ -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; + options_ctcss.set_options([&]() { + std::vector 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::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 diff --git a/firmware/application/external/two_tone_rx/ui_two_tone_rx.hpp b/firmware/application/external/two_tone_rx/ui_two_tone_rx.hpp new file mode 100644 index 000000000..acf96659f --- /dev/null +++ b/firmware/application/external/two_tone_rx/ui_two_tone_rx.hpp @@ -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; + +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 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(p); + this->on_tone_data(msg); + }}; +}; + +} // namespace ui::external_app::two_tone_rx + +#endif /* __UI_TWO_TONE_RX_H__ */ diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index 21b6f1977..89ffc124f 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -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 diff --git a/firmware/baseband/proc_tonedetect.cpp b/firmware/baseband/proc_tonedetect.cpp new file mode 100644 index 000000000..d3eefd412 --- /dev/null +++ b/firmware/baseband/proc_tonedetect.cpp @@ -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 + +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(p); + configure(msg.squelch_level, msg.ctcss_freq_x10); + break; + } + case Message::ID::NBFMConfigure: { + const auto& msg = *reinterpret_cast(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()}; + event_dispatcher.run(); + return 0; +} diff --git a/firmware/baseband/proc_tonedetect.hpp b/firmware/baseband/proc_tonedetect.hpp new file mode 100644 index 000000000..0e2437d5a --- /dev/null +++ b/firmware/baseband/proc_tonedetect.hpp @@ -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 dst{}; + const buffer_c16_t dst_buffer{dst.data(), dst.size()}; + std::array 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__ */ diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 475fcf585..91b99dd97 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -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__*/ diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index 6c9fde35e..1881b536b 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -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'}; diff --git a/firmware/tools/external_app_info.py b/firmware/tools/external_app_info.py index 41f7e9943..d8b31abf1 100644 --- a/firmware/tools/external_app_info.py +++ b/firmware/tools/external_app_info.py @@ -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