From e2b642ae25198788a7e40a2e6025bd8015e7936b Mon Sep 17 00:00:00 2001 From: Pezsma <159525557+Pezsma@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:18:13 +0100 Subject: [PATCH] New Morse TX app (#2948) * morse tx * global button repeat function improvement --- firmware/application/baseband_api.cpp | 10 + firmware/application/baseband_api.hpp | 2 + firmware/application/external/external.cmake | 13 +- firmware/application/external/external.ld | 8 + .../morse_practice/ui_morse_practice.cpp | 8 + .../morse_practice/ui_morse_practice.hpp | 3 +- .../external/morseradiotx/main.cpp | 87 ++++ .../external/morseradiotx/morsedecoder.hpp | 389 ++++++++++++++++ .../morseradiotx/ui_morse_radiotx.cpp | 437 ++++++++++++++++++ .../morseradiotx/ui_morse_radiotx.hpp | 171 +++++++ firmware/application/hw/debounce.cpp | 8 + firmware/application/hw/debounce.hpp | 2 + firmware/application/irq_controls.cpp | 16 + firmware/application/irq_controls.hpp | 2 + firmware/application/ui/ui_receiver.cpp | 10 +- firmware/baseband/CMakeLists.txt | 11 +- firmware/baseband/proc_morsetx.cpp | 97 ++++ firmware/baseband/proc_morsetx.hpp | 33 ++ firmware/common/message.hpp | 23 + firmware/common/spi_image.hpp | 1 + firmware/common/ui_widget.cpp | 131 ++++++ firmware/common/ui_widget.hpp | 47 ++ firmware/standalone/common/ui/ui_widget.cpp | 131 ++++++ firmware/standalone/common/ui/ui_widget.hpp | 47 ++ firmware/standalone/common/ui/utility.hpp | 8 +- 25 files changed, 1684 insertions(+), 11 deletions(-) create mode 100644 firmware/application/external/morseradiotx/main.cpp create mode 100644 firmware/application/external/morseradiotx/morsedecoder.hpp create mode 100644 firmware/application/external/morseradiotx/ui_morse_radiotx.cpp create mode 100644 firmware/application/external/morseradiotx/ui_morse_radiotx.hpp create mode 100644 firmware/baseband/proc_morsetx.cpp create mode 100644 firmware/baseband/proc_morsetx.hpp diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index 9bfffcf6e..838968bbc 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -373,6 +373,16 @@ void set_moreserx_config(uint8_t mode) { 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); +} + +void set_morsetx_key(bool key_down) { + const MorseTXkeyMessage message{key_down}; + 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 8bc5174da..cab673b7c 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -103,6 +103,8 @@ 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_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); void set_noaaapt_config(); void set_flex_config(); diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index 9d7a5db3c..3dc1cf906 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -91,9 +91,9 @@ set(EXTCPPSRC external/adsbtx/main.cpp external/adsbtx/ui_adsb_tx.cpp - #morse_tx 768 bytes - external/morse_tx/main.cpp - external/morse_tx/ui_morse.cpp + #morse_tx 768 bytes -- disabled because of the new morse tx app with more functions + #external/morse_tx/main.cpp + #external/morse_tx/ui_morse.cpp #sstvtx 456 bytes external/sstvtx/main.cpp @@ -293,6 +293,10 @@ set(EXTCPPSRC #morse_radio external/morse_radio/main.cpp external/morse_radio/ui_morse_radio.cpp + + #morseradiotx + external/morseradiotx/main.cpp + external/morseradiotx/ui_morse_radiotx.cpp ) set(EXTAPPLIST @@ -317,7 +321,7 @@ set(EXTAPPLIST tpmsrx protoview adsbtx - morse_tx + #morse_tx sstvtx sstvrx random_password @@ -366,4 +370,5 @@ set(EXTAPPLIST siggen sdusb morse_radio + morseradiotx ) diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index 954d59112..25c4e8447 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -93,6 +93,7 @@ MEMORY ram_external_app_sdusb (rwx) : org = 0xADF40000, len = 32k ram_external_app_morse_radio (rwx) : org = 0xADF50000, len = 32k ram_external_app_waterfall_designer (rwx) : org = 0xADF60000, len = 32k + ram_external_app_morseradiotx (rwx) : org = 0xADF70000, len = 32k } SECTIONS @@ -518,5 +519,12 @@ SECTIONS KEEP(*(.external_app.app_morse_radio.application_information)); *(*ui*external_app*morse_radio*); } > ram_external_app_morse_radio + + .external_app_morseradiotx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_morseradiotx.application_information)); + *(*ui*external_app*morseradiotx*); + } > ram_external_app_morseradiotx + } diff --git a/firmware/application/external/morse_practice/ui_morse_practice.cpp b/firmware/application/external/morse_practice/ui_morse_practice.cpp index 4ec20a08e..a92795be3 100644 --- a/firmware/application/external/morse_practice/ui_morse_practice.cpp +++ b/firmware/application/external/morse_practice/ui_morse_practice.cpp @@ -12,6 +12,13 @@ MorsePracticeView::MorsePracticeView(ui::NavigationView& nav) &btn_clear, &console_text, &field_volume}); + + initial_switch_config_ = get_switches_repeat_config(); + + SwitchesState config = initial_switch_config_; + config[toUType(Switch::Sel)] = false; + set_switches_repeat_config(config); + audio::set_rate(audio::Rate::Hz_24000); btn_tt.on_select = [this](Button&) { @@ -43,6 +50,7 @@ MorsePracticeView::MorsePracticeView(ui::NavigationView& nav) } MorsePracticeView::~MorsePracticeView() { + set_switches_repeat_config(initial_switch_config_); receiver_model.disable(); baseband::shutdown(); audio::output::stop(); diff --git a/firmware/application/external/morse_practice/ui_morse_practice.hpp b/firmware/application/external/morse_practice/ui_morse_practice.hpp index c28fdfa34..64135df03 100644 --- a/firmware/application/external/morse_practice/ui_morse_practice.hpp +++ b/firmware/application/external/morse_practice/ui_morse_practice.hpp @@ -68,7 +68,7 @@ class MorsePracticeView : public ui::View { 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)}}; + AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y(0)}}; uint8_t last_color_id = 255; uint8_t color_id = 255; @@ -77,6 +77,7 @@ class MorsePracticeView : public ui::View { bool button_touch = false; bool button_was_selected = false; bool decode_timeout_calc = false; + SwitchesState initial_switch_config_{}; MessageHandlerRegistration message_handler_framesync{ Message::ID::DisplayFrameSync, diff --git a/firmware/application/external/morseradiotx/main.cpp b/firmware/application/external/morseradiotx/main.cpp new file mode 100644 index 000000000..c4af55f94 --- /dev/null +++ b/firmware/application/external/morseradiotx/main.cpp @@ -0,0 +1,87 @@ +/* + * 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_radiotx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::morseradiotx { + +void initialize_app(NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::morseradiotx + +extern "C" { + +__attribute__((section(".external_app.app_morseradiotx.application_information"), used)) +application_information_t _application_information_morseradiotx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::morseradiotx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "Morse TX", + /*.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::TX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_none */ {'P', 'M', 'R', 'T'}, + /*.m4_app_offset = */ 0x00000000, // will be filled at compile time +}; + +} // extern "C" diff --git a/firmware/application/external/morseradiotx/morsedecoder.hpp b/firmware/application/external/morseradiotx/morsedecoder.hpp new file mode 100644 index 000000000..47b473f4d --- /dev/null +++ b/firmware/application/external/morseradiotx/morsedecoder.hpp @@ -0,0 +1,389 @@ +#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::morseradiotx { + +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(); + } + + const char* get_morse_pattern(char c) { + char upper_c = (c >= 'a' && c <= 'z') ? c - 32 : c; + + for (size_t i = 0; i < morse_table_size_; ++i) { + if (morse_table_[i].letter[0] == upper_c) { + return morse_table_[i].code.c_str(); + } + } + return nullptr; + } + + 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::morseradiotx + +#endif // __MORSEDECODER_HPP__ diff --git a/firmware/application/external/morseradiotx/ui_morse_radiotx.cpp b/firmware/application/external/morseradiotx/ui_morse_radiotx.cpp new file mode 100644 index 000000000..5ef99e5c7 --- /dev/null +++ b/firmware/application/external/morseradiotx/ui_morse_radiotx.cpp @@ -0,0 +1,437 @@ +#include "ui_morse_radiotx.hpp" + +using namespace portapack; + +namespace ui::external_app::morseradiotx { + +static msg_t tx_thread_fn(void* arg) { + auto view = reinterpret_cast(arg); + chRegSetThreadName("morse_tx_thread"); + view->transmit_morse_message(); + return 0; +} + +MorseRadiotxView::MorseRadiotxView(ui::NavigationView& nav) + : current_timings(calculate_morse_timings(20)), + nav_(nav) { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + add_children({&tx_view, + &field_volume, + &labels, + &options_mode, + &tone_, + &wpm_, + &txt_msg, + &btn_message, + &btn_calls, + &chk_trans, + &bandwidth, + &chk_callsgn, + &txt_last, + &console_text, + &btn_clear, + &btn_ptt}); + + initial_switch_config_ = get_switches_repeat_config(); + + SwitchesState config = initial_switch_config_; + config[toUType(Switch::Sel)] = false; + set_switches_repeat_config(config); + + audio::set_rate(audio::Rate::Hz_24000); + + btn_clear.on_select = [this](Button&) { + console_text.clear(true); + txt_last.set(""); + }; + + options_mode.on_change = [this](size_t, int32_t value) { + current_mode = (uint8_t)value; + baseband::set_morsetx_config(current_mode, tone, band); + ui_toggle(); + }; + + tone_.on_change = [this](int32_t v) { + tone = static_cast(v); + baseband::set_morsetx_config(current_mode, tone, band); + }; + + wpm_.on_change = [this](int16_t v) { + wpm = v; + current_timings = calculate_morse_timings(wpm); + }; + + bandwidth.on_change = [this](float v) { + band = v; + baseband::set_morsetx_config(current_mode, tone, band); + }; + + options_mode.set_selected_index(current_mode, true); + tone_.set_value(tone, true); + wpm_.set_value(wpm, true); + bandwidth.set_value(band, true); + + btn_ptt.on_select = [this](Button&) { + if (button_touch) { + button_touch = false; + return; + } + button_was_selected = true; + onPress(); + }; + + btn_ptt.on_touch_press = [this](Button&) { + button_touch = true; + button_was_selected = false; + onPress(); + }; + btn_ptt.on_touch_release = [this](Button&) { + if (chk_trans.value() && transmit) { + button_touch = true; + button_was_selected = false; + onRelease(); + } + }; + + btn_message.on_select = [this, &nav](Button&) { + if (!transmit) + text_prompt(nav, msg_buffer, 27, ENTER_KEYBOARD_MODE_ALPHA, [this](std::string& buffer) { + msg_buffer = buffer; + txt_msg.set("[" + msg_buffer + "] "); + }); + }; + + btn_calls.on_select = [this, &nav](Button&) { + if (!transmit) { + text_prompt(nav, call_sign, 10, ENTER_KEYBOARD_MODE_ALPHA, [this](std::string& buffer) { + call_sign = buffer; + if (call_sign.empty()) + btn_calls.set_text("call sign?"); + else + btn_calls.set_text(call_sign); + }); + } + }; + + audio::output::start(); + auto vol = field_volume.value(); + field_volume.set_value(0); + field_volume.set_value(vol); + + tx_view.on_start = [this]() { + if (!chk_trans.value() && msg_buffer.empty()) { + tx_view.set_transmitting(false); + return; + } + tx_view.focus(); + if (!tx_thread && !thread_running) { + thread_running = true; + tx_thread = chThdCreateFromHeap(NULL, 1024, NORMALPRIO + 5, tx_thread_fn, this); + tx_view.set_transmitting(true); + } + + else + tx_view.set_transmitting(false); + }; + + tx_view.on_stop = [this]() { + baseband::set_morsetx_key(false); + transmitter_model.disable(); + + if (tx_thread) { + chThdTerminate(tx_thread); + chThdWait(tx_thread); + } + transmit = false; + transmit_time = 0; + tx_thread = nullptr; + thread_running = false; + tx_view.set_transmitting(false); + ui_toggle(); + }; + + 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); + }; + }; +} + +MorseRadiotxView::~MorseRadiotxView() { + set_switches_repeat_config(initial_switch_config_); + if (tx_thread) { + chThdTerminate(tx_thread); + chThdWait(tx_thread); + if (!thread_running) tx_thread = nullptr; + } + + transmitter_model.disable(); + baseband::shutdown(); + audio::output::stop(); +} + +void MorseRadiotxView::on_show() { + ptt_button_visibility(true); + start_time = 0; + end_time = 0; + transmit_time = 0; +} + +void MorseRadiotxView::focus() { + options_mode.focus(); +} + +MorseRadiotxView::MorseTimings MorseRadiotxView::calculate_morse_timings(uint32_t wpm) { + MorseRadiotxView::MorseTimings t; + if (wpm < 10) wpm = 10; + if (wpm > 45) wpm = 45; + t.dot_ms = 1200 / wpm; + + t.dash_ms = 3 * t.dot_ms; + t.symbol_gap = t.dot_ms; + t.char_gap = 3 * t.dot_ms; + t.word_gap = 7 * t.dot_ms; + return t; +} + +void MorseRadiotxView::transmit_morse_message() { + std::string full_message = ""; + // cal sign + if (chk_callsgn.value() && !call_sign.empty()) { + full_message = call_sign; + if (!chk_trans.value() && !msg_buffer.empty()) { + full_message += " " + msg_buffer; + } + } else { + if (!chk_trans.value()) full_message = msg_buffer; + } + + if (full_message.empty() && !chk_trans.value()) { + ptt_button_visibility(false); + return; + } + + // enable transmit + if (!transmit) { + transmit = true; + transmitter_model.enable(); + ui_toggle(); + } + + for (size_t i = 0; i < full_message.length(); i++) { + if (chThdShouldTerminate()) break; + + char c = full_message[i]; + + std::string s_char(1, c); + // space + if (c == ' ') { + console_text.write(" "); + chThdSleepMilliseconds(current_timings.word_gap); + continue; + } + + const char* pattern = morse_decoder_.get_morse_pattern(c); + + if (pattern != nullptr) { + txt_last.set(pattern); + + // write blue char to console + console_text.write(STR_COLOR_BLUE + s_char); + + // Morze (dih/dah) send + for (int j = 0; pattern[j] != '\0'; j++) { + baseband::set_morsetx_key(true); + if (pattern[j] == '.') { + chThdSleepMilliseconds(current_timings.dot_ms); + } else if (pattern[j] == '-') { + chThdSleepMilliseconds(current_timings.dash_ms); + } + if (chThdShouldTerminate()) break; + // pause between signs + baseband::set_morsetx_key(false); + chThdSleepMilliseconds(current_timings.symbol_gap); + } + if (chThdShouldTerminate()) break; + if (current_timings.char_gap > current_timings.symbol_gap) { + chThdSleepMilliseconds(current_timings.char_gap - current_timings.symbol_gap); + } + } + } + + if (chk_trans.value()) ptt_button_visibility(false); + baseband::set_morsetx_key(false); + transmit_time = chTimeNow(); + decode_timeout_calc = false; + thread_running = false; +} + +void MorseRadiotxView::onPress() { + start_time = chTimeNow(); + if (!transmit) { + transmit = true; + transmitter_model.enable(); + } + baseband::set_morsetx_key(true); + 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; + transmit_time = 0; + decode_timeout_calc = false; +} + +void MorseRadiotxView::onRelease() { + end_time = chTimeNow(); + transmit_time = end_time; + baseband::set_morsetx_key(false); + 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 MorseRadiotxView::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 == 1.5) + color_id = 4; + 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); +} + +void MorseRadiotxView::ptt_button_visibility(bool hidden) { + bool hiddenorig = btn_ptt.hidden(); + if (hiddenorig != hidden) { + btn_ptt.hidden(hidden); + if (!hidden) btn_ptt.focus(); + if (hidden) { + // hack fix until widget hidig problem is not solved. + auto r = btn_ptt.screen_rect(); + Painter p; + p.fill_rectangle_unrolled8(r, Theme::getInstance()->fg_light->background); + } + } +} + +void MorseRadiotxView::ui_toggle() { + // 0=AM, 1=FM, 2=DSB, 3=USB, 4=LSB + + if (transmit) { + options_mode.set_style(Theme::getInstance()->fg_dark); + options_mode.set_focusable(false); + tone_.set_style(Theme::getInstance()->fg_dark); + tone_.set_focusable(false); + wpm_.set_style(Theme::getInstance()->fg_dark); + wpm_.set_focusable(false); + btn_message.set_style(Theme::getInstance()->fg_dark); + btn_calls.set_style(Theme::getInstance()->fg_dark); + chk_trans.set_style(Theme::getInstance()->fg_dark); + chk_trans.set_focusable(false); + bandwidth.set_style(Theme::getInstance()->fg_dark); + bandwidth.set_focusable(false); + chk_callsgn.set_style(Theme::getInstance()->fg_dark); + chk_callsgn.set_focusable(false); + + } else { + options_mode.set_style(Theme::getInstance()->bg_darker); + options_mode.set_focusable(true); + wpm_.set_style(Theme::getInstance()->bg_darker); + wpm_.set_focusable(true); + btn_message.set_style(Theme::getInstance()->bg_darker); + btn_calls.set_style(Theme::getInstance()->bg_darker); + chk_trans.set_style(Theme::getInstance()->bg_darker); + chk_trans.set_focusable(true); + chk_callsgn.set_style(Theme::getInstance()->bg_darker); + chk_callsgn.set_focusable(true); + ptt_button_visibility(true); + tone_.set_style(Theme::getInstance()->bg_darker); + tone_.set_focusable(true); + + if (current_mode == 1) { // FM mode + bandwidth.set_style(Theme::getInstance()->bg_darker); + bandwidth.set_focusable(true); + } else { + bandwidth.set_style(Theme::getInstance()->fg_dark); + bandwidth.set_focusable(false); + } + } // transmit false +} + +inline bool MorseRadiotxView::tx_button_held() { + const auto switches_state = get_switches_state(); + return switches_state[(size_t)ui::KeyEvent::Select]; +} + +void MorseRadiotxView::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; + } + } + if (transmit_time != 0 && transmit) { // Tx disable if time is up + int64_t gap_delta = (chTimeNow() - transmit_time); + if (gap_delta >= ((morse_decoder_.getInterWordThreshold() * ((chk_trans.value()) ? 10 : 1)))) { + if (tx_thread) { + chThdTerminate(tx_thread); + chThdWait(tx_thread); + } + transmit = false; + tx_view.set_transmitting(false); + transmitter_model.disable(); + thread_running = false; + tx_thread = nullptr; + transmit_time = 0; + if (!chk_trans.value()) writeCharToConsole(" ", 1.0); + ui_toggle(); + } + } +} + +} // namespace ui::external_app::morseradiotx diff --git a/firmware/application/external/morseradiotx/ui_morse_radiotx.hpp b/firmware/application/external/morseradiotx/ui_morse_radiotx.hpp new file mode 100644 index 000000000..c74bb576a --- /dev/null +++ b/firmware/application/external/morseradiotx/ui_morse_radiotx.hpp @@ -0,0 +1,171 @@ +/* + * 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_RADIOTX_H__ +#define __MORSE_RADIOTX_H__ + +#include "ui.hpp" +#include "ui_widget.hpp" +#include "ui_navigation.hpp" +#include "ui_language.hpp" +#include "ui_painter.hpp" +#include "ui_freq_field.hpp" +#include "ui_transmitter.hpp" +#include "ui_textentry.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 "string_format.hpp" +#include +#include + +namespace ui::external_app::morseradiotx { + +class MorseRadiotxView : public ui::View { + public: + MorseRadiotxView(ui::NavigationView& nav); + ~MorseRadiotxView(); + + MorseRadiotxView(const MorseRadiotxView&) = delete; + MorseRadiotxView(MorseRadiotxView&&) = delete; + MorseRadiotxView& operator=(const MorseRadiotxView&) = delete; + MorseRadiotxView& operator=(MorseRadiotxView&&) = delete; + + std::string title() const override { return "Morse Tx"; } + void focus() override; + void on_show() override; + void transmit_morse_message(); + + private: + struct MorseTimings { + uint32_t dot_ms; + uint32_t dash_ms; + uint32_t symbol_gap; + uint32_t char_gap; + uint32_t word_gap; + }; + + MorseTimings current_timings{}; + + std::string msg_indicator{""}; + MorseTimings calculate_morse_timings(uint32_t wpm); + void onPress(); + void onRelease(); + void on_framesync(); + void writeCharToConsole(const std::string& ch, double confidence); + void ui_toggle(); + bool tx_button_held(); + void ptt_button_visibility(bool hidden); + + ui::NavigationView& nav_; + MorseDecoder morse_decoder_{}; + TxRadioState radio_state_{}; + Thread* tx_thread{nullptr}; + + std::string msg_buffer{"PORTAPACK"}; + uint8_t current_mode{0}; // 0=AM, 1=FM, 2=DSB, 3=USB, 4=LSB + uint8_t wpm{20}; + uint32_t tone{700}; + float band{5.8}; + std::string call_sign{""}; + + app_settings::SettingsManager settings_{ + "tx_morseradio", + app_settings::Mode::TX, + { + {"cmode"sv, ¤t_mode}, + {"audtone"sv, &tone}, + {"wpm"sv, &wpm}, + {"message"sv, &msg_buffer}, + {"bandwith"sv, &band}, + {"call_sign"sv, &call_sign}, + }}; + + TransmitterView tx_view{ + (int16_t)UI_POS_Y_BOTTOM(4), + 10000, + 0, false}; + + AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y_BOTTOM(5)}}; + ui::OptionsField options_mode{ + {UI_POS_X(5), UI_POS_Y(0)}, + 3, + {{"AM", 0}, {"FM", 1}, {"DSB", 2}, {"USB", 3}, {"LSB", 4}}}; + NumberField tone_{{UI_POS_X(14), UI_POS_Y(0)}, 4, {400, 1400}, 10, ' ', true}; + NumberField wpm_{{UI_POS_X(25), UI_POS_Y(0)}, 2, {10, 45}, 1, ' ', true}; + FloatField bandwidth{{UI_POS_X(20), UI_POS_Y(3)}, 4, {1.0, 16.0}, 0.1, ' ', true, 1}; + + ui::Text txt_msg{{UI_POS_X(0), UI_POS_Y(1), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)}, "[" + msg_buffer + "] "}; + ui::Button btn_message{{UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(11), UI_POS_HEIGHT(1)}, "Message"}; + ui::Button btn_calls{{UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(11), UI_POS_HEIGHT(1)}, (call_sign.empty()) ? "call sign?" : call_sign}; + Checkbox chk_trans{{UI_POS_X(14), UI_POS_Y(2)}, 13, "Manual trans.", true}; + Checkbox chk_callsgn{{UI_POS_X(14), UI_POS_Y(4)}, 13, "Call sign", true}; + ui::Text txt_last{{UI_POS_X(10), UI_POS_Y(5), UI_POS_WIDTH_REMAINING(10), UI_POS_HEIGHT(1)}, ""}; + ui::Console console_text{{UI_POS_X(0), UI_POS_Y(7), UI_POS_MAXWIDTH, UI_POS_HEIGHT_REMAINING(14)}}; + ui::Button btn_clear{{UI_POS_X(0), UI_POS_Y_BOTTOM(5), UI_POS_WIDTH(5), UI_POS_HEIGHT(1)}, "CLR"}; + ui::Button btn_ptt{{UI_POS_X_CENTER(12), UI_POS_Y_BOTTOM(7), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)}, "PTT"}; + + ui::Labels labels{ + {{UI_POS_X(0), UI_POS_Y(0)}, "Mode:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(9), UI_POS_Y(0)}, "Tone:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(18), UI_POS_Y(0)}, "Hz", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(21), UI_POS_Y(0)}, "WPM:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(14), UI_POS_Y(3)}, "BandW:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(24), UI_POS_Y(3)}, "kHz", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(5)}, "Last seq:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(6)}, "Sent Message:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X_RIGHT(7), UI_POS_Y_BOTTOM(5)}, "Vol.:", Theme::getInstance()->fg_light->foreground}, + }; + + 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}; + bool transmit{false}; + bool thread_running = false; + + uint8_t send_indicator{5}; + int64_t start_time{0}; + int64_t end_time{0}; + int64_t transmit_time{0}; + SwitchesState initial_switch_config_{}; + + MessageHandlerRegistration message_handler_framesync{ + Message::ID::DisplayFrameSync, + [this](const Message* const p) { + (void)p; + this->on_framesync(); + }}; +}; + +} // namespace ui::external_app::morseradiotx + +#endif // __MORSE_RADIOTX_H__ diff --git a/firmware/application/hw/debounce.cpp b/firmware/application/hw/debounce.cpp index 17905edb1..585b66753 100644 --- a/firmware/application/hw/debounce.cpp +++ b/firmware/application/hw/debounce.cpp @@ -37,6 +37,14 @@ void Debounce::enable_repeat() { repeat_enabled_ = true; } +void Debounce::set_enable_repeat(bool enabled) { + repeat_enabled_ = enabled; +} + +bool Debounce::get_repeat_enabled() { + return repeat_enabled_; +} + bool Debounce::get_long_press_enabled() const { return long_press_enabled_; } diff --git a/firmware/application/hw/debounce.hpp b/firmware/application/hw/debounce.hpp index b40475588..d7a696125 100644 --- a/firmware/application/hw/debounce.hpp +++ b/firmware/application/hw/debounce.hpp @@ -43,6 +43,8 @@ class Debounce { bool feed(const uint8_t bit); uint8_t state(); void enable_repeat(); + void set_enable_repeat(bool enabled); + bool get_repeat_enabled(); bool get_long_press_enabled() const; void set_long_press_enabled(bool v); bool long_press_occurred(); diff --git a/firmware/application/irq_controls.cpp b/firmware/application/irq_controls.cpp index f1022035f..668b6c6a3 100644 --- a/firmware/application/irq_controls.cpp +++ b/firmware/application/irq_controls.cpp @@ -250,6 +250,22 @@ SwitchesState get_switches_state() { return result; } +/* Gets the repeat enabled state for all the switches. */ +SwitchesState get_switches_repeat_config() { + SwitchesState result; + + for (size_t i = 0; i < result.size(); i++) + result[i] = switch_debounce[i].get_repeat_enabled(); + + return result; +} + +/* Configures which switches support repeat.*/ +void set_switches_repeat_config(SwitchesState switch_config) { + for (size_t i = 0; i < switch_config.size(); i++) + switch_debounce[i].set_enable_repeat(switch_config[i]); +} + /* Gets the long press enabled state for all the switches. */ SwitchesState get_switches_long_press_config() { SwitchesState result; diff --git a/firmware/application/irq_controls.hpp b/firmware/application/irq_controls.hpp index cf0883c2b..6815b6db4 100644 --- a/firmware/application/irq_controls.hpp +++ b/firmware/application/irq_controls.hpp @@ -48,6 +48,8 @@ uint8_t swizzled_switches(); SwitchesState get_switches_state(); EncoderPosition get_encoder_position(); touch::Frame get_touch_frame(); +SwitchesState get_switches_repeat_config(); +void set_switches_repeat_config(SwitchesState switch_config); SwitchesState get_switches_long_press_config(); void set_switches_long_press_config(SwitchesState switch_config); diff --git a/firmware/application/ui/ui_receiver.cpp b/firmware/application/ui/ui_receiver.cpp index 429ee7e17..7a5b20822 100644 --- a/firmware/application/ui/ui_receiver.cpp +++ b/firmware/application/ui/ui_receiver.cpp @@ -26,6 +26,7 @@ #include "string_format.hpp" #include "ui_receiver.hpp" #include "ui_freqman.hpp" +#include "audio.hpp" using namespace portapack; @@ -591,8 +592,13 @@ AudioVolumeField::AudioVolumeField( /* fill char */ ' '} { set_value(receiver_model.normalized_headphone_volume()); - on_change = [](int32_t v) { - receiver_model.set_normalized_headphone_volume(v); + on_change = [](int32_t vol) { + // don't call receiver model, because this widget shuld be able to handle volume settinsg from any app, regardless of the receiver model's enables state. like the tx apps should be able to set volume too. + // this is identical to the receiver model's method + uint8_t v = clip(vol, 0, 99); + auto new_volume = volume_t::decibel(v - 99) + audio::headphone::volume_range().max; + persistent_memory::set_headphone_volume(new_volume); + audio::headphone::set_volume(new_volume); }; } diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index 0f61f5870..363a1eeaa 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -663,9 +663,10 @@ set(MODE_CPPSRC ) DeclareTargets(PSCD subcar) +### Morse RX Decoder + set(MODE_CPPSRC proc_morse.cpp - ) DeclareTargets(PMRS morse) @@ -676,6 +677,14 @@ set(MODE_INCDIR ${HACKRF_PATH}/firmware/common ${HACKRF_PATH}/firmware/libopencm3/include ) + +### Morse TX + +set(MODE_CPPSRC + proc_morsetx.cpp +) +DeclareTargets(PMRT morsetx) + set(MODE_CPPSRC sd_over_usb/proc_sd_over_usb.cpp diff --git a/firmware/baseband/proc_morsetx.cpp b/firmware/baseband/proc_morsetx.cpp new file mode 100644 index 000000000..8532f1955 --- /dev/null +++ b/firmware/baseband/proc_morsetx.cpp @@ -0,0 +1,97 @@ +#include "proc_morsetx.hpp" +#include "portapack_shared_memory.hpp" +#include "sine_table_int8.hpp" +#include "event_m4.hpp" + +#include + +void MorseTXProcessor::execute(const buffer_c8_t& buffer) { + if (!configured) return; + + for (size_t i = 0; i < buffer.count; i++) { + int8_t sample_sin; + int8_t sample_cos; + + sample_sin = (sine_table_i8[(tone_phase & 0xFF000000) >> 24]); + tone_phase += tone_delta; + + im = 0; + re = 0; + + // modulation logic + if (modulation == 0) { // AM modulation + if (key_down) { + re = 64 + (sample_sin >> 2); + } + } else if (modulation == 1) { // FM modulation + if (key_down) { + delta = sample_sin * fm_delta; + } else { + delta = 0; + } + + phase += delta; + sphase = phase + (64 << 24); + + re = (sine_table_i8[(sphase & 0xFF000000) >> 24]); + im = (sine_table_i8[(phase & 0xFF000000) >> 24]); + + } else if (modulation == 2) { // DSB + if (key_down) { + re = sample_sin; + } + } else if (modulation == 3) { // USB + if (key_down) { + sample_cos = (sine_table_i8[((tone_phase + 0x40000000) & 0xFF000000) >> 24]); + re = sample_cos; + im = sample_sin; + } + } else if (modulation == 4) { // LSB + if (key_down) { + sample_cos = (sine_table_i8[((tone_phase + 0x40000000) & 0xFF000000) >> 24]); + re = sample_cos; + im = (sine_table_i8[((tone_phase + 0x80000000) & 0xFF000000) >> 24]); + } + } + buffer.p[i] = {re, im}; + } +} + +void MorseTXProcessor::on_message(const Message* const p) { + switch (p->id) { + case Message::ID::MorseTXConfigure: { + auto message = *reinterpret_cast(p); + tone_delta = message.tone * 1398; // scale + tone = message.tone; // audio tone + modulation = message.modulation; + if (message.fm_delta == 0 && modulation == 1) { + fm_delta = 90000; + } else { + uint64_t scale = 0xFFFFFFFFULL; // 32-bit max + fm_delta = (uint32_t)((uint64_t)message.fm_delta * (scale / 1536000)); + } + break; + } + + case Message::ID::MorseTXkey: { + auto key = *reinterpret_cast(p); + key_down = key.key_down; + configured = true; + if (key_down) + audio::dma::beep_start(tone, 24000, 0); + else + audio::dma::beep_stop(); + break; + } + + default: + break; + } +} + +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_morsetx.hpp b/firmware/baseband/proc_morsetx.hpp new file mode 100644 index 000000000..2213b7652 --- /dev/null +++ b/firmware/baseband/proc_morsetx.hpp @@ -0,0 +1,33 @@ +#ifndef __PROC_MORSETX_H__ +#define __PROC_MORSETX_H__ + +#include "baseband_processor.hpp" +#include "baseband_thread.hpp" +#include "portapack_shared_memory.hpp" +#include "audio_output.hpp" +#include "audio_dma.hpp" + +#define AUDIO_OUTPUT_BUFFER_SIZE 32 +#define AUDIO_SAMPLING_RATE 24000 + +class MorseTXProcessor : public BasebandProcessor { + public: + void execute(const buffer_c8_t& buffer) override; + void on_message(const Message* const msg) override; + + private: + bool configured{false}; + bool key_down{false}; // currently the virtual key is pressed or not. + + int8_t sample{0}, re{0}, im{0}; + uint8_t modulation{0}; // 0=AM, 1=FM, 2=DSB, 3=USB, 4=LSB + uint32_t tone_delta{0}; // shifting value by tone + uint32_t fm_delta{}; + uint32_t tone_phase{0}; + uint32_t tone{0}; // audio tone frequeny + int32_t phase{0}, sphase{0}, delta{0}; // sample generation. + + BasebandThread baseband_thread{1536000, this, baseband::Direction::Transmit}; +}; + +#endif \ No newline at end of file diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 3236a17a9..2abbbe86c 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -150,6 +150,8 @@ class Message { MorseRXfreq = 92, MorseRXConfig = 93, TXDisabled = 94, + MorseTXConfigure = 95, + MorseTXkey = 96, MAX }; @@ -1734,4 +1736,25 @@ class TXDisabledMessage : public Message { } }; +class MorseTXConfigureMessage : public Message { + public: + constexpr MorseTXConfigureMessage(uint8_t modulation, uint32_t tone, float fm_delta) + : Message{ID::MorseTXConfigure}, + modulation{modulation}, + tone{tone}, + fm_delta{fm_delta} {} + + uint8_t modulation = 0; + uint32_t tone = 0; + float fm_delta = 0; +}; + +class MorseTXkeyMessage : public Message { + public: + constexpr MorseTXkeyMessage(bool key_down) + : Message{ID::MorseTXkey}, + key_down{key_down} {} + bool key_down = false; +}; + #endif /*__MESSAGE_H__*/ diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index efa53d579..4412c66a4 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -125,6 +125,7 @@ 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_morsetx{'P', 'M', 'R', 'T'}; constexpr image_tag_t image_tag_noop{'P', 'N', 'O', 'P'}; diff --git a/firmware/common/ui_widget.cpp b/firmware/common/ui_widget.cpp index 08279c6b3..ee9b52b41 100644 --- a/firmware/common/ui_widget.cpp +++ b/firmware/common/ui_widget.cpp @@ -2410,6 +2410,137 @@ bool NumberField::on_touch(const TouchEvent event) { return true; } +/* FloatField ***********************************************************/ + +FloatField::FloatField( + Point parent_pos, + int length, + range_t range, + float step, + char fill_char, + bool can_loop, + uint8_t precision_) + : Widget{{parent_pos, {8 * length, 16}}}, + range{range}, + step{step}, + length_{length}, + fill_char{fill_char}, + can_loop{can_loop}, + precision{precision_} { + set_focusable(true); +} + +float FloatField::value() const { + return value_; +} + +void FloatField::getAccessibilityText(std::string& result) { + result = to_string_decimal(value_, precision); +} +void FloatField::getWidgetName(std::string& result) { + result = "FloatField"; +} + +void FloatField::set_value(float new_value, bool trigger_change) { + const float lo = range.first; + const float hi = range.second; + + if (can_loop) { + if (new_value > hi) + new_value = lo; + else if (new_value < lo) + new_value = hi; + } + new_value = clip(new_value, lo, hi); + + // set final value if needed + if (new_value != value_) { + value_ = new_value; + if (on_change && trigger_change) on_change(value_); + set_dirty(); + } +} + +void FloatField::set_range(const float min, const float max) { + range.first = min; + range.second = max; + set_value(value_, false); +} + +void FloatField::set_step(const float new_step) { + step = new_step; +} + +void FloatField::paint(Painter& painter) { + auto text = to_string_decimal(value_, precision); + const auto paint_style = has_focus() ? style().invert() : style(); + // clip to widget size + const auto r = screen_rect(); + auto label_r = style().font.size_of(text); + size_t max_chars = (r.width()) / style().font.char_width(); + if (label_r.width() > r.width()) { + text = text.substr(0, max_chars); + } + size_t padneeded = max_chars - text.length(); + if (padneeded > 0 && fill_char) { + std::string filler(padneeded, fill_char); + text = filler + text; + } + painter.fill_rectangle(r, style().background); + painter.draw_string( + screen_pos(), + paint_style, + text); +} + +bool FloatField::on_key(const KeyEvent key) { + if (key == KeyEvent::Select) { + if (on_select) { + on_select(*this); + return true; + } else { + return on_encoder(1); + } + } + return false; +} + +bool FloatField::on_encoder(const EncoderEvent delta) { + float old_value = value_; + set_value(value() + (delta * step)); + + if (on_wrap) { + if ((delta > 0) && (value_ < old_value)) + on_wrap(1); + else if ((delta < 0) && (value_ > old_value)) + on_wrap(-1); + } + return true; +} + +bool FloatField::on_keyboard(const KeyboardEvent key) { + if (key == 10) { + if (on_select) { + on_select(*this); + return true; + } + } + if (key == '+' || key == ' ') { + return on_encoder(1); + } + if (key == '-' || key == 8) { + return on_encoder(-1); + } + return false; +} + +bool FloatField::on_touch(const TouchEvent event) { + if (event.type == TouchEvent::Type::Start) { + focus(); + } + return true; +} + /* SymField **************************************************************/ SymField::SymField( diff --git a/firmware/common/ui_widget.hpp b/firmware/common/ui_widget.hpp index f2ebe7199..8795d1f43 100644 --- a/firmware/common/ui_widget.hpp +++ b/firmware/common/ui_widget.hpp @@ -891,6 +891,53 @@ class NumberField : public Widget { bool can_loop{}; }; +class FloatField : public Widget { + public: + std::function on_select{}; + std::function on_change{}; + std::function on_wrap{}; + + using range_t = std::pair; + + FloatField(Point parent_pos, int length, range_t range, float step, char fill_char, bool can_loop, uint8_t precision_ = 1); + + FloatField(Point parent_pos, int length, range_t range, float step, char fill_char) + : FloatField{parent_pos, length, range, step, fill_char, true, 1} { + } + + FloatField() + : FloatField{{0, 0}, 1, {0, 1}, 1, ' ', true, 1} { + } + + FloatField(const FloatField&) = delete; + FloatField(FloatField&&) = delete; + + float value() const; + void set_value(float new_value, bool trigger_change = true); + void set_range(const float min, const float max); + void set_step(const float new_step); + void set_precision(uint8_t precision); + + void paint(Painter& painter) override; + + bool on_key(const KeyEvent key) override; + bool on_encoder(const EncoderEvent delta) override; + bool on_touch(const TouchEvent event) override; + bool on_keyboard(const KeyboardEvent event) override; + + void getAccessibilityText(std::string& result) override; + void getWidgetName(std::string& result) override; + + private: + range_t range; + float step; + const int length_; + const char fill_char; + float value_{0}; + bool can_loop{}; + uint8_t precision = 1; +}; + /* A widget that allows for character-by-character editing of its value. */ class SymField : public Widget { public: diff --git a/firmware/standalone/common/ui/ui_widget.cpp b/firmware/standalone/common/ui/ui_widget.cpp index f509f9d9a..96e5b10ef 100644 --- a/firmware/standalone/common/ui/ui_widget.cpp +++ b/firmware/standalone/common/ui/ui_widget.cpp @@ -2338,6 +2338,137 @@ bool NumberField::on_touch(const TouchEvent event) { return true; } +/* FloatField ***********************************************************/ + +FloatField::FloatField( + Point parent_pos, + int length, + range_t range, + float step, + char fill_char, + bool can_loop, + uint8_t precision_) + : Widget{{parent_pos, {8 * length, 16}}}, + range{range}, + step{step}, + length_{length}, + fill_char{fill_char}, + can_loop{can_loop}, + precision{precision_} { + set_focusable(true); +} + +float FloatField::value() const { + return value_; +} + +void FloatField::getAccessibilityText(std::string& result) { + result = to_string_decimal(value_, precision); +} +void FloatField::getWidgetName(std::string& result) { + result = "FloatField"; +} + +void FloatField::set_value(float new_value, bool trigger_change) { + const float lo = range.first; + const float hi = range.second; + + if (can_loop) { + if (new_value > hi) + new_value = lo; + else if (new_value < lo) + new_value = hi; + } + new_value = clip(new_value, lo, hi); + + // set final value if needed + if (new_value != value_) { + value_ = new_value; + if (on_change && trigger_change) on_change(value_); + set_dirty(); + } +} + +void FloatField::set_range(const float min, const float max) { + range.first = min; + range.second = max; + set_value(value_, false); +} + +void FloatField::set_step(const float new_step) { + step = new_step; +} + +void FloatField::paint(Painter& painter) { + auto text = to_string_decimal(value_, precision); + const auto paint_style = has_focus() ? style().invert() : style(); + // clip to widget size + const auto r = screen_rect(); + auto label_r = style().font.size_of(text); + size_t max_chars = (r.width()) / style().font.char_width(); + if (label_r.width() > r.width()) { + text = text.substr(0, max_chars); + } + size_t padneeded = max_chars - text.length(); + if (padneeded > 0 && fill_char) { + std::string filler(fill_char, padneeded); + text = filler + text; + } + painter.fill_rectangle(r, style().background); + painter.draw_string( + screen_pos(), + paint_style, + text); +} + +bool FloatField::on_key(const KeyEvent key) { + if (key == KeyEvent::Select) { + if (on_select) { + on_select(*this); + return true; + } else { + return on_encoder(1); + } + } + return false; +} + +bool FloatField::on_encoder(const EncoderEvent delta) { + float old_value = value(); + set_value(old_value + ((float)delta * step)); + + if (on_wrap) { + if ((delta > 0) && (value_ < old_value)) + on_wrap(1); + else if ((delta < 0) && (value_ > old_value)) + on_wrap(-1); + } + return true; +} + +bool FloatField::on_keyboard(const KeyboardEvent key) { + if (key == 10) { + if (on_select) { + on_select(*this); + return true; + } + } + if (key == '+' || key == ' ') { + return on_encoder(1); + } + if (key == '-' || key == 8) { + return on_encoder(-1); + } + return false; +} + +bool FloatField::on_touch(const TouchEvent event) { + if (event.type == TouchEvent::Type::Start) { + focus(); + } + return true; +} + /* SymField **************************************************************/ SymField::SymField( diff --git a/firmware/standalone/common/ui/ui_widget.hpp b/firmware/standalone/common/ui/ui_widget.hpp index d7d965e1b..344c3e7cf 100644 --- a/firmware/standalone/common/ui/ui_widget.hpp +++ b/firmware/standalone/common/ui/ui_widget.hpp @@ -885,6 +885,53 @@ class NumberField : public Widget { bool can_loop{}; }; +class FloatField : public Widget { + public: + std::function on_select{}; + std::function on_change{}; + std::function on_wrap{}; + + using range_t = std::pair; + + FloatField(Point parent_pos, int length, range_t range, float step, char fill_char, bool can_loop, uint8_t precision_ = 1); + + FloatField(Point parent_pos, int length, range_t range, float step, char fill_char) + : FloatField{parent_pos, length, range, step, fill_char, true, 1} { + } + + FloatField() + : FloatField{{0, 0}, 1, {0, 1}, 1, ' ', true, 1} { + } + + FloatField(const FloatField&) = delete; + FloatField(FloatField&&) = delete; + + float value() const; + void set_value(float new_value, bool trigger_change = true); + void set_range(const float min, const float max); + void set_step(const float new_step); + void set_precision(uint8_t precision); + + void paint(Painter& painter) override; + + bool on_key(const KeyEvent key) override; + bool on_encoder(const EncoderEvent delta) override; + bool on_touch(const TouchEvent event) override; + bool on_keyboard(const KeyboardEvent event) override; + + void getAccessibilityText(std::string& result) override; + void getWidgetName(std::string& result) override; + + private: + range_t range; + float step; + const int length_; + const char fill_char; + float value_{0}; + bool can_loop{}; + uint8_t precision = 1; +}; + /* A widget that allows for character-by-character editing of its value. */ class SymField : public Widget { public: diff --git a/firmware/standalone/common/ui/utility.hpp b/firmware/standalone/common/ui/utility.hpp index 9eab69e6d..6b88875f7 100644 --- a/firmware/standalone/common/ui/utility.hpp +++ b/firmware/standalone/common/ui/utility.hpp @@ -126,9 +126,11 @@ struct is_flags_type { template constexpr bool is_flags_type_v = is_flags_type::value; -#define ENABLE_FLAGS_OPERATORS(type) \ - template <> \ - struct is_flags_type { static constexpr bool value = true; }; +#define ENABLE_FLAGS_OPERATORS(type) \ + template <> \ + struct is_flags_type { \ + static constexpr bool value = true; \ + }; template constexpr std::enable_if_t, TEnum> operator|(TEnum a, TEnum b) {