Files
zxkmm 9ca36394d0 add oscilloscope (time sink) app (#3059)
* POC

* submodule

* seperate bb

* seperate bb

* seperate bb

* seperate bb

* seperate bb

* sadly have to reduce history

* icon

* bring it back

* bring it back

* bb

* format and move bb outside
2026-03-02 19:17:02 +01:00

353 lines
10 KiB
C++

/*
* 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 <algorithm>
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<uint8_t>(std::clamp<size_t>(
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<int32_t>(1, r.height() - 1);
const int32_t y = y_center - (static_cast<int32_t>(sample) * y_span) / 65536;
return static_cast<Coord>(std::clamp<int32_t>(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<size_t>({
length_,
max_columns,
static_cast<size_t>(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<Coord>(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<Coord>(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<uint8_t>(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<uint8_t>(v);
waveform.set_persistence_frames(persistence_frames);
};
options_trigger_mode.set_by_nearest_value(trigger_mode);
trigger_mode = static_cast<uint8_t>(options_trigger_mode.selected_index_value());
options_trigger_mode.on_change = [this](size_t, OptionsField::value_t v) {
trigger_mode = static_cast<uint8_t>(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<uint8_t>(TriggerMode::Off):
mode = TriggerMode::Off;
break;
case static_cast<uint8_t>(TriggerMode::Rising):
mode = TriggerMode::Rising;
break;
case static_cast<uint8_t>(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<int32_t>(
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<int32_t>(spectrum.db[src_index]) - 128;
const int32_t scaled = centered * 256;
waveform_buffer[x] = static_cast<int16_t>(std::clamp<int32_t>(scaled, -32768, 32767));
}
waveform.set_dirty();
}
void TimeSinkView::on_freqchg(int64_t freq) {
field_frequency.set_value(freq);
}
} // namespace ui::external_app::time_sink