diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index 73d93b0e5..64a999bd5 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -254,6 +254,10 @@ set(EXTCPPSRC external/bht_tx/main.cpp external/bht_tx/ui_bht_tx.cpp external/bht_tx/bht.cpp + + #morse_practice + external/morse_practice/main.cpp + external/morse_practice/ui_morse_practice.cpp ) set(EXTAPPLIST @@ -318,4 +322,5 @@ set(EXTAPPLIST soundboard game2048 bht_tx + morse_practice ) diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index e228830df..0ad3d8c7e 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -83,7 +83,8 @@ MEMORY ram_external_app_epirb_rx (rwx) : org = 0xADEA0000, len = 32k ram_external_app_soundboard (rwx) : org = 0xADEB0000, len = 32k ram_external_app_game2048 (rwx) : org = 0xADEC0000, len = 32k - ram_external_app_bht_tx (rwx) : org = 0xADED0000, len = 32k + ram_external_app_bht_tx (rwx) : org = 0xADED0000, len = 32k + ram_external_app_morse_practice (rwx) : org = 0xADEE0000, len = 32k } @@ -458,5 +459,12 @@ SECTIONS } > ram_external_app_bht_tx + .external_app_morse_practice : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_morse_practice.application_information)); + *(*ui*external_app*morse_practice*); + } > ram_external_app_morse_practice + + } diff --git a/firmware/application/external/morse_practice/main.cpp b/firmware/application/external/morse_practice/main.cpp new file mode 100644 index 000000000..c93674631 --- /dev/null +++ b/firmware/application/external/morse_practice/main.cpp @@ -0,0 +1,88 @@ +/* + * 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 "ui.hpp" +#include "ui_morse_practice.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::morse_practice { + +void initialize_app(NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::morse_practice + +extern "C" { + +// Az alkalmazás információ C-linkage-ként, hogy a firmware hívhassa +__attribute__((section(".external_app.app_morse_practice.application_information"), used)) +application_information_t _application_information_morse_practice = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::morse_practice::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "Morse P", + /*.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::GAMES, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_none */ {'P', 'A', 'B', 'P'}, + /*.m4_app_offset = */ 0x00000000, // will be filled at compile time +}; + +} // extern "C" diff --git a/firmware/application/external/morse_practice/morsedecoder.hpp b/firmware/application/external/morse_practice/morsedecoder.hpp new file mode 100644 index 000000000..6ebaf8dd9 --- /dev/null +++ b/firmware/application/external/morse_practice/morsedecoder.hpp @@ -0,0 +1,349 @@ +#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 + +#define MORSEDEC_ERROR "" + +namespace ui::external_app::morse_practice { + +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]; + } + + 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() {} + DecodeResult handleInput(int32_t duration_ms) { + DecodeResult result = {"", 0.0}; + 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; + if (gap_duration >= getInterWordThreshold()) { + result.text += " "; + } + current_sequence_ = ""; + } + } + + updateLearning(); + + 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 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_ = 160.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 MORSEDEC_ERROR; // 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_ = 42; + MorseEntry morse_table_[42] = { + {".-", "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_practice + +#endif // __MORSEDECODER_HPP__ \ No newline at end of file diff --git a/firmware/application/external/morse_practice/ui_morse_practice.cpp b/firmware/application/external/morse_practice/ui_morse_practice.cpp new file mode 100644 index 000000000..d2b12d8ae --- /dev/null +++ b/firmware/application/external/morse_practice/ui_morse_practice.cpp @@ -0,0 +1,144 @@ +#include "ui_morse_practice.hpp" + +using namespace portapack; + +namespace ui::external_app::morse_practice { + +MorsePracticeView::MorsePracticeView(ui::NavigationView& nav) + : nav_(nav) { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + add_children({&btn_tt, + &txt_last, + &btn_clear, + &console_text, + &field_volume}); + audio::set_rate(audio::Rate::Hz_24000); + + btn_tt.on_select = [this](Button&) { + if (button_touch) { + button_touch = false; + return; + } + button_was_selected = true; + onPress(); + }; + btn_tt.on_touch_press = [this](Button&) { + button_touch = true; + button_was_selected = false; + onPress(); + }; + btn_tt.on_touch_release = [this](Button&) { + button_touch = true; + button_was_selected = false; + onRelease(); + }; + btn_clear.on_select = [this](Button&) { + console_text.clear(true); + txt_last.set(""); + }; + audio::output::start(); + auto vol = field_volume.value(); + field_volume.set_value(0); + field_volume.set_value(vol); +} + +MorsePracticeView::~MorsePracticeView() { + receiver_model.disable(); + baseband::shutdown(); + audio::output::stop(); +} + +void MorsePracticeView::on_show() { + console_text.write("Morse Practice ready\n"); + start_time = 0; + end_time = 0; +} + +void MorsePracticeView::focus() { + btn_tt.focus(); +} + +void MorsePracticeView::onPress() { + start_time = chTimeNow(); + if (end_time != 0) { + int64_t gap_delta = (chTimeNow() - end_time); + auto result = morse_decoder_.handleInput(-gap_delta); + if (result.isValid()) { + writeCharToConsole(result.text, result.confidence); + } + } + end_time = 0; + decode_timeout_calc = false; + baseband::request_audio_beep(1000, 24000, 2000); +} + +void MorsePracticeView::onRelease() { + end_time = chTimeNow(); + if (start_time != 0) { + int32_t press_delta = (end_time - start_time); + auto result = morse_decoder_.handleInput(press_delta); + if (result.isValid()) { + writeCharToConsole(result.text, result.confidence); + } + } + start_time = 0; + decode_timeout_calc = true; + baseband::request_beep_stop(); +} + +void MorsePracticeView::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 == MORSEDEC_ERROR) { + 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); +} + +inline bool MorsePracticeView::tx_button_held() { + const auto switches_state = get_switches_state(); + return switches_state[(size_t)ui::KeyEvent::Select]; +} + +void MorsePracticeView::on_framesync() { + if (button_was_selected && !button_touch && !tx_button_held()) { + button_was_selected = false; + onRelease(); + } + + if (end_time != 0 && decode_timeout_calc) { + int64_t gap_delta = (chTimeNow() - end_time); + + if (gap_delta >= morse_decoder_.getInterCharThreshold()) { + auto result = morse_decoder_.handleInput(-(int32_t)gap_delta); + if (result.isValid()) { + writeCharToConsole(result.text, result.confidence); + } + } + if (gap_delta >= morse_decoder_.getInterWordThreshold()) { + writeCharToConsole(" ", 1.0); + end_time = 0; + decode_timeout_calc = false; + } + } +} + +} // namespace ui::external_app::morse_practice diff --git a/firmware/application/external/morse_practice/ui_morse_practice.hpp b/firmware/application/external/morse_practice/ui_morse_practice.hpp new file mode 100644 index 000000000..5ad19ab54 --- /dev/null +++ b/firmware/application/external/morse_practice/ui_morse_practice.hpp @@ -0,0 +1,91 @@ +/* + * 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. + */ + +#ifndef __MORSE_PRACTICE_H__ +#define __MORSE_PRACTICE_H__ + +#include "ui.hpp" +#include "ui_widget.hpp" +#include "ui_navigation.hpp" +#include "ui_language.hpp" +#include "ui_painter.hpp" +#include "ui_receiver.hpp" +#include "string_format.hpp" +#include "morsedecoder.hpp" +#include "irq_controls.hpp" +#include "radio_state.hpp" +#include "portapack.hpp" +#include "message.hpp" +#include "volume.hpp" +#include "audio.hpp" +#include "baseband_api.hpp" +#include "external_app.hpp" +#include + +namespace ui::external_app::morse_practice { + +class MorsePracticeView : public ui::View { + public: + MorsePracticeView(ui::NavigationView& nav); + ~MorsePracticeView(); + + std::string title() { return "Morse P"; } + void focus() override; + void on_show() override; + + private: + void onPress(); + void onRelease(); + void on_framesync(); + void writeCharToConsole(const std::string& ch, double confidence); + bool tx_button_held(); + + ui::NavigationView& nav_; + MorseDecoder morse_decoder_{}; + + int64_t start_time = 0; + int64_t end_time = 0; + + ui::Button btn_tt{{UI_POS_X_CENTER(12), UI_POS_Y(3), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)}, "KEY"}; + ui::Text txt_last{{UI_POS_X(0), UI_POS_Y(6), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)}, ""}; + ui::Button btn_clear{{UI_POS_X(0), UI_POS_Y_BOTTOM(2), UI_POS_WIDTH(6), UI_POS_HEIGHT(1)}, "CLR"}; + ui::Console console_text{{UI_POS_X(0), UI_POS_Y(7), UI_POS_MAXWIDTH, UI_POS_HEIGHT_REMAINING(10)}}; + AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_X(0)}}; + + 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}; + + bool button_touch = false; + bool button_was_selected = false; + bool decode_timeout_calc = false; + + MessageHandlerRegistration message_handler_framesync{ + Message::ID::DisplayFrameSync, + [this](const Message* const p) { + (void)p; + this->on_framesync(); + }}; +}; + +} // namespace ui::external_app::morse_practice + +#endif // __MORSE_PRACTICE_H__