diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index 7152b7430..7ba39f452 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -368,6 +368,11 @@ void set_subghzd_config(uint8_t modulation = 0, uint32_t sampling_rate = 0) { send_message(&message); } +void set_moreserx_config() { + const MorseRXConfigureMessage message{}; + send_message(&message); +} + static bool baseband_image_running = false; void run_image(const spi_flash::image_tag_t image_tag) { diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index 86d06604c..dea7bab41 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -102,6 +102,7 @@ void set_siggen_tone(const uint32_t tone); void set_siggen_config(const uint32_t bw, const uint32_t shape, const uint32_t duration); 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(); void set_wefax_config(uint8_t lpm, uint8_t ioc); void set_noaaapt_config(); void set_flex_config(); diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index 8aa407b25..ab7052daf 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -282,10 +282,14 @@ set(EXTCPPSRC external/siggen/ui_siggen.cpp #sdusb - external/sdusb/main.cpp external/sdusb/ui_sd_over_usb.cpp + + #morse_radio + external/morse_radio/main.cpp + external/morse_radio/ui_morse_radio.cpp + ) set(EXTAPPLIST @@ -357,4 +361,5 @@ set(EXTAPPLIST subcarrx siggen sdusb + morse_radio ) diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index cc8711285..99a9f2dba 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -91,7 +91,7 @@ MEMORY ram_external_app_subcarrx (rwx) : org = 0xADF20000, len = 32k ram_external_app_siggen (rwx) : org = 0xADF30000, len = 32k ram_external_app_sdusb (rwx) : org = 0xADF40000, len = 32k - + ram_external_app_morse_radio (rwx) : org = 0xADF50000, len = 32k } SECTIONS @@ -506,5 +506,11 @@ SECTIONS *(*ui*external_app*sdusb*); } > ram_external_app_sdusb + .external_app_morse_radio : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_morse_radio.application_information)); + *(*ui*external_app*morse_radio*); + } > ram_external_app_morse_radio + } diff --git a/firmware/application/external/morse_practice/morsedecoder.hpp b/firmware/application/external/morse_practice/morsedecoder.hpp index 4e1058f04..bd73f936d 100644 --- a/firmware/application/external/morse_practice/morsedecoder.hpp +++ b/firmware/application/external/morse_practice/morsedecoder.hpp @@ -24,8 +24,7 @@ #include #include - -#define MORSEDEC_ERROR "" +#include "string_format.hpp" namespace ui::external_app::morse_practice { @@ -89,21 +88,28 @@ class MorseDecoder { }; MorseDecoder() {} - DecodeResult handleInput(int32_t duration_ms) { + + DecodeResult + handleInput(int32_t duration_ms) { DecodeResult result = {"", 0.0}; + + if (duration_ms < 5 && duration_ms > -5) return result; + if (duration_ms > 0) { pulse_history_.push_back(duration_ms); double dah_prob = getDahProbability(duration_ms); current_sequence_ += (dah_prob > 0.5) ? '-' : '.'; last_confidence_ = (dah_prob > 0.5) ? dah_prob : (1.0 - dah_prob); last_sequence_ = current_sequence_; + } else { uint32_t gap_duration = -duration_ms; pulse_gaps_.push_back(gap_duration); if (gap_duration >= getInterCharThreshold() && !current_sequence_.empty()) { result.text = lookupMorse(current_sequence_); - result.confidence = (result.text != MORSEDEC_ERROR) ? last_confidence_ : 0.0; + + result.confidence = (result.text[0] != '{') ? last_confidence_ : 0.0; if (gap_duration >= getInterWordThreshold()) { result.text += " "; } @@ -116,9 +122,9 @@ class MorseDecoder { return result; } - inline double getInterElementThreshold() { return time_unit_ms_ * 2.0; } - inline double getInterCharThreshold() { return time_unit_ms_ * 4.0; } - inline double getInterWordThreshold() { return time_unit_ms_ * 7.0; } + inline double getInterElementThreshold() { return time_unit_ms_ * 0.8; } + inline double getInterCharThreshold() { return time_unit_ms_ * 2.5; } + inline double getInterWordThreshold() { return time_unit_ms_ * 6.0; } inline double getCurrentTimeUnit() { return time_unit_ms_; } inline std::string getLastSequence() { return last_sequence_; } @@ -126,7 +132,7 @@ class MorseDecoder { private: std::string current_sequence_ = ""; std::string last_sequence_ = ""; - double time_unit_ms_ = 160.0; + double time_unit_ms_ = 119.0; double last_confidence_ = 0.0; MorseRingBuffer pulse_history_{}; MorseRingBuffer pulse_gaps_{}; @@ -136,7 +142,7 @@ class MorseDecoder { if (seq == morse_table_[i].code) return morse_table_[i].letter; } - return MORSEDEC_ERROR; // not found + return "{" + seq + "}"; // not found } double getDahProbability(uint32_t duration_ms) { @@ -298,8 +304,8 @@ class MorseDecoder { time_unit_ms_ = (time_unit_ms_ * (1.0 - learning_factor)) + (new_time_unit * learning_factor); } - size_t morse_table_size_ = 41; - MorseEntry morse_table_[41] = { + size_t morse_table_size_ = 50; + MorseEntry morse_table_[50] = { {".-", "A"}, {"-...", "B"}, {"-.-.", "C"}, @@ -340,7 +346,16 @@ class MorseDecoder { {"..--..", "?"}, {"-.-.--", "!"}, {"--..--", ","}, - {"-...-", "="}}; + {"-...-", "="}, + {"-..-.", "/"}, + {".--.-.", "@"}, + {"---...", ":"}, + {"-....-", "-"}, + {".----.", "'"}, + {".-..-.", "\""}, + {"-.--.", "("}, + {"-.--.-", ")"}, + {".-.-.", "+"}}; }; } // namespace ui::external_app::morse_practice diff --git a/firmware/application/external/morse_practice/ui_morse_practice.cpp b/firmware/application/external/morse_practice/ui_morse_practice.cpp index d2b12d8ae..4ec20a08e 100644 --- a/firmware/application/external/morse_practice/ui_morse_practice.cpp +++ b/firmware/application/external/morse_practice/ui_morse_practice.cpp @@ -98,7 +98,7 @@ void MorsePracticeView::writeCharToConsole(const std::string& ch, double confide if (ch == " ") { color_id = 0; - } else if (ch == MORSEDEC_ERROR) { + } else if (ch[0] == '{') { // no match color_id = 0; } else { if (confidence < 0.8) diff --git a/firmware/application/external/morse_practice/ui_morse_practice.hpp b/firmware/application/external/morse_practice/ui_morse_practice.hpp index 5ad19ab54..c28fdfa34 100644 --- a/firmware/application/external/morse_practice/ui_morse_practice.hpp +++ b/firmware/application/external/morse_practice/ui_morse_practice.hpp @@ -47,7 +47,7 @@ class MorsePracticeView : public ui::View { MorsePracticeView(ui::NavigationView& nav); ~MorsePracticeView(); - std::string title() { return "Morse P"; } + std::string title() const override { return "Morse Practice"; } void focus() override; void on_show() override; diff --git a/firmware/application/external/morse_radio/main.cpp b/firmware/application/external/morse_radio/main.cpp new file mode 100644 index 000000000..f4ff612b9 --- /dev/null +++ b/firmware/application/external/morse_radio/main.cpp @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2026 Pezsma + * + * 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_morse_radio.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::morse_radio { + +void initialize_app(NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::morse_radio + +extern "C" { + +__attribute__((section(".external_app.app_morse_radio.application_information"), used)) +application_information_t _application_information_morse_radio = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::morse_radio::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "Morse", + /*.bitmap_data = */ { + 0x00, + 0x00, + 0xFE, + 0x7F, + 0xFF, + 0xFF, + 0xBB, + 0xD0, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x0B, + 0xE1, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xEB, + 0xD0, + 0xFF, + 0xFF, + 0xFE, + 0x7F, + 0x70, + 0x00, + 0x30, + 0x00, + 0x10, + 0x00, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::yellow().v, + /*.menu_location = */ app_location_t::RX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_morse_radio */ {'P', 'M', 'R', 'S'}, + /*.m4_app_offset = */ 0x00000000, // will be filled at compile time +}; +} diff --git a/firmware/application/external/morse_radio/morsedecoder.hpp b/firmware/application/external/morse_radio/morsedecoder.hpp new file mode 100644 index 000000000..50fb01b7b --- /dev/null +++ b/firmware/application/external/morse_radio/morsedecoder.hpp @@ -0,0 +1,378 @@ +#ifndef __MORSEDECODER_HPP__ +#define __MORSEDECODER_HPP__ + +/* + * Copyright (C) 2025 Pezsma + * + * 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 +#include +#include "string_format.hpp" + +namespace ui::external_app::morse_radio { + +class MorseRingBuffer { + public: + MorseRingBuffer() + : head_(0), tail_(0), count_(0) {} + + void push_back(const uint32_t& value) { + data_[head_] = value; + head_ = (head_ + 1) % 40; + if (count_ < 40) { + count_++; + } else { + // overwrite oldest element + tail_ = (tail_ + 1) % 40; + } + } + + void pop_front() { + if (count_ > 0) { + tail_ = (tail_ + 1) % 40; + count_--; + } + } + + size_t size() const { return count_; } + bool empty() const { return count_ == 0; } + const uint32_t& front() const { return data_[tail_]; } + // Access by index (0 = oldest) + uint32_t operator[](size_t idx) const { + return data_[(tail_ + idx) % 40]; + } + // Convert to vector-like access for sorting etc. + void copy_to_array(uint32_t* out) const { + for (size_t i = 0; i < count_; ++i) + out[i] = (*this)[i]; + } + + void clear() { + head_ = 0; + tail_ = 0; + count_ = 0; + } + + private: + uint32_t data_[40]; + size_t head_; + size_t tail_; + size_t count_; +}; + +class MorseDecoder { + public: + struct DecodeResult { + std::string text = ""; + double confidence = 0.0; + + bool isValid() const { + return !text.empty(); + } + }; + + struct MorseEntry { + std::string code; + std::string letter; + }; + + MorseDecoder() {} + + void resetLearning() { + time_unit_ms_ = 119.0; + current_sequence_ = ""; + last_sequence_ = ""; + last_confidence_ = 0.0; + pulse_history_.clear(); + pulse_gaps_.clear(); + } + + DecodeResult + handleInput(int32_t duration_ms) { + DecodeResult result = {"", 0.0}; + + if (duration_ms < 5 && duration_ms > -5) return result; + + if (duration_ms > 0) { + pulse_history_.push_back(duration_ms); + double dah_prob = getDahProbability(duration_ms); + current_sequence_ += (dah_prob > 0.5) ? '-' : '.'; + last_confidence_ = (dah_prob > 0.5) ? dah_prob : (1.0 - dah_prob); + last_sequence_ = current_sequence_; + + } else { + uint32_t gap_duration = -duration_ms; + pulse_gaps_.push_back(gap_duration); + + if (gap_duration >= getInterCharThreshold() && !current_sequence_.empty()) { + result.text = lookupMorse(current_sequence_); + + result.confidence = (result.text[0] != '{') ? last_confidence_ : 0.0; + if (gap_duration >= getInterWordThreshold()) { + result.text += " "; + } + current_sequence_ = ""; + } + } + + updateLearning(); + + return result; + } + + inline double getInterElementThreshold() { return time_unit_ms_ * 0.8; } + inline double getInterCharThreshold() { return time_unit_ms_ * 2.5; } + inline double getInterWordThreshold() { return time_unit_ms_ * 6.0; } + + inline double getCurrentTimeUnit() { return time_unit_ms_; } + inline std::string getLastSequence() { return last_sequence_; } + + private: + std::string current_sequence_ = ""; + std::string last_sequence_ = ""; + double time_unit_ms_ = 119.0; + double last_confidence_ = 0.0; + MorseRingBuffer pulse_history_{}; + MorseRingBuffer pulse_gaps_{}; + + std::string lookupMorse(const std::string& seq) { + for (size_t i = 0; i < morse_table_size_; i++) { + if (seq == morse_table_[i].code) + return morse_table_[i].letter; + } + return "{" + seq + "}"; // not found + } + + double getDahProbability(uint32_t duration_ms) { + double start_interp = 1.5 * time_unit_ms_; + double end_interp = 2.5 * time_unit_ms_; + + if (duration_ms <= start_interp) return 0.0; + if (duration_ms >= end_interp) return 1.0; + return ((double)(duration_ms)-start_interp) / (end_interp - start_interp); + } + + size_t findDecisionBoundary(uint32_t* sorted_data, size_t sorted_data_size) { + if (sorted_data_size < 4) return 0; + + size_t best_split_index = 0; + uint32_t max_diff = 0; + + for (size_t i = 1; i < sorted_data_size; ++i) { + uint32_t diff = sorted_data[i] - sorted_data[i - 1]; + if (diff > sorted_data[i - 1] * 0.5 && diff > max_diff) { + max_diff = diff; + best_split_index = i; + } + } + return best_split_index; + } + + bool calculatePulseUnit(double& unit, double& confidence) { + if (pulse_history_.size() < 10) return false; + uint32_t sorted_pulses[pulse_history_.size()]; + pulse_history_.copy_to_array(sorted_pulses); + sort_uint32(sorted_pulses, pulse_history_.size()); + + size_t split_index = findDecisionBoundary(sorted_pulses, pulse_history_.size()); + if (split_index == 0 || split_index < 3 || (pulse_history_.size() - split_index) < 2) { + return false; + } + double dit_sum = sum_uint32_range(sorted_pulses, 0, split_index); + double dah_sum = sum_uint32_range(sorted_pulses, split_index, pulse_history_.size()); + + double avg_dit = dit_sum / split_index; + double avg_dah = dah_sum / (pulse_history_.size() - split_index); + if (avg_dah <= avg_dit) return false; + + double ratio = avg_dah / avg_dit; + if (ratio > 1.5 && ratio < 5.0) { + unit = avg_dit; + double tmpabs = ratio - 3.0; + if (tmpabs < 0) tmpabs *= -1; + tmpabs /= 3.0; + tmpabs = 1.0 - tmpabs; + if (tmpabs < 0) tmpabs = 0; + confidence = tmpabs; // 0..1 + return true; + } + + return false; + } + + bool calculateGapUnit(double& unit, double& confidence) { + if (pulse_gaps_.size() < 10) return false; + double threshold = getInterElementThreshold(); + double valid_gaps[pulse_gaps_.size()]; + size_t valid_count = 0; + for (size_t i = 0; i < pulse_gaps_.size(); i++) { + double gap = pulse_gaps_[i]; + if (gap <= threshold) { + valid_gaps[valid_count++] = gap; + } + } + if (valid_count < 2) { + return false; + } + double sum = sum_double_range(valid_gaps, 0, valid_count); + size_t count_to_average = valid_count; + + if (count_to_average > 0) { + unit = sum / count_to_average; + confidence = 0.8; + return true; + } + + return false; + } + + double sum_uint32_range(const uint32_t* data, size_t start, size_t end) { + double sum = 0.0; + for (size_t i = start; i < end; i++) { + sum += data[i]; + } + return sum; + } + double sum_double_range(const double* data, size_t start, size_t end) { + double sum = 0.0; + for (size_t i = start; i < end; i++) { + sum += data[i]; + } + return sum; + } + + void sort_uint32(uint32_t* data, size_t size) { + if (size < 2) + return; + + for (size_t i = 1; i < size; i++) { + uint32_t key = data[i]; + size_t j = i; + + while (j > 0 && data[j - 1] > key) { + data[j] = data[j - 1]; + j--; + } + + data[j] = key; + } + } + + double clamp_double(double value, double min_val, double max_val) { + if (value < min_val) + return min_val; + else if (value > max_val) + return max_val; + else + return value; + } + + void updateLearning() { + double pulse_unit = -1.0, pulse_confidence = 0.0; + double gap_unit = -1.0, gap_confidence = 0.0; + + bool pulse_success = calculatePulseUnit(pulse_unit, pulse_confidence); + bool gap_success = calculateGapUnit(gap_unit, gap_confidence); + + double new_time_unit = -1.0; + if (pulse_success && pulse_confidence > 0.5) { + new_time_unit = pulse_unit; + } else if (pulse_success && gap_success) { + gap_confidence = 0.2; + double total_confidence = pulse_confidence + gap_confidence; + new_time_unit = (pulse_unit * pulse_confidence + gap_unit * gap_confidence) / total_confidence; + } else if (gap_success) { + new_time_unit = gap_unit; + } else { + return; + } + + double max_change = time_unit_ms_ * 0.25; + new_time_unit = clamp_double(new_time_unit, time_unit_ms_ - max_change, time_unit_ms_ + max_change); + double DEFAULT_TIME_UNIT = 160.0; + double BASE_LEARNING_RATE = 0.05; + double MAX_LEARNING_RATE = 0.25; + double tudeltaabs = new_time_unit - DEFAULT_TIME_UNIT; + if (tudeltaabs < 0) tudeltaabs *= -1; + double deviation_from_default = tudeltaabs / DEFAULT_TIME_UNIT; + double tpp = deviation_from_default * 2.0; + if (tpp > 1) tpp = 1; + double learning_factor = BASE_LEARNING_RATE + (MAX_LEARNING_RATE - BASE_LEARNING_RATE) * tpp; + + time_unit_ms_ = (time_unit_ms_ * (1.0 - learning_factor)) + (new_time_unit * learning_factor); + } + + size_t morse_table_size_ = 50; + MorseEntry morse_table_[50] = { + {".-", "A"}, + {"-...", "B"}, + {"-.-.", "C"}, + {"-..", "D"}, + {".", "E"}, + {"..-.", "F"}, + {"--.", "G"}, + {"....", "H"}, + {"..", "I"}, + {".---", "J"}, + {"-.-", "K"}, + {".-..", "L"}, + {"--", "M"}, + {"-.", "N"}, + {"---", "O"}, + {".--.", "P"}, + {"--.-", "Q"}, + {".-.", "R"}, + {"...", "S"}, + {"-", "T"}, + {"..-", "U"}, + {"...-", "V"}, + {".--", "W"}, + {"-..-", "X"}, + {"-.--", "Y"}, + {"--..", "Z"}, + {".----", "1"}, + {"..---", "2"}, + {"...--", "3"}, + {"....-", "4"}, + {".....", "5"}, + {"-....", "6"}, + {"--...", "7"}, + {"---..", "8"}, + {"----.", "9"}, + {"-----", "0"}, + {".-.-.-", "."}, + {"..--..", "?"}, + {"-.-.--", "!"}, + {"--..--", ","}, + {"-...-", "="}, + {"-..-.", "/"}, + {".--.-.", "@"}, + {"---...", ":"}, + {"-....-", "-"}, + {".----.", "'"}, + {".-..-.", "\""}, + {"-.--.", "("}, + {"-.--.-", ")"}, + {".-.-.", "+"}}; +}; + +} // namespace ui::external_app::morse_radio + +#endif // __MORSEDECODER_HPP__ diff --git a/firmware/application/external/morse_radio/ui_morse_radio.cpp b/firmware/application/external/morse_radio/ui_morse_radio.cpp new file mode 100644 index 000000000..4570d4fa2 --- /dev/null +++ b/firmware/application/external/morse_radio/ui_morse_radio.cpp @@ -0,0 +1,266 @@ +#include "ui_morse_radio.hpp" +#include "portapack_persistent_memory.hpp" + +using namespace portapack; + +namespace ui::external_app::morse_radio { + +MorseRadioView::MorseRadioView(ui::NavigationView& nav) + : nav_(nav) { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + add_children({&rssi, + &field_rf_amp, + &field_lna, + &field_vga, + &field_volume, + &field_frequency, + &txt_last, + &txt_speed, + &btn_clear, + &console_text, + &labels, + &chk_log, + &field_squelch}); + field_frequency.set_step(100); + + btn_clear.on_select = [this](Button&) { + console_text.clear(true); + txt_last.set(""); + time_stamp = false; + if (logger && save_log) + logger->init_daily_log(logs_dir); + }; + + audio::set_rate(audio::Rate::Hz_24000); + audio::output::start(); + + receiver_model.set_sampling_rate(3072000); + receiver_model.set_baseband_bandwidth(1750000); + receiver_model.set_hidden_offset(0); + baseband::set_moreserx_config(); + receiver_model.set_am_configuration(4); + receiver_model.set_modulation(ReceiverModel::Mode::NarrowbandFMAudio); + receiver_model.enable(); + + auto vol = field_volume.value(); // audio volume fix + field_volume.set_value(0); + field_volume.set_value(vol); + + field_squelch.set_value(receiver_model.squelch_level()); + field_squelch.on_change = [this](int32_t v) { + receiver_model.set_squelch_level(v); + }; + + logger = std::make_unique(); + + chk_log.on_select = [this](Checkbox&, bool save) { + save_log = save; + if (logger && save_log) + logger->init_daily_log(logs_dir); + }; +} + +void MorseLogger::init_daily_log(const std::filesystem::path& log_dir) { + auto now = rtc_time::now(); + std::string pattern = "morse_"; + uint16_t y = now.year(); + pattern += (char)('0' + (y / 1000) % 10); + pattern += (char)('0' + (y / 100) % 10); + pattern += (char)('0' + (y / 10) % 10); + pattern += (char)('0' + (y % 10)); + + uint8_t m = now.month(); + pattern += (char)('0' + (m / 10) % 10); + pattern += (char)('0' + (m % 10)); + + uint8_t d = now.day(); + pattern += (char)('0' + (d / 10) % 10); + pattern += (char)('0' + (d % 10)); + + pattern += "_???.TXT"; + + auto full_pattern_path = log_dir / pattern; + auto final_path = next_filename_matching_pattern(full_pattern_path); + if (!final_path.empty()) { + this->append(final_path); + } +} + +void MorseLogger::radio_set_log() { + int64_t freq = receiver_model.target_frequency(); + std::string header = "Freq:" + to_string_dec_int(freq / 1000000) + "." + to_string_dec_int((freq % 1000000) / 1000, 3); + header += "MHz SQ:" + to_string_dec_uint(receiver_model.squelch_level()); + header += " LNA:" + to_string_dec_uint(receiver_model.lna()); + header += " VGA:" + to_string_dec_uint(receiver_model.vga()); + header += " AMP:" + std::string(receiver_model.rf_amp() ? "ON" : "OFF"); + header += "\r\nMessage:\r\n"; + + log_file.write_raw(header); +} + +bool MorseLogger::on_packet(const std::string& content, bool time) { + if (!time) { + log_file.write_raw_no_newline("\r\n\r\n"); + auto timestamp = to_string_datetime(rtc_time::now(), YMDHMS); + log_file.write_raw("[" + timestamp + "] "); + + radio_set_log(); + + char_count = 0; + time = true; + } + + if (content.empty()) return time; + + for (char c : content) { + std::string s(1, c); + + if ((char_count >= 35 && c == ' ') || (char_count >= 47)) { + log_file.write_raw_no_newline("\r\n"); + + if (c != ' ') { + log_file.write_raw_no_newline(s); + char_count = 1; + } else { + char_count = 0; + } + } else { + log_file.write_raw_no_newline(s); + char_count++; + } + } + + return time; +} + +MorseRadioView::~MorseRadioView() { + receiver_model.disable(); + baseband::shutdown(); + audio::output::stop(); +} + +void MorseRadioView::focus() { + field_frequency.focus(); +} + +void MorseRadioView::check_for_timeout() { + uint64_t now = chTimeNow(); + + if (last_activity_time == 0) { + last_activity_time = now; + return; + } + + uint64_t quiet_duration = now - last_activity_time; + + if (quiet_duration >= 60000) { + morse_decoder_.resetLearning(); + time_stamp = false; + last_activity_time = now; + } +} + +int32_t MorseRadioView::ProcessSignal(int32_t sig_time_us) { + int8_t sign = (sig_time_us > 0) ? 1 : ((sig_time_us < 0) ? -1 : 0); + int32_t result = 0; + + if (last_sign_ == 0) { + last_sign_ = sign; + accumulator_us_ = sig_time_us; + long_pause_sent_ = false; + return 0; + } + + if (sign == last_sign_) { + accumulator_us_ += sig_time_us; + + if (sign < 0) { + int32_t threshold_us = morse_decoder_.getInterWordThreshold() * 1000; + + if (!long_pause_sent_ && accumulator_us_ <= -threshold_us) { + result = accumulator_us_ / 1000; + long_pause_sent_ = true; + } + } + } else { + // state change + if (!long_pause_sent_) { + result = accumulator_us_ / 1000; + } else { + result = 0; + } + + accumulator_us_ = sig_time_us; + last_sign_ = sign; + long_pause_sent_ = false; + } + + return result; +} + +void MorseRadioView::on_data(const MorseRXDataMessage* message) { + uint16_t start = 0; + uint16_t stop = 0; + bool has_valid = false; + int32_t r; + + for (uint16_t i = 0; i <= message->maxptr; ++i) { + if (message->state_durations[i] >= 30000 || message->state_durations[i] <= -30000) { + if (!has_valid) { + start = i; + } + } else { + has_valid = true; + stop = i; + } + } + if (!has_valid) return; // no valid data arrived + + for (uint16_t i = start; i <= stop; i++) { + r = ProcessSignal(message->state_durations[i]); + auto result = morse_decoder_.handleInput((r != 0) ? r : 0); + if (result.isValid()) { + last_activity_time = chTimeNow(); // start reset timer on valid input + writeCharToConsole(result.text, result.confidence); + if (logger && save_log) { + time_stamp = logger->on_packet(result.text, time_stamp); + } + float dah_time = morse_decoder_.getCurrentTimeUnit() * 3.0f; + + if (dah_time > 0) { + uint16_t wpm = (uint16_t)(3600.0f / (dah_time + 18.0f) + 0.5f); + + txt_speed.set(to_string_dec_uint(wpm)); + } + } + } +} + +void MorseRadioView::writeCharToConsole(const std::string& ch, double confidence) { + if (ch.empty()) { + return; + } + + txt_last.set(morse_decoder_.getLastSequence().c_str()); + + last_color_id = color_id; + std::string color = ""; + + if (ch == " ") { + color_id = 0; + } else if (ch[0] == '{') { // no match + color_id = 0; + } else { + if (confidence < 0.8) + color_id = 1; + else if (confidence < 0.9) + color_id = 2; + else + color_id = 3; + } + color = arr_color[color_id]; + last_color_id = color_id; + console_text.write(color + ch); +} + +} // namespace ui::external_app::morse_radio diff --git a/firmware/application/external/morse_radio/ui_morse_radio.hpp b/firmware/application/external/morse_radio/ui_morse_radio.hpp new file mode 100644 index 000000000..379c37c1f --- /dev/null +++ b/firmware/application/external/morse_radio/ui_morse_radio.hpp @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2026 Pezsma + * + * 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 __MORSE_RADIO_H__ +#define __MORSE_RADIO_H__ + +#include "ui.hpp" +#include "ui_widget.hpp" +#include "ui_navigation.hpp" +#include "string_format.hpp" +#include "external_app.hpp" +#include "ui_freq_field.hpp" +#include "radio_state.hpp" +#include "portapack.hpp" +#include "message.hpp" +#include "audio.hpp" +#include "baseband_api.hpp" +#include "receiver_model.hpp" +#include "tone_key.hpp" +#include "log_file.hpp" +#include "file_path.hpp" +#include "file.hpp" +#include "rtc_time.hpp" +#include "morsedecoder.hpp" +#include + +namespace ui::external_app::morse_radio { + +class MorseRadioView; + +class MorseLogger { + public: + Optional append(const std::filesystem::path& filename) { + return log_file.append(filename); + } + + void init_daily_log(const std::filesystem::path& log_dir); + bool on_packet(const std::string& content, bool time); + void radio_set_log(); + + private: + LogFile log_file{}; + uint8_t char_count{0}; // log file line break +}; + +class MorseRadioView : public ui::View { + public: + MorseRadioView(ui::NavigationView& nav); + ~MorseRadioView(); + std::string title() const override { + return "Morse"; + } + void focus() override; + + private: + void writeCharToConsole(const std::string& ch, double confidence); + void on_data(const MorseRXDataMessage* message); + void on_message(const Message* const p); + void check_for_timeout(); + int32_t ProcessSignal(int32_t sig_time_us); + ui::NavigationView& nav_; + MorseDecoder morse_decoder_{}; + RxRadioState radio_state_{}; + std::unique_ptr logger{}; + app_settings::SettingsManager settings_{ + "trx_morese_radio", app_settings::Mode::RX_TX}; + + 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)}}; + + NumberField field_squelch{ + {UI_POS_X(10), UI_POS_Y(1)}, + 2, + {0, 99}, + 1, + ' ', + }; + ui::Text txt_speed{{UI_POS_X(23), UI_POS_Y(1), UI_POS_WIDTH(3), UI_POS_HEIGHT(1)}, "??"}; + ui::Text txt_last{{UI_POS_X(12), UI_POS_Y(4), UI_POS_WIDTH_REMAINING(12), UI_POS_HEIGHT(1)}, ""}; + ui::Console console_text{{UI_POS_X(0), UI_POS_Y(5), UI_POS_MAXWIDTH, UI_POS_HEIGHT_REMAINING(7)}}; + ui::Labels labels{ + {{UI_POS_X(0), UI_POS_Y(1)}, "Squelch:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(15), UI_POS_Y(1)}, "Speed:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(26), UI_POS_Y(1)}, "wpm", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(4)}, "Last seq.:", Theme::getInstance()->fg_light->foreground}, + }; + Checkbox chk_log{{UI_POS_X(0), UI_POS_Y(2)}, 12, "Log", false}; + ui::Button btn_clear{{UI_POS_X(0), UI_POS_Y_BOTTOM(2), UI_POS_WIDTH(6), UI_POS_HEIGHT(1)}, "CLR"}; + + uint8_t last_color_id{255}; + uint8_t color_id{255}; + std::string arr_color[4] = {STR_COLOR_WHITE, STR_COLOR_RED, STR_COLOR_YELLOW, STR_COLOR_GREEN}; + + int32_t accumulator_us_{0}; + int8_t last_sign_{0}; + uint8_t space_timer{0}; + bool long_pause_sent_{false}; + bool save_log{false}; + bool reset_triggered_{false}; + bool time_stamp{false}; + uint64_t last_activity_time{0}; + + MessageHandlerRegistration message_handler_packet{ + Message::ID::MorseRXData, + [this](Message* const p) { + const auto message = static_cast(p); + this->on_data(message); + }}; + + MessageHandlerRegistration message_handler_framesync{ + Message::ID::DisplayFrameSync, + [this](const Message* const p) { + (void)p; + this->check_for_timeout(); + }}; +}; + +} // namespace ui::external_app::morse_radio + +#endif // __MORSE_RADIO_H__ diff --git a/firmware/application/log_file.cpp b/firmware/application/log_file.cpp index 105fd7bad..8f915dc6e 100644 --- a/firmware/application/log_file.cpp +++ b/firmware/application/log_file.cpp @@ -38,3 +38,11 @@ Optional LogFile::write_raw(const std::string& message) { } return error; } + +Optional LogFile::write_raw_no_newline(const std::string& message) { + auto result_s = file.write(message.c_str(), message.size()); + if (!result_s.is_error()) { + file.sync(); + } + return {result_s.error()}; +} diff --git a/firmware/application/log_file.hpp b/firmware/application/log_file.hpp index 423552e38..b5e963318 100644 --- a/firmware/application/log_file.hpp +++ b/firmware/application/log_file.hpp @@ -40,6 +40,7 @@ class LogFile { Optional write_entry(const std::string& entry); Optional write_entry(const rtc::RTC& datetime, const std::string& entry); Optional write_raw(const std::string& message); + Optional write_raw_no_newline(const std::string& message); private: File file{}; diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index 442dbcac0..0f61f5870 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -663,6 +663,11 @@ set(MODE_CPPSRC ) DeclareTargets(PSCD subcar) +set(MODE_CPPSRC + proc_morse.cpp + +) +DeclareTargets(PMRS morse) ### SD over USB diff --git a/firmware/baseband/proc_morse.cpp b/firmware/baseband/proc_morse.cpp new file mode 100644 index 000000000..449116c05 --- /dev/null +++ b/firmware/baseband/proc_morse.cpp @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2026 Pezsma + * + * 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_morse.hpp" +#include "audio_dma.hpp" +#include "portapack_shared_memory.hpp" +#include "event_m4.hpp" +#include + +void MorseProcessor::configure() { + configured = false; + baseband_fs = 3072000; + baseband_thread.set_sampling_rate(baseband_fs); + + // 1. DSP filters + 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.configure(24000, 5000); + audio_output.configure(iir_config_passthrough, iir_config_passthrough, 0.0f); + + // 2. Resetting variables + dc_offset = 0; + lpf_sample = 0; + prev_sample = 0; + + // 3. Algorithm reset + zc_counter = 0; + last_zc_counter = 0; + current_freq = 700.0f; + update_goertzel_coeff(700.0f); + + goertzel_count = 0; + s_prev_i = 0; + s_prev2_i = 0; + duration_samples = 0; + was_signaling = false; + + noise_floor = 5000; // learning speed + + startup_delay = 20; + + squelch_is_open = false; + configured = true; +} + +void MorseProcessor::on_message(const Message* const p) { + if (p->id == Message::ID::MorseRXConfig) { + configure(); + } else if (p->id == Message::ID::NBFMConfigure) { + auto nbfm_msg = *reinterpret_cast(p); + user_squelch_level = nbfm_msg.squelch_level; + audio_output.configure(iir_config_passthrough, iir_config_passthrough, (float)user_squelch_level / 100.0f); + } +} + +void MorseProcessor::update_goertzel_coeff(float freq) { + if (freq < 400.0f) freq = 400.0f; + if (freq > 1500.0f) freq = 1500.0f; // limit to algo capacity min/max + float omega = 2.0f * M_PI * freq / 24000.0f; + coeff_int = (int32_t)(2.0f * cosf(omega) * 16384.0f); +} + +void MorseProcessor::execute(const buffer_c8_t& buffer) { + if (!configured) return; + + buffer_c16_t dst_buffer_c16(dst_buffer.data(), dst_buffer.size()); + const auto decim_0_out = decim_0.execute(buffer, dst_buffer_c16); + const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer_c16); + const auto channel = channel_filter.execute(decim_1_out, dst_buffer_c16); + + feed_channel_stats(channel); + + buffer_s16_t audio_buffer_s16(audio_buffer.data(), audio_buffer.size()); + auto audio_buf = demod.execute(channel, audio_buffer_s16); + + for (size_t i = 0; i < audio_buf.count; i++) { + int32_t raw_sample = audio_buf.p[i]; + + // 1. DC & LPF + dc_offset += (raw_sample - dc_offset) / 32; + int32_t sample = raw_sample - dc_offset; + lpf_sample = (lpf_sample * 3 + sample) / 4; + + // 2. Squelch + int32_t abs_sample = (sample < 0) ? -sample : sample; + int32_t audio_threshold = (user_squelch_level * user_squelch_level) * 3; + int32_t current_audio_threshold = squelch_is_open ? (audio_threshold / 2) : audio_threshold; + + if (abs_sample > current_audio_threshold || user_squelch_level == 0) { + squelch_is_open = true; + squelch_hold = 2400; + } else { + if (squelch_hold > 0) + squelch_hold--; + else + squelch_is_open = false; + } + + // 3. Frequency measurement (ZC) + if (squelch_is_open && !was_signaling) { + if ((lpf_sample >= 0 && prev_sample < 0) || (lpf_sample < 0 && prev_sample >= 0)) { + if (zc_counter >= 8 && zc_counter <= 32) { + int32_t diff = zc_counter - last_zc_counter; + if (diff >= -1 && diff <= 1) { + float n_freq = 24000.0f / (zc_counter * 2.0f); + + // Hybrid tracking + + if (zc_counter < 15) { + // This stabilizes the 1000-1400 Hz range + current_freq = (current_freq * 0.6f) + (n_freq * 0.4f); + } else { + current_freq = n_freq; + } + + update_goertzel_coeff(current_freq); + } + last_zc_counter = zc_counter; + } else { + last_zc_counter = 0; + } + zc_counter = 0; + } else { + if (zc_counter < 100) zc_counter++; + } + } + prev_sample = (int16_t)lpf_sample; + + // 4. Goertzel + int64_t s = (int64_t)sample + (((int64_t)coeff_int * s_prev_i) >> 14) - s_prev2_i; + s_prev2_i = s_prev_i; + s_prev_i = (int32_t)s; + goertzel_count++; + + // 5. Detection + if (goertzel_count >= 60) { + if (startup_delay > 0) { + startup_delay--; + int64_t pwr = (int64_t)s_prev_i * s_prev_i + (int64_t)s_prev2_i * s_prev2_i - + (((int64_t)s_prev_i * s_prev2_i * coeff_int) >> 14); + noise_floor = (noise_floor * 15 + pwr) / 16; + } else { + int64_t power = (int64_t)s_prev_i * s_prev_i + (int64_t)s_prev2_i * s_prev2_i - + (((int64_t)s_prev_i * s_prev2_i * coeff_int) >> 14); + + if (!was_signaling) { + noise_floor = (noise_floor * 127 + power) / 128; + } + + int64_t sensitivity = 4 + (user_squelch_level / 10); + int64_t base_pwr_threshold = noise_floor * sensitivity; + int64_t current_pwr_threshold = was_signaling ? (base_pwr_threshold / 2) : base_pwr_threshold; + + bool is_tone = squelch_is_open && (power > current_pwr_threshold) && (power > 150000); + + if (is_tone != was_signaling) { + int32_t duration_us = (int32_t)((int64_t)duration_samples * 125 / 3); + + if (duration_us > 10000) { + message.state_durations[0] = was_signaling ? duration_us : -duration_us; + message.measured_frequency = (uint32_t)current_freq; + message.state_cnt = 1; + shared_memory.application_queue.push(message); + } + was_signaling = is_tone; + duration_samples = 0; + } + + if (!was_signaling && duration_samples > 28800) { + int32_t duration_us = (int32_t)((int64_t)duration_samples * 125 / 3); + message.state_durations[0] = -duration_us; + message.state_cnt = 1; + shared_memory.application_queue.push(message); + duration_samples = 0; + } + } + + duration_samples += 60; + s_prev_i = 0; + s_prev2_i = 0; + goertzel_count = 0; + } + + audio_buf.p[i] = squelch_is_open ? (int16_t)sample : 0; + } + audio_output.write(audio_buf); +} + +int main() { + audio::dma::init_audio_out(); + EventDispatcher event_dispatcher{std::make_unique()}; + event_dispatcher.run(); + return 0; +} \ No newline at end of file diff --git a/firmware/baseband/proc_morse.hpp b/firmware/baseband/proc_morse.hpp new file mode 100644 index 000000000..5baa03c87 --- /dev/null +++ b/firmware/baseband/proc_morse.hpp @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 Pezsma + * + * 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_MORSE_H__ +#define __PROC_MORSE_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" + +class MorseProcessor : public BasebandProcessor { + public: + MorseProcessor() {} + + void execute(const buffer_c8_t& buffer) override; + void on_message(const Message* const p) override; + void update_goertzel_coeff(float freq); + + private: + bool configured{false}; + + size_t baseband_fs = 3072000; + + BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive}; + RSSIThread rssi_thread{}; + + std::array dst_buffer{}; + std::array audio_buffer{}; + + dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{}; + dsp::decimate::FIRC16xR16x32Decim8 decim_1{}; + dsp::decimate::FIRAndDecimateComplex channel_filter{}; + dsp::demodulate::FM demod{}; + AudioOutput audio_output{}; + + // Goertzel and signal detection + int32_t coeff_int{0}; + int32_t s_prev_i{0}; + int32_t s_prev2_i{0}; + uint32_t goertzel_count{0}; + uint32_t duration_samples{0}; + bool was_signaling{false}; + + // Variables required for measurement + int16_t prev_sample{0}; + uint32_t zc_counter{0}; + float current_freq{0.0f}; + + int32_t lpf_sample{0}; + int32_t dc_offset{0}; + + // Squelch and stability + bool squelch_is_open{false}; + int32_t squelch_hold{0}; + + int32_t user_squelch_level{0}; + int64_t noise_floor{0}; + + int32_t startup_delay{0}; // Power-on delay + int32_t last_zc_counter{0}; // Previous ZC measurement for comparison + + MorseRXDataMessage message{}; + + void configure(); +}; + +#endif // __PROC_MORSE_H__ \ No newline at end of file diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 49f2a6818..930342111 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -146,7 +146,9 @@ class Message { SSTVRXPhaseSlant = 88, SSTVRXCalibration = 89, SubCarData = 90, - TXDisabled = 91, + MorseRXData = 91, + MorseRXConfig = 92, + TXDisabled = 93, MAX }; @@ -1700,6 +1702,22 @@ class SubCarDataMessage : public Message { uint64_t data2 = 0; }; +class MorseRXDataMessage : public Message { + public: + constexpr MorseRXDataMessage() + : Message{ID::MorseRXData} {} + uint32_t measured_frequency = 0; + int32_t state_durations[2] = {0}; // positive: high, negative: low + uint16_t state_cnt = 0; + const uint16_t maxptr = 1; +}; + +class MorseRXConfigureMessage : public Message { + public: + constexpr MorseRXConfigureMessage() + : Message{ID::MorseRXConfig} {} +}; + class TXDisabledMessage : public Message { public: constexpr TXDisabledMessage() diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index 6dee37495..efa53d579 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -124,6 +124,7 @@ constexpr image_tag_t image_tag_protoview{'P', 'P', 'V', 'W'}; constexpr image_tag_t image_tag_wefaxrx{'P', 'W', 'F', 'X'}; constexpr image_tag_t image_tag_noaaapt_rx{'P', 'N', 'O', 'A'}; constexpr image_tag_t image_tag_sstv_rx{'P', 'S', 'R', 'X'}; +constexpr image_tag_t image_tag_morse{'P', 'M', 'R', 'S'}; constexpr image_tag_t image_tag_noop{'P', 'N', 'O', 'P'};