/* * 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