diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index ee6c7e9a6..c8920b411 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -334,12 +334,22 @@ void set_rds_data(const uint16_t message_length) { send_message(&message); } -void set_spectrum(const size_t sampling_rate, const size_t trigger) { +void set_spectrum( + const size_t sampling_rate, + const size_t trigger) { const WidebandSpectrumConfigMessage message{ sampling_rate, trigger}; send_message(&message); } +void set_time_sink( + const size_t sampling_rate, + const size_t trigger) { + const TimeSinkConfigMessage message{ + sampling_rate, trigger}; + send_message(&message); +} + void set_wefax_config(uint8_t lpm = 120, uint8_t ioc = 0) { const WeFaxRxConfigureMessage message{lpm, ioc}; send_message(&message); diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index ab8a40899..ff4b8250d 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -97,7 +97,12 @@ void set_pocsag(int8_t baud_config = -1); void set_adsb(); void set_jammer(const bool run, const jammer::JammerType type, const uint32_t speed); void set_rds_data(const uint16_t message_length); -void set_spectrum(const size_t sampling_rate, const size_t trigger); +void set_spectrum( + const size_t sampling_rate, + const size_t trigger); +void set_time_sink( + const size_t sampling_rate, + const size_t trigger); void set_siggen_tone(const uint32_t tone); void set_siggen_config(const uint32_t bw, const uint32_t shape, const uint32_t duration); void set_spectrum_painter_config(const uint16_t width, const uint16_t height, bool update, int32_t bw); diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index 064d397f5..eb0b942f3 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -312,6 +312,10 @@ set(EXTCPPSRC #pocsag_tx external/pocsag_tx/main.cpp external/pocsag_tx/ui_pocsag_tx.cpp + + #time_sink + external/time_sink/main.cpp + external/time_sink/ui_time_sink.cpp ) set(EXTAPPLIST @@ -386,10 +390,11 @@ set(EXTAPPLIST siggen morse_radio morseradiotx - keeloqtx + keeloqtx rtty_rx rtty_tx pocsag_tx + time_sink ) # sdusb has type conflicts with PRALINE (HackRF Pro) - add only for non-PRALINE builds diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index 0673473cb..5794c6282 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -99,6 +99,8 @@ MEMORY ram_external_app_rtty_tx (rwx) : org = 0xADFA0000, len = 32k ram_external_app_tpmstx (rwx) : org = 0xADFB0000, len = 32k ram_external_app_pocsag_tx (rwx) : org = 0xADFC0000, len = 32k + ram_external_app_time_sink (rwx) : org = 0xADFD0000, len = 32k + } SECTIONS @@ -559,5 +561,11 @@ SECTIONS KEEP(*(.external_app.app_pocsag_tx.application_information)); *(*ui*external_app*pocsag_tx*); } > ram_external_app_pocsag_tx + + .external_app_time_sink : ALIGN(4) SUBALIGN(4) + { + KEEP(*(.external_app.app_time_sink.application_information)); + *(*ui*external_app*time_sink*); + } > ram_external_app_time_sink } diff --git a/firmware/application/external/time_sink/main.cpp b/firmware/application/external/time_sink/main.cpp new file mode 100644 index 000000000..cc4d415ee --- /dev/null +++ b/firmware/application/external/time_sink/main.cpp @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2025 Bernd Herzog + * + * This file is part of PortaPack. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; see the file COPYING. If not, write to + * the Free Software Foundation, Inc., 51 Franklin Street, + * Boston, MA 02110-1301, USA. + */ + +#include "ui.hpp" +#include "ui_time_sink.hpp" +#include "ui_navigation.hpp" +#include "external_app.hpp" + +namespace ui::external_app::time_sink { +void initialize_app(ui::NavigationView& nav) { + nav.push(); +} +} // namespace ui::external_app::time_sink +extern "C" { + +__attribute__((section(".external_app.app_time_sink.application_information"), used)) application_information_t _application_information_time_sink = { + /*.memory_location = */ (uint8_t*)0x00000000, + /*.externalAppEntry = */ ui::external_app::time_sink::initialize_app, + /*.header_version = */ CURRENT_HEADER_VERSION, + /*.app_version = */ VERSION_MD5, + + /*.app_name = */ "Time Sink", + /*.bitmap_data = */ { + 0x18, + 0x00, + 0x24, + 0x00, + 0x42, + 0x00, + 0x42, + 0x00, + 0x42, + 0x00, + 0x42, + 0x00, + 0x81, + 0x00, + 0xAB, + 0x6A, + 0x80, + 0x40, + 0x00, + 0x21, + 0x00, + 0x21, + 0x00, + 0x21, + 0x00, + 0x21, + 0x00, + 0x12, + 0x00, + 0x0C, + 0x00, + 0x00, + }, + /*.icon_color = */ ui::Color::cyan().v, + /*.menu_location = */ app_location_t::RX, + /*.desired_menu_position = */ -1, + + /*.m4_app_tag = portapack::spi_flash::image_tag_none */ {'P', 'T', 'S', 'K'}, + /*.m4_app_offset = */ 0x00000000, // will be filled at compile time +}; +} \ No newline at end of file diff --git a/firmware/application/external/time_sink/ui_time_sink.cpp b/firmware/application/external/time_sink/ui_time_sink.cpp new file mode 100644 index 000000000..c8be8f8cc --- /dev/null +++ b/firmware/application/external/time_sink/ui_time_sink.cpp @@ -0,0 +1,352 @@ +/* + * Copyleft zxkmm (>) 2026 + * + * 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_time_sink.hpp" + +#include "baseband_api.hpp" +#include "portapack.hpp" +#include "ui_spectrum.hpp" + +#include + +using namespace portapack; + +namespace ui::external_app::time_sink { + +TimeSinkWaveformWidget::TimeSinkWaveformWidget( + Rect parent_rect, + const int16_t* data, + size_t length, + Color color) + : Widget{parent_rect}, + data_{data}, + length_{length}, + color_{color} { + reset_cache(); +} + +void TimeSinkWaveformWidget::set_parent_rect(const Rect new_parent_rect) { + Widget::set_parent_rect(new_parent_rect); + reset_cache(); +} + +void TimeSinkWaveformWidget::on_show() { + reset_cache(); + set_dirty(); +} + +void TimeSinkWaveformWidget::reset_cache() { + history_count_ = 0; + history_head_ = 0; + needs_clear_ = true; +} + +void TimeSinkWaveformWidget::set_persistence_frames(uint8_t frames) { + const auto clamped = static_cast(std::clamp( + frames, + 1, + max_persistence_frames)); + if (clamped != persistence_frames_) { + persistence_frames_ = clamped; + reset_cache(); + set_dirty(); + } +} + +Coord TimeSinkWaveformWidget::sample_to_y(const Rect& r, int16_t sample) const { + const int32_t y_center = r.top() + (r.height() / 2); + const int32_t y_span = std::max(1, r.height() - 1); + const int32_t y = y_center - (static_cast(sample) * y_span) / 65536; + return static_cast(std::clamp(y, r.top(), r.bottom() - 1)); +} + +void TimeSinkWaveformWidget::paint(Painter& painter) { + const auto r = screen_rect(); + const auto background = Theme::getInstance()->bg_darkest->background; + + if (!r || !data_ || !length_) { + if (needs_clear_) { + painter.fill_rectangle_unrolled8(r, background); + needs_clear_ = false; + } + history_count_ = 0; + history_head_ = 0; + return; + } + + const size_t columns = std::min({ + length_, + max_columns, + static_cast(r.width()), + }); + if (!columns) { + history_count_ = 0; + history_head_ = 0; + return; + } + + if (needs_clear_) { + painter.fill_rectangle_unrolled8(r, background); + needs_clear_ = false; + history_count_ = 0; + history_head_ = 0; + } + + for (size_t x = 0; x < columns; ++x) { + const size_t src_index = (x * length_) / columns; + current_y_[x] = sample_to_y(r, data_[src_index]); + } + + // Draw first, then erase stale pixels so the trace never disappears mid-refresh. + for (size_t x = 0; x < columns; ++x) { + display.draw_pixel( + {static_cast(r.left() + x), current_y_[x]}, + color_); + } + + if (history_count_ >= persistence_frames_) { + const size_t expired_slot = history_head_; + + for (size_t x = 0; x < columns; ++x) { + const auto expired_y = history_y_[expired_slot][x]; + bool keep = (expired_y == current_y_[x]); + + if (!keep) { + for (size_t i = 1; i < history_count_; ++i) { + const size_t slot = (history_head_ + i) % max_persistence_frames; + if (history_y_[slot][x] == expired_y) { + keep = true; + break; + } + } + } + + if (!keep) { + display.draw_pixel( + {static_cast(r.left() + x), expired_y}, + background); + } + } + + history_head_ = (history_head_ + 1) % max_persistence_frames; + --history_count_; + } + + const size_t tail_slot = (history_head_ + history_count_) % max_persistence_frames; + std::copy_n(current_y_.begin(), columns, history_y_[tail_slot].begin()); + ++history_count_; +} + +TimeSinkView::TimeSinkView(NavigationView& nav) + : nav_(nav) { + baseband::run_prepared_image(portapack::memory::map::m4_code.base()); + + add_children({ + &labels, + &field_frequency, + &field_frequency_step, + &field_rf_amp, + &field_lna, + &field_vga, + &options_sample_rate, + &field_trigger, + &options_persistence, + &options_trigger_mode, + &field_trigger_level, + &waveform, + }); + + field_frequency_step.set_by_value(receiver_model.frequency_step()); + field_frequency_step.on_change = [this](size_t, OptionsField::value_t v) { + receiver_model.set_frequency_step(v); + field_frequency.set_step(v); + }; + + options_sample_rate.set_by_nearest_value(sampling_rate); + sampling_rate = options_sample_rate.selected_index_value(); + options_sample_rate.on_change = [this](size_t, OptionsField::value_t v) { + sampling_rate = v; + apply_spectrum_config(); + }; + + field_trigger.set_value(trigger); + field_trigger.on_change = [this](int32_t v) { + trigger = static_cast(v); + apply_spectrum_config(); + }; + + options_persistence.set_by_nearest_value(persistence_frames); + persistence_frames = options_persistence.selected_index_value(); + waveform.set_persistence_frames(persistence_frames); + options_persistence.on_change = [this](size_t, OptionsField::value_t v) { + persistence_frames = static_cast(v); + waveform.set_persistence_frames(persistence_frames); + }; + + options_trigger_mode.set_by_nearest_value(trigger_mode); + trigger_mode = static_cast(options_trigger_mode.selected_index_value()); + options_trigger_mode.on_change = [this](size_t, OptionsField::value_t v) { + trigger_mode = static_cast(v); + trigger_lock_valid = false; + }; + + field_trigger_level.set_value(trigger_level); + field_trigger_level.on_change = [this](int32_t v) { + trigger_level = v; + trigger_lock_valid = false; + }; + + receiver_model.set_squelch_level(0); + receiver_model.enable(); + apply_spectrum_config(); +} + +TimeSinkView::~TimeSinkView() { + receiver_model.disable(); + baseband::shutdown(); +} + +void TimeSinkView::focus() { + field_frequency.focus(); +} + +void TimeSinkView::on_show() { + baseband::spectrum_streaming_start(); +} + +void TimeSinkView::on_hide() { + baseband::spectrum_streaming_stop(); +} + +void TimeSinkView::set_parent_rect(const Rect new_parent_rect) { + View::set_parent_rect(new_parent_rect); + waveform.set_parent_rect({0, header_height, new_parent_rect.width(), new_parent_rect.height() - header_height}); +} + +void TimeSinkView::apply_spectrum_config() { + receiver_model.set_sampling_rate(sampling_rate); + receiver_model.set_baseband_bandwidth(filter_bandwidth_for_sampling_rate(sampling_rate)); + + baseband::set_time_sink( + sampling_rate, + trigger); +} + +size_t TimeSinkView::find_stable_trigger_index(const ChannelSpectrum& spectrum) { + if (spectrum.db.size() < 2) { + return 0; + } + + TriggerMode mode = TriggerMode::Rising; + switch (trigger_mode) { + case static_cast(TriggerMode::Off): + mode = TriggerMode::Off; + break; + case static_cast(TriggerMode::Rising): + mode = TriggerMode::Rising; + break; + case static_cast(TriggerMode::Falling): + mode = TriggerMode::Falling; + break; + default: + break; + } + + if (mode == TriggerMode::Off) { + trigger_lock_valid = false; + return 0; + } + + constexpr int32_t hysteresis = 2; + const int32_t threshold = std::clamp( + 128 + trigger_level, + hysteresis, + 255 - hysteresis); + const size_t center = spectrum.db.size() / 2; + const size_t reference_index = trigger_lock_valid ? trigger_lock_index : center; + + bool found = false; + size_t best_index = 0; + size_t best_distance = spectrum.db.size(); + const auto circular_distance = [count = spectrum.db.size()](size_t a, size_t b) -> size_t { + const size_t linear = + (a > b) ? (a - b) : (b - a); + return std::min(linear, count - linear); + }; + + for (size_t i = 1; i < spectrum.db.size(); ++i) { + const int32_t prev = spectrum.db[i - 1]; + const int32_t curr = spectrum.db[i]; + + bool crossing = false; + if (mode == TriggerMode::Rising) { + crossing = + (prev <= (threshold - hysteresis)) && + (curr >= (threshold + hysteresis)); + } else { + crossing = + (prev >= (threshold + hysteresis)) && + (curr <= (threshold - hysteresis)); + } + + if (!crossing) { + continue; + } + + const size_t distance = circular_distance(i, reference_index); + if (!found || (distance < best_distance)) { + found = true; + best_index = i; + best_distance = distance; + } + } + + if (found) { + trigger_lock_index = best_index; + trigger_lock_valid = true; + return best_index; + } + + return trigger_lock_valid ? trigger_lock_index : 0; +} + +void TimeSinkView::on_channel_spectrum(const ChannelSpectrum& spectrum) { + const size_t trigger_index = find_stable_trigger_index(spectrum); + const size_t source_count = spectrum.db.size(); + const size_t window_size = source_count; + + for (size_t x = 0; x < waveform_points; x++) { + const size_t offset = (x * window_size) / waveform_points; + const size_t src_index = + (trigger_index + offset) % source_count; + const int32_t centered = static_cast(spectrum.db[src_index]) - 128; + const int32_t scaled = centered * 256; + waveform_buffer[x] = static_cast(std::clamp(scaled, -32768, 32767)); + } + + waveform.set_dirty(); +} + +void TimeSinkView::on_freqchg(int64_t freq) { + field_frequency.set_value(freq); +} + +} // namespace ui::external_app::time_sink diff --git a/firmware/application/external/time_sink/ui_time_sink.hpp b/firmware/application/external/time_sink/ui_time_sink.hpp new file mode 100644 index 000000000..611c555d5 --- /dev/null +++ b/firmware/application/external/time_sink/ui_time_sink.hpp @@ -0,0 +1,238 @@ +/* + * Copyleft zxkmm (>) 2026 + * + * 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 __UI_TIME_SINK_APP_H__ +#define __UI_TIME_SINK_APP_H__ + +#include "app_settings.hpp" +#include "baseband_api.hpp" +#include "message.hpp" +#include "radio_state.hpp" +#include "ui.hpp" +#include "ui_freq_field.hpp" +#include "ui_navigation.hpp" +#include "ui_receiver.hpp" +#include "ui_widget.hpp" + +#include + +namespace ui::external_app::time_sink { + +constexpr size_t time_sink_waveform_points = 240; + +class TimeSinkWaveformWidget : public Widget { + public: + TimeSinkWaveformWidget(Rect parent_rect, const int16_t* data, size_t length, Color color); + TimeSinkWaveformWidget(const TimeSinkWaveformWidget&) = delete; + TimeSinkWaveformWidget(TimeSinkWaveformWidget&&) = delete; + TimeSinkWaveformWidget& operator=(const TimeSinkWaveformWidget&) = delete; + TimeSinkWaveformWidget& operator=(TimeSinkWaveformWidget&&) = delete; + + void set_parent_rect(const Rect new_parent_rect) override; + void on_show() override; + void paint(Painter& painter) override; + void set_persistence_frames(uint8_t frames); + + private: + static constexpr size_t max_columns = time_sink_waveform_points; + static constexpr size_t max_persistence_frames = 16; // this is sad that we cant have 32 histories in ext app due to memory constraints + + void reset_cache(); + Coord sample_to_y(const Rect& r, int16_t sample) const; + + const int16_t* data_; + size_t length_; + Color color_; + std::array current_y_{}; + std::array, max_persistence_frames> history_y_{}; + size_t history_count_{0}; + size_t history_head_{0}; + uint8_t persistence_frames_{1}; + bool needs_clear_{true}; +}; + +class TimeSinkView : public View { + public: + TimeSinkView(NavigationView& nav); + ~TimeSinkView(); + + TimeSinkView(const TimeSinkView&) = delete; + TimeSinkView(TimeSinkView&&) = delete; + TimeSinkView& operator=(const TimeSinkView&) = delete; + TimeSinkView& operator=(TimeSinkView&&) = delete; + + std::string title() const override { return "Time Sink"; }; + void focus() override; + void on_show() override; + void on_hide() override; + void set_parent_rect(const Rect new_parent_rect) override; + + private: + enum class TriggerMode : uint8_t { + Off = 0, + Rising = 1, + Falling = 2, + }; + + static constexpr Dim header_height = 3 * 16; + static constexpr size_t waveform_points = time_sink_waveform_points; + + NavigationView& nav_; + RxRadioState radio_state_{ + 433'920'000, + 1'750'000, + 2'000'000, + ReceiverModel::Mode::SpectrumAnalysis}; + + uint32_t sampling_rate{2'000'000}; + uint8_t trigger{0}; + uint8_t persistence_frames{1}; + uint8_t trigger_mode{static_cast(TriggerMode::Rising)}; + int32_t trigger_level{0}; + size_t trigger_lock_index{0}; + bool trigger_lock_valid{false}; + app_settings::SettingsManager settings_{ + "rx_time_sink"sv, + app_settings::Mode::RX, + { + {"sampling_rate"sv, &sampling_rate}, + {"trigger"sv, &trigger}, + {"persistence_frames"sv, &persistence_frames}, + {"trigger_mode"sv, &trigger_mode}, + {"trigger_level"sv, &trigger_level}, + }}; + + int16_t waveform_buffer[waveform_points]{0}; + ChannelSpectrumFIFO* fifo = nullptr; + + Labels labels{ + {{UI_POS_X(0), UI_POS_Y(1)}, "SR:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(11), UI_POS_Y(1)}, "DEC:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(0), UI_POS_Y(2)}, "PST:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(9), UI_POS_Y(2)}, "TRM:", Theme::getInstance()->fg_light->foreground}, + {{UI_POS_X(19), UI_POS_Y(2)}, "LVL:", Theme::getInstance()->fg_light->foreground}, + }; + + RxFrequencyField field_frequency{ + {UI_POS_X(0), UI_POS_Y(0)}, + nav_}; + + FrequencyStepView field_frequency_step{ + {10 * 8, UI_POS_Y(0)}}; + + RFAmpField field_rf_amp{ + {16 * 8, UI_POS_Y(0)}}; + + LNAGainField field_lna{ + {18 * 8, UI_POS_Y(0)}}; + + VGAGainField field_vga{ + {21 * 8, UI_POS_Y(0)}}; + + OptionsField options_sample_rate{ + {UI_POS_X(3), UI_POS_Y(1)}, + 6, + { + {"1.0M ", 1'000'000}, + {"2.0M ", 2'000'000}, + {"5.0M ", 5'000'000}, + {"10.0M ", 10'000'000}, + {"20.0M ", 20'000'000}, + }}; + + NumberField field_trigger{ + {UI_POS_X(17), UI_POS_Y(1)}, + 3, + {0, 128}, + 1, + ' '}; + + OptionsField options_persistence{ + {UI_POS_X(3), UI_POS_Y(2)}, + 3, + { + {"1 ", 1}, + {"2 ", 2}, + {"4 ", 4}, + {"8 ", 8}, + {"16 ", 16}, + }}; + + OptionsField options_trigger_mode{ + {UI_POS_X(13), UI_POS_Y(2)}, + 4, + { + {"Off ", static_cast(TriggerMode::Off)}, + {"Rise", static_cast(TriggerMode::Rising)}, + {"Fall", static_cast(TriggerMode::Falling)}, + }}; + + NumberField field_trigger_level{ + {UI_POS_X(23), UI_POS_Y(2)}, + 4, + {-127, 127}, + 1, + ' '}; + + TimeSinkWaveformWidget waveform{ + {0, header_height, screen_width, screen_height - header_height}, + waveform_buffer, + waveform_points, + Theme::getInstance()->fg_light->foreground}; + + MessageHandlerRegistration message_handler_spectrum_config{ + Message::ID::ChannelSpectrumConfig, + [this](const Message* const p) { + const auto message = *reinterpret_cast(p); + this->fifo = message.fifo; + }}; + + MessageHandlerRegistration message_handler_frame_sync{ + Message::ID::DisplayFrameSync, + [this](const Message* const) { + if (this->fifo) { + ChannelSpectrum spectrum{}; + bool has_spectrum = false; + while (fifo->out(spectrum)) { + has_spectrum = true; + } + if (has_spectrum) { + this->on_channel_spectrum(spectrum); + } + } + }}; + + MessageHandlerRegistration message_handler_freqchg{ + Message::ID::FreqChangeCommand, + [this](Message* const p) { + const auto message = static_cast(p); + this->on_freqchg(message->freq); + }}; + + void apply_spectrum_config(); + size_t find_stable_trigger_index(const ChannelSpectrum& spectrum); + void on_channel_spectrum(const ChannelSpectrum& spectrum); + void on_freqchg(int64_t freq); +}; + +} // namespace ui::external_app::time_sink + +#endif // __UI_TIME_SINK_APP_H__ diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index ed20f0f6b..1cf0fd95d 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -467,6 +467,7 @@ set(MODE_CPPSRC ) DeclareTargets(PSPE wideband_spectrum) + ### WFM Audio set(MODE_CPPSRC @@ -705,6 +706,13 @@ set(MODE_CPPSRC ) DeclareTargets(PMRT morsetx) +### Time Sink + +set(MODE_CPPSRC + proc_time_sink.cpp +) +DeclareTargets(PTSK time_sink) + set(MODE_CPPSRC sd_over_usb/proc_sd_over_usb.cpp diff --git a/firmware/baseband/proc_time_sink.cpp b/firmware/baseband/proc_time_sink.cpp new file mode 100644 index 000000000..782fbaf98 --- /dev/null +++ b/firmware/baseband/proc_time_sink.cpp @@ -0,0 +1,128 @@ +/* + * Copyleft zxkmm (>) 2026 + * + * 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_time_sink.hpp" + +#include "event_m4.hpp" +#include "portapack_shared_memory.hpp" + +#include +#include +#include +#include + +void TimeSinkProcessor::execute(const buffer_c8_t& buffer) { + // 2048 complex8_t samples per buffer. + // 102.4us per buffer. 20480 instruction cycles per buffer. + + if (!configured) { + return; + } + + execute_time_domain(buffer); +} + +void TimeSinkProcessor::execute_time_domain(const buffer_c8_t& buffer) { + if (!time_streaming || buffer.count == 0) { + return; + } + + if (phase < trigger) { + phase++; + return; + } + + phase = 0; + + if (time_domain_request_update) { + return; + } + + const size_t stride = std::max(1, buffer.count / time_domain_spectrum.db.size()); + time_domain_spectrum.sampling_rate = buffer.sampling_rate / stride; + time_domain_spectrum.channel_filter_low_frequency = 0; + time_domain_spectrum.channel_filter_high_frequency = 0; + time_domain_spectrum.channel_filter_transition = 0; + + for (size_t i = 0; i < time_domain_spectrum.db.size(); i++) { + const size_t sample_index = std::min(i * stride, buffer.count - 1); + const int32_t normalized = std::clamp( + static_cast(buffer.p[sample_index].real()) + 128, + 0, + 255); + time_domain_spectrum.db[i] = static_cast(normalized); + } + + time_domain_request_update = true; + EventDispatcher::events_flag(EVT_MASK_SPECTRUM); +} + +void TimeSinkProcessor::set_time_streaming_state(const SpectrumStreamingConfigMessage& message) { + if (message.mode == SpectrumStreamingConfigMessage::Mode::Running) { + time_streaming = true; + ChannelSpectrumConfigMessage fifo_message{&fifo}; + shared_memory.application_queue.push(fifo_message); + } else { + time_streaming = false; + time_domain_request_update = false; + fifo.reset_in(); + } +} + +void TimeSinkProcessor::update_time_domain() { + if (time_streaming && time_domain_request_update) { + fifo.in(time_domain_spectrum); + } + + time_domain_request_update = false; +} + +void TimeSinkProcessor::on_message(const Message* const msg) { + switch (msg->id) { + case Message::ID::UpdateSpectrum: + update_time_domain(); + break; + + case Message::ID::SpectrumStreamingConfig: + set_time_streaming_state(*reinterpret_cast(msg)); + break; + + case Message::ID::TimeSinkConfig: { + const auto& message = *reinterpret_cast(msg); + baseband_fs = message.sampling_rate; + trigger = message.trigger; + baseband_thread.set_sampling_rate(baseband_fs); + phase = 0; + time_domain_request_update = false; + configured = true; + break; + } + + default: + break; + } +} + +int main() { + EventDispatcher event_dispatcher{std::make_unique()}; + event_dispatcher.run(); + return 0; +} diff --git a/firmware/baseband/proc_time_sink.hpp b/firmware/baseband/proc_time_sink.hpp new file mode 100644 index 000000000..56676f15d --- /dev/null +++ b/firmware/baseband/proc_time_sink.hpp @@ -0,0 +1,56 @@ +/* + * Copyleft zxkmm (>) 2026 + * + * 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_TIME_SINK_H__ +#define __PROC_TIME_SINK_H__ + +#include "baseband_processor.hpp" +#include "baseband_thread.hpp" + +#include "message.hpp" + +#include + +class TimeSinkProcessor : public BasebandProcessor { + public: + void execute(const buffer_c8_t& buffer) override; + void on_message(const Message* const message) override; + + private: + bool configured = false; + size_t baseband_fs = 20000000; + + void execute_time_domain(const buffer_c8_t& buffer); + void set_time_streaming_state(const SpectrumStreamingConfigMessage& message); + void update_time_domain(); + ChannelSpectrum time_domain_spectrum{}; + ChannelSpectrum fifo_data[1 << ChannelSpectrumConfigMessage::fifo_k]{}; + ChannelSpectrumFIFO fifo{fifo_data, ChannelSpectrumConfigMessage::fifo_k}; + + size_t phase = 0, trigger = 127; + bool time_streaming = false; + volatile bool time_domain_request_update = false; + + /* NB: Threads should be the last members in the class definition. */ + BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive}; +}; + +#endif /*__PROC_TIME_SINK_H__*/ diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 5476077be..34436faa3 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -155,6 +155,7 @@ class Message { StreamTXConfiguration = 97, RTTYData = 98, NotificationData = 99, + TimeSinkConfig = 100, MAX }; @@ -309,6 +310,20 @@ class WidebandSpectrumConfigMessage : public Message { size_t trigger{0}; }; +class TimeSinkConfigMessage : public Message { + public: + constexpr TimeSinkConfigMessage( + size_t sampling_rate, + size_t trigger) + : Message{ID::TimeSinkConfig}, + sampling_rate{sampling_rate}, + trigger{trigger} { + } + + size_t sampling_rate{0}; + size_t trigger{0}; +}; + struct AudioSpectrum { std::array db{{0}}; // uint32_t sampling_rate { 0 }; diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index 2cff109ef..c09809ac4 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -96,6 +96,7 @@ constexpr image_tag_t image_tag_sonde{'P', 'S', 'O', 'N'}; constexpr image_tag_t image_tag_tpms{'P', 'T', 'P', 'M'}; constexpr image_tag_t image_tag_wfm_audio{'P', 'W', 'F', 'M'}; constexpr image_tag_t image_tag_wideband_spectrum{'P', 'S', 'P', 'E'}; +constexpr image_tag_t image_tag_time_sink{'P', 'T', 'S', 'K'}; constexpr image_tag_t image_tag_test{'P', 'T', 'S', 'T'}; constexpr image_tag_t image_tag_adsb_tx{'P', 'A', 'D', 'T'};