From b048b8f4f1b68be12b696e4c5bc022d180de094f Mon Sep 17 00:00:00 2001 From: Oleg Belousov Date: Sat, 8 Aug 2026 22:47:54 +0300 Subject: [PATCH] Feature/sliding freq (#3280) * Add sliding-frequency audio receiver tuning * Move ADS-B receiver to external app * Move AIS receiver to external app * Move APRS receiver to external app * Move APRS transmitter to external app * Isolate filtered spectrum collection from WFM * Fixed the issues #3280 * fpga_bridge.c: init registers per reference fpga_init, add quarter-shift mode setter * radio.hpp: expose cached FPGA quarter-rate shift * radio.cpp: program FPGA quarter-rate shift together with tuning offset * tuning.hpp: pass AFE rate and direction to tuning config, add quarter_shift field * tuning.cpp: import full PRALINE RX/TX tuning tables with quarter-shift offsets * clock_manager: stop writing bogus RX digital gain, keep quarter shift across rate changes * receiver_model: port LPF bandwidth from reference auto_bandwidth, use cached quarter shift * adsb_rx: enable RF amp by default on first run * ui_geomap.hpp: add hemisphere fields, degrees become magnitude * ui_geomap: use hemisphere selector for lat/lon sign, fix minute/second wrap carry * ui_menu: guard select against empty menu * waterfall_designer: header updates for profile file handling * waterfall_designer: exception-free profile parsing, CRLF handling, deferred nav callbacks, backup cleanup * external.ld: scope app section globs to their own object directories * CMakeLists: relink when external.ld changes * tools: add external app symbol placement checker * tools: add guru meditation address lookup script * tools: add per-function stack usage report script Co-authored-by: gullradriel --- firmware/application/CMakeLists.txt | 6 +- .../application/apps/analog_audio_app.cpp | 78 ++- .../application/apps/analog_audio_app.hpp | 8 + firmware/application/baseband_api.cpp | 5 + firmware/application/baseband_api.hpp | 1 + firmware/application/external/adsbrx/main.cpp | 85 ++++ .../{apps => external/adsbrx}/ui_adsb_rx.cpp | 16 +- .../{apps => external/adsbrx}/ui_adsb_rx.hpp | 4 +- .../{apps => external/ais_rx}/ais_app.cpp | 66 +-- .../{apps => external/ais_rx}/ais_app.hpp | 6 +- firmware/application/external/ais_rx/main.cpp | 85 ++++ .../application/external/aprs_rx/main.cpp | 85 ++++ .../{apps => external/aprs_rx}/ui_aprs_rx.cpp | 14 +- .../{apps => external/aprs_rx}/ui_aprs_rx.hpp | 6 +- .../application/external/aprs_tx/main.cpp | 85 ++++ .../{apps => external/aprs_tx}/ui_aprs_tx.cpp | 4 +- .../{apps => external/aprs_tx}/ui_aprs_tx.hpp | 4 +- firmware/application/external/external.cmake | 21 +- firmware/application/external/external.ld | 28 ++ firmware/application/receiver_model.cpp | 9 + firmware/application/receiver_model.hpp | 1 + firmware/application/ui/ui_freq_field.hpp | 12 +- firmware/application/ui/ui_spectrum.cpp | 117 ++++- firmware/application/ui/ui_spectrum.hpp | 11 +- firmware/application/ui_navigation.cpp | 8 - firmware/baseband/CMakeLists.txt | 1 + firmware/baseband/dsp_decimate.cpp | 97 +++- firmware/baseband/dsp_decimate.hpp | 36 ++ firmware/baseband/dsp_frequency_xlator.hpp | 472 ++++++++++++++++++ .../baseband/filtered_spectrum_collector.cpp | 90 ++++ .../baseband/filtered_spectrum_collector.hpp | 43 ++ firmware/baseband/proc_am_audio.cpp | 46 +- firmware/baseband/proc_am_audio.hpp | 16 +- firmware/baseband/proc_nfm_audio.cpp | 45 +- firmware/baseband/proc_nfm_audio.hpp | 15 +- firmware/baseband/spectrum_collector.cpp | 28 +- firmware/baseband/spectrum_collector.hpp | 16 +- firmware/common/dsp_fir_taps.hpp | 105 ++++ firmware/common/message.hpp | 12 + firmware/tools/external_app_info.py | 5 +- 40 files changed, 1650 insertions(+), 142 deletions(-) create mode 100644 firmware/application/external/adsbrx/main.cpp rename firmware/application/{apps => external/adsbrx}/ui_adsb_rx.cpp (99%) rename firmware/application/{apps => external/adsbrx}/ui_adsb_rx.hpp (99%) rename firmware/application/{apps => external/ais_rx}/ais_app.cpp (84%) rename firmware/application/{apps => external/ais_rx}/ais_app.hpp (98%) create mode 100644 firmware/application/external/ais_rx/main.cpp create mode 100644 firmware/application/external/aprs_rx/main.cpp rename firmware/application/{apps => external/aprs_rx}/ui_aprs_rx.cpp (97%) rename firmware/application/{apps => external/aprs_rx}/ui_aprs_rx.hpp (99%) create mode 100644 firmware/application/external/aprs_tx/main.cpp rename firmware/application/{apps => external/aprs_tx}/ui_aprs_tx.cpp (98%) rename firmware/application/{apps => external/aprs_tx}/ui_aprs_tx.hpp (98%) create mode 100644 firmware/baseband/dsp_frequency_xlator.hpp create mode 100644 firmware/baseband/filtered_spectrum_collector.cpp create mode 100644 firmware/baseband/filtered_spectrum_collector.hpp diff --git a/firmware/application/CMakeLists.txt b/firmware/application/CMakeLists.txt index 2372f1dbd..610c210b2 100644 --- a/firmware/application/CMakeLists.txt +++ b/firmware/application/CMakeLists.txt @@ -279,16 +279,12 @@ set(CPPSRC ui/ui_tone_key.cpp ui/ui_transmitter.cpp ui/ui_bmpview.cpp - apps/ais_app.cpp apps/analog_audio_app.cpp apps/ble_rx_app.cpp apps/ble_tx_app.cpp apps/capture_app.cpp apps/pocsag_app.cpp apps/ui_about_simple.cpp - apps/ui_adsb_rx.cpp - apps/ui_aprs_rx.cpp - apps/ui_aprs_tx.cpp apps/ui_battinfo.cpp apps/ui_bmp_file_viewer.cpp apps/ui_btle_rx.cpp @@ -516,4 +512,4 @@ add_custom_target( DEPENDS ${PROJECT_NAME}.bin ) -add_dependencies(${PROJECT_NAME} baseband) \ No newline at end of file +add_dependencies(${PROJECT_NAME} baseband) diff --git a/firmware/application/apps/analog_audio_app.cpp b/firmware/application/apps/analog_audio_app.cpp index 5e0adfd17..91621f4b4 100644 --- a/firmware/application/apps/analog_audio_app.cpp +++ b/firmware/application/apps/analog_audio_app.cpp @@ -280,6 +280,12 @@ AnalogAudioView::AnalogAudioView( field_frequency.on_show_options = [this]() { this->on_show_options_frequency(); }; + field_frequency.changing = [this](rf::Frequency frequency) { + return this->on_frequency_changed(frequency); + }; + field_frequency.entered = [this](rf::Frequency frequency) { + this->set_frequency_absolute(frequency); + }; field_lna.on_show_options = [this]() { this->on_show_options_rf_gain(); @@ -308,8 +314,10 @@ AnalogAudioView::AnalogAudioView( }; waterfall.on_select = [this](int32_t offset) { - field_frequency.set_value(receiver_model.target_frequency() + offset); + field_frequency.set_value( + field_frequency.value() + offset * receiver_model.frequency_step()); }; + waterfall.set_live_tuning(true); #ifdef PRALINE button_pro.on_select = [this](Button&) { this->on_show_options_praline(); }; @@ -319,6 +327,7 @@ AnalogAudioView::AnalogAudioView( // This call starts the correct baseband image to run // and sets the radio up as necessary for the given modulation. + sliding_center_frequency = receiver_model.target_frequency(); on_modulation_changed(modulation); } @@ -328,7 +337,7 @@ AnalogAudioView::AnalogAudioView( : AnalogAudioView(nav) { // Settings to override when launched from another app (versus from AppSettings .ini file) // TODO: Which other settings make sense to override? - field_frequency.set_value(override.frequency_app_override); + set_frequency_absolute(override.frequency_app_override); on_frequency_step_changed(override.frequency_step); options_modulation.set_by_value(toUType(override.mode)); } @@ -573,10 +582,14 @@ void AnalogAudioView::update_modulation(ReceiverModel::Mode modulation) { receiver_model.set_sampling_rate(is_wideband_spectrum_mode ? spec_bw : 3072000); receiver_model.set_baseband_bandwidth(is_wideband_spectrum_mode ? spec_bw / 2 : 1750000); - receiver_model.set_hidden_offset(modulation == ReceiverModel::Mode::AMAudioFMApt ? -2200 : 0); // wefax needs to be shifted, see wefax rx app. + reset_sliding_frequency(modulation); receiver_model.enable(); + if (sliding_enabled) { + baseband::set_audio_ddc_frequency(0); + } + // TODO: This doesn't belong here! There's a better way. size_t sampling_rate = 0; switch (modulation) { @@ -610,7 +623,64 @@ void AnalogAudioView::handle_coded_squelch(uint32_t value) { } void AnalogAudioView::on_freqchg(int64_t freq) { - field_frequency.set_value(freq); + set_frequency_absolute(freq); +} + +void AnalogAudioView::set_frequency_absolute(rf::Frequency frequency) { + if (!sliding_enabled) { + field_frequency.set_value(frequency); + return; + } + + sliding_center_frequency = frequency; + + /* set_value() does not call on_change when the displayed frequency is + * already equal, so reset the hardware and DDC explicitly in that case. */ + if (field_frequency.value() == frequency) { + receiver_model.set_target_frequency_with_hidden_offset(frequency, 0); + baseband::set_audio_ddc_frequency(0); + } else { + field_frequency.set_value(frequency); + } +} + +int32_t AnalogAudioView::sliding_limit() const { + const bool zoom_x2 = + receiver_model.modulation() == ReceiverModel::Mode::AMAudio && + previous_zoom != 0; + return zoom_x2 ? sliding_limit_zoom_x2 : sliding_limit_zoom_x1; +} + +void AnalogAudioView::reset_sliding_frequency(ReceiverModel::Mode modulation) { + sliding_enabled = + modulation == ReceiverModel::Mode::AMAudio || + modulation == ReceiverModel::Mode::NarrowbandFMAudio; + sliding_center_frequency = receiver_model.target_frequency(); + + /* AMFM keeps its existing Wefax offset; sliding applies to AM and NFM. */ + receiver_model.set_hidden_offset( + modulation == ReceiverModel::Mode::AMAudioFMApt ? -2200 : 0); +} + +bool AnalogAudioView::on_frequency_changed(rf::Frequency frequency) { + if (!sliding_enabled) + return false; + + const auto limit = sliding_limit(); + int64_t offset = frequency - sliding_center_frequency; + if (offset > limit) { + sliding_center_frequency = frequency - limit; + offset = limit; + } else if (offset < -limit) { + sliding_center_frequency = frequency + limit; + offset = -limit; + } + + /* Store the displayed frequency and retune the hardware centre atomically. */ + receiver_model.set_target_frequency_with_hidden_offset( + frequency, sliding_center_frequency - frequency); + baseband::set_audio_ddc_frequency(static_cast(offset)); + return true; } #ifdef PRALINE diff --git a/firmware/application/apps/analog_audio_app.hpp b/firmware/application/apps/analog_audio_app.hpp index 2e1b1ccfb..f3a89bc10 100644 --- a/firmware/application/apps/analog_audio_app.hpp +++ b/firmware/application/apps/analog_audio_app.hpp @@ -269,6 +269,10 @@ class AnalogAudioView : public View { uint8_t zoom_factor_amfm{0}; // initial zoom factor in AMFM mode uint8_t previous_AM_mode_option{0}; // GUI 5 AM modes : (0..4 ) (DSB9K, DSB6K, USB,LSB, CW). Used to select proper FIR filter (0..11) AM mode + offset 0 (zoom+1) or +6 (if zoom+2) uint8_t previous_zoom{0}; // GUI ZOOM+1, ZOOM+2 , equivalent to two values offset 0 (zoom+1) or +6 (if zoom+2) + static constexpr int32_t sliding_limit_zoom_x1 = 50000; + static constexpr int32_t sliding_limit_zoom_x2 = 30000; + rf::Frequency sliding_center_frequency{0}; + bool sliding_enabled{false}; app_settings::SettingsManager settings_{ "rx_audio", @@ -354,6 +358,10 @@ class AnalogAudioView : public View { void handle_coded_squelch(uint32_t value); void on_freqchg(int64_t freq); + bool on_frequency_changed(rf::Frequency frequency); + void set_frequency_absolute(rf::Frequency frequency); + int32_t sliding_limit() const; + void reset_sliding_frequency(ReceiverModel::Mode modulation); MessageHandlerRegistration message_handler_coded_squelch{ Message::ID::CodedSquelch, diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index cea9a51e0..173e277de 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -343,6 +343,11 @@ void set_spectrum( send_message(&message); } +void set_audio_ddc_frequency(int32_t frequency) { + const AudioDDCConfigMessage message{frequency}; + send_message(&message); +} + void set_time_sink( const size_t sampling_rate, const size_t trigger) { diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index 10eee20b5..4953cae6d 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -100,6 +100,7 @@ void set_rds_data(const uint16_t message_length); void set_spectrum( const size_t sampling_rate, const size_t trigger); +void set_audio_ddc_frequency(int32_t frequency); void set_time_sink( const size_t sampling_rate, const size_t trigger); diff --git a/firmware/application/external/adsbrx/main.cpp b/firmware/application/external/adsbrx/main.cpp new file mode 100644 index 000000000..7acede10a --- /dev/null +++ b/firmware/application/external/adsbrx/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; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui_adsb_rx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::adsbrx { + +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::adsbrx + +extern "C" { + +__attribute__((section(".external_app.app_adsbrx.application_information"), used)) application_information_t _application_information_adsbrx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::adsbrx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "ADS-B", + /*.bitmap_data = */ { + 0x80, + 0x01, + 0xC0, + 0x03, + 0xC0, + 0x03, + 0xC0, + 0x03, + 0xC0, + 0x03, + 0xE0, + 0x07, + 0xF8, + 0x1F, + 0xFE, + 0x7F, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xC0, + 0x03, + 0xC0, + 0x03, + 0xC0, + 0x03, + 0xE0, + 0x07, + 0xF0, + 0x0F, + 0xF8, + 0x1F, + }, + /*.icon_color = */ ui::Color::green().v, + /*.menu_location = */ app_location_t::RX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_adsbrx */ {'P', 'A', 'D', 'R'}, + /*.m4_app_offset = */ 0x00000000, +}; + +} // extern "C" diff --git a/firmware/application/apps/ui_adsb_rx.cpp b/firmware/application/external/adsbrx/ui_adsb_rx.cpp similarity index 99% rename from firmware/application/apps/ui_adsb_rx.cpp rename to firmware/application/external/adsbrx/ui_adsb_rx.cpp index c84e8fe40..69334ac38 100644 --- a/firmware/application/apps/ui_adsb_rx.cpp +++ b/firmware/application/external/adsbrx/ui_adsb_rx.cpp @@ -38,7 +38,7 @@ using namespace portapack; namespace pmem = portapack::persistent_memory; -namespace ui { +namespace ui::external_app::adsbrx { static const char speed_type_msg[][6] = {" Spd:", " IAS:", " TAS:"}; @@ -46,13 +46,19 @@ static std::string get_map_tag(const AircraftRecentEntry& entry) { return trimr(entry.callsign.empty() ? entry.icao_str : entry.callsign); } +} // namespace ui::external_app::adsbrx + +namespace ui { + template <> -void RecentEntriesTable::draw( +void RecentEntriesTable::draw( const Entry& entry, const Rect& target_rect, Painter& painter, const Style& style, RecentEntriesColumns& columns) { + using namespace external_app::adsbrx; + Color target_color; std::string entry_string; @@ -107,6 +113,10 @@ void RecentEntriesTable::draw( bitmap_target, target_color, style.background); } +} // namespace ui + +namespace ui::external_app::adsbrx { + /* ADSBLogger ********************************************/ void ADSBLogger::log(const ADSBLogEntry& log_entry) { @@ -828,4 +838,4 @@ void ADSBRxView::remove_expired_entries() { recent.erase(it.base(), recent.end()); } -} /* namespace ui */ +} // namespace ui::external_app::adsbrx diff --git a/firmware/application/apps/ui_adsb_rx.hpp b/firmware/application/external/adsbrx/ui_adsb_rx.hpp similarity index 99% rename from firmware/application/apps/ui_adsb_rx.hpp rename to firmware/application/external/adsbrx/ui_adsb_rx.hpp index c9a6e0468..1e761d9e6 100644 --- a/firmware/application/apps/ui_adsb_rx.hpp +++ b/firmware/application/external/adsbrx/ui_adsb_rx.hpp @@ -39,7 +39,7 @@ using namespace adsb; -namespace ui { +namespace ui::external_app::adsbrx { #define AIRCRAFT_ID_L 1 // aircraft ID message type (lowest type id) #define AIRCRAFT_ID_H 4 // aircraft ID message type (highest type id) @@ -491,4 +491,4 @@ class ADSBRxView : public View { }}; }; -} /* namespace ui */ +} // namespace ui::external_app::adsbrx diff --git a/firmware/application/apps/ais_app.cpp b/firmware/application/external/ais_rx/ais_app.cpp similarity index 84% rename from firmware/application/apps/ais_app.cpp rename to firmware/application/external/ais_rx/ais_app.cpp index 8eaa23bb0..0317d1b6f 100644 --- a/firmware/application/apps/ais_app.cpp +++ b/firmware/application/external/ais_rx/ais_app.cpp @@ -35,7 +35,8 @@ using namespace portapack; namespace pmem = portapack::persistent_memory; -namespace ais { +namespace ui::external_app::ais_rx { + namespace format { static std::string latlon_abs_normalized(const int32_t normalized, const char suffixes[2]) { @@ -47,7 +48,7 @@ static std::string latlon_abs_normalized(const int32_t normalized, const char su return to_string_dec_uint(degrees) + "." + to_string_dec_uint(fraction, 6, '0') + suffix; } -static std::string latlon(const Latitude latitude, const Longitude longitude) { +static std::string latlon(const ais::Latitude latitude, const ais::Longitude longitude) { if (latitude.is_valid() && longitude.is_valid()) { return latlon_abs_normalized(latitude.normalized(), "SN") + " " + latlon_abs_normalized(longitude.normalized(), "WE"); } else if (latitude.is_not_available() && longitude.is_not_available()) { @@ -128,7 +129,7 @@ static std::string navigational_status(const unsigned int value) { } } -static std::string rate_of_turn(const RateOfTurn value) { +static std::string rate_of_turn(const ais::RateOfTurn value) { switch (value) { case -128: return "not available"; @@ -149,7 +150,7 @@ static std::string rate_of_turn(const RateOfTurn value) { } } -static std::string speed_over_ground(const SpeedOverGround value) { +static std::string speed_over_ground(const ais::SpeedOverGround value) { if (value == 1023) { return "not available"; } else if (value == 1022) { @@ -159,7 +160,7 @@ static std::string speed_over_ground(const SpeedOverGround value) { } } -static std::string course_over_ground(const CourseOverGround value) { +static std::string course_over_ground(const ais::CourseOverGround value) { if (value > 3600) { return "invalid"; } else if (value == 3600) { @@ -169,7 +170,7 @@ static std::string course_over_ground(const CourseOverGround value) { } } -static std::string true_heading(const TrueHeading value) { +static std::string true_heading(const ais::TrueHeading value) { if (value == 511) { return "not available"; } else if (value > 359) { @@ -180,7 +181,6 @@ static std::string true_heading(const TrueHeading value) { } } /* namespace format */ -} /* namespace ais */ void AISLogger::on_packet(const ais::Packet& packet) { // TODO: Unstuff here, not in baseband! @@ -265,25 +265,33 @@ void AISRecentEntry::update(const ais::Packet& packet) { } } +} // namespace ui::external_app::ais_rx + namespace ui { template <> -void RecentEntriesTable::draw( +void RecentEntriesTable::draw( const Entry& entry, const Rect& target_rect, Painter& painter, const Style& style, RecentEntriesColumns&) { - std::string line = ais::format::mmsi(entry.mmsi) + " "; + using namespace external_app::ais_rx; + + std::string line = format::mmsi(entry.mmsi) + " "; if (!entry.name.empty()) { - line += ais::format::text(entry.name); + line += format::text(entry.name); } else { - line += ais::format::text(entry.call_sign); + line += format::text(entry.call_sign); } line.resize(target_rect.width() / 8, ' '); painter.draw_string(target_rect.location(), style, line); } +} // namespace ui + +namespace ui::external_app::ais_rx { + AISRecentEntryDetailView::AISRecentEntryDetailView(NavigationView& nav) { add_children({ &button_done, @@ -298,12 +306,12 @@ AISRecentEntryDetailView::AISRecentEntryDetailView(NavigationView& nav) { button_see_map.on_select = [this, &nav](Button&) { geomap_view = nav.push( - ais::format::text(entry_.name), + format::text(entry_.name), 0, GeoPos::alt_unit::METERS, GeoPos::spd_unit::KNOTS, - ais::format::latlon_float(entry_.last_position.latitude.normalized()), - ais::format::latlon_float(entry_.last_position.longitude.normalized()), + format::latlon_float(entry_.last_position.latitude.normalized()), + format::latlon_float(entry_.last_position.longitude.normalized()), entry_.last_position.true_heading, [this]() { send_updates = false; @@ -324,14 +332,14 @@ AISRecentEntryDetailView& AISRecentEntryDetailView::operator=(const AISRecentEnt void AISRecentEntryDetailView::update_position() { if (send_updates) - geomap_view->update_position(ais::format::latlon_float(entry_.last_position.latitude.normalized()), ais::format::latlon_float(entry_.last_position.longitude.normalized()), (float)entry_.last_position.true_heading, 0, entry_.last_position.speed_over_ground > 1022 ? 0 : entry_.last_position.speed_over_ground / 10); + geomap_view->update_position(format::latlon_float(entry_.last_position.latitude.normalized()), format::latlon_float(entry_.last_position.longitude.normalized()), (float)entry_.last_position.true_heading, 0, entry_.last_position.speed_over_ground > 1022 ? 0 : entry_.last_position.speed_over_ground / 10); } bool AISRecentEntryDetailView::add_map_marker(const AISRecentEntry& entry) { if (geomap_view && send_updates) { GeoMarker marker{}; - marker.lon = ais::format::latlon_float(entry.last_position.longitude.normalized()); - marker.lat = ais::format::latlon_float(entry.last_position.latitude.normalized()); + marker.lon = format::latlon_float(entry.last_position.longitude.normalized()); + marker.lat = format::latlon_float(entry.last_position.latitude.normalized()); marker.angle = entry.last_position.true_heading; marker.tag = entry.call_sign.empty() ? to_string_dec_uint(entry.mmsi) : entry.call_sign; auto markerStored = geomap_view->store_marker(marker); @@ -377,18 +385,18 @@ void AISRecentEntryDetailView::paint(Painter& painter) { auto field_rect = Rect{rect.left(), rect.top() + 16, rect.width(), 16}; - field_rect = draw_field(painter, field_rect, s, "MMSI", ais::format::mmsi(entry_.mmsi)); - field_rect = draw_field(painter, field_rect, s, "Ctry", ais::format::mid(entry_.mmsi)); - field_rect = draw_field(painter, field_rect, s, "Name", ais::format::text(entry_.name)); - field_rect = draw_field(painter, field_rect, s, "Call", ais::format::text(entry_.call_sign)); - field_rect = draw_field(painter, field_rect, s, "Dest", ais::format::text(entry_.destination)); + field_rect = draw_field(painter, field_rect, s, "MMSI", format::mmsi(entry_.mmsi)); + field_rect = draw_field(painter, field_rect, s, "Ctry", format::mid(entry_.mmsi)); + field_rect = draw_field(painter, field_rect, s, "Name", format::text(entry_.name)); + field_rect = draw_field(painter, field_rect, s, "Call", format::text(entry_.call_sign)); + field_rect = draw_field(painter, field_rect, s, "Dest", format::text(entry_.destination)); field_rect = draw_field(painter, field_rect, s, "Last", to_string_datetime(entry_.last_position.timestamp)); - field_rect = draw_field(painter, field_rect, s, "Pos ", ais::format::latlon(entry_.last_position.latitude, entry_.last_position.longitude)); - field_rect = draw_field(painter, field_rect, s, "Stat", ais::format::navigational_status(entry_.navigational_status)); - field_rect = draw_field(painter, field_rect, s, "RoT ", ais::format::rate_of_turn(entry_.last_position.rate_of_turn)); - field_rect = draw_field(painter, field_rect, s, "SoG ", ais::format::speed_over_ground(entry_.last_position.speed_over_ground)); - field_rect = draw_field(painter, field_rect, s, "CoG ", ais::format::course_over_ground(entry_.last_position.course_over_ground)); - field_rect = draw_field(painter, field_rect, s, "Head", ais::format::true_heading(entry_.last_position.true_heading)); + field_rect = draw_field(painter, field_rect, s, "Pos ", format::latlon(entry_.last_position.latitude, entry_.last_position.longitude)); + field_rect = draw_field(painter, field_rect, s, "Stat", format::navigational_status(entry_.navigational_status)); + field_rect = draw_field(painter, field_rect, s, "RoT ", format::rate_of_turn(entry_.last_position.rate_of_turn)); + field_rect = draw_field(painter, field_rect, s, "SoG ", format::speed_over_ground(entry_.last_position.speed_over_ground)); + field_rect = draw_field(painter, field_rect, s, "CoG ", format::course_over_ground(entry_.last_position.course_over_ground)); + field_rect = draw_field(painter, field_rect, s, "Head", format::true_heading(entry_.last_position.true_heading)); field_rect = draw_field(painter, field_rect, s, "Rx #", to_string_dec_uint(entry_.received_count)); } @@ -504,4 +512,4 @@ void AISAppView::on_show_detail(const AISRecentEntry& entry) { recent_entry_detail_view.update_map_markers(recent); } -} /* namespace ui */ +} // namespace ui::external_app::ais_rx diff --git a/firmware/application/apps/ais_app.hpp b/firmware/application/external/ais_rx/ais_app.hpp similarity index 98% rename from firmware/application/apps/ais_app.hpp rename to firmware/application/external/ais_rx/ais_app.hpp index 22566de6e..ea9bbd6fb 100644 --- a/firmware/application/apps/ais_app.hpp +++ b/firmware/application/external/ais_rx/ais_app.hpp @@ -50,6 +50,8 @@ using namespace lpc43xx; #include "recent_entries.hpp" +namespace ui::external_app::ais_rx { + struct AISPosition { rtc::RTC timestamp{}; ais::Latitude latitude{}; @@ -109,8 +111,6 @@ class AISLogger { LogFile log_file{}; }; -namespace ui { - using AISRecentEntriesView = RecentEntriesView; class AISRecentEntryDetailView : public View { @@ -239,6 +239,6 @@ class AISAppView : public View { void on_tick_second(); }; -} /* namespace ui */ +} // namespace ui::external_app::ais_rx #endif /*__AIS_APP_H__*/ diff --git a/firmware/application/external/ais_rx/main.cpp b/firmware/application/external/ais_rx/main.cpp new file mode 100644 index 000000000..f20fb01a3 --- /dev/null +++ b/firmware/application/external/ais_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; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ais_app.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::ais_rx { + +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::ais_rx + +extern "C" { + +__attribute__((section(".external_app.app_ais_rx.application_information"), used)) application_information_t _application_information_ais_rx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::ais_rx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "AIS Boats", + /*.bitmap_data = */ { + 0x00, + 0x01, + 0x80, + 0x01, + 0xC0, + 0x01, + 0xC0, + 0x0D, + 0xE0, + 0x3D, + 0xF0, + 0x3D, + 0xF8, + 0x7D, + 0xFC, + 0x7D, + 0xFC, + 0x7D, + 0xFE, + 0x7D, + 0xFF, + 0x7D, + 0x00, + 0x00, + 0xF8, + 0x7F, + 0xF8, + 0x3F, + 0xF0, + 0x0F, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::green().v, + /*.menu_location = */ app_location_t::RX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_ais */ {'P', 'A', 'I', 'S'}, + /*.m4_app_offset = */ 0x00000000, +}; + +} // extern "C" diff --git a/firmware/application/external/aprs_rx/main.cpp b/firmware/application/external/aprs_rx/main.cpp new file mode 100644 index 000000000..feefce9d0 --- /dev/null +++ b/firmware/application/external/aprs_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; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui_aprs_rx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::aprs_rx { + +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::aprs_rx + +extern "C" { + +__attribute__((section(".external_app.app_aprs_rx.application_information"), used)) application_information_t _application_information_aprs_rx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::aprs_rx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "APRS", + /*.bitmap_data = */ { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xF0, + 0x0F, + 0x4C, + 0x32, + 0xFE, + 0x7F, + 0x25, + 0xA4, + 0x25, + 0xA4, + 0xFF, + 0xFF, + 0x25, + 0xA4, + 0x25, + 0xA4, + 0xFE, + 0x7F, + 0x4C, + 0x32, + 0xF0, + 0x0F, + 0x00, + 0x00, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::green().v, + /*.menu_location = */ app_location_t::RX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_aprs_rx */ {'P', 'A', 'P', 'R'}, + /*.m4_app_offset = */ 0x00000000, +}; + +} // extern "C" diff --git a/firmware/application/apps/ui_aprs_rx.cpp b/firmware/application/external/aprs_rx/ui_aprs_rx.cpp similarity index 97% rename from firmware/application/apps/ui_aprs_rx.cpp rename to firmware/application/external/aprs_rx/ui_aprs_rx.cpp index bada71afa..020032382 100644 --- a/firmware/application/apps/ui_aprs_rx.cpp +++ b/firmware/application/external/aprs_rx/ui_aprs_rx.cpp @@ -31,19 +31,25 @@ using namespace portapack; +namespace ui::external_app::aprs_rx { + void APRSLogger::log_raw_data(const std::string& data) { log_file.write_entry(data); } +} // namespace ui::external_app::aprs_rx + namespace ui { template <> -void RecentEntriesTable::draw( +void RecentEntriesTable::draw( const Entry& entry, const Rect& target_rect, Painter& painter, const Style& style, RecentEntriesColumns& columns) { + using namespace external_app::aprs_rx; + Color target_color; // auto entry_age = entry.age; @@ -68,6 +74,10 @@ void RecentEntriesTable::draw( } } +} // namespace ui + +namespace ui::external_app::aprs_rx { + void APRSRxView::focus() { options_region.focus(); } @@ -402,4 +412,4 @@ void APRSRXView::focus() { APRSRXView::~APRSRXView() { } -} /* namespace ui */ +} // namespace ui::external_app::aprs_rx diff --git a/firmware/application/apps/ui_aprs_rx.hpp b/firmware/application/external/aprs_rx/ui_aprs_rx.hpp similarity index 99% rename from firmware/application/apps/ui_aprs_rx.hpp rename to firmware/application/external/aprs_rx/ui_aprs_rx.hpp index bcb10b07c..08685619d 100644 --- a/firmware/application/apps/ui_aprs_rx.hpp +++ b/firmware/application/external/aprs_rx/ui_aprs_rx.hpp @@ -38,6 +38,8 @@ #include "utility.hpp" #include "file_path.hpp" +namespace ui::external_app::aprs_rx { + class APRSLogger { public: Optional append(const std::filesystem::path& filename) { @@ -50,8 +52,6 @@ class APRSLogger { LogFile log_file{}; }; -namespace ui { - struct APRSRecentEntry { using Key = uint64_t; @@ -290,6 +290,6 @@ class APRSRXView : public View { }}; }; -} /* namespace ui */ +} // namespace ui::external_app::aprs_rx #endif /*__UI_APRS_RX_H__*/ diff --git a/firmware/application/external/aprs_tx/main.cpp b/firmware/application/external/aprs_tx/main.cpp new file mode 100644 index 000000000..d0240e5e8 --- /dev/null +++ b/firmware/application/external/aprs_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; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui_aprs_tx.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::aprs_tx { + +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} + +} // namespace ui::external_app::aprs_tx + +extern "C" { + +__attribute__((section(".external_app.app_aprs_tx.application_information"), used)) application_information_t _application_information_aprs_tx = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::aprs_tx::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "APRS TX", + /*.bitmap_data = */ { + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xF0, + 0x0F, + 0x4C, + 0x32, + 0xFE, + 0x7F, + 0x25, + 0xA4, + 0x25, + 0xA4, + 0xFF, + 0xFF, + 0x25, + 0xA4, + 0x25, + 0xA4, + 0xFE, + 0x7F, + 0x4C, + 0x32, + 0xF0, + 0x0F, + 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_afsk */ {'P', 'A', 'F', 'T'}, + /*.m4_app_offset = */ 0x00000000, +}; + +} // extern "C" diff --git a/firmware/application/apps/ui_aprs_tx.cpp b/firmware/application/external/aprs_tx/ui_aprs_tx.cpp similarity index 98% rename from firmware/application/apps/ui_aprs_tx.cpp rename to firmware/application/external/aprs_tx/ui_aprs_tx.cpp index 939ceab12..c8f5267b8 100644 --- a/firmware/application/apps/ui_aprs_tx.cpp +++ b/firmware/application/external/aprs_tx/ui_aprs_tx.cpp @@ -37,7 +37,7 @@ using namespace aprs; using namespace portapack; -namespace ui { +namespace ui::external_app::aprs_tx { void APRSTXView::focus() { tx_view.focus(); @@ -242,4 +242,4 @@ APRSTXView::APRSTXView(NavigationView& nav) { // process_coordinates(last_lat, last_lon); //don't load last, so won't confuse users } -} /* namespace ui */ +} // namespace ui::external_app::aprs_tx diff --git a/firmware/application/apps/ui_aprs_tx.hpp b/firmware/application/external/aprs_tx/ui_aprs_tx.hpp similarity index 98% rename from firmware/application/apps/ui_aprs_tx.hpp rename to firmware/application/external/aprs_tx/ui_aprs_tx.hpp index 769a0bcf8..c07c8aa3c 100644 --- a/firmware/application/apps/ui_aprs_tx.hpp +++ b/firmware/application/external/aprs_tx/ui_aprs_tx.hpp @@ -33,7 +33,7 @@ #include "radio_state.hpp" #include "portapack.hpp" -namespace ui { +namespace ui::external_app::aprs_tx { class APRSTXView : public View { public: @@ -161,4 +161,4 @@ class APRSTXView : public View { }}; }; -} /* namespace ui */ +} // namespace ui::external_app::aprs_tx diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index f1c85eccc..9a300561b 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -391,6 +391,22 @@ set(EXTCPPSRC external/tetra_rx/tetra_rcpc.cpp external/tetra_rx/tetra_viterbi.cpp + #adsb rx + external/adsbrx/main.cpp + external/adsbrx/ui_adsb_rx.cpp + + #ais rx + external/ais_rx/main.cpp + external/ais_rx/ais_app.cpp + + #aprs rx + external/aprs_rx/main.cpp + external/aprs_rx/ui_aprs_rx.cpp + + #aprs tx + external/aprs_tx/main.cpp + external/aprs_tx/ui_aprs_tx.cpp + ) set(EXTAPPLIST @@ -485,6 +501,10 @@ set(EXTAPPLIST secplustx signal_hunter tetra_rx + adsbrx + ais_rx + aprs_rx + aprs_tx ) # sdusb has type conflicts with PRALINE (HackRF Pro) - add only for non-PRALINE builds @@ -495,4 +515,3 @@ if(NOT BOARD STREQUAL "PRALINE") ) list(APPEND EXTAPPLIST sdusb) endif() - diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index d745d1da6..79824d757 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -115,6 +115,10 @@ MEMORY ram_external_app_vor_tx (rwx) : org = 0xAE0A0000, len = 32k ram_external_app_signal_hunter (rwx) : org = 0xAE0B0000, len = 32k ram_external_app_tetra_rx (rwx) : org = 0xAE0C0000, len = 32k + ram_external_app_adsbrx (rwx) : org = 0xAE0D0000, len = 32k + ram_external_app_ais_rx (rwx) : org = 0xAE0E0000, len = 32k + ram_external_app_aprs_rx (rwx) : org = 0xAE0F0000, len = 32k + ram_external_app_aprs_tx (rwx) : org = 0xAE100000, len = 32k } SECTIONS @@ -671,6 +675,30 @@ SECTIONS */external/tetra_rx/*(*ui*external_app*tetra_rx*); } > ram_external_app_tetra_rx + .external_app_adsbrx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_adsbrx.application_information)); + *(*ui*external_app*adsbrx*); + } > ram_external_app_adsbrx + + .external_app_ais_rx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_ais_rx.application_information)); + *(*ui*external_app*ais_rx*); + } > ram_external_app_ais_rx + + .external_app_aprs_rx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_aprs_rx.application_information)); + *(*ui*external_app*aprs_rx*); + } > ram_external_app_aprs_rx + + .external_app_aprs_tx : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_aprs_tx.application_information)); + *(*ui*external_app*aprs_tx*); + } > ram_external_app_aprs_tx + } diff --git a/firmware/application/receiver_model.cpp b/firmware/application/receiver_model.cpp index 87142016c..cfd1906af 100644 --- a/firmware/application/receiver_model.cpp +++ b/firmware/application/receiver_model.cpp @@ -88,6 +88,15 @@ void ReceiverModel::set_target_frequency(rf::Frequency f) { update_tuning_frequency(); } +void ReceiverModel::set_target_frequency_with_hidden_offset( + rf::Frequency f, + rf::Frequency offset) { + persistent_memory::set_target_frequency(f); + settings_.frequency_app_override = f; + hidden_offset = offset; + update_tuning_frequency(); +} + uint32_t ReceiverModel::baseband_bandwidth() const { return settings_.baseband_bandwidth; } diff --git a/firmware/application/receiver_model.hpp b/firmware/application/receiver_model.hpp index ea9317bf5..414208541 100644 --- a/firmware/application/receiver_model.hpp +++ b/firmware/application/receiver_model.hpp @@ -66,6 +66,7 @@ class ReceiverModel { /* The frequency to receive (no offset). */ rf::Frequency target_frequency() const; void set_target_frequency(rf::Frequency f); + void set_target_frequency_with_hidden_offset(rf::Frequency f, rf::Frequency offset); uint32_t baseband_bandwidth() const; void set_baseband_bandwidth(uint32_t v); diff --git a/firmware/application/ui/ui_freq_field.hpp b/firmware/application/ui/ui_freq_field.hpp index d604cf1da..57b8d8c29 100644 --- a/firmware/application/ui/ui_freq_field.hpp +++ b/firmware/application/ui/ui_freq_field.hpp @@ -39,6 +39,8 @@ class BoundFrequencyField : public FrequencyField { public: decltype(FrequencyField::on_change) updated{}; + std::function changing{}; + std::function entered{}; BoundFrequencyField(Point parent_pos, NavigationView& nav) : FrequencyField(parent_pos) { @@ -47,7 +49,8 @@ class BoundFrequencyField : public FrequencyField { set_value(model->target_frequency()); on_change = [this](rf::Frequency f) { - model->set_target_frequency(f); + if (!changing || !changing(f)) + model->set_target_frequency(f); if (updated) updated(f); }; @@ -57,7 +60,10 @@ class BoundFrequencyField : public FrequencyField { on_edit_shown(); auto freq_view = nav.push(model->target_frequency()); freq_view->on_changed = [this](rf::Frequency f) { - set_value(f); + if (entered) + entered(f); + else + set_value(f); }; nav.set_on_pop([this]() { if (on_edit_hidden) @@ -76,4 +82,4 @@ using TxFrequencyField = BoundFrequencyFieldbg_darkest->background); + draw_filter_ranges(painter, r); + restore_tick_lines(painter, r, dirty); +} + +void FrequencyScale::restore_tick_lines( + Painter& painter, + const Rect r, + const Rect dirty) { + const auto draw_if_dirty = [&](const Coord x) { + if (x >= dirty.left() && x < dirty.right()) { + painter.fill_rectangle( + {x, dirty.top(), 1, dirty.height()}, + Theme::getInstance()->bg_darkest->foreground); + } + }; + + const auto x_center = r.left() + r.width() / 2; + draw_if_dirty(x_center); + + constexpr int tick_count_max = 4; + float rough_tick_interval = float(spectrum_sampling_rate) / tick_count_max; + int magnitude = 1; + while (rough_tick_interval >= 10.0f) { + rough_tick_interval /= 10; + magnitude *= 10; + } + + const int tick_interval = std::ceil(rough_tick_interval); + auto tick_offset = tick_interval; + while ((tick_offset * magnitude) < spectrum_sampling_rate / 2) { + const Dim pixel_offset = + tick_offset * magnitude * spectrum_bins / spectrum_sampling_rate; + draw_if_dirty(x_center - pixel_offset); + draw_if_dirty(x_center + pixel_offset); + tick_offset += tick_interval; + } +} + void FrequencyScale::draw_filter_ranges(Painter& painter, const Rect r) { if (channel_filter_low_frequency != channel_filter_high_frequency) { - const auto x_center = r.width() / 2; + const auto x_center = r.width() / 2 + + channel_filter_offset * spectrum_bins / spectrum_sampling_rate; const auto x_low = x_center + channel_filter_low_frequency * spectrum_bins / spectrum_sampling_rate; const auto x_high = x_center + channel_filter_high_frequency * spectrum_bins / spectrum_sampling_rate; @@ -220,6 +305,11 @@ void FrequencyScale::on_blur() { } bool FrequencyScale::on_encoder(const EncoderEvent delta) { + if (live_tuning) { + if (on_select) on_select(delta); + return true; + } + cursor_position += delta; cursor_position = std::min(cursor_position, screen_width / 2 - 1); @@ -326,9 +416,11 @@ WaterfallView::WaterfallView(const bool cursor) { frequency_scale.focus(); // focus on frequency scale to show cursor if (sampling_rate) { - // screen x to frequency scale x, NB we need two widgets align - int32_t cursor_position = x - (screen_width / 2); - frequency_scale.set_cursor_position(cursor_position); + const int32_t cursor_position = x - (screen_width / 2); + if (!frequency_scale.is_live_tuning()) { + // screen x to frequency scale x, NB we need two widgets align + frequency_scale.set_cursor_position(cursor_position); + } } }; @@ -405,6 +497,7 @@ void WaterfallView::on_channel_spectrum(const ChannelSpectrum& spectrum) { sampling_rate = spectrum.sampling_rate; frequency_scale.set_spectrum_sampling_rate(sampling_rate); frequency_scale.set_channel_filter( + spectrum.channel_filter_offset, spectrum.channel_filter_low_frequency, spectrum.channel_filter_high_frequency, spectrum.channel_filter_transition); diff --git a/firmware/application/ui/ui_spectrum.hpp b/firmware/application/ui/ui_spectrum.hpp index d2d8ce868..df17aa8d8 100644 --- a/firmware/application/ui/ui_spectrum.hpp +++ b/firmware/application/ui/ui_spectrum.hpp @@ -72,6 +72,8 @@ class AudioSpectrumView : public View { class FrequencyScale : public Widget { public: + /* Receives a frequency offset in Hz for key/touch selection. With live + * tuning enabled, encoder events instead pass the raw encoder delta. */ std::function on_select{}; void on_show() override; @@ -83,8 +85,10 @@ class FrequencyScale : public Widget { bool on_touch(const TouchEvent touch) override; void set_spectrum_sampling_rate(const int new_sampling_rate); - void set_channel_filter(const int low_frequency, const int high_frequency, const int transition); + void set_channel_filter(const int offset, const int low_frequency, const int high_frequency, const int transition); void set_cursor_position(const int32_t position); + void set_live_tuning(const bool enabled) { live_tuning = enabled; } + bool is_live_tuning() const { return live_tuning; } void paint(Painter& painter) override; @@ -94,15 +98,19 @@ class FrequencyScale : public Widget { int32_t cursor_position{0}; int spectrum_sampling_rate{0}; const int spectrum_bins = std::tuple_size::value; + int channel_filter_offset{0}; int channel_filter_low_frequency{0}; int channel_filter_high_frequency{0}; int channel_filter_transition{0}; + bool live_tuning{false}; void clear(); void clear_background(Painter& painter, const Rect r); void draw_frequency_ticks(Painter& painter, const Rect r); void draw_filter_ranges(Painter& painter, const Rect r); + void redraw_filter_cursor(const int old_offset); + void restore_tick_lines(Painter& painter, const Rect r, const Rect dirty); }; /* NB: These visualizations rely on having a baseband image running. @@ -147,6 +155,7 @@ class WaterfallView : public View { void set_parent_rect(const Rect new_parent_rect) override; void show_audio_spectrum_view(const bool show); void load_gradient(); + void set_live_tuning(const bool enabled) { frequency_scale.set_live_tuning(enabled); } private: void update_widgets_rect(); diff --git a/firmware/application/ui_navigation.cpp b/firmware/application/ui_navigation.cpp index 6880b720e..ab6fc1e47 100644 --- a/firmware/application/ui_navigation.cpp +++ b/firmware/application/ui_navigation.cpp @@ -30,9 +30,6 @@ #include "portapack.hpp" #include "ui_about_simple.hpp" -#include "ui_adsb_rx.hpp" -#include "ui_aprs_rx.hpp" -#include "ui_aprs_tx.hpp" #include "ui_btle_rx.hpp" #include "ui_debug.hpp" #include "ui_encoders.hpp" @@ -60,7 +57,6 @@ #include "ui_battinfo.hpp" #include "ui_external_items_menu_loader.hpp" -#include "ais_app.hpp" #include "analog_audio_app.hpp" #include "ble_rx_app.hpp" #include "ble_tx_app.hpp" @@ -99,9 +95,6 @@ const NavigationView::AppList NavigationView::appList = { {nullptr, "Games", HOME, Color::cyan(), &bitmap_icon_games, new ViewFactory()}, {nullptr, "Settings", HOME, Color::cyan(), &bitmap_icon_setup, new ViewFactory()}, /* RX ********************************************************************/ - {"adsbrx", "ADS-B", RX, Color::green(), &bitmap_icon_adsb, new ViewFactory()}, - {"ais", "AIS Boats", RX, Color::green(), &bitmap_icon_ais, new ViewFactory()}, - {"aprsrx", "APRS", RX, Color::green(), &bitmap_icon_aprs, new ViewFactory()}, {"audio", "Audio", RX, Color::green(), &bitmap_icon_speaker, new ViewFactory()}, {"blerx", "BLE Rx", RX, Color::green(), &bitmap_icon_btle, new ViewFactory()}, {"pocsag", "POCSAG", RX, Color::green(), &bitmap_icon_pocsag, new ViewFactory()}, @@ -110,7 +103,6 @@ const NavigationView::AppList NavigationView::appList = { {"subghzd", "SubGhzD", RX, Color::yellow(), &bitmap_icon_remote, new ViewFactory()}, {"weather", "Weather", RX, Color::green(), &bitmap_icon_thermometer, new ViewFactory()}, /* TX ********************************************************************/ - {"aprstx", "APRS TX", TX, ui::Color::green(), &bitmap_icon_aprs, new ViewFactory()}, {"bletx", "BLE Tx", TX, ui::Color::green(), &bitmap_icon_btle, new ViewFactory()}, {"ooktx", "OOK", TX, ui::Color::yellow(), &bitmap_icon_remote, new ViewFactory()}, {"rdstx", "RDS", TX, ui::Color::green(), &bitmap_icon_rds, new ViewFactory()}, diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index 6ea2595dd..abc2ac5fa 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -124,6 +124,7 @@ set(CPPSRC dsp_goertzel.cpp matched_filter.cpp spectrum_collector.cpp + filtered_spectrum_collector.cpp tv_collector.cpp stream_input.cpp stream_output.cpp diff --git a/firmware/baseband/dsp_decimate.cpp b/firmware/baseband/dsp_decimate.cpp index 7b0af12ab..e7aef4f4a 100644 --- a/firmware/baseband/dsp_decimate.cpp +++ b/firmware/baseband/dsp_decimate.cpp @@ -337,6 +337,66 @@ buffer_c16_t FIRC16xR16x16Decim2::execute( src.sampling_rate / decimation_factor}; } +// FIRC16xR16x63HalfbandDecim2 //////////////////////////////////////////// + +void FIRC16xR16x63HalfbandDecim2::configure( + const std::array& taps) { + std::copy(taps.cbegin(), taps.cend(), taps_.begin()); + reset(); +} + +void FIRC16xR16x63HalfbandDecim2::reset() { + samples_.fill({}); + samples_head_ = 0; +} + +buffer_c16_t FIRC16xR16x63HalfbandDecim2::execute( + const buffer_c16_t& src, + const buffer_c16_t& dst) { + auto* dst_p = reinterpret_cast(dst.p); + + for (size_t output = 0; output < src.count / decimation_factor; ++output) { + for (size_t i = 0; i < decimation_factor; ++i) { + const auto sample = src.p[output * decimation_factor + i]; + samples_[samples_head_] = sample; + samples_[samples_head_ + taps_count] = sample; + if (++samples_head_ == taps_count) + samples_head_ = 0; + } + + int64_t real = 0; + int64_t imag = 0; + for (size_t tap = 0; tap < taps_count; tap += 4) { + const auto sample_0 = + *reinterpret_cast(&samples_[samples_head_ + tap]); + const auto sample_1 = + *reinterpret_cast(&samples_[samples_head_ + tap + 2]); + const auto real_pair = __PKHBT(sample_0, sample_1, 16); + const auto imag_pair = __PKHTB(sample_1, sample_0, 16); + const auto taps_pair = uint32_t(uint16_t(taps_[tap])) | + (uint32_t(uint16_t(taps_[tap + 2])) << 16); + real = __SMLALD(real_pair, taps_pair, real); + imag = __SMLALD(imag_pair, taps_pair, imag); + } + + const auto center = + *reinterpret_cast(&samples_[samples_head_ + taps_count / 2]); + real += int16_t(center) * taps_[taps_count / 2]; + imag += int16_t(center >> 16) * taps_[taps_count / 2]; + + const auto real_s16 = + __SSAT((real + (real >= 0 ? 32768 : -32768)) / 65536, 16); + const auto imag_s16 = + __SSAT((imag + (imag >= 0 ? 32768 : -32768)) / 65536, 16); + *(dst_p++) = __PKHBT(real_s16, imag_s16, 16); + } + + return { + dst.p, + src.count / decimation_factor, + src.sampling_rate / decimation_factor}; +} + // FIRC16xR16x32Decim8 //////////////////////////////////////////////////// void FIRC16xR16x32Decim8::configure( @@ -625,10 +685,13 @@ buffer_s16_t FIR64AndDecimateBy2Real::execute( void FIRAndDecimateComplex::configure_common( const size_t taps_count, const size_t decimation_factor) { - samples_ = std::make_unique(taps_count); + /* Mirror the delay line so a convolution always sees one contiguous + * taps_count window, even when the logical head wraps. */ + samples_ = std::make_unique(taps_count * 2); taps_reversed_ = std::make_unique(taps_count); taps_count_ = taps_count; decimation_factor_ = decimation_factor; + samples_head_ = 0; } buffer_c16_t FIRAndDecimateComplex::execute( @@ -647,15 +710,18 @@ buffer_c16_t FIRAndDecimateComplex::execute( const void* src_p = src.p; size_t outer_count = output_samples; while (outer_count > 0) { - /* Put new samples into delay buffer */ - void* z_new_p = &samples_[taps_count_ - decimation_factor_]; + /* Put new samples into both halves of the mirrored ring. */ for (size_t i = 0; i < decimation_factor_; i++) { - *__SIMD32(z_new_p)++ = *__SIMD32(src_p)++; + const uint32_t sample = *__SIMD32(src_p)++; + *reinterpret_cast(&samples_[samples_head_]) = sample; + *reinterpret_cast(&samples_[samples_head_ + taps_count_]) = sample; + if (++samples_head_ == taps_count_) + samples_head_ = 0; } size_t loop_count = taps_count_ / 8; void* t_p = &taps_reversed_[0]; - void* z_p = &samples_[0]; + void* z_p = &samples_[samples_head_]; int64_t t_real = 0; int64_t t_imag = 0; @@ -712,27 +778,6 @@ buffer_c16_t FIRAndDecimateComplex::execute( i_sat, 16); - /* Shift sample buffer left/down by decimation factor. */ - const size_t unroll_factor = 4; - size_t shift_count = (taps_count_ - decimation_factor_) / unroll_factor; - - void* t = &samples_[0]; - const void* s = &samples_[decimation_factor_]; - - while (shift_count > 0) { - *__SIMD32(t)++ = *__SIMD32(s)++; - *__SIMD32(t)++ = *__SIMD32(s)++; - *__SIMD32(t)++ = *__SIMD32(s)++; - *__SIMD32(t)++ = *__SIMD32(s)++; - shift_count--; - } - - shift_count = (taps_count_ - decimation_factor_) % unroll_factor; - while (shift_count > 0) { - *__SIMD32(t)++ = *__SIMD32(s)++; - shift_count--; - } - outer_count--; } diff --git a/firmware/baseband/dsp_decimate.hpp b/firmware/baseband/dsp_decimate.hpp index a5f8a9cd2..8e9bc4283 100644 --- a/firmware/baseband/dsp_decimate.hpp +++ b/firmware/baseband/dsp_decimate.hpp @@ -170,6 +170,24 @@ class FIRC16xR16x16Decim2 { int32_t output_scale = 0; }; +class FIRC16xR16x63HalfbandDecim2 { + public: + static constexpr size_t taps_count = 63; + static constexpr size_t decimation_factor = 2; + + void configure(const std::array& taps); + void reset(); + + buffer_c16_t execute( + const buffer_c16_t& src, + const buffer_c16_t& dst); + + private: + alignas(4) std::array samples_{}; + alignas(4) std::array taps_{}; + size_t samples_head_{0}; +}; + class FIRC16xR16x32Decim8 { public: static constexpr size_t taps_count = 32; @@ -210,6 +228,23 @@ class FIRAndDecimateComplex { configure(taps.data(), taps.size(), decimation_factor); } + template + void configure( + const std::array& taps, + const size_t decimation_factor) { + configure_common(N, decimation_factor); + for (size_t i = 0; i < N; ++i) { + taps_reversed_[i] = {taps[N - 1 - i], 0}; + } + } + + template + void set_taps(const std::array& taps) { + if (N == taps_count_) { + std::reverse_copy(taps.begin(), taps.end(), &taps_reversed_[0]); + } + } + buffer_c16_t execute( const buffer_c16_t& src, const buffer_c16_t& dst); @@ -221,6 +256,7 @@ class FIRAndDecimateComplex { std::unique_ptr taps_reversed_{}; size_t taps_count_{0}; size_t decimation_factor_{1}; + size_t samples_head_{0}; template void configure( diff --git a/firmware/baseband/dsp_frequency_xlator.hpp b/firmware/baseband/dsp_frequency_xlator.hpp new file mode 100644 index 000000000..df0eb4d87 --- /dev/null +++ b/firmware/baseband/dsp_frequency_xlator.hpp @@ -0,0 +1,472 @@ +/* + * Copyright (C) 2026 + * + * This file is part of PortaPack. + */ + +#ifndef __DSP_FREQUENCY_XLATOR_H__ +#define __DSP_FREQUENCY_XLATOR_H__ + +#include "dsp_decimate.hpp" +#include "dsp_types.hpp" + +#include +#include + +namespace dsp { + +/* Fixed-point complex mixer for the Audio RX channelizer. */ +class FrequencyTranslator { + public: + FrequencyTranslator() { + for (size_t i = 0; i < oscillator_q15_.size(); ++i) { + oscillator_q15_[i] = + static_cast(sine_q15_[static_cast(i + 64)]) | + (static_cast( + static_cast(sine_q15_[i])) + << 16); + } + } + + void set_sample_rate(const uint32_t sampling_rate) { + sampling_rate_ = sampling_rate; + update_phase_increment(); + } + + void set_frequency(const int32_t frequency) { + frequency_ = frequency; + update_phase_increment(); + } + + buffer_c16_t execute(const buffer_c16_t& src, const buffer_c16_t& dst) { + auto phase = phase_; + for (size_t i = 0; i < src.count; ++i) { + const uint8_t index = phase >> 24; + const uint8_t next = index + 1; + const int32_t fraction = (phase >> 16) & 0xff; + const uint32_t oscillator_first = oscillator_q15_[index]; + const uint32_t oscillator_next = oscillator_q15_[next]; + const int32_t sine_first = + static_cast(oscillator_first >> 16); + const int32_t cosine_first = + static_cast(oscillator_first); + const int32_t sine = + sine_first + + (((static_cast(oscillator_next >> 16) - + sine_first) * + fraction) >> + 8); + const int32_t cosine = + cosine_first + + (((static_cast(oscillator_next) - + cosine_first) * + fraction) >> + 8); + const uint32_t oscillator = + static_cast(cosine) | + (static_cast( + static_cast(sine)) + << 16); + const uint32_t sample = + *reinterpret_cast(&src.p[i]); + + /* Two packed dual-16-bit multiplies implement + * (I+jQ) * (cos-j sin). */ + const int32_t out_i = + rounded_shift(__SMUAD(sample, oscillator), 15); + const int32_t out_q = + rounded_shift(__SMUSDX(oscillator, sample), 15); + *reinterpret_cast(&dst.p[i]) = + __PKHBT(__SSAT(out_i, 16), __SSAT(out_q, 16), 16); + phase += phase_increment_; + } + phase_ = phase; + return {dst.p, src.count, src.sampling_rate}; + } + + private: + friend class FrequencyTranslatingDecimator32By8; + + static constexpr int32_t rounded_shift( + const int32_t value, + const uint32_t bits) { + const int32_t rounding = int32_t{1} << (bits - 1); + return value >= 0 + ? (value + rounding) >> bits + : -((-value + rounding) >> bits); + } + + void update_phase_increment() { + if (sampling_rate_) { + phase_increment_ = static_cast( + (static_cast(frequency_) * (int64_t{1} << 32)) / + sampling_rate_); + } + } + + static constexpr std::array sine_q15_{{ + 0, + 804, + 1608, + 2410, + 3212, + 4011, + 4808, + 5602, + 6393, + 7179, + 7962, + 8739, + 9512, + 10278, + 11039, + 11793, + 12539, + 13279, + 14010, + 14732, + 15446, + 16151, + 16846, + 17530, + 18204, + 18868, + 19519, + 20159, + 20787, + 21403, + 22005, + 22594, + 23170, + 23731, + 24279, + 24811, + 25329, + 25832, + 26319, + 26790, + 27245, + 27683, + 28105, + 28510, + 28898, + 29268, + 29621, + 29956, + 30273, + 30571, + 30852, + 31113, + 31356, + 31580, + 31785, + 31971, + 32137, + 32285, + 32412, + 32521, + 32609, + 32678, + 32728, + 32757, + 32767, + 32757, + 32728, + 32678, + 32609, + 32521, + 32412, + 32285, + 32137, + 31971, + 31785, + 31580, + 31356, + 31113, + 30852, + 30571, + 30273, + 29956, + 29621, + 29268, + 28898, + 28510, + 28105, + 27683, + 27245, + 26790, + 26319, + 25832, + 25329, + 24811, + 24279, + 23731, + 23170, + 22594, + 22005, + 21403, + 20787, + 20159, + 19519, + 18868, + 18204, + 17530, + 16846, + 16151, + 15446, + 14732, + 14010, + 13279, + 12539, + 11793, + 11039, + 10278, + 9512, + 8739, + 7962, + 7179, + 6393, + 5602, + 4808, + 4011, + 3212, + 2410, + 1608, + 804, + 0, + -804, + -1608, + -2410, + -3212, + -4011, + -4808, + -5602, + -6393, + -7179, + -7962, + -8739, + -9512, + -10278, + -11039, + -11793, + -12539, + -13279, + -14010, + -14732, + -15446, + -16151, + -16846, + -17530, + -18204, + -18868, + -19519, + -20159, + -20787, + -21403, + -22005, + -22594, + -23170, + -23731, + -24279, + -24811, + -25329, + -25832, + -26319, + -26790, + -27245, + -27683, + -28105, + -28510, + -28898, + -29268, + -29621, + -29956, + -30273, + -30571, + -30852, + -31113, + -31356, + -31580, + -31785, + -31971, + -32137, + -32285, + -32412, + -32521, + -32609, + -32678, + -32728, + -32757, + -32767, + -32757, + -32728, + -32678, + -32609, + -32521, + -32412, + -32285, + -32137, + -31971, + -31785, + -31580, + -31356, + -31113, + -30852, + -30571, + -30273, + -29956, + -29621, + -29268, + -28898, + -28510, + -28105, + -27683, + -27245, + -26790, + -26319, + -25832, + -25329, + -24811, + -24279, + -23731, + -23170, + -22594, + -22005, + -21403, + -20787, + -20159, + -19519, + -18868, + -18204, + -17530, + -16846, + -16151, + -15446, + -14732, + -14010, + -13279, + -12539, + -11793, + -11039, + -10278, + -9512, + -8739, + -7962, + -7179, + -6393, + -5602, + -4808, + -4011, + -3212, + -2410, + -1608, + -804, + }}; + + std::array oscillator_q15_{}; + uint32_t phase_{0}; + uint32_t phase_increment_{0}; + uint32_t sampling_rate_{192000}; + int32_t frequency_{0}; +}; + +/* + * Frequency-translating 32-tap FIR decimator. Frequency translation is + * split between coefficients modulated when tuning changes and a cheap + * output-rate phase rotation. This avoids running an NCO at the 384kHz + * input rate. + */ +class FrequencyTranslatingDecimator32By8 { + public: + static constexpr size_t decimation_factor = 8; + static constexpr size_t taps_count = 32; + + void configure( + const std::array& taps, + const uint32_t input_sampling_rate) { + taps_ = taps; + input_sampling_rate_ = input_sampling_rate; + decimator_.configure(complex_taps_, decimation_factor); + output_xlator_.set_sample_rate(input_sampling_rate / decimation_factor); + update_taps(); + } + + void set_frequency(const int32_t frequency) { + frequency_ = frequency; + output_xlator_.set_frequency(frequency); + update_taps(); + } + + buffer_c16_t execute( + const buffer_c16_t& src, + const buffer_c16_t& dst) { + const auto filtered = decimator_.execute(src, dst); + return output_xlator_.execute(filtered, dst); + } + + private: + static void oscillator( + const uint32_t phase, + int32_t& sine, + int32_t& cosine) { + const uint8_t index = phase >> 24; + const uint8_t next = index + 1; + const uint8_t cosine_index = index + 64; + const uint8_t cosine_next = cosine_index + 1; + const int32_t fraction = (phase >> 16) & 0xff; + const int32_t sine_first = FrequencyTranslator::sine_q15_[index]; + const int32_t cosine_first = FrequencyTranslator::sine_q15_[cosine_index]; + sine = sine_first + + (((FrequencyTranslator::sine_q15_[next] - sine_first) * fraction) >> 8); + cosine = cosine_first + + (((FrequencyTranslator::sine_q15_[cosine_next] - cosine_first) * fraction) >> 8); + } + + void update_taps() { + if (!input_sampling_rate_) + return; + + const uint32_t tap_phase_increment = static_cast( + (static_cast(frequency_) * (int64_t{1} << 32)) / + input_sampling_rate_); + + /* Centre the modulation on the FIR midpoint. Besides changing + * only a constant output phase, this makes the two coefficients + * in each symmetric pair complex conjugates. That property is + * important after quantization: starting at tap zero accumulated + * a one-sided phase and rounding error across the whole filter. */ + uint32_t phase = static_cast( + -((static_cast( + static_cast(tap_phase_increment)) * + static_cast(taps_count - 1)) / + 2)); + for (size_t i = 0; i < taps_count; ++i) { + int32_t sine; + int32_t cosine; + oscillator(phase, sine, cosine); + /* FIRAndDecimateComplex uses Q16 coefficients; the source + * real-tap filters use Q15 coefficients. */ + const int32_t tap = taps_[i]; + complex_taps_[i] = { + static_cast( + FrequencyTranslator::rounded_shift( + tap * cosine, 14)), + static_cast( + FrequencyTranslator::rounded_shift( + tap * sine, 14))}; + phase += tap_phase_increment; + } + decimator_.set_taps(complex_taps_); + } + + std::array taps_{}; + std::array complex_taps_{}; + decimate::FIRAndDecimateComplex decimator_{}; + FrequencyTranslator output_xlator_{}; + uint32_t input_sampling_rate_{0}; + int32_t frequency_{0}; +}; + +} /* namespace dsp */ + +#endif /*__DSP_FREQUENCY_XLATOR_H__*/ diff --git a/firmware/baseband/filtered_spectrum_collector.cpp b/firmware/baseband/filtered_spectrum_collector.cpp new file mode 100644 index 000000000..c0a7ec5f1 --- /dev/null +++ b/firmware/baseband/filtered_spectrum_collector.cpp @@ -0,0 +1,90 @@ +/* + * 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. + */ + +#include "filtered_spectrum_collector.hpp" + +#include "dsp_fir_taps.hpp" +#include "event_m4.hpp" + +#include + +void FilteredSpectrumCollector::on_message(const Message* const message) { + if (message->id == Message::ID::UpdateSpectrum) { + update(); + } + SpectrumCollector::on_message(message); +} + +void FilteredSpectrumCollector::start_capture( + const size_t decimation_factor) { + capture_decimation_ = decimation_factor; + capture_count_ = 0; + capture_ready_ = false; +} + +bool FilteredSpectrumCollector::feed( + const buffer_c16_t& channel, + const int32_t filter_low_frequency, + const int32_t filter_high_frequency, + const int32_t filter_transition) { + set_filter( + filter_low_frequency, + filter_high_frequency, + filter_transition); + + const size_t required_samples = 256 * capture_decimation_; + const size_t copy_count = std::min( + channel.count, required_samples - capture_count_); + std::copy_n( + channel.p, + copy_count, + capture_.begin() + capture_count_); + capture_count_ += copy_count; + + if (capture_count_ == required_samples) { + sampling_rate_ = channel.sampling_rate / capture_decimation_; + capture_ready_ = true; + EventDispatcher::events_flag(EVT_MASK_SPECTRUM); + if (is_streaming()) { + capture_ready_ = true; + EventDispatcher::events_flag(EVT_MASK_SPECTRUM); + } else { + capture_ready_ = false; + } + return true; + } + return false; +} + +void FilteredSpectrumCollector::update() { + if (!capture_ready_) { + return; + } + + decim_0_.configure(taps_audio_spectrum_halfband.taps); + const buffer_c16_t capture{ + capture_.data(), + 256 * capture_decimation_, + sampling_rate_ * capture_decimation_}; + const buffer_c16_t stage_0{ + stage_0_.data(), + stage_0_.size()}; + const auto filtered = decim_0_.execute(capture, stage_0); + + if (capture_decimation_ == 4) { + decim_1_.configure(taps_audio_spectrum_halfband.taps); + const buffer_c16_t stage_1{ + stage_1_.data(), + stage_1_.size()}; + post_message(decim_1_.execute(filtered, stage_1)); + } else { + post_message(filtered); + } + capture_ready_ = false; +} diff --git a/firmware/baseband/filtered_spectrum_collector.hpp b/firmware/baseband/filtered_spectrum_collector.hpp new file mode 100644 index 000000000..4d8e2edfd --- /dev/null +++ b/firmware/baseband/filtered_spectrum_collector.hpp @@ -0,0 +1,43 @@ +/* + * 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. + */ + +#ifndef __FILTERED_SPECTRUM_COLLECTOR_H__ +#define __FILTERED_SPECTRUM_COLLECTOR_H__ + +#include "dsp_decimate.hpp" +#include "spectrum_collector.hpp" + +#include + +class FilteredSpectrumCollector : public SpectrumCollector { + public: + void on_message(const Message* const message); + + void start_capture(const size_t decimation_factor); + bool feed( + const buffer_c16_t& channel, + const int32_t filter_low_frequency, + const int32_t filter_high_frequency, + const int32_t filter_transition); + + private: + std::array capture_{}; + std::array stage_0_{}; + std::array stage_1_{}; + dsp::decimate::FIRC16xR16x63HalfbandDecim2 decim_0_{}; + dsp::decimate::FIRC16xR16x63HalfbandDecim2 decim_1_{}; + size_t capture_count_{0}; + size_t capture_decimation_{1}; + uint32_t sampling_rate_{0}; + bool capture_ready_{false}; + + void update(); +}; + +#endif /*__FILTERED_SPECTRUM_COLLECTOR_H__*/ diff --git a/firmware/baseband/proc_am_audio.cpp b/firmware/baseband/proc_am_audio.cpp index a4bb87487..32b497cc0 100644 --- a/firmware/baseband/proc_am_audio.cpp +++ b/firmware/baseband/proc_am_audio.cpp @@ -45,10 +45,26 @@ void NarrowbandAMAudio::execute(const buffer_c8_t& buffer) { } const auto decim_0_out = decim_0.execute(buffer, dst_buffer); - const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer); + const auto audio_decim_0_out = audio_decim_0.execute(decim_0_out, dst_buffer); - channel_spectrum.feed(decim_1_out, channel_filter_low_f, channel_filter_high_f, channel_filter_transition); + spectrum_samples += decim_0_out.count; + if (!spectrum_capture_active && + spectrum_samples >= spectrum_interval_samples) { + spectrum_samples -= spectrum_interval_samples; + channel_spectrum.start_capture(spectrum_zoom_x2 ? 4 : 2); + spectrum_capture_active = true; + } + if (spectrum_capture_active && + channel_spectrum.feed( + audio_decim_0_out, + channel_filter_low_f, + channel_filter_high_f, + channel_filter_transition)) { + spectrum_capture_active = false; + } + + const auto decim_1_out = translating_decim_1.execute(audio_decim_0_out, dst_buffer); const auto decim_2_out = decim_2.execute(decim_1_out, dst_buffer); const auto channel_out = channel_filter.execute(decim_2_out, dst_buffer); @@ -97,6 +113,10 @@ void NarrowbandAMAudio::on_message(const Message* const message) { capture_config(*reinterpret_cast(message)); break; + case Message::ID::AudioDDCConfig: + ddc_config(*reinterpret_cast(message)); + break; + default: break; } @@ -106,17 +126,19 @@ void NarrowbandAMAudio::configure(const AMConfigureMessage& message) { constexpr size_t decim_0_input_fs = baseband_fs; constexpr size_t decim_0_output_fs = decim_0_input_fs / decim_0.decimation_factor; - constexpr size_t decim_1_input_fs = decim_0_output_fs; - constexpr size_t decim_1_output_fs = decim_1_input_fs / decim_1.decimation_factor; - + constexpr size_t audio_decim_0_output_fs = decim_0_output_fs / 2; + constexpr size_t decim_1_output_fs = + audio_decim_0_output_fs / translating_decim_1.decimation_factor; constexpr size_t decim_2_input_fs = decim_1_output_fs; constexpr size_t decim_2_output_fs = decim_2_input_fs / decim_2_decimation_factor; constexpr size_t channel_filter_input_fs = decim_2_output_fs; // const size_t channel_filter_output_fs = channel_filter_input_fs / channel_filter_decimation_factor; - decim_0.configure(message.decim_0_filter.taps); - decim_1.configure(message.decim_1_filter.taps); + decim_0.configure(message.decim_0_filter.taps, 33554432); + audio_decim_0.configure(taps_audio_wide_halfband_0.taps); + translating_decim_1.configure( + message.decim_1_filter.taps, audio_decim_0_output_fs); decim_2.configure(message.decim_2_filter.taps, decim_2_decimation_factor); channel_filter.configure(message.channel_filter.taps, channel_filter_decimation_factor); channel_filter_low_f = message.channel_filter.low_frequency_normalized * channel_filter_input_fs; @@ -124,12 +146,20 @@ void NarrowbandAMAudio::configure(const AMConfigureMessage& message) { channel_filter_transition = message.channel_filter.transition_normalized * channel_filter_input_fs; modulation_ssb = (int)message.modulation; // now sending by message , 3 types of AM demod : enum class Modulation : int32_t {DSB = 0, SSB = 1, SSB_FM = 2} - channel_spectrum.set_decimation_factor(message.channel_spectrum_decimation_factor); + spectrum_zoom_x2 = message.channel_spectrum_decimation_factor == 2; + channel_spectrum.set_decimation_factor(1); + spectrum_interval_samples = + decim_0_output_fs / spectrum_rate_hz; audio_output.configure(message.audio_hpf_lpf_config); // hpf in all AM demod modes (AM-6K/9K, USB/LSB,DSB), except Wefax (lpf there). configured = true; } +void NarrowbandAMAudio::ddc_config(const AudioDDCConfigMessage& message) { + translating_decim_1.set_frequency(message.frequency); + channel_spectrum.set_channel_filter_offset(message.frequency); +} + void NarrowbandAMAudio::capture_config(const CaptureConfigMessage& message) { if (message.config) { audio_output.set_stream(std::make_unique(message.config)); diff --git a/firmware/baseband/proc_am_audio.hpp b/firmware/baseband/proc_am_audio.hpp index 296094889..7ccf37054 100644 --- a/firmware/baseband/proc_am_audio.hpp +++ b/firmware/baseband/proc_am_audio.hpp @@ -28,10 +28,11 @@ #include "dsp_decimate.hpp" #include "dsp_demodulate.hpp" +#include "dsp_frequency_xlator.hpp" #include "audio_compressor.hpp" #include "audio_output.hpp" -#include "spectrum_collector.hpp" +#include "filtered_spectrum_collector.hpp" #include @@ -44,6 +45,7 @@ class NarrowbandAMAudio : public BasebandProcessor { private: static constexpr size_t baseband_fs = 3072000; + static constexpr auto spectrum_rate_hz = 30.0f; static constexpr size_t decim_2_decimation_factor = 4; static constexpr size_t channel_filter_decimation_factor = 1; @@ -56,14 +58,19 @@ class NarrowbandAMAudio : public BasebandProcessor { audio.data(), audio.size()}; - dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{}; - dsp::decimate::FIRC16xR16x32Decim8 decim_1{}; + dsp::decimate::FIRC8xR16x24FS4Decim4 decim_0{}; + dsp::decimate::FIRC16xR16x16Decim2 audio_decim_0{}; + dsp::FrequencyTranslatingDecimator32By8 translating_decim_1{}; dsp::decimate::FIRAndDecimateComplex decim_2{}; 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}; + size_t spectrum_interval_samples{0}; + size_t spectrum_samples{0}; + bool spectrum_capture_active{false}; + bool spectrum_zoom_x2{false}; // bool modulation_ssb = false; // Origianlly we only had 2 AM demod types {DSB = 0, SSB = 1} , and we could handle it with bool var , 1 bit. int8_t modulation_ssb = 0; // Now we have 3 AM demod types we will send now index integer {DSB = 0, SSB = 1, SSB_FM = 2} @@ -73,7 +80,7 @@ class NarrowbandAMAudio : public BasebandProcessor { FeedForwardCompressor audio_compressor{}; AudioOutput audio_output{}; - SpectrumCollector channel_spectrum{}; + FilteredSpectrumCollector channel_spectrum{}; /* NB: Threads should be the last members in the class definition. */ #ifdef PRALINE @@ -86,6 +93,7 @@ class NarrowbandAMAudio : public BasebandProcessor { #endif void configure(const AMConfigureMessage& message); + void ddc_config(const AudioDDCConfigMessage& message); void capture_config(const CaptureConfigMessage& message); buffer_f32_t demodulate(const buffer_c16_t& channel); diff --git a/firmware/baseband/proc_nfm_audio.cpp b/firmware/baseband/proc_nfm_audio.cpp index 72a156599..3e79afb32 100644 --- a/firmware/baseband/proc_nfm_audio.cpp +++ b/firmware/baseband/proc_nfm_audio.cpp @@ -51,10 +51,26 @@ void NarrowbandFMAudio::execute(const buffer_c8_t& buffer) { } const auto decim_0_out = decim_0.execute(buffer, dst_buffer); - const auto decim_1_out = decim_1.execute(decim_0_out, dst_buffer); + const auto audio_decim_0_out = audio_decim_0.execute(decim_0_out, dst_buffer); - channel_spectrum.feed(decim_1_out, channel_filter_low_f, channel_filter_high_f, channel_filter_transition); + spectrum_samples += decim_0_out.count; + if (!spectrum_capture_active && + spectrum_samples >= spectrum_interval_samples) { + spectrum_samples -= spectrum_interval_samples; + channel_spectrum.start_capture(2); + spectrum_capture_active = true; + } + if (spectrum_capture_active && + channel_spectrum.feed( + audio_decim_0_out, + channel_filter_low_f, + channel_filter_high_f, + channel_filter_transition)) { + spectrum_capture_active = false; + } + + const auto decim_1_out = translating_decim_1.execute(audio_decim_0_out, dst_buffer); const auto channel_out = channel_filter.execute(decim_1_out, dst_buffer); feed_channel_stats(channel_out); @@ -141,6 +157,10 @@ void NarrowbandFMAudio::on_message(const Message* const message) { pitch_rssi_config(*reinterpret_cast(message)); break; + case Message::ID::AudioDDCConfig: + ddc_config(*reinterpret_cast(message)); + break; + default: break; } @@ -150,22 +170,26 @@ void NarrowbandFMAudio::configure(const NBFMConfigureMessage& message) { constexpr size_t decim_0_input_fs = baseband_fs; constexpr size_t decim_0_output_fs = decim_0_input_fs / decim_0.decimation_factor; - constexpr size_t decim_1_input_fs = decim_0_output_fs; - constexpr size_t decim_1_output_fs = decim_1_input_fs / decim_1.decimation_factor; - + constexpr size_t audio_decim_0_output_fs = decim_0_output_fs / 2; + constexpr size_t decim_1_output_fs = + audio_decim_0_output_fs / translating_decim_1.decimation_factor; constexpr size_t channel_filter_input_fs = decim_1_output_fs; const size_t channel_filter_output_fs = channel_filter_input_fs / message.channel_decimation; const size_t demod_input_fs = channel_filter_output_fs; - decim_0.configure(message.decim_0_filter.taps); - decim_1.configure(message.decim_1_filter.taps); + decim_0.configure(message.decim_0_filter.taps, 33554432); + audio_decim_0.configure(taps_audio_wide_halfband_0.taps); + translating_decim_1.configure( + message.decim_1_filter.taps, audio_decim_0_output_fs); channel_filter.configure(message.channel_filter.taps, message.channel_decimation); demod.configure(demod_input_fs, message.deviation); 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(1.0f); + channel_spectrum.set_decimation_factor(1); + spectrum_interval_samples = + decim_0_output_fs / spectrum_rate_hz; audio_output.configure(message.audio_hpf_config, message.audio_deemph_config, (float)message.squelch_level / 100.0); hpf.configure(audio_24k_hpf_30hz_config); @@ -174,6 +198,11 @@ void NarrowbandFMAudio::configure(const NBFMConfigureMessage& message) { configured = true; } +void NarrowbandFMAudio::ddc_config(const AudioDDCConfigMessage& message) { + translating_decim_1.set_frequency(message.frequency); + channel_spectrum.set_channel_filter_offset(message.frequency); +} + void NarrowbandFMAudio::pitch_rssi_config(const PitchRSSIConfigureMessage& message) { pitch_rssi_enabled = message.enabled; tone_delta = (message.rssi + 1000) * ((1ULL << 32) / 24000); diff --git a/firmware/baseband/proc_nfm_audio.hpp b/firmware/baseband/proc_nfm_audio.hpp index a66975503..2b44335a7 100644 --- a/firmware/baseband/proc_nfm_audio.hpp +++ b/firmware/baseband/proc_nfm_audio.hpp @@ -29,10 +29,11 @@ #include "dsp_decimate.hpp" #include "dsp_demodulate.hpp" +#include "dsp_frequency_xlator.hpp" #include "dsp_iir.hpp" #include "audio_output.hpp" -#include "spectrum_collector.hpp" +#include "filtered_spectrum_collector.hpp" #include @@ -50,6 +51,7 @@ class NarrowbandFMAudio : public BasebandProcessor { private: static constexpr size_t baseband_fs = 3072000; + static constexpr auto spectrum_rate_hz = 30.0f; std::array dst{}; const buffer_c16_t dst_buffer{ @@ -69,8 +71,9 @@ class NarrowbandFMAudio : public BasebandProcessor { (int16_t*)tone.data(), sizeof(tone) / sizeof(int16_t)}; - dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{}; - dsp::decimate::FIRC16xR16x32Decim8 decim_1{}; + dsp::decimate::FIRC8xR16x24FS4Decim4 decim_0{}; + dsp::decimate::FIRC16xR16x16Decim2 audio_decim_0{}; + dsp::FrequencyTranslatingDecimator32By8 translating_decim_1{}; dsp::decimate::FIRAndDecimateComplex channel_filter{}; int32_t channel_filter_low_f = 0; int32_t channel_filter_high_f = 0; @@ -84,7 +87,10 @@ class NarrowbandFMAudio : public BasebandProcessor { AudioOutput audio_output{}; - SpectrumCollector channel_spectrum{}; + FilteredSpectrumCollector channel_spectrum{}; + size_t spectrum_interval_samples{0}; + size_t spectrum_samples{0}; + bool spectrum_capture_active{false}; uint32_t tone_phase{0}; uint32_t tone_delta{0}; @@ -113,6 +119,7 @@ class NarrowbandFMAudio : public BasebandProcessor { void pitch_rssi_config(const PitchRSSIConfigureMessage& message); void configure(const NBFMConfigureMessage& message); void capture_config(const CaptureConfigMessage& message); + void ddc_config(const AudioDDCConfigMessage& message); }; #endif /*__PROC_NFM_AUDIO_H__*/ diff --git a/firmware/baseband/spectrum_collector.cpp b/firmware/baseband/spectrum_collector.cpp index e8bb5311a..31d5780b9 100644 --- a/firmware/baseband/spectrum_collector.cpp +++ b/firmware/baseband/spectrum_collector.cpp @@ -73,21 +73,34 @@ void SpectrumCollector::set_decimation_factor( * perform the deferred task on the buffer of data we prepared. */ -void SpectrumCollector::feed( +bool SpectrumCollector::feed( const buffer_c16_t& channel, const int32_t filter_low_frequency, const int32_t filter_high_frequency, const int32_t filter_transition) { // Called from baseband processing thread. + set_filter( + filter_low_frequency, + filter_high_frequency, + filter_transition); + + bool block_completed = false; + channel_spectrum_decimator.feed( + channel, + [this, &block_completed](const buffer_c16_t& data) { + this->post_message(data); + block_completed = true; + }); + return block_completed; +} + +void SpectrumCollector::set_filter( + const int32_t filter_low_frequency, + const int32_t filter_high_frequency, + const int32_t filter_transition) { channel_filter_low_frequency = filter_low_frequency; channel_filter_high_frequency = filter_high_frequency; channel_filter_transition = filter_transition; - - channel_spectrum_decimator.feed( - channel, - [this](const buffer_c16_t& data) { - this->post_message(data); - }); } void SpectrumCollector::post_message(const buffer_c16_t& data) { @@ -136,6 +149,7 @@ void SpectrumCollector::update() { ChannelSpectrum spectrum; spectrum.sampling_rate = channel_spectrum_sampling_rate; + spectrum.channel_filter_offset = channel_filter_offset; spectrum.channel_filter_low_frequency = channel_filter_low_frequency; spectrum.channel_filter_high_frequency = channel_filter_high_frequency; spectrum.channel_filter_transition = channel_filter_transition; diff --git a/firmware/baseband/spectrum_collector.hpp b/firmware/baseband/spectrum_collector.hpp index 15d193ba7..3f36d0696 100644 --- a/firmware/baseband/spectrum_collector.hpp +++ b/firmware/baseband/spectrum_collector.hpp @@ -40,13 +40,24 @@ class SpectrumCollector { void on_message(const Message* const message); void set_decimation_factor(const size_t decimation_factor); + void set_channel_filter_offset(const int32_t offset) { + channel_filter_offset = offset; + } - void feed( + bool feed( const buffer_c16_t& channel, const int32_t filter_low_frequency, const int32_t filter_high_frequency, const int32_t filter_transition); + protected: + bool is_streaming() const { return streaming; } + void set_filter( + const int32_t filter_low_frequency, + const int32_t filter_high_frequency, + const int32_t filter_transition); + void post_message(const buffer_c16_t& data); + private: BlockDecimator channel_spectrum_decimator{1}; ChannelSpectrum fifo_data[1 << ChannelSpectrumConfigMessage::fifo_k]{}; @@ -59,8 +70,7 @@ class SpectrumCollector { int32_t channel_filter_low_frequency{0}; int32_t channel_filter_high_frequency{0}; int32_t channel_filter_transition{0}; - - void post_message(const buffer_c16_t& data); + int32_t channel_filter_offset{0}; void set_state(const SpectrumStreamingConfigMessage& message); void start(); diff --git a/firmware/common/dsp_fir_taps.hpp b/firmware/common/dsp_fir_taps.hpp index 1f32898f3..94bca8f17 100644 --- a/firmware/common/dsp_fir_taps.hpp +++ b/firmware/common/dsp_fir_taps.hpp @@ -44,6 +44,111 @@ struct fir_taps_complex { std::array taps; }; +/* + * 768kHz -> 384kHz half-band prefilter. The broad 80...304kHz + * transition makes this stage inexpensive while protecting the useful band. + */ +constexpr fir_taps_real<16> taps_audio_wide_halfband_0{ + .low_frequency_normalized = -80000.0f / 768000.0f, + .high_frequency_normalized = 80000.0f / 768000.0f, + .transition_normalized = 224000.0f / 768000.0f, + .taps = {{ + -171, + 0, + 1144, + 0, + -4481, + 0, + 19892, + 32767, + 19892, + 0, + -4481, + 0, + 1144, + 0, + -171, + 0, + }}, +}; + +/* + * Spectrum capture anti-alias half-band filter. It is run only while a + * contiguous 256-sample FFT frame is being collected: + * 384kHz -> 192kHz (Zoom x1) + * 192kHz -> 96kHz (additional stage for Zoom x2) + */ +constexpr fir_taps_real<63> taps_audio_spectrum_halfband{ + .low_frequency_normalized = -0.23f, + .high_frequency_normalized = 0.23f, + .transition_normalized = 0.04f, + .taps = {{ + -15, + 0, + 37, + 0, + -70, + 0, + 117, + 0, + -184, + 0, + 274, + 0, + -393, + 0, + 548, + 0, + -751, + 0, + 1018, + 0, + -1374, + 0, + 1872, + 0, + -2622, + 0, + 3910, + 0, + -6794, + 0, + 20812, + 32767, + 20812, + 0, + -6794, + 0, + 3910, + 0, + -2622, + 0, + 1872, + 0, + -1374, + 0, + 1018, + 0, + -751, + 0, + 548, + 0, + -393, + 0, + 274, + 0, + -184, + 0, + 117, + 0, + -70, + 0, + 37, + 0, + -16, + }}, +}; + // NBFM 16K0F3E emission type ///////////////////////////////////////////// // IFIR image-reject filter: fs=3072000, pass=8000, stop=344000, decim=8, fout=384000 diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 818c8d36f..a9d71b62a 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -170,6 +170,7 @@ class Message { HunterStop = 112, TetraBsch = 113, TetraDnb = 114, + AudioDDCConfig = 115, MAX }; @@ -310,6 +311,16 @@ class SpectrumStreamingConfigMessage : public Message { Mode mode{Mode::Stopped}; }; +class AudioDDCConfigMessage : public Message { + public: + constexpr AudioDDCConfigMessage(int32_t frequency) + : Message{ID::AudioDDCConfig}, + frequency{frequency} { + } + + int32_t frequency{0}; +}; + class WidebandSpectrumConfigMessage : public Message { public: constexpr WidebandSpectrumConfigMessage( @@ -357,6 +368,7 @@ class AudioSpectrumMessage : public Message { struct ChannelSpectrum { std::array db{{0}}; uint32_t sampling_rate{0}; + int32_t channel_filter_offset{0}; int32_t channel_filter_low_frequency{0}; int32_t channel_filter_high_frequency{0}; int32_t channel_filter_transition{0}; diff --git a/firmware/tools/external_app_info.py b/firmware/tools/external_app_info.py index 32a372f67..390d66ff0 100644 --- a/firmware/tools/external_app_info.py +++ b/firmware/tools/external_app_info.py @@ -21,7 +21,8 @@ # Boston, MA 02110-1301, USA. # -# external app address ranges below must match those in linker file "external.ld" +# External app address ranges below must match those in linker file "external.ld". +# The end address is exclusive. maximum_application_size = 32*1024 external_apps_address_start = 0xADB00000 -external_apps_address_end = 0xAE0B0000 +external_apps_address_end = 0xAE108000