From b5d84fcc4d11e882294f74c5b20ea0c38e301ba1 Mon Sep 17 00:00:00 2001 From: Luca <61653175+iu2frl@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:49:21 +0200 Subject: [PATCH] Airband VOR receiver and trasmitter (#3244) * First test * Reorder includes for consistency and fix string dereference in VorCdiIndicator * Add memory section and linker script entry for vor_rx application * Testing VOR TX * Moving VOR TX to SD * Code refactoring to align with guidelines * Add TODO comment to verify radial sign convention in vor_tx_config * Remove unnecessary set_dirty() calls in VorRxView methods * Update Course Deviation Indicator label and adjust UI element positions * Update VorTxView to run prepared image from memory map * Update build condition in nightly release workflow to include workflow_dispatch event * Reverting action changes * Adjust Course Deviation Indicator layout and refine painting logic * Refactor VorRxView UI elements for improved layout and clarity * Add calibration field and update radial calculation logic in VorRxView * Enhance VOR processing: add renormalization to phase oscillator, improve signal handling, and update configuration parameters * Implement radial smoothing and TO/FROM state handling in VorRxView; update VOR processing logic * Setting comments inline * Refactor VorCdiIndicator and VorRxView: change normalize_signed_degrees to use int32_t, update radial smoothing to use fixed point arithmetic to reduce flash usage * Update external app address end to 0xAE0B0000 * Update set_vor_tx_config to include enabled parameter for consistency * Refactor VorRx to omit decim_2 stage and update comments for clarity on VOR processing * Add RSSI, Channel, and Audio fields to VorRxView to mimic existing apps * Refactor VorRxView labels and status messages * Update VorRxView calibration field range to allow full circle input * Completing Ident transmission logic * Fixing TX gain and moving log label * Refactor VOR phase calculations and update radial offset handling for accurate bearing representation * Add low-pass filters to strip 9960 Hz subcarrier from audio output --- .gitignore | 1 + firmware/application/baseband_api.cpp | 10 + firmware/application/baseband_api.hpp | 2 + firmware/application/external/external.cmake | 10 + firmware/application/external/external.ld | 14 + firmware/application/external/vor_rx/main.cpp | 85 +++++ .../application/external/vor_rx/ui_vor_rx.cpp | 341 ++++++++++++++++++ .../application/external/vor_rx/ui_vor_rx.hpp | 200 ++++++++++ firmware/application/external/vor_tx/main.cpp | 85 +++++ .../application/external/vor_tx/ui_vor_tx.cpp | 133 +++++++ .../application/external/vor_tx/ui_vor_tx.hpp | 113 ++++++ firmware/baseband/CMakeLists.txt | 14 + firmware/baseband/proc_vor_rx.cpp | 272 ++++++++++++++ firmware/baseband/proc_vor_rx.hpp | 150 ++++++++ firmware/baseband/proc_vor_tx.cpp | 169 +++++++++ firmware/baseband/proc_vor_tx.hpp | 90 +++++ firmware/common/dsp_iir_config.hpp | 8 + firmware/common/message.hpp | 65 ++++ firmware/common/spi_image.hpp | 2 + firmware/tools/external_app_info.py | 2 +- 20 files changed, 1765 insertions(+), 1 deletion(-) create mode 100644 firmware/application/external/vor_rx/main.cpp create mode 100644 firmware/application/external/vor_rx/ui_vor_rx.cpp create mode 100644 firmware/application/external/vor_rx/ui_vor_rx.hpp create mode 100644 firmware/application/external/vor_tx/main.cpp create mode 100644 firmware/application/external/vor_tx/ui_vor_tx.cpp create mode 100644 firmware/application/external/vor_tx/ui_vor_tx.hpp create mode 100644 firmware/baseband/proc_vor_rx.cpp create mode 100644 firmware/baseband/proc_vor_rx.hpp create mode 100644 firmware/baseband/proc_vor_tx.cpp create mode 100644 firmware/baseband/proc_vor_tx.hpp diff --git a/.gitignore b/.gitignore index dae8329db..209a6d33e 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ venv/ # generated bitmap arr file # TODO: generate bitmap during build, since we use python during build anyway, lemme know if this is a bad idea @zxkmm /firmware/tools/bitmap.hpp +tmp/* diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index fd894245b..54d7b2b28 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -361,6 +361,16 @@ void set_noaaapt_config() { send_message(&message); } +void set_vor_config(bool enabled) { + const VorRxConfigureMessage message{enabled}; + send_message(&message); +} + +void set_vor_tx_config(uint16_t radial_deg, bool ident_enabled, const std::string& ident, bool enabled) { + const VorTxConfigureMessage message{radial_deg, ident_enabled, ident.c_str(), enabled}; + send_message(&message); +} + void set_flex_config() { const FlexConfigureMessage message{}; send_message(&message); diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index 09bc7d3f5..fbf3b2258 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -113,6 +113,8 @@ 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_vor_config(bool enabled = true); +void set_vor_tx_config(uint16_t radial_deg, bool ident_enabled = true, const std::string& ident = "", bool enabled = true); void set_flex_config(); void set_bitstream_config(uint32_t deviation, uint8_t mode); // mode 0 for am, 1 for 2fsk void set_rtty_config(uint16_t baud, uint16_t shift, uint8_t* payload = nullptr, uint16_t payload_length = 0); // baud*100 diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index 54b4b562e..1bd66eb39 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -133,6 +133,14 @@ set(EXTCPPSRC external/noaaapt_rx/main.cpp external/noaaapt_rx/ui_noaaapt_rx.cpp + #vor_rx + external/vor_rx/main.cpp + external/vor_rx/ui_vor_rx.cpp + + #vor_tx + external/vor_tx/main.cpp + external/vor_tx/ui_vor_tx.cpp + #shoppingcart_lock 272 bytes external/shoppingcart_lock/main.cpp external/shoppingcart_lock/shoppingcart_lock.cpp @@ -401,6 +409,8 @@ set(EXTAPPLIST acars_rx wefax_rx noaaapt_rx + vor_rx + vor_tx shoppingcart_lock ookbrute ook_editor diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index cb93f8a9f..9a4e8b803 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -111,6 +111,8 @@ MEMORY ram_external_app_flex_tx (rwx) : org = 0xAE060000, len = 32k ram_external_app_hard_reset (rwx) : org = 0xAE070000, len = 32k ram_external_app_secplustx (rwx) : org = 0xAE080000, len = 32k + ram_external_app_vor_rx (rwx) : org = 0xAE090000, len = 32k + ram_external_app_vor_tx (rwx) : org = 0xAE0A0000, len = 32k } SECTIONS @@ -391,6 +393,18 @@ SECTIONS *(*ui*external_app*noaaapt_rx*); } > ram_external_app_noaaapt_rx + .external_app_vor_rx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_vor_rx.application_information)); + *(*ui*external_app*vor_rx*); + } > ram_external_app_vor_rx + + .external_app_vor_tx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_vor_tx.application_information)); + *(*ui*external_app*vor_tx*); + } > ram_external_app_vor_tx + .external_app_breakout : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_breakout.application_information)); diff --git a/firmware/application/external/vor_rx/main.cpp b/firmware/application/external/vor_rx/main.cpp new file mode 100644 index 000000000..e2268cf60 --- /dev/null +++ b/firmware/application/external/vor_rx/main.cpp @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui.hpp" +#include "ui_vor_rx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::vor_rx { + +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::vor_rx + +extern "C" { + +__attribute__((section(".external_app.app_vor_rx.application_information"), used)) application_information_t _application_information_vor_rx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::vor_rx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "VOR RX", + /*.bitmap_data = */ { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x80, + 0x01, + 0xC0, + 0x03, + 0xE0, + 0x07, + 0xF0, + 0x0F, + 0xF8, + 0x1F, + 0xF8, + 0x1F, + 0xF0, + 0x0F, + 0xE0, + 0x07, + 0xC0, + 0x03, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::orange().v, + /*.menu_location = */ app_location_t::RX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_vor_rx */ {'P', 'V', 'R', 'X'}, + /*.m4_app_offset = */ 0x00000000, // will be filled at compile time +}; +} \ No newline at end of file diff --git a/firmware/application/external/vor_rx/ui_vor_rx.cpp b/firmware/application/external/vor_rx/ui_vor_rx.cpp new file mode 100644 index 000000000..4dd9fe0ad --- /dev/null +++ b/firmware/application/external/vor_rx/ui_vor_rx.cpp @@ -0,0 +1,341 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui_vor_rx.hpp" + +#include "audio.hpp" +#include "string_format.hpp" + +#include +#include + +using namespace portapack; +using namespace ui; + +namespace ui::external_app::vor_rx { + +void VorLogger::write_header() { + log_file.write_raw("Time;Course;Radial;Deviation;Quality;Flag;"); +} + +void VorLogger::log_status(const VorRxStatusDataMessage& message, uint16_t course_deg) { + const int16_t deviation = static_cast((static_cast(message.radial_deg) - static_cast(course_deg) + 540) % 360 - 180); + std::string row = ";"; + row += to_string_dec_uint(course_deg); + row += ";" + to_string_dec_uint(message.radial_deg); + row += ";" + std::to_string(deviation); + row += ";" + to_string_dec_uint(message.quality); + row += ";" + std::string(message.valid ? (message.to_from ? "TO" : "FROM") : "--"); + log_file.write_entry(rtc_time::now(), row); +} + +VorCdiIndicator::VorCdiIndicator(Point position) + : Widget{{position, {screen_width, 32}}} { +} + +void VorCdiIndicator::set_course(uint16_t course_deg) { + if (course_deg_ != course_deg) { + course_deg_ = course_deg; + set_dirty(); + } +} + +void VorCdiIndicator::set_radial(uint16_t radial_deg) { + if (radial_deg_ != radial_deg) { + radial_deg_ = radial_deg; + set_dirty(); + } +} + +void VorCdiIndicator::set_valid(bool valid) { + if (valid_ != valid) { + valid_ = valid; + set_dirty(); + } +} + +int32_t VorCdiIndicator::normalize_signed_degrees(int32_t degrees) { + while (degrees <= -180) { + degrees += 360; + } + while (degrees > 180) { + degrees -= 360; + } + return degrees; +} + +void VorCdiIndicator::paint(Painter& painter) { + const auto r = screen_rect(); + const auto theme = Theme::getInstance(); + const auto line_color = valid_ ? theme->fg_light->foreground : theme->bg_medium->foreground; + const auto needle_color = valid_ ? theme->fg_red->foreground : theme->bg_medium->foreground; + + // Clear background so old needle positions don't linger. + painter.fill_rectangle(r, theme->bg_darkest->background); + + const auto center_x = static_cast(r.left() + r.width() / 2); + const auto center_y = static_cast(r.top() + r.height() / 2); + + // Horizontal scale line. + painter.draw_hline({static_cast(r.left() + 8), center_y}, r.width() - 16, line_color); + + // Evenly spaced tick marks, longer at center. + constexpr int32_t tick_spacing = 40; + for (int32_t tick = -2; tick <= 2; ++tick) { + const auto x = static_cast(center_x + tick * tick_spacing); + const auto len = (tick == 0) ? 14 : 8; + painter.draw_vline({x, static_cast(center_y - len / 2)}, len, line_color); + } + + // Deviation needle, clamped to +/-10 degrees full-scale. + const int32_t deviation = normalize_signed_degrees(static_cast(radial_deg_) - static_cast(course_deg_)); + const int32_t scaled = std::max(-10, std::min(10, deviation)); + // +/-10 deg full-scale maps to +/-2 ticks (2 * tick_spacing); the division + // is exact for the clamped range so no rounding is needed. + const int16_t needle_offset = static_cast((scaled * (2 * tick_spacing)) / 10); + const auto needle_x = static_cast(center_x + needle_offset); + painter.draw_vline({needle_x, static_cast(r.top() + 4)}, r.height() - 8, needle_color); +} + +VorRxView::VorRxView(NavigationView& nav) + : nav_{nav} { + add_children({&field_rf_amp, + &field_lna, + &field_vga, + &field_volume, + &field_frequency, + &rssi, + &channel, + &audio, + &labels, + &text_status, + &text_band, + &text_next, + &field_course, + &text_course_unit, + &field_calibration, + &text_calib_unit, + &text_radial, + &text_flag, + &text_cdi_title, + &cdi_indicator, + &check_log, + &button_start_stop}); + + field_frequency.set_step(8333); + if (field_frequency.value() == 0) { + field_frequency.set_value(110'000'000); + } + + field_course.set_value(0); + field_course.on_change = [this](int32_t) { + update_cdi(); + }; + + field_calibration.set_value(0); + field_calibration.on_change = [this](int32_t) { + refresh_radial(); + }; + + logger = std::make_unique(); + check_log.set_value(logging_); + check_log.on_select = [this](Checkbox&, bool v) { + logging_ = v; + update_logging(); + }; + update_logging(); + + button_start_stop.on_select = [this](Button&) { + if (running_) { + stop_receiver(); + } else { + start_receiver(); + } + }; + + start_receiver(); +} + +VorRxView::~VorRxView() { + stop_receiver(); +} + +void VorRxView::focus() { + field_frequency.focus(); +} + +void VorRxView::start_receiver() { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + baseband::set_vor_config(true); + + radial_filter_valid_ = false; + flag_state_ = 0; + + receiver_model.set_hidden_offset(0); + receiver_model.set_modulation(ReceiverModel::Mode::AMAudio); + receiver_model.set_frequency_step(8333); + receiver_model.set_sampling_rate(3072000); + receiver_model.set_baseband_bandwidth(1750000); + receiver_model.enable(); + + // enable() applies the AM configuration, which forces the audio codec to + // 12 kHz. The VOR baseband keeps its channel at 48 kHz (so the 9960 Hz + // subcarrier stays representable), so re-assert 48 kHz audio afterwards. + audio::set_rate(audio::Rate::Hz_48000); + audio::output::start(); + + running_ = true; + update_status(); +} + +void VorRxView::stop_receiver() { + if (!running_) { + return; + } + + running_ = false; + baseband::set_vor_config(false); + receiver_model.disable(); + baseband::shutdown(); + audio::output::stop(); + update_status(); +} + +void VorRxView::update_status() { + text_status.set(running_ ? "Running" : "Idle"); + button_start_stop.set_text(running_ ? "Stop" : "Start"); +} + +void VorRxView::update_logging() { + if (logger && logging_) { + logger->append(logs_dir / (std::string("VOR_") + to_string_timestamp(rtc_time::now()) + ".CSV")); + logger->write_header(); + } +} + +void VorRxView::on_vor_status(const VorRxStatusDataMessage& message) { + if (!running_) { + return; + } + + last_radial_deg_ = message.radial_deg; + last_valid_ = message.valid; + have_status_ = true; + + if (message.valid) { + last_radial_deg_ = smooth_radial(message.radial_deg); + } else { + // Drop the filter history so a fresh lock doesn't drag from stale data. + radial_filter_valid_ = false; + } + + text_next.set(message.valid ? "Locked" : "Searching"); + refresh_radial(); + if (logger && logging_) { + VorRxStatusDataMessage calibrated = message; + calibrated.radial_deg = calibrated_radial(last_radial_deg_); + calibrated.to_from = (flag_state_ == 2); + logger->log_status(calibrated, field_course.value()); + } +} + +uint16_t VorRxView::smooth_radial(uint16_t radial_deg) { + // Wrapped exponential moving average. At ~10 updates/s blending 20% of each + // new reading (alpha = 0.8) gives a ~0.5 s time constant while cutting the + // jitter by ~3x. Stepping along the shortest arc in 1/64 deg fixed point + // handles the 0/360 deg wrap without any trig (which would otherwise pull + // newlib's float sin/cos/atan2 argument-reduction code into flash ROM). + constexpr int32_t scale = 64; + constexpr int32_t full_circle = 360 * scale; + const int32_t target = static_cast(radial_deg) * scale; + + if (!radial_filter_valid_) { + radial_smoothed_fp_ = target; + radial_filter_valid_ = true; + } else { + int32_t diff = (target - radial_smoothed_fp_) % full_circle; + if (diff < -full_circle / 2) { + diff += full_circle; + } else if (diff > full_circle / 2) { + diff -= full_circle; + } + // 20% step toward the new reading (1 - alpha). + radial_smoothed_fp_ += diff / 5; + radial_smoothed_fp_ %= full_circle; + if (radial_smoothed_fp_ < 0) { + radial_smoothed_fp_ += full_circle; + } + } + + return static_cast((radial_smoothed_fp_ + scale / 2) / scale) % 360; +} + +uint16_t VorRxView::calibrated_radial(uint16_t radial_deg) const { + int32_t value = (static_cast(radial_deg) + field_calibration.value()) % 360; + if (value < 0) { + value += 360; + } + return static_cast(value); +} + +void VorRxView::refresh_radial() { + if (!have_status_) { + return; + } + + const uint16_t radial = calibrated_radial(last_radial_deg_); + text_radial.set(to_string_dec_uint(radial, 3) + " deg"); + text_flag.set(to_from_label(radial)); + cdi_indicator.set_radial(radial); + cdi_indicator.set_valid(last_valid_); +} + +const char* VorRxView::to_from_label(uint16_t radial_deg) { + if (!last_valid_) { + flag_state_ = 0; + return "--"; + } + + // TO/FROM is set by the selected OBS course relative to the received radial, + // not by the radial alone. Take the difference wrapped to [-180, 180]: the + // switch is at the 90 deg abeam point. |diff| < 90 means the selected course + // leads away from the station (FROM); |diff| > 90 means toward it (TO). + int32_t diff = (static_cast(radial_deg) - field_course.value() + 540) % 360 - 180; + const int32_t adiff = (diff < 0) ? -diff : diff; + + // Hysteresis: hold the current flag within a +/-5 deg dead zone around the + // 90 deg boundary so noise near abeam doesn't rapidly toggle TO/FROM. + if (adiff < 85) { + flag_state_ = 1; // FROM + } else if (adiff > 95) { + flag_state_ = 2; // TO + } + + if (flag_state_ == 1) return "FROM"; + if (flag_state_ == 2) return "TO"; + return "--"; // abeam / ambiguous +} + +void VorRxView::update_cdi() { + cdi_indicator.set_course(field_course.value()); +} + +} // namespace ui::external_app::vor_rx \ No newline at end of file diff --git a/firmware/application/external/vor_rx/ui_vor_rx.hpp b/firmware/application/external/vor_rx/ui_vor_rx.hpp new file mode 100644 index 000000000..2940480fc --- /dev/null +++ b/firmware/application/external/vor_rx/ui_vor_rx.hpp @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#ifndef __UI_VOR_RX_H__ +#define __UI_VOR_RX_H__ + +#include "audio.hpp" +#include "baseband_api.hpp" +#include "file_path.hpp" +#include "log_file.hpp" +#include "rtc_time.hpp" +#include "portapack.hpp" +#include "ui.hpp" +#include "ui_audio.hpp" +#include "ui_channel.hpp" +#include "ui_freq_field.hpp" +#include "ui_navigation.hpp" +#include "ui_receiver.hpp" +#include "ui_rssi.hpp" + +namespace ui::external_app::vor_rx { + +class VorLogger { + public: + Optional append(const std::filesystem::path& filename) { + return log_file.append(filename); + } + + void write_header(); + void log_status(const VorRxStatusDataMessage& message, uint16_t course_deg); + + private: + LogFile log_file{}; +}; + +class VorCdiIndicator : public Widget { + public: + VorCdiIndicator(Point position); + + void set_course(uint16_t course_deg); + void set_radial(uint16_t radial_deg); + void set_valid(bool valid); + + void paint(Painter& painter) override; + + private: + static int32_t normalize_signed_degrees(int32_t degrees); + + uint16_t course_deg_{0}; + uint16_t radial_deg_{0}; + bool valid_{false}; +}; + +class VorRxView : public View { + public: + VorRxView(NavigationView& nav); + ~VorRxView(); + + void focus() override; + + std::string title() const override { return "VOR RX"; } + + private: + void start_receiver(); + void stop_receiver(); + void update_status(); + void on_vor_status(const VorRxStatusDataMessage& message); + void update_cdi(); + void refresh_radial(); + uint16_t calibrated_radial(uint16_t radial_deg) const; + uint16_t smooth_radial(uint16_t radial_deg); + const char* to_from_label(uint16_t radial_deg); + void update_logging(); + + NavigationView& nav_; + bool running_{false}; + bool logging_{false}; + bool have_status_{false}; + uint16_t last_radial_deg_{0}; + bool last_valid_{false}; + uint8_t flag_state_{0}; // TO/FROM hysteresis: 0=unknown, 1=FROM, 2=TO + // Circular exponential moving average of the radial. Each 100 ms estimate + // is noisy (~10 deg std even when locked), so blend it along the shortest + // arc in 1/64 deg fixed point to average correctly across the 0/360 deg + // wrap without trig. + bool radial_filter_valid_{false}; + int32_t radial_smoothed_fp_{0}; + + 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)}}; + AudioVolumeField field_volume{ + {screen_width - 2 * 8, UI_POS_Y(0)}}; + + RSSI rssi{ + {UI_POS_X(21), 0, UI_POS_WIDTH_REMAINING(21) - UI_POS_WIDTH(2), 4}}; + Channel channel{ + {UI_POS_X(21), 5, UI_POS_WIDTH_REMAINING(21) - UI_POS_WIDTH(2), 4}}; + Audio audio{ + {UI_POS_X(21), 10, UI_POS_WIDTH_REMAINING(21) - UI_POS_WIDTH(2), 4}}; + + NumberField field_course{ + {UI_POS_X(14), UI_POS_Y(4)}, + 3, + {0, 359}, + 1, + '0'}; + + NumberField field_calibration{ + {UI_POS_X(14), UI_POS_Y(7)}, + 4, + {-359, 359}, + 1, + ' '}; + + Labels labels{ + {{UI_POS_X(0), UI_POS_Y(1)}, "Status:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(2)}, "VOR Band:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(3)}, "Decoder:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(4)}, "Course (OBS):", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(5)}, "Rec. Radial:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(6)}, "Flag:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(7)}, "Calibration:", Theme::getInstance()->fg_light->foreground}}; + + Text text_status{ + {UI_POS_X(14), UI_POS_Y(1), UI_POS_WIDTH_REMAINING(14), UI_POS_HEIGHT(1)}, + "Idle"}; + Text text_band{ + {UI_POS_X(14), UI_POS_Y(2), UI_POS_WIDTH_REMAINING(14), UI_POS_HEIGHT(1)}, + "108.00-117.95MHz"}; + Text text_next{ + {UI_POS_X(14), UI_POS_Y(3), UI_POS_WIDTH_REMAINING(14), UI_POS_HEIGHT(1)}, + "Pending"}; + Text text_course_unit{ + {UI_POS_X(18), UI_POS_Y(4), UI_POS_WIDTH_REMAINING(18), UI_POS_HEIGHT(1)}, + "deg"}; + Text text_radial{ + {UI_POS_X(14), UI_POS_Y(5), UI_POS_WIDTH_REMAINING(14), UI_POS_HEIGHT(1)}, + "--"}; + Text text_flag{ + {UI_POS_X(14), UI_POS_Y(6), UI_POS_WIDTH_REMAINING(14), UI_POS_HEIGHT(1)}, + "--"}; + Text text_calib_unit{ + {UI_POS_X(19), UI_POS_Y(7), UI_POS_WIDTH_REMAINING(19), UI_POS_HEIGHT(1)}, + "deg"}; + + Text text_cdi_title{ + {UI_POS_X(0), UI_POS_Y(9), UI_POS_WIDTH_REMAINING(0), UI_POS_HEIGHT(1)}, + "Course Deviation Indicator"}; + + VorCdiIndicator cdi_indicator{ + {UI_POS_X(0), UI_POS_Y(10)}}; + + std::unique_ptr logger{}; + + Button button_start_stop{ + {UI_POS_X(9), UI_POS_Y(14), UI_POS_WIDTH(12), UI_POS_HEIGHT(2)}, + "Start"}; + + Checkbox check_log{ + {UI_POS_X(7), UI_POS_Y(17)}, + 3, + "LOG to SD Card", + true}; + + MessageHandlerRegistration message_handler_vor_status{ + Message::ID::VorRxStatusData, + [this](const Message* p) { + const auto message = *reinterpret_cast(p); + on_vor_status(message); + }}; +}; + +} // namespace ui::external_app::vor_rx + +#endif // __UI_VOR_RX_H__ \ No newline at end of file diff --git a/firmware/application/external/vor_tx/main.cpp b/firmware/application/external/vor_tx/main.cpp new file mode 100644 index 000000000..bf9fdfa65 --- /dev/null +++ b/firmware/application/external/vor_tx/main.cpp @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui.hpp" +#include "ui_vor_tx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::vor_tx { + +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::vor_tx + +extern "C" { + +__attribute__((section(".external_app.app_vor_tx.application_information"), used)) application_information_t _application_information_vor_tx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::vor_tx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "VOR TX", + /*.bitmap_data = */ { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x80, + 0x01, + 0xC0, + 0x03, + 0xE0, + 0x07, + 0xF0, + 0x0F, + 0xF8, + 0x1F, + 0xF8, + 0x1F, + 0xF0, + 0x0F, + 0xE0, + 0x07, + 0xC0, + 0x03, + 0x80, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::green().v, + /*.menu_location = */ app_location_t::TX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_vor_tx */ {'P', 'V', 'T', 'X'}, + /*.m4_app_offset = */ 0x00000000, // will be filled at compile time +}; +} diff --git a/firmware/application/external/vor_tx/ui_vor_tx.cpp b/firmware/application/external/vor_tx/ui_vor_tx.cpp new file mode 100644 index 000000000..6aa90e9da --- /dev/null +++ b/firmware/application/external/vor_tx/ui_vor_tx.cpp @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui_vor_tx.hpp" + +#include "string_format.hpp" +#include "ui_textentry.hpp" + +using namespace portapack; +using namespace ui; + +namespace ui::external_app::vor_tx { + +VorTxView::VorTxView(NavigationView& nav) + : nav_{nav} { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + + add_children({&labels, + &field_radial, + &text_radial_unit, + &check_ident, + &button_ident_text, + &text_status, + &tx_view}); + + field_radial.set_value(radial_); + field_radial.on_change = [this](int32_t v) { + radial_ = v; + if (transmitting_) { + update_config(); + } + }; + + check_ident.set_value(ident_); + check_ident.on_select = [this](Checkbox&, bool v) { + ident_ = v; + if (transmitting_) { + update_config(); + } + }; + + button_ident_text.set_text(ident_text_); + button_ident_text.on_select = [this](Button&) { + text_prompt(nav_, ident_text_, 7, ENTER_KEYBOARD_MODE_ALPHA, [this](std::string&) { + button_ident_text.set_text(ident_text_); + if (transmitting_) { + update_config(); + } + }); + }; + + tx_view.on_edit_frequency = [this, &nav]() { + auto new_view = nav.push(transmitter_model.target_frequency()); + new_view->on_changed = [this](rf::Frequency f) { + transmitter_model.set_target_frequency(f); + }; + }; + + tx_view.on_start = [this]() { + if (tx_acknowledged_) { + start_tx(); + return; + } + nav_.display_modal( + "TX WARNING", + "VOR is an aviation nav\nband! TX only into a\ndummy load.", + YESNO, + [this](bool choice) { + if (choice) { + tx_acknowledged_ = true; + start_tx(); + } else { + tx_view.set_transmitting(false); + } + }); + }; + + tx_view.on_stop = [this]() { + stop_tx(); + }; +} + +VorTxView::~VorTxView() { + transmitter_model.disable(); + baseband::shutdown(); +} + +void VorTxView::focus() { + tx_view.focus(); +} + +void VorTxView::update_config() { + baseband::set_vor_tx_config(static_cast(radial_), ident_, ident_text_); +} + +void VorTxView::start_tx() { + transmitter_model.set_sampling_rate(1536000); + transmitter_model.set_baseband_bandwidth(1750000); + transmitter_model.enable(); + update_config(); + transmitting_ = true; + tx_view.set_transmitting(true); + text_status.set("TX radial " + to_string_dec_uint(radial_)); + set_dirty(); +} + +void VorTxView::stop_tx() { + transmitter_model.disable(); + transmitting_ = false; + tx_view.set_transmitting(false); + text_status.set("Idle"); + set_dirty(); +} + +} // namespace ui::external_app::vor_tx diff --git a/firmware/application/external/vor_tx/ui_vor_tx.hpp b/firmware/application/external/vor_tx/ui_vor_tx.hpp new file mode 100644 index 000000000..380d558e1 --- /dev/null +++ b/firmware/application/external/vor_tx/ui_vor_tx.hpp @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#ifndef __UI_VOR_TX_H__ +#define __UI_VOR_TX_H__ + +#include "app_settings.hpp" +#include "baseband_api.hpp" +#include "portapack.hpp" +#include "radio_state.hpp" +#include "ui.hpp" +#include "ui_navigation.hpp" +#include "ui_receiver.hpp" +#include "ui_transmitter.hpp" + +namespace ui::external_app::vor_tx { + +class VorTxView : public View { + public: + VorTxView(NavigationView& nav); + ~VorTxView(); + + void focus() override; + + std::string title() const override { return "VOR TX"; } + + private: + void start_tx(); + void stop_tx(); + void update_config(); + + NavigationView& nav_; + bool transmitting_{false}; + bool tx_acknowledged_{false}; + + uint32_t radial_{0}; + bool ident_{true}; + std::string ident_text_{"VOR"}; + + TxRadioState radio_state_{ + 113'500'000 /* frequency */, + 1750000 /* bandwidth */, + 1536000 /* sampling rate */ + }; + + app_settings::SettingsManager settings_{ + "tx_vor", + app_settings::Mode::TX, + { + {"radial"sv, &radial_}, + {"ident"sv, &ident_}, + {"ident_text"sv, &ident_text_}, + }}; + + Labels labels{ + {{UI_POS_X(0), UI_POS_Y(1)}, "Radial:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(5)}, "Ident:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(8)}, "Status:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(10)}, "Lab use: set any freq &", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(11)}, "TX into a dummy load.", Theme::getInstance()->fg_light->foreground}}; + + NumberField field_radial{ + {UI_POS_X(8), UI_POS_Y(1)}, + 3, + {0, 359}, + 1, + '0'}; + + Text text_radial_unit{ + {UI_POS_X(12), UI_POS_Y(1), UI_POS_WIDTH(4), UI_POS_HEIGHT(1)}, + "deg"}; + + Checkbox check_ident{ + {UI_POS_X(0), UI_POS_Y(3)}, + 13, + "CW identifier", + true}; + + Button button_ident_text{ + {UI_POS_X(8), UI_POS_Y(5), UI_POS_WIDTH(10), UI_POS_HEIGHT(2)}, + "VOR"}; + + Text text_status{ + {UI_POS_X(8), UI_POS_Y(8), UI_POS_WIDTH_REMAINING(8), UI_POS_HEIGHT(1)}, + "Idle"}; + + TransmitterView tx_view{ + (int16_t)UI_POS_Y_BOTTOM(4), + 8333, + 1750000}; +}; + +} // namespace ui::external_app::vor_tx + +#endif // __UI_VOR_TX_H__ diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index c5939a12b..e90364e20 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -516,6 +516,20 @@ DeclareTargets(PFUT flash_utility) set(add_to_firmware FALSE) set(MODE_FLAGS "-O3") +### VOR TX + +set(MODE_CPPSRC + proc_vor_tx.cpp +) +DeclareTargets(PVTX vor_tx) + +### VOR RX + +set(MODE_CPPSRC + proc_vor_rx.cpp +) +DeclareTargets(PVRX vor_rx) + ### FLEX RX set(MODE_CPPSRC diff --git a/firmware/baseband/proc_vor_rx.cpp b/firmware/baseband/proc_vor_rx.cpp new file mode 100644 index 000000000..67e697138 --- /dev/null +++ b/firmware/baseband/proc_vor_rx.cpp @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2014 Jared Boone, ShareBrained Technology, Inc. + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "proc_vor_rx.hpp" + +#include "audio_output.hpp" +#include "audio_dma.hpp" +#include "dsp_iir_config.hpp" +#include "portapack_shared_memory.hpp" + +#include "event_m4.hpp" + +#include + +namespace { +constexpr float kPi = 3.14159265358979323846f; +} + +VorRx::VorRx() { + channel_spectrum.set_decimation_factor(1); + + baseband_thread.start(); + rssi_thread.start(); +} + +void VorRx::execute(const buffer_c8_t& buffer) { + if (!configured) { + return; + } + + const auto decim_0_out = decim_0.execute(buffer, dst_buffer); + const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer); + + channel_spectrum.feed(decim_1_out, channel_filter_low_f, channel_filter_high_f, channel_filter_transition); + + // No decim_2 stage: the standard AM decim_2 filter is a ~4.5 kHz low-pass + // that would attenuate the 9960 Hz VOR subcarrier by ~60 dB and destroy the + // reference phase. The channel stays at 48 kHz so the subcarrier survives. + const auto channel_out = channel_filter.execute(decim_1_out, dst_buffer); + + feed_channel_stats(channel_out); + + auto audio = demod_am.execute(channel_out, audio_buffer); + if (vor_enabled) { + process_vor_metrics(audio); + // Strip the 9960 Hz subcarrier from the audible signal (the metrics + // above still ran on the unfiltered audio so the decode is unaffected). + audio_lpf_1.execute_in_place(audio); + audio_lpf_2.execute_in_place(audio); + } + audio_compressor.execute_in_place(audio); + audio_output.write(audio); +} + +void VorRx::on_message(const Message* const message) { + switch (message->id) { + case Message::ID::UpdateSpectrum: + case Message::ID::SpectrumStreamingConfig: + channel_spectrum.on_message(message); + break; + + case Message::ID::VorRxConfigure: + configure_vor(*reinterpret_cast(message)); + break; + + case Message::ID::AMConfigure: + configure(*reinterpret_cast(message)); + break; + + default: + break; + } +} + +void VorRx::configure(const AMConfigureMessage& message) { + decim_0.configure(message.decim_0_filter.taps); + decim_1.configure(message.decim_1_filter.taps); + channel_filter.configure(message.channel_filter.taps, channel_filter_decimation_factor); + + constexpr size_t decim_0_output_fs = baseband_fs / decim_0.decimation_factor; + constexpr size_t decim_1_output_fs = decim_0_output_fs / decim_1.decimation_factor; + constexpr size_t channel_filter_input_fs = decim_1_output_fs; + + channel_filter_low_f = message.channel_filter.low_frequency_normalized * channel_filter_input_fs; + channel_filter_high_f = message.channel_filter.high_frequency_normalized * channel_filter_input_fs; + channel_filter_transition = message.channel_filter.transition_normalized * channel_filter_input_fs; + + channel_spectrum.set_decimation_factor(message.channel_spectrum_decimation_factor); + audio_output.configure(message.audio_hpf_lpf_config); + + configured = true; +} + +void VorRx::PhaseOscillator::configure(float frequency_hz, float sample_rate_hz) { + const float step = 2.0f * kPi * frequency_hz / sample_rate_hz; + step_sin = sinf(step); + step_cos = cosf(step); + sin_v = 0.0f; + cos_v = 1.0f; +} + +void VorRx::PhaseOscillator::advance() { + const float next_cos = (cos_v * step_cos) - (sin_v * step_sin); + const float next_sin = (sin_v * step_cos) + (cos_v * step_sin); + cos_v = next_cos; + sin_v = next_sin; +} + +void VorRx::PhaseOscillator::renormalize() { + // The recursive rotator slowly drifts in amplitude over long runs; + // rescale to the unit circle so accumulated tone levels stay meaningful. + const float mag = sqrtf((cos_v * cos_v) + (sin_v * sin_v)); + if (mag > 0.0f) { + const float inv = 1.0f / mag; + cos_v *= inv; + sin_v *= inv; + } +} + +void VorRx::configure_vor(const VorRxConfigureMessage& message) { + vor_enabled = message.enabled; + vor_reference_osc.configure(vor_reference_hz, 48000.0f); + vor_subcarrier_osc.configure(vor_subcarrier_hz, 48000.0f); + audio_lpf_1.configure(audio_48k_lpf_3200hz_config); + audio_lpf_2.configure(audio_48k_lpf_3200hz_config); + vor_ref_i = 0.0f; + vor_ref_q = 0.0f; + vor_var_i = 0.0f; + vor_var_q = 0.0f; + vor_dc_sum = 0.0f; + vor_sub_lp_i = 0.0f; + vor_sub_lp_q = 0.0f; + vor_sub_prev_i = 0.0f; + vor_sub_prev_q = 0.0f; + vor_sample_count = 0; +} + +uint16_t VorRx::normalize_degrees(float degrees) { + while (degrees < 0.0f) { + degrees += 360.0f; + } + while (degrees >= 360.0f) { + degrees -= 360.0f; + } + return static_cast(degrees + 0.5f); +} + +void VorRx::send_vor_status(uint16_t phase_deg, uint16_t radial_deg, uint16_t ref_level, uint16_t var_level, uint8_t quality, bool valid, bool to_from) { + const VorRxStatusDataMessage message{phase_deg, radial_deg, ref_level, var_level, quality, valid, to_from}; + shared_memory.application_queue.push(message); +} + +void VorRx::process_vor_metrics(const buffer_f32_t& audio) { + for (size_t i = 0; i < audio.count; ++i) { + const float sample = audio.p[i]; + const float ref_cos = vor_reference_osc.cosine(); + const float ref_sin = vor_reference_osc.sine(); + const float sub_cos = vor_subcarrier_osc.cosine(); + const float sub_sin = vor_subcarrier_osc.sine(); + + // Variable signal: 30 Hz amplitude modulation of the carrier envelope. + // Correlate the envelope against a local 30 Hz tone (DC rejected because + // the window spans an exact integer number of 30 Hz cycles). + vor_dc_sum += sample; + vor_var_i += sample * ref_cos; + vor_var_q -= sample * ref_sin; + + // Reference signal: 30 Hz FM on the 9960 Hz subcarrier. Quadrature + // down-convert the subcarrier, low-pass to its +/-480 Hz baseband, then + // FM-demodulate (phase difference between successive samples). + const float sc_i = sample * sub_cos; + const float sc_q = -sample * sub_sin; + vor_sub_lp_i += (1.0f - vor_sub_lp_alpha) * (sc_i - vor_sub_lp_i); + vor_sub_lp_q += (1.0f - vor_sub_lp_alpha) * (sc_q - vor_sub_lp_q); + + const float num = (vor_sub_lp_q * vor_sub_prev_i) - (vor_sub_lp_i * vor_sub_prev_q); + const float den = (vor_sub_lp_i * vor_sub_prev_i) + (vor_sub_lp_q * vor_sub_prev_q); + const float fm = atan2f(num, den); + vor_sub_prev_i = vor_sub_lp_i; + vor_sub_prev_q = vor_sub_lp_q; + + vor_ref_i += fm * ref_cos; + vor_ref_q -= fm * ref_sin; + + vor_reference_osc.advance(); + vor_subcarrier_osc.advance(); + + ++vor_sample_count; + if (vor_sample_count < vor_window_samples) { + continue; + } + + const float inv_n = 1.0f / static_cast(vor_window_samples); + const float variable_phase = atan2f(vor_var_q, vor_var_i); + const float reference_phase = atan2f(vor_ref_q, vor_ref_i); + // Real VOR radials increase clockwise, i.e. the variable tone lags the + // reference by the bearing. The decoder therefore reports + // (reference - variable); the opposite order mirrors every bearing to + // 360 - true. Add back the receive-chain phase lag (see + // vor_ref_phase_lag_deg): with this subtraction order the delayed + // reference makes the raw radial read low, so the correction is added. + float phase_diff = ((reference_phase - variable_phase) * 180.0f / kPi) + vor_ref_phase_lag_deg; + while (phase_diff < 0.0f) { + phase_diff += 360.0f; + } + while (phase_diff >= 360.0f) { + phase_diff -= 360.0f; + } + + // Scale-invariant lock metrics (independent of RF/AGC level): + // - var_ratio: 30 Hz AM depth vs. carrier DC (~0.30 for a real VOR). + // - ref_amp: FM-demodulated 30 Hz amplitude (phase-only, so it does + // not depend on signal amplitude, ~0.065 when locked). + const float dc_level = vor_dc_sum * inv_n; + const float var_amp = 2.0f * sqrtf((vor_var_i * vor_var_i) + (vor_var_q * vor_var_q)) * inv_n; + const float ref_amp = 2.0f * sqrtf((vor_ref_i * vor_ref_i) + (vor_ref_q * vor_ref_q)) * inv_n; + const float var_ratio = (dc_level > 0.0f) ? (var_amp / dc_level) : 0.0f; + + const auto phase_deg = normalize_degrees(phase_diff); + const auto radial_deg = phase_deg; + const auto ref_level = static_cast(std::min(65535.0f, ref_amp * 10000.0f)); + const auto var_level = static_cast(std::min(65535.0f, var_ratio * 1000.0f)); + const bool valid = (ref_amp > 0.03f) && (var_ratio > 0.15f); + // TO/FROM depends on the operator-selected OBS course, which only the + // application knows, so it is derived there (see VorRxView). Emit a + // stable placeholder here rather than a radial-only guess. + const bool to_from = false; + uint8_t quality = 0; + if (valid) { + const float q = (ref_amp / 0.065f) * 100.0f; + quality = static_cast(std::min(100.0f, std::max(0.0f, q))); + } + + send_vor_status(phase_deg, radial_deg, ref_level, var_level, quality, valid, to_from); + + vor_reference_osc.renormalize(); + vor_subcarrier_osc.renormalize(); + vor_sample_count = 0; + vor_ref_i = 0.0f; + vor_ref_q = 0.0f; + vor_var_i = 0.0f; + vor_var_q = 0.0f; + vor_dc_sum = 0.0f; + } +} + +int main() { + audio::dma::init_audio_out(); + + EventDispatcher event_dispatcher{std::make_unique()}; + event_dispatcher.run(); + return 0; +} diff --git a/firmware/baseband/proc_vor_rx.hpp b/firmware/baseband/proc_vor_rx.hpp new file mode 100644 index 000000000..793ee7588 --- /dev/null +++ b/firmware/baseband/proc_vor_rx.hpp @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2014 Jared Boone, ShareBrained Technology, Inc. + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#ifndef __PROC_VOR_RX_H__ +#define __PROC_VOR_RX_H__ + +#include "baseband_processor.hpp" +#include "baseband_thread.hpp" +#include "rssi_thread.hpp" + +#include "dsp_decimate.hpp" +#include "dsp_demodulate.hpp" +#include "dsp_iir.hpp" +#include "audio_compressor.hpp" + +#include "audio_output.hpp" +#include "spectrum_collector.hpp" + +#include +#include +#include + +// Standalone VOR receiver baseband. +// Demodulates the AM channel at 48 kHz (so the 9960 Hz subcarrier stays +// representable) and decodes the 30 Hz reference vs. variable phase to derive +// the radial. +class VorRx : public BasebandProcessor { + public: + VorRx(); + + void execute(const buffer_c8_t& buffer) override; + void on_message(const Message* const message) override; + + private: + static constexpr size_t baseband_fs = 3072000; + // VOR keeps the channel at 48 kHz instead of the AM audio 12 kHz, so the + // 9960 Hz subcarrier stays below Nyquist. The standard AM decim_2 stage is + // therefore omitted (its ~4.5 kHz low-pass would remove the subcarrier). + static constexpr size_t channel_filter_decimation_factor = 1; + + std::array dst{}; + const buffer_c16_t dst_buffer{ + dst.data(), + dst.size()}; + std::array audio{}; + const buffer_f32_t audio_buffer{ + audio.data(), + audio.size()}; + + dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{}; + dsp::decimate::FIRC16xR16x32Decim8 decim_1{}; + dsp::decimate::FIRAndDecimateComplex channel_filter{}; + int32_t channel_filter_low_f = 0; + int32_t channel_filter_high_f = 0; + int32_t channel_filter_transition = 0; + bool configured{false}; + bool vor_enabled{false}; + + static constexpr float vor_reference_hz{30.0f}; + static constexpr float vor_subcarrier_hz{9960.0f}; + // 100 ms at 48 kHz (exactly 3 cycles of 30 Hz) + static constexpr uint32_t vor_window_samples{4800}; + // One-pole low-pass to isolate the +/-480 Hz FM subcarrier baseband + // after quadrature down-conversion (corner ~600 Hz at 48 kHz). + static constexpr float vor_sub_lp_alpha{0.9245f}; + // Fixed phase lag the receive chain adds to the recovered 30 Hz reference + // tone, expressed in degrees at 30 Hz. It comes almost entirely from the + // one-pole subcarrier low-pass above (~2.75 deg of lag at 30 Hz for + // alpha = 0.9245) plus a ~0.1 deg half-sample delay in the FM + // differentiator. Because the reference tone is delayed, the raw radial + // (reference - variable) reads low by this amount, so we add it back. + // Value verified by a TX->RX loopback (proc_vor_tx fed through this + // decoder): every transmitted radial decoded ~3.2 deg low before this + // correction, and on-bearing afterwards. + static constexpr float vor_ref_phase_lag_deg{3.2f}; + + struct PhaseOscillator { + float sin_v{0.0f}; + float cos_v{1.0f}; + float step_sin{0.0f}; + float step_cos{1.0f}; + + void configure(float frequency_hz, float sample_rate_hz); + float cosine() const { return cos_v; } + float sine() const { return sin_v; } + void advance(); + void renormalize(); + } vor_reference_osc{}, vor_subcarrier_osc{}; + // 30 Hz DFT accumulators for the variable (AM) and reference (FM) tones. + float vor_ref_i{0.0f}; + float vor_ref_q{0.0f}; + float vor_var_i{0.0f}; + float vor_var_q{0.0f}; + float vor_dc_sum{0.0f}; + // Subcarrier quadrature down-converter / FM-demodulator state. + float vor_sub_lp_i{0.0f}; + float vor_sub_lp_q{0.0f}; + float vor_sub_prev_i{0.0f}; + float vor_sub_prev_q{0.0f}; + uint32_t vor_sample_count{0}; + + dsp::demodulate::AM demod_am{}; + // Two cascaded 2nd-order Butterworth low-passes (4th-order total) that strip + // the 9960 Hz FM subcarrier out of the audio before it reaches the speaker. + // The channel is kept wide (48 kHz) so the subcarrier survives for the + // radial decode, so it must be filtered out of the audible path here. + IIRBiquadFilter audio_lpf_1{}; + IIRBiquadFilter audio_lpf_2{}; + FeedForwardCompressor audio_compressor{}; + AudioOutput audio_output{}; + + SpectrumCollector channel_spectrum{}; + + /* NB: Threads should be the last members in the class definition. */ +#ifdef PRALINE + BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive, + /*auto_start*/ false}; + RSSIThread rssi_thread{/*auto_start*/ false}; +#else + BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive}; + RSSIThread rssi_thread{}; +#endif + + void configure(const AMConfigureMessage& message); + void configure_vor(const VorRxConfigureMessage& message); + void process_vor_metrics(const buffer_f32_t& audio); + void send_vor_status(uint16_t phase_deg, uint16_t radial_deg, uint16_t ref_level, uint16_t var_level, uint8_t quality, bool valid, bool to_from); + static uint16_t normalize_degrees(float degrees); +}; + +#endif /*__PROC_VOR_RX_H__*/ diff --git a/firmware/baseband/proc_vor_tx.cpp b/firmware/baseband/proc_vor_tx.cpp new file mode 100644 index 000000000..9103dd5fe --- /dev/null +++ b/firmware/baseband/proc_vor_tx.cpp @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "proc_vor_tx.hpp" + +#include "morse.hpp" +#include "sine_table_int8.hpp" +#include "event_m4.hpp" + +void VorTxProcessor::execute(const buffer_c8_t& buffer) { + for (size_t i = 0; i < buffer.count; i++) { + if (!configured) { + buffer.p[i] = {0, 0}; + continue; + } + + // 30 Hz reference and variable tones (same frequency, offset by the radial). + const int32_t ref_sine = sine_table_i8[(phase_30 >> 24) & 0xFF]; + const int32_t var_sine = sine_table_i8[((phase_30 - radial_offset) >> 24) & 0xFF]; + phase_30 += delta_30; + + // 9960 Hz subcarrier, FM-modulated +/- 480 Hz by the 30 Hz reference tone. + const int32_t sub_inc = static_cast(delta_sub) + ((ref_sine * static_cast(delta_480)) >> 7); + phase_sub += static_cast(sub_inc); + const int32_t sub_sine = sine_table_i8[(phase_sub >> 24) & 0xFF]; + + // Optional 1020 Hz identification tone. + int32_t id_sine = 0; + if (ident_enabled && ident_tone_active()) { + id_sine = sine_table_i8[(phase_id >> 24) & 0xFF]; + phase_id += delta_1020; + } + + // DSB-AM envelope (carrier kept at LO -> Q = 0). + int32_t env = carrier_level + (((var_sine * var_depth) + (sub_sine * sub_depth) + (id_sine * id_depth)) >> 7); + if (env < 0) env = 0; + if (env > 127) env = 127; + + buffer.p[i] = {static_cast(env), 0}; + } +} + +void VorTxProcessor::vor_tx_config(const VorTxConfigureMessage& message) { + // Real VOR radials increase clockwise: the variable tone lags the 30 Hz + // reference by the bearing, so the offset is subtracted (see the matching + // (reference - variable) decode in proc_vor_rx). A TX->RX loopback with this + // convention still decodes a transmitted radial of N degrees as N degrees, + // and it now matches real ground-station VOR bearings. + radial_offset = static_cast(static_cast(message.radial_deg % 360) * phase_period / 360); + ident_enabled = message.ident_enabled; + + for (size_t i = 0; i < sizeof(ident_text); ++i) + ident_text[i] = message.ident_text[i]; + ident_text[sizeof(ident_text) - 1] = '\0'; + build_ident_schedule(); + + configured = message.enabled; +} + +// Render the ident text into a repeating sequence of keyed-on / keyed-off +// sample runs using the shared ITU Morse table. Timing is derived from the +// standard "dot = 1200 / wpm" relation; letters are separated by a 3-unit gap, +// words by 7 units, and a trailing gap pads each cycle to a fixed 10 s period. +void VorTxProcessor::build_ident_schedule() { + ident_segment_count = 0; + ident_index = 0; + ident_sample_counter = 0; + + const uint32_t dot_samples = static_cast(1200ULL * baseband_fs / (ident_wpm * 1000ULL)); + + uint32_t total_samples = 0; + auto push_samples = [&](bool on, uint32_t samples) { + if (ident_segment_count >= max_ident_segments) return; + ident_segments[ident_segment_count++] = {on, samples}; + total_samples += samples; + }; + auto push = [&](bool on, uint32_t units) { + push_samples(on, units * dot_samples); + }; + + bool any = false; + for (size_t n = 0; n < sizeof(ident_text) && ident_text[n]; ++n) { + char ch = ident_text[n]; + if (ch >= 'a' && ch <= 'z') ch -= 32; + + uint16_t code = 0; + if (ch >= '!' && ch <= '_') code = morse::morse_ITU[ch - '!']; + + if (!code) { + // Space or unsupported character: word gap between symbols. + if (any) push(false, MORSE_WORD_SPACE); + continue; + } + + const uint16_t code_size = code & 7; + for (uint16_t c = 0; c < code_size; ++c) { + const bool dash = ((code << c) & 0x8000) != 0; + push(true, dash ? MORSE_DASH : MORSE_DOT); + if (c + 1 < code_size) + push(false, MORSE_SYMBOL_SPACE); + } + push(false, MORSE_LETTER_SPACE); + any = true; + } + + if (!any) { + // No sendable characters: disable keying entirely. + ident_segment_count = 0; + return; + } + + // Pad the trailing silence so ident-start to ident-start is a full 10 s. + // If the keyed sequence is somehow longer than the period, fall back to a + // one word-space gap so repeats never run back-to-back. + const uint32_t min_gap = MORSE_WORD_SPACE * dot_samples; + const uint32_t gap = (ident_period_samples > total_samples + min_gap) + ? ident_period_samples - total_samples + : min_gap; + push_samples(false, gap); +} + +// Advances the keying schedule by one sample and returns whether the ident +// tone should be sounding right now. Returns false when there is nothing to +// send so the caller leaves the ident tone off. +bool VorTxProcessor::ident_tone_active() { + if (ident_segment_count == 0) return false; + + if (ident_sample_counter == 0) + ident_sample_counter = ident_segments[ident_index].length; + + const bool on = ident_segments[ident_index].on; + + if (ident_sample_counter == 0 || --ident_sample_counter == 0) { + ++ident_index; + if (ident_index >= ident_segment_count) ident_index = 0; + } + + return on; +} + +void VorTxProcessor::on_message(const Message* const msg) { + if (msg->id == Message::ID::VorTxConfigure) { + vor_tx_config(*reinterpret_cast(msg)); + } +} + +int main() { + EventDispatcher event_dispatcher{std::make_unique()}; + event_dispatcher.run(); + return 0; +} diff --git a/firmware/baseband/proc_vor_tx.hpp b/firmware/baseband/proc_vor_tx.hpp new file mode 100644 index 000000000..e2407b240 --- /dev/null +++ b/firmware/baseband/proc_vor_tx.hpp @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 PortaPack Mayhem + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#ifndef __PROC_VOR_TX_H__ +#define __PROC_VOR_TX_H__ + +#include "baseband_processor.hpp" +#include "baseband_thread.hpp" + +// Generates a conventional VOR composite signal as DSB-AM (carrier at LO, Q = 0): +// - 30 Hz "variable" tone, amplitude-modulated directly (phase = transmitted radial) +// - 9960 Hz subcarrier, FM-modulated (+/- 480 Hz) by a 30 Hz "reference" tone +// - optional 1020 Hz identification tone +// The radial encoded is the phase difference between the variable and reference 30 Hz tones. +class VorTxProcessor : public BasebandProcessor { + public: + void execute(const buffer_c8_t& buffer) override; + void on_message(const Message* const msg) override; + + private: + static constexpr size_t baseband_fs = 1536000; + static constexpr uint64_t phase_period = 1ULL << 32; + // Per-sample phase increments (top byte indexes the 256-entry sine table). + static constexpr uint32_t delta_30 = static_cast(30ULL * phase_period / baseband_fs); + static constexpr uint32_t delta_sub = static_cast(9960ULL * phase_period / baseband_fs); + static constexpr uint32_t delta_1020 = static_cast(1020ULL * phase_period / baseband_fs); + static constexpr uint32_t delta_480 = static_cast(480ULL * phase_period / baseband_fs); + + // AM levels (int8 scale). carrier_level + sum of contributions must stay <= 127. + static constexpr int32_t carrier_level = 60; + static constexpr int32_t var_depth = 18; // ~30% of carrier + static constexpr int32_t sub_depth = 18; // ~30% of carrier + static constexpr int32_t id_depth = 6; // ~10% of carrier + + // CW identifier keying. Real VOR stations key the 1020 Hz ident tone in + // Morse at ~7 words per minute, so the schedule below is expressed in + // "dot" time units and rendered to on/off sample runs at config time. + static constexpr uint32_t ident_wpm = 7; + static constexpr size_t max_ident_segments = 128; + + // VOR ident is transmitted at least every 10 s; repeat the keyed identifier + // on a fixed 10 s cycle (ident start to next ident start). + static constexpr uint32_t ident_period_samples = 10 * baseband_fs; + + struct IdentSegment { + bool on; + uint32_t length; // duration in samples + }; + + uint32_t phase_30{0}; + uint32_t phase_sub{0}; + uint32_t phase_id{0}; + uint32_t radial_offset{0}; + + bool ident_enabled{true}; + bool configured{false}; + + char ident_text[8]{}; + IdentSegment ident_segments[max_ident_segments]{}; + size_t ident_segment_count{0}; + size_t ident_index{0}; + uint32_t ident_sample_counter{0}; + + void vor_tx_config(const VorTxConfigureMessage& message); + void build_ident_schedule(); + bool ident_tone_active(); + + /* NB: Threads should be the last members in the class definition. */ + BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Transmit}; +}; + +#endif /* __PROC_VOR_TX_H__ */ diff --git a/firmware/common/dsp_iir_config.hpp b/firmware/common/dsp_iir_config.hpp index b1ab54506..489b463be 100644 --- a/firmware/common/dsp_iir_config.hpp +++ b/firmware/common/dsp_iir_config.hpp @@ -119,4 +119,12 @@ constexpr iir_biquad_config_t apt_audio_12k_lpf_2000hz_config{ {0.15505103f, 0.31010205f, 0.15505103f}, {1.00000000f, -0.6202041f, 0.24040821f}}; +// scipy.signal.butter(2, 3200 / 24000.0, 'low', analog=False) +// VOR RX audio low-pass. Removes the 9960 Hz FM subcarrier (the annoying +// high-pitch whine) while passing voice and the 1020 Hz ident tone. Cascade +// two of these sections for a 4th-order roll-off (~44 dB at 9960 Hz). +constexpr iir_biquad_config_t audio_48k_lpf_3200hz_config{ + {0.03357181f, 0.06714362f, 0.03357181f}, + {1.00000000f, -1.41898265f, 0.55326989f}}; + #endif /*__DSP_IIR_CONFIG_H__*/ diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index db712a168..1ac8f27e6 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -162,6 +162,9 @@ class Message { ToneDetectConfig = 104, FlexTosend = 105, EPIRBRXConfig = 106, + VorRxConfigure = 107, + VorRxStatusData = 108, + VorTxConfigure = 109, MAX }; @@ -1899,6 +1902,68 @@ class ToneDetectConfigureMessage : public Message { uint32_t ctcss_freq_x10{0}; // CTCSS frequency × 10 (e.g. 1000 = 100.0 Hz); 0 = None }; +class VorRxConfigureMessage : public Message { + public: + constexpr VorRxConfigureMessage(bool enabled = true) + : Message{ID::VorRxConfigure}, + enabled{enabled} { + } + + bool enabled{true}; +}; + +class VorRxStatusDataMessage : public Message { + public: + constexpr VorRxStatusDataMessage( + uint16_t phase_deg = 0, + uint16_t radial_deg = 0, + uint16_t reference_level = 0, + uint16_t variable_level = 0, + uint8_t quality = 0, + bool valid = false, + bool to_from = false) + : Message{ID::VorRxStatusData}, + phase_deg{phase_deg}, + radial_deg{radial_deg}, + reference_level{reference_level}, + variable_level{variable_level}, + quality{quality}, + valid{valid}, + to_from{to_from} { + } + + uint16_t phase_deg{0}; + uint16_t radial_deg{0}; + uint16_t reference_level{0}; + uint16_t variable_level{0}; + uint8_t quality{0}; + bool valid{false}; + bool to_from{false}; +}; + +class VorTxConfigureMessage : public Message { + public: + VorTxConfigureMessage( + uint16_t radial_deg = 0, + bool ident_enabled = true, + const char* ident = "", + bool enabled = true) + : Message{ID::VorTxConfigure}, + radial_deg{radial_deg}, + ident_enabled{ident_enabled}, + enabled{enabled} { + size_t i = 0; + for (; ident && ident[i] && i < sizeof(ident_text) - 1; ++i) + ident_text[i] = ident[i]; + ident_text[i] = '\0'; + } + + uint16_t radial_deg{0}; + bool ident_enabled{true}; + bool enabled{true}; + char ident_text[8]{}; // CW identifier, null-terminated (max 7 chars) +}; + class FlexTosendMessage : public Message { public: constexpr FlexTosendMessage( diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index 1881b536b..a7350c83f 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -129,6 +129,8 @@ 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_vor_tx{'P', 'V', 'T', 'X'}; +constexpr image_tag_t image_tag_vor_rx{'P', 'V', 'R', 'X'}; constexpr image_tag_t image_tag_rttyrx{'P', 'R', 'T', 'R'}; constexpr image_tag_t image_tag_rttytx{'P', 'R', 'T', 'T'}; constexpr image_tag_t image_tag_tonedetect{'P', 'T', 'N', 'E'}; diff --git a/firmware/tools/external_app_info.py b/firmware/tools/external_app_info.py index da9db4d78..32a372f67 100644 --- a/firmware/tools/external_app_info.py +++ b/firmware/tools/external_app_info.py @@ -24,4 +24,4 @@ # external app address ranges below must match those in linker file "external.ld" maximum_application_size = 32*1024 external_apps_address_start = 0xADB00000 -external_apps_address_end = 0xAE090000 +external_apps_address_end = 0xAE0B0000