mirror of
https://github.com/portapack-mayhem/mayhem-firmware.git
synced 2026-08-20 14:39:01 +00:00
Addition of SSTV RX application (#2888)
This commit is contained in:
@@ -147,6 +147,19 @@ void set_sstv_data(const uint8_t vis_code, const uint32_t pixel_duration) {
|
||||
send_message(&message);
|
||||
}
|
||||
|
||||
void set_sstvrx_data(const uint8_t code) {
|
||||
const SSTVRXConfigureMessage message{
|
||||
code};
|
||||
send_message(&message);
|
||||
}
|
||||
|
||||
void set_sstvrx_phase_slant(const int16_t phase, const int16_t slant) {
|
||||
const SSTVRXPhaseSlantMessage message{
|
||||
phase,
|
||||
slant};
|
||||
send_message(&message);
|
||||
}
|
||||
|
||||
void set_afsk(const uint32_t baudrate, const uint32_t word_length, const uint32_t trigger_value, const bool trigger_word) {
|
||||
const AFSKRxConfigureMessage message{
|
||||
baudrate,
|
||||
|
||||
@@ -74,6 +74,8 @@ void set_tone(const uint32_t index, const uint32_t delta, const uint32_t duratio
|
||||
void set_tones_config(const uint32_t bw, const uint32_t pre_silence, const uint16_t tone_count, const bool dual_tone, const bool audio_out);
|
||||
void kill_tone();
|
||||
void set_sstv_data(const uint8_t vis_code, const uint32_t pixel_duration);
|
||||
void set_sstvrx_data(const uint8_t code);
|
||||
void set_sstvrx_phase_slant(const int16_t phase, const int16_t slant);
|
||||
void set_audiotx_config(const uint32_t divider, const float deviation_hz, const float audio_gain, uint8_t audio_shift_bits_s16, uint8_t bits_per_sample, const uint32_t tone_key_delta, const bool am_enabled, const bool dsb_enabled, const bool usb_enabled, const bool lsb_enabled);
|
||||
void set_fifo_data(const int8_t* data);
|
||||
void set_pitch_rssi(int32_t avg, bool enabled);
|
||||
|
||||
@@ -99,6 +99,10 @@ set(EXTCPPSRC
|
||||
external/sstvtx/main.cpp
|
||||
external/sstvtx/ui_sstvtx.cpp
|
||||
|
||||
#sstvrx
|
||||
external/sstvrx/main.cpp
|
||||
external/sstvrx/ui_sstvrx.cpp
|
||||
|
||||
#random 464 bytes.
|
||||
external/random_password/main.cpp
|
||||
external/random_password/ui_random_password.cpp
|
||||
@@ -294,6 +298,7 @@ set(EXTAPPLIST
|
||||
adsbtx
|
||||
morse_tx
|
||||
sstvtx
|
||||
sstvrx
|
||||
random_password
|
||||
# acars_rx --not working
|
||||
wefax_rx
|
||||
|
||||
+7
@@ -87,6 +87,7 @@ MEMORY
|
||||
ram_external_app_morse_practice (rwx) : org = 0xADEE0000, len = 32k
|
||||
ram_external_app_adult_toys_controller (rwx) : org = 0xADEF0000, len = 32k
|
||||
ram_external_app_flex_rx (rwx) : org = 0xADF00000, len = 32k
|
||||
ram_external_app_sstvrx (rwx) : org = 0xADF10000, len = 32k
|
||||
|
||||
}
|
||||
|
||||
@@ -477,5 +478,11 @@ SECTIONS
|
||||
KEEP(*(.external_app.app_flex_rx.application_information));
|
||||
*(*ui*external_app*flex_rx*);
|
||||
} > ram_external_app_flex_rx
|
||||
|
||||
.external_app_sstvrx : ALIGN(4) SUBALIGN(4)
|
||||
{
|
||||
KEEP(*(.external_app.app_sstvrx.application_information));
|
||||
*(*ui*external_app*sstvrx*);
|
||||
} > ram_external_app_sstvrx
|
||||
}
|
||||
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2025 StarVore Labs
|
||||
*
|
||||
* 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_sstvrx.hpp"
|
||||
#include "ui_navigation.hpp"
|
||||
#include "external_app.hpp"
|
||||
|
||||
namespace ui::external_app::sstvrx {
|
||||
|
||||
void initialize_app(NavigationView& nav) {
|
||||
nav.push<SstvRxView>();
|
||||
}
|
||||
|
||||
} // namespace ui::external_app::sstvrx
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Az alkalmazás információ C-linkage-ként, hogy a firmware hívhassa
|
||||
__attribute__((section(".external_app.app_sstvrx.application_information"), used))
|
||||
application_information_t _application_information_sstvrx = {
|
||||
/*.memory_location = */ (uint8_t*)0x00000000,
|
||||
/*.externalAppEntry = */ ui::external_app::sstvrx::initialize_app,
|
||||
/*.header_version = */ CURRENT_HEADER_VERSION,
|
||||
/*.app_version = */ VERSION_MD5,
|
||||
|
||||
/*.app_name = */ "SSTV RX",
|
||||
/*.bitmap_data = */ {
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFE,
|
||||
0x7F,
|
||||
0x03,
|
||||
0xC0,
|
||||
0x53,
|
||||
0xD5,
|
||||
0xAB,
|
||||
0xCA,
|
||||
0x53,
|
||||
0xD5,
|
||||
0xAB,
|
||||
0xCA,
|
||||
0x53,
|
||||
0xD5,
|
||||
0xAB,
|
||||
0xCA,
|
||||
0x53,
|
||||
0xD5,
|
||||
0x03,
|
||||
0xC0,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0xFB,
|
||||
0xD7,
|
||||
0xFE,
|
||||
0x7F,
|
||||
0x00,
|
||||
0x00,
|
||||
},
|
||||
/*.icon_color = */ ui::Color::yellow().v,
|
||||
/*.menu_location = */ app_location_t::RX,
|
||||
/*.desired_menu_position = */ -1,
|
||||
|
||||
/*.m4_app_tag = portapack::spi_flash::image_tag_none */ {'P', 'S', 'R', 'X'},
|
||||
/*.m4_app_offset = */ 0x00000000, // will be filled at compile time
|
||||
};
|
||||
|
||||
} // extern "C"
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
/*
|
||||
* Copyright (C) 2025 StarVore Labs
|
||||
*
|
||||
* 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_sstvrx.hpp"
|
||||
|
||||
#include "portapack_persistent_memory.hpp"
|
||||
#include "portapack.hpp"
|
||||
#include "hackrf_hal.hpp"
|
||||
#include "file_path.hpp"
|
||||
#include "message.hpp"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <stdio.h>
|
||||
|
||||
using namespace portapack;
|
||||
using namespace modems;
|
||||
using namespace ui;
|
||||
|
||||
#if SSTVRX_ENABLE_LOGGER
|
||||
#define SSTVRX_LOG_INFO(msg) \
|
||||
do { \
|
||||
if (logger) { \
|
||||
logger->log_info(msg); \
|
||||
} \
|
||||
} while (0)
|
||||
#define SSTVRX_LOG_ERROR(msg) \
|
||||
do { \
|
||||
if (logger) { \
|
||||
logger->log_error(msg); \
|
||||
} \
|
||||
} while (0)
|
||||
#else
|
||||
#define SSTVRX_LOG_INFO(msg) \
|
||||
do { \
|
||||
(void)sizeof(msg); \
|
||||
} while (0)
|
||||
#define SSTVRX_LOG_ERROR(msg) \
|
||||
do { \
|
||||
(void)sizeof(msg); \
|
||||
} while (0)
|
||||
#endif
|
||||
|
||||
// SSTV RX View Implementation
|
||||
namespace ui::external_app::sstvrx {
|
||||
|
||||
static_assert(sizeof(shared_memory.bb_data.data) == 512,
|
||||
"SSTV shared buffer size mismatch");
|
||||
|
||||
#if SSTVRX_ENABLE_LOGGER
|
||||
void SstvRxLogger::log_error(const std::string& error_message) {
|
||||
log_file.write_entry(rtc_time::now(), "ERROR: " + error_message);
|
||||
}
|
||||
|
||||
void SstvRxLogger::log_info(const std::string& info_message) {
|
||||
log_file.write_entry(rtc_time::now(), "INFO: " + info_message);
|
||||
}
|
||||
#endif
|
||||
|
||||
SstvRxView::SstvRxView(ui::NavigationView& nav)
|
||||
: nav_(nav) {
|
||||
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
|
||||
DISPLAY_HEIGHT = screen_height - SSTV_IMG_START_ROW * 16 - 16;
|
||||
DISPLAY_WIDTH = screen_width;
|
||||
add_children({&field_rf_amp,
|
||||
&field_lna,
|
||||
&field_vga,
|
||||
&rssi,
|
||||
&channel,
|
||||
&field_frequency,
|
||||
&field_volume,
|
||||
&audio,
|
||||
&start_stop_btn,
|
||||
&options_mode,
|
||||
&field_phase,
|
||||
&field_slant,
|
||||
&labels,
|
||||
&text_calibration});
|
||||
|
||||
// Initialize audio with proper rate for SSTV
|
||||
audio::set_rate(audio::Rate::Hz_48000);
|
||||
audio::output::start();
|
||||
|
||||
// Configure receiver with optimal settings for SSTV
|
||||
// NOTE: Do NOT set modulation mode - SSTV uses a custom baseband processor
|
||||
// Standard sampling rate (3.072MHz) with wide bandwidth to capture full SSTV audio spectrum
|
||||
// SSTV uses 1200-2300 Hz tones, so we need wide baseband to avoid distortion
|
||||
receiver_model.set_sampling_rate(3072000);
|
||||
receiver_model.set_baseband_bandwidth(1750000); // Standard wideband setting
|
||||
receiver_model.set_squelch_level(1);
|
||||
receiver_model.set_hidden_offset(0); // No offset needed
|
||||
|
||||
// Field values will be set in on_show() to ensure proper initialization
|
||||
|
||||
using option_t = std::pair<std::string, int32_t>;
|
||||
using options_t = std::vector<option_t>;
|
||||
options_t mode_options;
|
||||
uint32_t c;
|
||||
|
||||
// Start/Stop button handler - toggles between start and stop
|
||||
start_stop_btn.on_select = [this](Button&) {
|
||||
start_stop_btn.focus();
|
||||
on_start_stop();
|
||||
};
|
||||
|
||||
// Initialize frequency field from settings or use default
|
||||
if (settings_.loaded() && settings_.raw().rx_frequency != 0) {
|
||||
field_frequency.set_value(settings_.raw().rx_frequency);
|
||||
} else if (field_frequency.value() == 0) {
|
||||
field_frequency.set_value(145800000); // Default to 145.800 MHz (ISS)
|
||||
}
|
||||
field_frequency.set_step(25000);
|
||||
|
||||
// Populate mode list
|
||||
for (c = 0; c < SSTV_MODES_NB; c++)
|
||||
mode_options.emplace_back(sstv_modes[c].name, c);
|
||||
options_mode.set_options(mode_options);
|
||||
|
||||
options_mode.on_change = [this](size_t i, int32_t) {
|
||||
this->on_mode_changed(i);
|
||||
};
|
||||
options_mode.set_selected_index(1); // Scottie 2
|
||||
on_mode_changed(1);
|
||||
|
||||
// Initialize phase and slant controls from loaded settings
|
||||
field_phase.set_value(phase_adjustment);
|
||||
field_phase.on_change = [this](int32_t v) {
|
||||
phase_adjustment = v;
|
||||
if (is_receiving) {
|
||||
baseband::set_sstvrx_phase_slant(phase_adjustment, slant_adjustment);
|
||||
} else if (max_received_line > 0) {
|
||||
// Auto-redraw when adjusting after reception
|
||||
redraw_image();
|
||||
}
|
||||
};
|
||||
|
||||
field_slant.set_value(slant_adjustment);
|
||||
field_slant.on_change = [this](int32_t v) {
|
||||
slant_adjustment = v;
|
||||
if (is_receiving) {
|
||||
baseband::set_sstvrx_phase_slant(phase_adjustment, slant_adjustment);
|
||||
} else if (max_received_line > 0) {
|
||||
// Auto-redraw when adjusting after reception
|
||||
redraw_image();
|
||||
}
|
||||
};
|
||||
|
||||
#if SSTVRX_ENABLE_LOGGER
|
||||
logger = std::make_unique<SstvRxLogger>();
|
||||
if (logger) {
|
||||
logger->append(logs_dir / "SSTVRX.txt");
|
||||
logger->log_info("----------SSTV RX Started----------");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Destructor: Ensure reception is stopped
|
||||
SstvRxView::~SstvRxView() {
|
||||
is_receiving = true;
|
||||
on_stop();
|
||||
baseband::shutdown();
|
||||
SSTVRX_LOG_INFO("SSTV RX Stopped");
|
||||
}
|
||||
|
||||
void SstvRxView::on_show() {
|
||||
// Update field values from receiver model to reflect loaded settings
|
||||
field_lna.set_value(receiver_model.lna());
|
||||
field_vga.set_value(receiver_model.vga());
|
||||
field_rf_amp.set_value(receiver_model.rf_amp());
|
||||
field_volume.set_value(receiver_model.normalized_headphone_volume());
|
||||
}
|
||||
|
||||
void SstvRxView::focus() {
|
||||
field_frequency.focus();
|
||||
}
|
||||
|
||||
// Combined start/stop handler - toggles based on current state
|
||||
void SstvRxView::on_start_stop() {
|
||||
if (is_receiving) {
|
||||
// Currently receiving - stop it
|
||||
on_stop();
|
||||
start_stop_btn.set_text("Start RX");
|
||||
} else {
|
||||
// Currently stopped - start reception
|
||||
SSTVRX_LOG_INFO("Starting SSTV RX Reception");
|
||||
start_audio();
|
||||
start_stop_btn.set_text("Stop RX");
|
||||
}
|
||||
}
|
||||
|
||||
// Stop NFM audio reception
|
||||
void SstvRxView::on_stop() {
|
||||
SSTVRX_LOG_INFO("Stopping SSTV RX Reception");
|
||||
if (is_receiving) {
|
||||
// Stop in reverse order of start
|
||||
receiver_model.disable();
|
||||
audio::output::stop();
|
||||
|
||||
// Reset state
|
||||
is_receiving = false;
|
||||
|
||||
// Close image file if still open
|
||||
bmp.close();
|
||||
|
||||
pending_line_valid = false;
|
||||
pending_chunk_mask = 0;
|
||||
std::fill(pending_line_rgb.begin(), pending_line_rgb.end(), 0);
|
||||
*reinterpret_cast<volatile uint8_t*>(&shared_memory.bb_data.data[CHUNK_FLAG_INDEX]) = 0;
|
||||
|
||||
SSTVRX_LOG_INFO("SSTV RX Reception Stopped");
|
||||
} else {
|
||||
SSTVRX_LOG_ERROR("SSTV RX Reception Not Running");
|
||||
}
|
||||
}
|
||||
|
||||
// Start NFM audio reception
|
||||
void SstvRxView::start_audio() {
|
||||
SSTVRX_LOG_INFO("Configuring SSTV RX Audio Reception");
|
||||
|
||||
// Configure the baseband processor with VIS code
|
||||
if (rx_sstv_mode) {
|
||||
baseband::set_sstvrx_data(rx_sstv_mode->vis_code);
|
||||
SSTVRX_LOG_INFO("Sent VIS code to processor: " + to_string_dec_uint(rx_sstv_mode->vis_code));
|
||||
}
|
||||
|
||||
// Send phase and slant adjustments
|
||||
baseband::set_sstvrx_phase_slant(phase_adjustment, slant_adjustment);
|
||||
|
||||
// Initialize audio path
|
||||
audio::output::stop();
|
||||
audio::set_rate(audio::Rate::Hz_48000);
|
||||
|
||||
// Set audio routing and volume
|
||||
// audio::output::start();
|
||||
|
||||
// Clear display area and reset line counter
|
||||
portapack::display.fill_rectangle(
|
||||
{0, SSTV_IMG_START_ROW * 16, DISPLAY_WIDTH, DISPLAY_HEIGHT},
|
||||
{0, 0, 0});
|
||||
line_num = 0;
|
||||
file_line_num = 0;
|
||||
max_received_line = 0;
|
||||
pending_line_valid = false;
|
||||
pending_chunk_mask = 0;
|
||||
std::fill(pending_line_rgb.begin(), pending_line_rgb.end(), 0);
|
||||
*reinterpret_cast<volatile uint8_t*>(&shared_memory.bb_data.data[CHUNK_FLAG_INDEX]) = 0;
|
||||
|
||||
// Clear calibration display
|
||||
text_calibration.set("Calibrating...");
|
||||
|
||||
// Initialize new image file
|
||||
current_line_rx = 0;
|
||||
auto timestamp = to_string_timestamp(rtc_time::now());
|
||||
auto dir_error = ensure_directory(sstv_dir / "RX");
|
||||
if (!dir_error.ok()) {
|
||||
SSTVRX_LOG_ERROR("Failed to create directory: SSTV/RX");
|
||||
}
|
||||
current_image_path = sstv_dir / ("RX/SSTV_" + timestamp + ".bmp");
|
||||
auto ok = bmp.create(current_image_path, IMAGE_WIDTH, 1);
|
||||
if (!ok) {
|
||||
SSTVRX_LOG_ERROR("Failed to create file: " + current_image_path.string());
|
||||
bmp.close();
|
||||
}
|
||||
|
||||
// Start audio output
|
||||
audio::output::start();
|
||||
audio::headphone::set_volume(persistent_memory::headphone_volume());
|
||||
|
||||
// Keep ReceiverModel in capture mode so it doesn't override our custom baseband/audio configuration.
|
||||
receiver_model.set_modulation(ReceiverModel::Mode::Capture);
|
||||
|
||||
// Enable receiver last
|
||||
receiver_model.enable();
|
||||
is_receiving = true;
|
||||
SSTVRX_LOG_INFO("SSTV RX Started");
|
||||
}
|
||||
|
||||
void SstvRxView::on_mode_changed(const size_t index) {
|
||||
rx_sstv_mode = &sstv_modes[index];
|
||||
}
|
||||
|
||||
void SstvRxView::write_line_to_file(uint16_t line_num, const uint8_t* rgb_line) {
|
||||
(void)line_num;
|
||||
if (!bmp.is_loaded()) return;
|
||||
// Ensure BMP height is sufficient
|
||||
if (bmp.get_real_height() <= file_line_num) {
|
||||
bmp.expand_y(file_line_num + 1);
|
||||
}
|
||||
bmp.seek(0, file_line_num);
|
||||
|
||||
// Write RGB data in BGR order
|
||||
for (uint16_t x = 0; x < IMAGE_WIDTH; x++) {
|
||||
uint8_t r = rgb_line[x * 3 + 0];
|
||||
uint8_t g = rgb_line[x * 3 + 1];
|
||||
uint8_t b = rgb_line[x * 3 + 2];
|
||||
Color px(g, b, r);
|
||||
bmp.write_next_px(px);
|
||||
}
|
||||
file_line_num++;
|
||||
}
|
||||
|
||||
void SstvRxView::update_display(uint16_t current_line, const uint8_t* rgb_line) {
|
||||
if (current_line >= IMAGE_HEIGHT) return;
|
||||
|
||||
// Reset line counter if we reach the bottom of display
|
||||
if (line_num >= DISPLAY_HEIGHT) {
|
||||
line_num = 0;
|
||||
}
|
||||
|
||||
// Scale line to display width
|
||||
for (uint16_t x = 0; x < DISPLAY_WIDTH; x++) {
|
||||
// Scale x coordinate
|
||||
uint16_t src_x = (x * IMAGE_WIDTH) / DISPLAY_WIDTH;
|
||||
if (src_x >= IMAGE_WIDTH) continue;
|
||||
|
||||
// Get RGB values from interleaved data [R,G,B,R,G,B,...]
|
||||
uint8_t r = rgb_line[src_x * 3 + 0];
|
||||
uint8_t g = rgb_line[src_x * 3 + 1];
|
||||
uint8_t b = rgb_line[src_x * 3 + 2];
|
||||
// Display uses BGR order like BMP format
|
||||
// line_buffer[x] = Color(b, r, g);
|
||||
line_buffer[x] = Color(g, b, r);
|
||||
}
|
||||
|
||||
// Render the line at the current position
|
||||
portapack::display.render_line(
|
||||
{0, line_num + SSTV_IMG_START_ROW * 16},
|
||||
DISPLAY_WIDTH,
|
||||
line_buffer);
|
||||
|
||||
// Increment line counter
|
||||
line_num++;
|
||||
}
|
||||
void SstvRxView::redraw_image() {
|
||||
// Disabled: Post-reception redraw requires 245KB buffer which exceeds M0 memory
|
||||
// Phase and slant adjustments must be set before reception starts
|
||||
}
|
||||
|
||||
void SstvRxView::on_progress(uint16_t line, uint16_t total_lines) {
|
||||
if (!is_receiving) {
|
||||
if (line < 0xFFF0) {
|
||||
*reinterpret_cast<volatile uint8_t*>(&shared_memory.bb_data.data[CHUNK_FLAG_INDEX]) = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle debug messages
|
||||
if (line == 0xFFFF) {
|
||||
SSTVRX_LOG_ERROR("Processor not configured");
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFFE) {
|
||||
SSTVRX_LOG_INFO("Sync pulse duration: " + to_string_dec_uint(total_lines) + " samples");
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFFD) {
|
||||
SSTVRX_LOG_INFO("Sync detected, count=" + to_string_dec_uint(total_lines));
|
||||
text_calibration.set("Syncs: " + to_string_dec_uint(total_lines));
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFFC) {
|
||||
SSTVRX_LOG_INFO("Expected interval: " + to_string_dec_uint(total_lines) + " samples");
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFFB) {
|
||||
SSTVRX_LOG_INFO("Actual interval: " + to_string_dec_uint(total_lines) + " samples");
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFFA) {
|
||||
SSTVRX_LOG_INFO("SYNC TIMEOUT after " + to_string_dec_uint(total_lines) + " samples - continuing without sync");
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFF9) {
|
||||
SSTVRX_LOG_INFO("Detected frequency at sync: " + to_string_dec_uint(total_lines) + " Hz");
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFF8) {
|
||||
SSTVRX_LOG_INFO("OUTLIER REJECTED: interval=" + to_string_dec_uint(total_lines) + " samples (expected 10000-15000)");
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFF7) {
|
||||
SSTVRX_LOG_INFO("PRE-RECORD: sync_history_count=" + to_string_dec_uint(total_lines));
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFF6) {
|
||||
SSTVRX_LOG_INFO("MAX_SYNC_HISTORY EXCEEDED: count=" + to_string_dec_uint(total_lines));
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFF5) {
|
||||
// Decode rejection reason bit flags: 0x1=interval, 0x2=freq, 0x4=duration
|
||||
std::string reasons = "";
|
||||
if (total_lines & 0x1) reasons += "interval ";
|
||||
if (total_lines & 0x2) reasons += "freq ";
|
||||
if (total_lines & 0x4) reasons += "duration ";
|
||||
if (reasons.empty()) reasons = "unknown";
|
||||
SSTVRX_LOG_INFO("FALSE SYNC PAIR REJECTED on Line 0: " + reasons);
|
||||
return;
|
||||
}
|
||||
if (line == 0xFFF4) {
|
||||
SSTVRX_LOG_INFO("STARTING LINE 0 DECODE after sync_sample_count=" + to_string_dec_uint(total_lines));
|
||||
return;
|
||||
}
|
||||
|
||||
std::array<uint8_t, CHUNK_COPY_BYTES> chunk{};
|
||||
memcpy(chunk.data(), shared_memory.bb_data.data, chunk.size());
|
||||
*reinterpret_cast<volatile uint8_t*>(&shared_memory.bb_data.data[CHUNK_FLAG_INDEX]) = 0;
|
||||
|
||||
const uint16_t line_num_encoded = chunk[0] | (chunk[1] << 8);
|
||||
const bool is_second_chunk = (line_num_encoded & 1) == 1;
|
||||
const uint16_t actual_line_num = line_num_encoded / 2;
|
||||
|
||||
if (actual_line_num >= IMAGE_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool multi_chunk_line = PIXELS_PER_LINE > MAX_CHUNK_PIXELS;
|
||||
if (!pending_line_valid || pending_line_number != actual_line_num) {
|
||||
pending_line_number = actual_line_num;
|
||||
pending_line_valid = true;
|
||||
pending_chunk_mask = 0;
|
||||
std::fill(pending_line_rgb.begin(), pending_line_rgb.end(), 0);
|
||||
}
|
||||
|
||||
const uint16_t chunk_pixels = multi_chunk_line
|
||||
? (is_second_chunk ? (PIXELS_PER_LINE - MAX_CHUNK_PIXELS) : MAX_CHUNK_PIXELS)
|
||||
: PIXELS_PER_LINE;
|
||||
const uint16_t dest_pixel_offset = (multi_chunk_line && is_second_chunk) ? MAX_CHUNK_PIXELS : 0;
|
||||
const uint16_t chunk_bytes = chunk_pixels * 3;
|
||||
const uint16_t max_copy_bytes = static_cast<uint16_t>(chunk.size() > CHUNK_HEADER_BYTES ? (chunk.size() - CHUNK_HEADER_BYTES) : 0);
|
||||
const uint16_t bytes_to_copy = (chunk_bytes < max_copy_bytes) ? chunk_bytes : max_copy_bytes;
|
||||
const uint16_t dest_byte_offset = dest_pixel_offset * 3;
|
||||
|
||||
if (bytes_to_copy > 0 && (dest_byte_offset + bytes_to_copy) <= pending_line_rgb.size()) {
|
||||
memcpy(pending_line_rgb.data() + dest_byte_offset, chunk.data() + CHUNK_HEADER_BYTES, bytes_to_copy);
|
||||
}
|
||||
|
||||
const uint8_t chunk_bit = (multi_chunk_line && is_second_chunk) ? 0x2 : 0x1;
|
||||
pending_chunk_mask |= chunk_bit;
|
||||
const uint8_t required_mask = multi_chunk_line ? 0x3 : 0x1;
|
||||
if (pending_chunk_mask != required_mask) {
|
||||
return;
|
||||
}
|
||||
|
||||
pending_line_valid = false;
|
||||
pending_chunk_mask = 0;
|
||||
current_line_rx = actual_line_num;
|
||||
if (actual_line_num < IMAGE_HEIGHT) {
|
||||
write_line_to_file(actual_line_num, pending_line_rgb.data());
|
||||
update_display(actual_line_num, pending_line_rgb.data());
|
||||
max_received_line = max_received_line > (actual_line_num + 1) ? max_received_line : (actual_line_num + 1);
|
||||
}
|
||||
|
||||
#if SSTVRX_ENABLE_LOGGER
|
||||
if (logger && (actual_line_num % 10 == 0)) {
|
||||
logger->log_info("Line " + to_string_dec_uint(actual_line_num) + "/" + to_string_dec_uint(total_lines));
|
||||
}
|
||||
#endif
|
||||
|
||||
// if (actual_line_num >= (total_lines - 1)) { //don't auto finish image upon end, user need to manually stop. this method is not reliable enough.
|
||||
// finish_image();
|
||||
// }
|
||||
}
|
||||
|
||||
void SstvRxView::finish_image() {
|
||||
bmp.close();
|
||||
SSTVRX_LOG_INFO("Image completed: " + current_image_path.string());
|
||||
}
|
||||
|
||||
void SstvRxView::on_calibration(int16_t suggested_phase, int16_t suggested_slant, uint16_t sync_count) {
|
||||
if (!is_receiving) return;
|
||||
|
||||
// Display calibration suggestions to the user
|
||||
if (sync_count >= 4) {
|
||||
std::string cal_text = "Try Slant=" + to_string_dec_int(suggested_slant);
|
||||
text_calibration.set(cal_text);
|
||||
text_calibration.set_dirty();
|
||||
|
||||
// Don't auto-apply yet - just suggest for now until we verify the values are correct
|
||||
// User can manually adjust if needed
|
||||
|
||||
// Log the suggestion
|
||||
SSTVRX_LOG_INFO("Calibration suggestion: phase=" + to_string_dec_int(suggested_phase) +
|
||||
" slant=" + to_string_dec_int(suggested_slant) +
|
||||
" (from " + to_string_dec_uint(sync_count) + " syncs)");
|
||||
} else {
|
||||
// Log that we received calibration but not enough syncs yet
|
||||
SSTVRX_LOG_INFO("Calibration received: " + to_string_dec_uint(sync_count) + " syncs (need 4+)");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ui::external_app::sstvrx
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (C) 2025 StarVore Labs
|
||||
*
|
||||
* 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 __SSTVRX_H__
|
||||
#define __SSTVRX_H__
|
||||
|
||||
#include "ui.hpp"
|
||||
#include "ui_widget.hpp"
|
||||
#include "ui_receiver.hpp"
|
||||
#include "ui_navigation.hpp"
|
||||
#include "ui_receiver.hpp"
|
||||
#include "ui_freq_field.hpp"
|
||||
#include "ui_freqman.hpp"
|
||||
#include "ui_channel.hpp"
|
||||
#include "baseband_api.hpp"
|
||||
#include "event_m0.hpp"
|
||||
#include "message.hpp"
|
||||
#include "sstv.hpp"
|
||||
#include "file.hpp"
|
||||
#include "bmpfile.hpp"
|
||||
#include "app_settings.hpp"
|
||||
#include "radio_state.hpp"
|
||||
#include "oversample.hpp"
|
||||
#include "string_format.hpp"
|
||||
#include "log_file.hpp"
|
||||
#include "utility.hpp"
|
||||
#include "audio.hpp"
|
||||
#include "portapack.hpp"
|
||||
#include <array>
|
||||
#include <ch.h>
|
||||
|
||||
#ifndef SSTVRX_ENABLE_LOGGER
|
||||
#define SSTVRX_ENABLE_LOGGER 0
|
||||
#endif
|
||||
|
||||
using namespace sstv;
|
||||
|
||||
namespace ui::external_app::sstvrx {
|
||||
|
||||
#define FMR_BTNGRID_TOP 60
|
||||
|
||||
#if SSTVRX_ENABLE_LOGGER
|
||||
class SstvRxLogger {
|
||||
public:
|
||||
Optional<File::Error> append(const std::filesystem::path& filename) {
|
||||
return log_file.append(filename);
|
||||
}
|
||||
|
||||
void log_error(const std::string& error_message);
|
||||
void log_info(const std::string& info_message);
|
||||
|
||||
private:
|
||||
LogFile log_file{};
|
||||
};
|
||||
#endif
|
||||
|
||||
class SstvRxView : public ui::View {
|
||||
public:
|
||||
SstvRxView(ui::NavigationView& nav);
|
||||
SstvRxView& operator=(const SstvRxView&) = delete;
|
||||
SstvRxView(const SstvRxView&) = delete;
|
||||
~SstvRxView();
|
||||
|
||||
std::string title() { return "SSTV RX"; }
|
||||
void focus() override;
|
||||
void on_show() override;
|
||||
|
||||
private:
|
||||
ui::NavigationView& nav_;
|
||||
#if SSTVRX_ENABLE_LOGGER
|
||||
std::unique_ptr<SstvRxLogger> logger{};
|
||||
#endif
|
||||
|
||||
// Phase and slant adjustments (runtime only, not persisted)
|
||||
int16_t phase_adjustment{0}; // Horizontal offset in pixels (-50 to +50)
|
||||
int16_t slant_adjustment{0}; // Timing adjustment in 0.1% units (-100 to +100)
|
||||
|
||||
// Settings must be declared before UI controls
|
||||
app_settings::SettingsManager settings_{
|
||||
"rx_sstv",
|
||||
app_settings::Mode::RX};
|
||||
|
||||
ReceiverModel::Mode receiver_mode = ReceiverModel::Mode::WidebandFMAudio;
|
||||
AudioSpectrum* audio_spectrum_data{nullptr};
|
||||
int16_t audio_spectrum[128]{0};
|
||||
RxRadioState radio_state_{};
|
||||
audio::Rate audio_sampling_rate = audio::Rate::Hz_48000;
|
||||
uint8_t radio_bw = 0;
|
||||
bool is_receiving = false;
|
||||
const sstv_mode* rx_sstv_mode{};
|
||||
|
||||
// Image data storage - only store current line to save memory
|
||||
static constexpr uint16_t IMAGE_WIDTH = 320;
|
||||
static constexpr uint16_t IMAGE_HEIGHT = 256;
|
||||
static constexpr uint16_t PIXELS_PER_LINE = 320;
|
||||
uint16_t DISPLAY_WIDTH = 240; // Scaled display width
|
||||
uint16_t DISPLAY_HEIGHT = 192; // Scaled display height
|
||||
static constexpr uint16_t SSTV_IMG_START_ROW = 7; // Start drawing at row 7 (after controls)
|
||||
static constexpr size_t SHARED_BUFFER_BYTES = 512;
|
||||
static constexpr size_t CHUNK_FLAG_INDEX = SHARED_BUFFER_BYTES - 1;
|
||||
static constexpr size_t CHUNK_HEADER_BYTES = 2;
|
||||
static constexpr size_t CHUNK_COPY_BYTES = CHUNK_FLAG_INDEX; // Exclude flag byte
|
||||
static constexpr uint16_t MAX_CHUNK_PIXELS = (CHUNK_COPY_BYTES - CHUNK_HEADER_BYTES) / 3;
|
||||
|
||||
uint16_t current_line_rx{0};
|
||||
BMPFile bmp{};
|
||||
std::filesystem::path current_image_path{};
|
||||
ui::Color line_buffer[320];
|
||||
uint16_t line_num{0}, file_line_num{0};
|
||||
std::array<uint8_t, IMAGE_WIDTH * 3> pending_line_rgb{};
|
||||
uint16_t pending_line_number{0};
|
||||
uint8_t pending_chunk_mask{0};
|
||||
bool pending_line_valid{false};
|
||||
|
||||
// Note: Post-reception phase/slant adjustment disabled due to M0 memory constraints
|
||||
// The 245KB image buffer exceeds available heap memory
|
||||
uint16_t max_received_line{0};
|
||||
|
||||
MessageHandlerRegistration message_handler_progress{
|
||||
Message::ID::SSTVRXProgress,
|
||||
[this](const Message* const p) {
|
||||
const auto message = *reinterpret_cast<const SSTVRXProgressMessage*>(p);
|
||||
this->on_progress(message.line, message.total_lines);
|
||||
}};
|
||||
|
||||
MessageHandlerRegistration message_handler_calibration{
|
||||
Message::ID::SSTVRXCalibration,
|
||||
[this](const Message* const p) {
|
||||
const auto message = *reinterpret_cast<const SSTVRXCalibrationMessage*>(p);
|
||||
this->on_calibration(message.suggested_phase, message.suggested_slant, message.sync_count);
|
||||
}};
|
||||
|
||||
// UI Elements
|
||||
RFAmpField field_rf_amp{{UI_POS_X(13), UI_POS_Y(0)}};
|
||||
LNAGainField field_lna{{UI_POS_X(15), UI_POS_Y(0)}};
|
||||
VGAGainField field_vga{{UI_POS_X(18), UI_POS_Y(0)}};
|
||||
|
||||
RSSI rssi{{UI_POS_X(21), 0, UI_POS_WIDTH_REMAINING(24), 4}};
|
||||
Channel channel{{UI_POS_X(21), 5, UI_POS_WIDTH_REMAINING(24), 4}};
|
||||
RxFrequencyField field_frequency{{UI_POS_X(0), UI_POS_Y(0)}, nav_};
|
||||
AudioVolumeField field_volume{{UI_POS_X_RIGHT(2), UI_POS_Y(0)}};
|
||||
|
||||
OptionsField options_mode{{UI_POS_X(6), UI_POS_Y(1)}, 16, {}};
|
||||
|
||||
NumberField field_phase{{UI_POS_X(4), UI_POS_Y(2)}, 3, {-50, 50}, 1, ' '};
|
||||
NumberField field_slant{{UI_POS_X(13), UI_POS_Y(2)}, 4, {-100, 100}, 1, ' '};
|
||||
|
||||
Labels labels{
|
||||
{{UI_POS_X(1), UI_POS_Y(1)}, "Mode:", Theme::getInstance()->fg_light->foreground},
|
||||
{{UI_POS_X(1), UI_POS_Y(2)}, "Ph:", Theme::getInstance()->fg_light->foreground},
|
||||
{{UI_POS_X(8), UI_POS_Y(2)}, "Slnt:", Theme::getInstance()->fg_light->foreground}};
|
||||
|
||||
Audio audio{{UI_POS_X(21), 10, UI_POS_WIDTH(6), 4}};
|
||||
ui::Button start_stop_btn{{UI_POS_X_RIGHT(12), UI_POS_Y(3), UI_POS_WIDTH(11), UI_POS_HEIGHT(2)}, "Start RX"};
|
||||
// ui::Button redraw_btn{{16 * 8, UI_POS_Y(5), UI_POS_WIDTH(12), UI_POS_HEIGHT(3)}, "Redraw"};
|
||||
|
||||
// Calibration suggestion display
|
||||
Text text_calibration{
|
||||
{UI_POS_X(1), UI_POS_Y(3), UI_POS_WIDTH(17), UI_POS_HEIGHT(1)},
|
||||
"Calib: N/A"};
|
||||
|
||||
void on_audio_spectrum();
|
||||
void update_display(uint16_t line_num, const uint8_t* rgb_line);
|
||||
void redraw_image(); // Disabled due to memory constraints
|
||||
void start_audio();
|
||||
void on_start_stop(); // Combined start/stop handler
|
||||
void on_stop();
|
||||
void on_mode_changed(const size_t index);
|
||||
void on_progress(uint16_t line, uint16_t total_lines);
|
||||
void on_calibration(int16_t suggested_phase, int16_t suggested_slant, uint16_t sync_count);
|
||||
void write_bmp_header();
|
||||
void write_line_to_file(uint16_t line_num, const uint8_t* rgb_line);
|
||||
void finish_image();
|
||||
};
|
||||
|
||||
} // namespace ui::external_app::sstvrx
|
||||
|
||||
#endif // __SSTVRX_H__
|
||||
@@ -651,6 +651,13 @@ set(MODE_CPPSRC
|
||||
)
|
||||
DeclareTargets(PSTX sstvtx)
|
||||
|
||||
### SSTV RX
|
||||
|
||||
set(MODE_CPPSRC
|
||||
proc_sstvrx.cpp
|
||||
)
|
||||
DeclareTargets(PSRX sstvrx)
|
||||
|
||||
### TPMS
|
||||
|
||||
set(MODE_CPPSRC
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
/*
|
||||
* Copyright (C) 2025 StarVore Labs
|
||||
*
|
||||
* 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_sstvrx.hpp"
|
||||
#include "event_m4.hpp"
|
||||
#include "portapack_shared_memory.hpp"
|
||||
#include "audio_dma.hpp"
|
||||
#include "sine_table_int8.hpp"
|
||||
#include "fxpt_atan2.hpp"
|
||||
#include "message.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
constexpr size_t sstv_shared_buffer_bytes = sizeof(shared_memory.bb_data.data);
|
||||
constexpr size_t sstv_chunk_flag_index = sstv_shared_buffer_bytes - 1; // Reserve last byte as ownership flag
|
||||
constexpr size_t sstv_chunk_header_bytes = 2;
|
||||
constexpr size_t sstv_chunk_copy_bytes = sstv_shared_buffer_bytes - 1; // Bytes copied to M0 (excludes flag)
|
||||
constexpr uint16_t sstv_max_chunk_pixels = (sstv_chunk_copy_bytes - sstv_chunk_header_bytes) / 3;
|
||||
|
||||
inline volatile uint8_t& chunk_flag() {
|
||||
return *reinterpret_cast<volatile uint8_t*>(&shared_memory.bb_data.data[sstv_chunk_flag_index]);
|
||||
}
|
||||
|
||||
inline void wait_for_chunk_slot() {
|
||||
while (chunk_flag() != 0) {
|
||||
__asm__ volatile("nop");
|
||||
}
|
||||
}
|
||||
|
||||
inline void mark_chunk_ready() {
|
||||
chunk_flag() = 1;
|
||||
}
|
||||
|
||||
inline const sstv_mode* find_mode_by_vis_code(const uint8_t vis_code) {
|
||||
for (const auto& mode : sstv_modes) {
|
||||
if (mode.vis_code == vis_code) {
|
||||
return &mode;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline std::array<uint8_t, 3> color_order_for_mode(const sstv_mode& mode) {
|
||||
switch (mode.color_sequence) {
|
||||
case SSTV_COLOR_RGB:
|
||||
return {0, 1, 2};
|
||||
case SSTV_COLOR_GBR:
|
||||
return {1, 2, 0};
|
||||
default:
|
||||
return {0, 1, 2};
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void SSTVRXProcessor::execute(const buffer_c8_t& buffer) {
|
||||
if (!configured) {
|
||||
// Just return silently if not configured
|
||||
return;
|
||||
}
|
||||
|
||||
// Decimation chain (same as NFM)
|
||||
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 channel = channel_filter.execute(decim_1_out, dst_buffer);
|
||||
feed_channel_stats(channel);
|
||||
// FM demodulation and audio processing
|
||||
// Demodulator outputs 24kHz audio after channel filter decimation
|
||||
auto audio = demod.execute(channel, work_audio_buffer);
|
||||
|
||||
// Feed audio samples to output and use for frequency estimation
|
||||
audio_output.write(audio);
|
||||
|
||||
// Process each audio sample for SSTV decoding
|
||||
// audio is buffer_s16_t, so audio.p[i] is int16_t
|
||||
for (size_t i = 0; i < audio.count; i++) {
|
||||
// Get int16 audio sample directly (no float conversion needed)
|
||||
int32_t audio_sample = audio.p[i];
|
||||
|
||||
// Increment global sample counter for calibration
|
||||
global_sample_count++;
|
||||
|
||||
// Estimate frequency using Goertzel algorithm on the audio tones
|
||||
estimate_frequency_goertzel(audio_sample);
|
||||
|
||||
// Process based on current state
|
||||
switch (state) {
|
||||
case STATE_SYNC_SEARCH:
|
||||
// Before Line 0: wait for initial sync pulses to establish timing
|
||||
if (current_line == 0) {
|
||||
detect_sync(current_freq);
|
||||
}
|
||||
// After Line 0 started: we're at end of a line, waiting for next sync
|
||||
// Just wait - the sync will be detected and we'll transition to separator
|
||||
else {
|
||||
detect_sync(current_freq);
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_VIS_DECODE:
|
||||
// VIS code detection not implemented yet
|
||||
// Skip directly to separator wait
|
||||
state = STATE_SEPARATOR;
|
||||
sample_count = 0;
|
||||
break;
|
||||
|
||||
case STATE_SEPARATOR:
|
||||
// Wait for separator/porch tone to finish before resuming pixels
|
||||
sample_count++;
|
||||
if (separator_target == 0 || sample_count >= separator_target) {
|
||||
sample_count = 0;
|
||||
state = STATE_IMAGE_DATA;
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_IMAGE_DATA:
|
||||
// Process pixels continuously
|
||||
process_pixel_sample(current_freq);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate frequency from audio samples using Goertzel algorithm
|
||||
void SSTVRXProcessor::estimate_frequency_goertzel(int32_t audio_sample) {
|
||||
// Normalize sample to float [-1.0, 1.0]
|
||||
float sample = audio_sample / 32768.0f;
|
||||
|
||||
// Update Goertzel filters for each target frequency
|
||||
for (int f = 0; f < 4; f++) {
|
||||
float Q0 = goertzel_coeff[f] * goertzel_Q1[f] - goertzel_Q2[f] + sample;
|
||||
goertzel_Q2[f] = goertzel_Q1[f];
|
||||
goertzel_Q1[f] = Q0;
|
||||
}
|
||||
|
||||
goertzel_count++;
|
||||
|
||||
// Calculate magnitudes every N samples
|
||||
if (goertzel_count >= GOERTZEL_N) {
|
||||
float magnitudes[4];
|
||||
|
||||
for (int f = 0; f < 4; f++) {
|
||||
// Calculate magnitude^2 (we don't need sqrt for comparison)
|
||||
magnitudes[f] = goertzel_Q1[f] * goertzel_Q1[f] +
|
||||
goertzel_Q2[f] * goertzel_Q2[f] -
|
||||
goertzel_Q1[f] * goertzel_Q2[f] * goertzel_coeff[f];
|
||||
|
||||
// Reset for next block
|
||||
goertzel_Q1[f] = 0;
|
||||
goertzel_Q2[f] = 0;
|
||||
}
|
||||
|
||||
// Find which frequency has the strongest response
|
||||
int max_idx = 0;
|
||||
float max_mag = magnitudes[0];
|
||||
for (int f = 1; f < 4; f++) {
|
||||
if (magnitudes[f] > max_mag) {
|
||||
max_mag = magnitudes[f];
|
||||
max_idx = f;
|
||||
}
|
||||
}
|
||||
|
||||
// Map index to frequency
|
||||
// 0=1200Hz, 1=1500Hz, 2=1900Hz, 3=2300Hz
|
||||
const int freqs[4] = {1200, 1500, 1900, 2300};
|
||||
|
||||
// Check if we have a strong enough signal
|
||||
// Lowered threshold for weak signals (SSTV often has low audio levels)
|
||||
if (max_mag > 0.001f) { // Very low threshold - accept weak signals
|
||||
int freq_est = freqs[max_idx];
|
||||
|
||||
// Improved linear interpolation between bins
|
||||
if (max_idx > 0 && magnitudes[max_idx - 1] > 0.0005f) {
|
||||
float ratio = magnitudes[max_idx - 1] / max_mag;
|
||||
if (ratio > 0.2f) {
|
||||
freq_est -= (int)((freqs[max_idx] - freqs[max_idx - 1]) * ratio * 0.5f);
|
||||
}
|
||||
}
|
||||
if (max_idx < 3 && magnitudes[max_idx + 1] > 0.0005f) {
|
||||
float ratio = magnitudes[max_idx + 1] / max_mag;
|
||||
if (ratio > 0.2f) {
|
||||
freq_est += (int)((freqs[max_idx + 1] - freqs[max_idx]) * ratio * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
// Light smoothing to reduce noise while maintaining responsiveness
|
||||
current_freq = (current_freq + freq_est) / 2;
|
||||
} else {
|
||||
// Signal too weak - don't update frequency (keeps last valid estimate)
|
||||
// This prevents spurious detections from noise
|
||||
}
|
||||
|
||||
goertzel_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert frequency to pixel value (0-255)
|
||||
int32_t SSTVRXProcessor::freq_to_pixel(int32_t freq) {
|
||||
// SSTV standard: 1500 Hz = black (0), 2300 Hz = white (255)
|
||||
if (freq < FREQ_BLACK) freq = FREQ_BLACK;
|
||||
if (freq > FREQ_WHITE) freq = FREQ_WHITE;
|
||||
|
||||
// Linear mapping
|
||||
int32_t pixel = ((freq - FREQ_BLACK) * 255) / (FREQ_WHITE - FREQ_BLACK);
|
||||
|
||||
if (pixel < 0) pixel = 0;
|
||||
if (pixel > 255) pixel = 255;
|
||||
|
||||
return pixel;
|
||||
}
|
||||
|
||||
// Detect horizontal sync pulses
|
||||
void SSTVRXProcessor::detect_sync(int32_t freq) {
|
||||
// Sync pulse is 1200 Hz for ~9ms
|
||||
const int32_t sync_tolerance = 150; // Hz - tolerance for sync detection
|
||||
|
||||
// Check for sync frequency (1200 Hz ± 150 Hz)
|
||||
if (freq > (FREQ_SYNC - sync_tolerance) && freq < (FREQ_SYNC + sync_tolerance)) {
|
||||
sync_sample_count++;
|
||||
in_sync = true;
|
||||
} else {
|
||||
// Not sync frequency - check if we just finished a valid sync
|
||||
// Require at least 1/3 of expected sync duration (more lenient for weak signals)
|
||||
if (in_sync && sync_sample_count >= (samples_per_sync / 3)) {
|
||||
// Valid sync pulse detected - always record it for timing tracking
|
||||
// Debug: log current history count before recording
|
||||
SSTVRXProgressMessage pre_count_msg{0xFFF7, sync_history_count};
|
||||
shared_memory.application_queue.push(pre_count_msg);
|
||||
|
||||
if (sync_history_count < MAX_SYNC_HISTORY) {
|
||||
sync_positions[sync_history_count] = global_sample_count;
|
||||
sync_history_count++;
|
||||
|
||||
// Send debug message with sync count
|
||||
SSTVRXProgressMessage sync_debug{0xFFFD, sync_history_count};
|
||||
shared_memory.application_queue.push(sync_debug);
|
||||
|
||||
// Check if this sync should be used for calibration (reject outliers)
|
||||
bool use_for_calibration = true;
|
||||
if (sync_history_count > 1) {
|
||||
uint32_t interval = sync_positions[sync_history_count - 1] - sync_positions[sync_history_count - 2];
|
||||
const uint32_t nominal_interval = compute_nominal_line_interval();
|
||||
if (nominal_interval == 0) {
|
||||
use_for_calibration = false;
|
||||
} else {
|
||||
const uint32_t tolerance = nominal_interval / 4;
|
||||
const uint32_t min_interval = (nominal_interval > tolerance) ? (nominal_interval - tolerance) : 0;
|
||||
const uint32_t max_interval = nominal_interval + tolerance;
|
||||
if (interval < min_interval || interval > max_interval) {
|
||||
use_for_calibration = false; // Don't use this sync for calibration
|
||||
// Debug: Send outlier rejection message (use 0xFFF8 for interval value)
|
||||
SSTVRXProgressMessage outlier_msg{0xFFF8, (uint16_t)(interval & 0xFFFF)};
|
||||
shared_memory.application_queue.push(outlier_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate calibration after collecting enough syncs for accuracy
|
||||
// Wait for 8 syncs to get better statistics, then update every 8 syncs
|
||||
if (use_for_calibration && sync_history_count >= 8 && pixel_time_frac != 0.0f && sync_history_count % 8 == 0) {
|
||||
calculate_calibration();
|
||||
}
|
||||
} else {
|
||||
// Debug: MAX_SYNC_HISTORY exceeded
|
||||
SSTVRXProgressMessage max_reached_msg{0xFFF6, sync_history_count};
|
||||
shared_memory.application_queue.push(max_reached_msg);
|
||||
}
|
||||
|
||||
// Debug: Send sync detection info with timing data
|
||||
// Also send current frequency estimate for debugging
|
||||
SSTVRXProgressMessage debug_msg{0xFFFE, (uint16_t)sync_sample_count};
|
||||
shared_memory.application_queue.push(debug_msg);
|
||||
|
||||
// Send frequency estimate for debugging (use 0xFFF9)
|
||||
SSTVRXProgressMessage freq_msg{0xFFF9, (uint16_t)current_freq};
|
||||
shared_memory.application_queue.push(freq_msg);
|
||||
|
||||
bool ready_for_line = false;
|
||||
if (waiting_for_first_line) {
|
||||
if (sync_history_count >= 2) {
|
||||
waiting_for_first_line = false;
|
||||
ready_for_line = true;
|
||||
SSTVRXProgressMessage start_msg{0xFFF4, static_cast<uint16_t>(sync_sample_count)};
|
||||
shared_memory.application_queue.push(start_msg);
|
||||
}
|
||||
} else if (state == STATE_SYNC_SEARCH) {
|
||||
ready_for_line = true;
|
||||
}
|
||||
|
||||
if (ready_for_line) {
|
||||
begin_line_after_sync();
|
||||
}
|
||||
// else: Line 0 without enough syncs, or mid-image but not in SYNC_SEARCH - just track the sync
|
||||
}
|
||||
in_sync = false;
|
||||
sync_sample_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate phase and slant calibration from sync timing
|
||||
void SSTVRXProcessor::calculate_calibration() {
|
||||
if (sync_history_count < 2 || pixel_time_frac == 0.0f) return;
|
||||
|
||||
expected_sync_interval = compute_nominal_line_interval();
|
||||
if (expected_sync_interval == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send debug info about expected interval
|
||||
SSTVRXProgressMessage debug_interval{0xFFFC, (uint16_t)(expected_sync_interval & 0xFFFF)};
|
||||
shared_memory.application_queue.push(debug_interval);
|
||||
|
||||
// Calculate average timing error (slant) from recent intervals
|
||||
// Use last 8 intervals for more responsive calibration, but filter outliers
|
||||
int32_t total_timing_error = 0;
|
||||
uint32_t last_interval = 0;
|
||||
uint16_t start_idx = (sync_history_count > 8) ? (sync_history_count - 8) : 1;
|
||||
uint16_t interval_count = 0;
|
||||
|
||||
for (uint16_t i = start_idx; i < sync_history_count; i++) {
|
||||
uint32_t actual_interval = sync_positions[i] - sync_positions[i - 1];
|
||||
last_interval = actual_interval;
|
||||
|
||||
// Filter out outliers: reject intervals >20% off expected value
|
||||
// These are likely missed syncs, not actual timing drift
|
||||
int32_t timing_error = (int32_t)actual_interval - (int32_t)expected_sync_interval;
|
||||
int32_t max_deviation = (int32_t)expected_sync_interval / 5; // 20% threshold
|
||||
|
||||
// Only include intervals within ±20% of expected
|
||||
if (timing_error >= -max_deviation && timing_error <= max_deviation) {
|
||||
total_timing_error += timing_error;
|
||||
interval_count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Send debug info about last actual interval
|
||||
SSTVRXProgressMessage debug_actual{0xFFFB, (uint16_t)(last_interval & 0xFFFF)};
|
||||
shared_memory.application_queue.push(debug_actual);
|
||||
|
||||
if (interval_count == 0) return; // Safety check - no valid intervals
|
||||
|
||||
// Average error per line
|
||||
int32_t avg_error = total_timing_error / interval_count;
|
||||
|
||||
// Convert to slant adjustment (0.1% units)
|
||||
// Error in samples / expected_sync_interval = fractional error
|
||||
// Multiply by 1000 to get 0.1% units
|
||||
int16_t suggested_slant = (int16_t)(((int64_t)avg_error * 1000) / expected_sync_interval);
|
||||
|
||||
// Clamp to reasonable range (±10% = ±100 in 0.1% units)
|
||||
if (suggested_slant > 100) suggested_slant = 100;
|
||||
if (suggested_slant < -100) suggested_slant = -100;
|
||||
|
||||
// Phase is harder to detect automatically without knowing absolute position
|
||||
// For now, we only suggest slant correction
|
||||
int16_t suggested_phase = 0;
|
||||
|
||||
// Send calibration suggestion
|
||||
SSTVRXCalibrationMessage cal_msg{suggested_phase, suggested_slant, sync_history_count};
|
||||
shared_memory.application_queue.push(cal_msg);
|
||||
}
|
||||
|
||||
uint32_t SSTVRXProcessor::compute_nominal_line_interval() const {
|
||||
const uint32_t channel_sections = (channel_count > 0) ? channel_count : 1U;
|
||||
const uint32_t gap_sections = (samples_per_gap == 0)
|
||||
? 0U
|
||||
: ((active_mode && active_mode->gaps) ? channel_sections : 1U);
|
||||
const float samples_per_channel_f = pixel_time_frac * static_cast<float>(PIXELS_PER_LINE);
|
||||
const float rounded_channel = std::round(samples_per_channel_f);
|
||||
const uint32_t samples_per_channel = static_cast<uint32_t>(std::max(1.0f, rounded_channel));
|
||||
const uint32_t total_channel_samples = samples_per_channel * channel_sections;
|
||||
const uint32_t total_gap_samples = samples_per_gap * gap_sections;
|
||||
return samples_per_sync + total_gap_samples + total_channel_samples;
|
||||
}
|
||||
|
||||
// Process pixel samples during image data state
|
||||
void SSTVRXProcessor::process_pixel_sample(int32_t freq) {
|
||||
// Accumulate frequency samples for averaging
|
||||
pixel_accumulator += freq;
|
||||
pixel_sample_count++;
|
||||
|
||||
// Advance pixel phase (1.0 per sample, adjusted by slant)
|
||||
pixel_phase += slant_factor;
|
||||
|
||||
// Check if we've accumulated enough samples for one or more pixels
|
||||
// pixel_time_frac is the number of audio samples per pixel for the current mode
|
||||
// Use a loop to handle cases where pixel_phase exceeds pixel_time_frac by more than one pixel
|
||||
while (pixel_phase >= pixel_time_frac && pixel_index < PIXELS_PER_LINE) {
|
||||
// Pixel complete - calculate average frequency
|
||||
// Prevent division by zero
|
||||
int32_t avg_freq;
|
||||
if (pixel_sample_count > 0) {
|
||||
avg_freq = pixel_accumulator / pixel_sample_count;
|
||||
} else {
|
||||
avg_freq = freq; // Use current frequency if no samples accumulated
|
||||
}
|
||||
|
||||
// Convert to pixel value
|
||||
uint8_t pixel_value = freq_to_pixel(avg_freq);
|
||||
|
||||
// Apply phase offset (horizontal shift) and clamp to prevent out-of-bounds writes
|
||||
// Clamping prevents pixels from wrapping around and causing duplication
|
||||
int32_t adjusted_pixel_index = (int32_t)pixel_index + phase_offset;
|
||||
if (adjusted_pixel_index < 0) {
|
||||
adjusted_pixel_index = 0;
|
||||
} else if (adjusted_pixel_index >= PIXELS_PER_LINE) {
|
||||
adjusted_pixel_index = PIXELS_PER_LINE - 1;
|
||||
}
|
||||
|
||||
store_pixel_value(channel_index, static_cast<uint16_t>(adjusted_pixel_index), pixel_value);
|
||||
|
||||
pixel_index++;
|
||||
// Reset accumulator for next pixel
|
||||
// If this is not the last pixel in the loop, subsequent pixels will use current sample
|
||||
pixel_accumulator = freq;
|
||||
pixel_sample_count = 1;
|
||||
pixel_phase -= pixel_time_frac; // Keep fractional part for next pixel
|
||||
|
||||
// Check if we finished a color channel
|
||||
if (pixel_index >= PIXELS_PER_LINE) {
|
||||
pixel_index = 0;
|
||||
|
||||
const bool last_channel = ((channel_index + 1) >= channel_count);
|
||||
if (last_channel) {
|
||||
process_line();
|
||||
channel_index = 0;
|
||||
state = STATE_SYNC_SEARCH;
|
||||
sync_sample_count = 0;
|
||||
in_sync = false;
|
||||
reset_pixel_state();
|
||||
break;
|
||||
} else {
|
||||
channel_index++;
|
||||
reset_pixel_state();
|
||||
if (channel_gap_samples > 0) {
|
||||
start_gap(channel_gap_samples);
|
||||
} else {
|
||||
state = STATE_IMAGE_DATA;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::process_line() {
|
||||
if (current_line >= mode_total_lines) current_line = 1; // reset, maybe a new image
|
||||
if (mode_total_lines == 0) return; // not set
|
||||
|
||||
const uint16_t first_chunk_pixels = (PIXELS_PER_LINE < sstv_max_chunk_pixels) ? PIXELS_PER_LINE : sstv_max_chunk_pixels;
|
||||
const uint16_t remaining_pixels = (PIXELS_PER_LINE > sstv_max_chunk_pixels) ? (PIXELS_PER_LINE - sstv_max_chunk_pixels) : 0;
|
||||
|
||||
auto write_chunk = [&](const uint16_t encoded_line, const uint16_t start_pixel, const uint16_t pixel_count) {
|
||||
if (pixel_count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
wait_for_chunk_slot();
|
||||
|
||||
uint8_t* data_ptr = shared_memory.bb_data.data;
|
||||
data_ptr[0] = encoded_line & 0xFF;
|
||||
data_ptr[1] = (encoded_line >> 8) & 0xFF;
|
||||
|
||||
for (uint16_t i = 0; i < pixel_count; i++) {
|
||||
const uint16_t src_idx = start_pixel + i;
|
||||
const size_t dst = sstv_chunk_header_bytes + i * 3;
|
||||
data_ptr[dst + 0] = line_buffer_r[src_idx];
|
||||
data_ptr[dst + 1] = line_buffer_g[src_idx];
|
||||
data_ptr[dst + 2] = line_buffer_b[src_idx];
|
||||
}
|
||||
|
||||
mark_chunk_ready();
|
||||
SSTVRXProgressMessage progress_message{encoded_line, mode_total_lines};
|
||||
shared_memory.application_queue.push(progress_message);
|
||||
};
|
||||
|
||||
write_chunk(static_cast<uint16_t>(current_line * 2), 0, first_chunk_pixels);
|
||||
|
||||
if (remaining_pixels) {
|
||||
write_chunk(static_cast<uint16_t>(current_line * 2 + 1), first_chunk_pixels, remaining_pixels);
|
||||
}
|
||||
|
||||
current_line++;
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::on_message(const Message* const msg) {
|
||||
switch (msg->id) {
|
||||
case Message::ID::CaptureConfig:
|
||||
capture_config(*reinterpret_cast<const CaptureConfigMessage*>(msg));
|
||||
break;
|
||||
|
||||
case Message::ID::SSTVRXPhaseSlant: {
|
||||
const auto message = *reinterpret_cast<const SSTVRXPhaseSlantMessage*>(msg);
|
||||
phase_offset = message.phase;
|
||||
slant_rate = message.slant;
|
||||
// Convert slant from 0.1% units to a multiplier
|
||||
// slant_rate of +10 = +1% faster = multiply by 1.01
|
||||
slant_factor = 1.0f + (slant_rate / 1000.0f);
|
||||
break;
|
||||
}
|
||||
|
||||
case Message::ID::SSTVRXConfigure: {
|
||||
const auto message = *reinterpret_cast<const SSTVRXConfigureMessage*>(msg);
|
||||
vis_code = message.code;
|
||||
|
||||
active_mode = find_mode_by_vis_code(message.code);
|
||||
if (!active_mode) {
|
||||
configured = false;
|
||||
SSTVRXProgressMessage error_msg{0xFFFF, 0};
|
||||
shared_memory.application_queue.push(error_msg);
|
||||
break;
|
||||
}
|
||||
if (active_mode->pixels != PIXELS_PER_LINE) {
|
||||
configured = false;
|
||||
SSTVRXProgressMessage error_msg{0xFFFF, 0};
|
||||
shared_memory.application_queue.push(error_msg);
|
||||
break;
|
||||
}
|
||||
mode_total_lines = active_mode->lines;
|
||||
if (mode_total_lines == 0) {
|
||||
mode_total_lines = 1;
|
||||
}
|
||||
channel_count = static_cast<uint8_t>(active_mode->color ? 3U : 1U);
|
||||
if (channel_count == 0) {
|
||||
channel_count = 1;
|
||||
}
|
||||
color_order = color_order_for_mode(*active_mode);
|
||||
waiting_for_first_line = true;
|
||||
|
||||
// Configure decimation chain using NFM filters (narrower than WFMAM)
|
||||
decim_0.configure(taps_11k0_decim_0.taps); // NFM decim0 filter
|
||||
decim_1.configure(taps_11k0_decim_1.taps); // NFM decim1 filter
|
||||
channel_filter.configure(taps_11k0_channel.taps, 1); // Keep 48kHz audio for better pixel resolution
|
||||
|
||||
// Calculate filter parameters
|
||||
const size_t decim_0_input_fs = baseband_fs;
|
||||
const size_t decim_0_output_fs = decim_0_input_fs / decim_0.decimation_factor;
|
||||
const size_t decim_1_input_fs = decim_0_output_fs;
|
||||
const size_t decim_1_output_fs = decim_1_input_fs / decim_1.decimation_factor;
|
||||
const size_t channel_filter_output_fs = decim_1_output_fs; // Final rate: 48kHz
|
||||
|
||||
// Configure demodulator for SSTV - use moderate NFM deviation
|
||||
// SSTV needs wider deviation than voice NFM to capture 1200-2300 Hz tone range
|
||||
demod.configure(channel_filter_output_fs, 7500); // 7.5kHz deviation (wider for SSTV tones)
|
||||
// No audio filter needed - we want clean SSTV tones without filtering
|
||||
// Enable audio output for monitoring with passthrough filters
|
||||
audio_output.configure(iir_config_passthrough, iir_config_passthrough, 0.0f);
|
||||
|
||||
// Initialize Goertzel coefficients for 24kHz sample rate
|
||||
// coeff = 2 * cos(2 * PI * freq / sample_rate)
|
||||
const float sample_rate = static_cast<float>(channel_filter_output_fs);
|
||||
const float target_freqs[4] = {1200.0f, 1500.0f, 1900.0f, 2300.0f};
|
||||
for (int f = 0; f < 4; f++) {
|
||||
float k = (GOERTZEL_N * target_freqs[f]) / sample_rate;
|
||||
float omega = (2.0f * M_PI * k) / GOERTZEL_N;
|
||||
goertzel_coeff[f] = 2.0f * cosf(omega);
|
||||
goertzel_Q1[f] = 0;
|
||||
goertzel_Q2[f] = 0;
|
||||
}
|
||||
goertzel_count = 0;
|
||||
|
||||
// Initialize state variables
|
||||
current_freq = 1200; // Default to sync frequency
|
||||
configured = true;
|
||||
current_line = 0;
|
||||
sample_count = 0;
|
||||
pixel_index = 0;
|
||||
channel_index = 0;
|
||||
pixel_accumulator = 0;
|
||||
pixel_sample_count = 0;
|
||||
sync_sample_count = 0;
|
||||
in_sync = false;
|
||||
state = STATE_SYNC_SEARCH;
|
||||
separator_target = 0;
|
||||
clear_line_buffers();
|
||||
|
||||
// Reset frequency offset calibration
|
||||
freq_offset = 0;
|
||||
freq_offset_calibrated = false;
|
||||
sync_freq_accumulator = 0;
|
||||
sync_freq_count = 0;
|
||||
|
||||
// Reset sync history for calibration
|
||||
sync_history_count = 0;
|
||||
memset(sync_positions, 0, sizeof(sync_positions));
|
||||
|
||||
// Translate SSTV timing constants (expressed for 3.072MHz TX) to 48kHz RX domain
|
||||
const float conversion = sample_rate / static_cast<float>(SSTV_SAMPLERATE);
|
||||
pixel_time_frac = static_cast<float>(active_mode->samples_per_pixel) * conversion;
|
||||
if (pixel_time_frac < 1.0f) {
|
||||
pixel_time_frac = 1.0f;
|
||||
}
|
||||
samples_per_pixel = static_cast<uint32_t>(pixel_time_frac + 0.5f);
|
||||
|
||||
const auto convert_interval = [conversion](uint32_t value) -> uint32_t {
|
||||
const float samples = static_cast<float>(value) * conversion;
|
||||
const float rounded = std::round(samples);
|
||||
const float clamped = std::max(1.0f, rounded);
|
||||
return static_cast<uint32_t>(clamped);
|
||||
};
|
||||
|
||||
samples_per_sync = convert_interval(active_mode->samples_per_sync);
|
||||
samples_per_gap = convert_interval(active_mode->samples_per_gap);
|
||||
channel_gap_samples = active_mode->gaps ? samples_per_gap : 0;
|
||||
pixel_phase = 0.0f;
|
||||
reset_pixel_state();
|
||||
shared_memory.bb_data.data[sstv_chunk_flag_index] = 0;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::reset_pixel_state() {
|
||||
pixel_accumulator = 0;
|
||||
pixel_sample_count = 0;
|
||||
pixel_phase = 0.0f;
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::start_gap(const uint32_t duration) {
|
||||
reset_pixel_state();
|
||||
separator_target = duration;
|
||||
sample_count = 0;
|
||||
if (duration == 0) {
|
||||
state = STATE_IMAGE_DATA;
|
||||
} else {
|
||||
state = STATE_SEPARATOR;
|
||||
}
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::clear_line_buffers() {
|
||||
std::fill_n(line_buffer_r, PIXELS_PER_LINE, uint8_t{0});
|
||||
std::fill_n(line_buffer_g, PIXELS_PER_LINE, uint8_t{0});
|
||||
std::fill_n(line_buffer_b, PIXELS_PER_LINE, uint8_t{0});
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::begin_line_after_sync() {
|
||||
pixel_index = 0;
|
||||
channel_index = 0;
|
||||
clear_line_buffers();
|
||||
start_gap(samples_per_gap);
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::store_pixel_value(const uint32_t channel, const uint16_t pixel, const uint8_t value) {
|
||||
if (!active_mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active_mode->color) {
|
||||
line_buffer_r[pixel] = value;
|
||||
line_buffer_g[pixel] = value;
|
||||
line_buffer_b[pixel] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel >= channel_count || channel >= color_order.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (color_order[channel]) {
|
||||
case 0:
|
||||
line_buffer_r[pixel] = value;
|
||||
break;
|
||||
case 1:
|
||||
line_buffer_g[pixel] = value;
|
||||
break;
|
||||
case 2:
|
||||
line_buffer_b[pixel] = value;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SSTVRXProcessor::capture_config(const CaptureConfigMessage& message) {
|
||||
if (message.config) {
|
||||
audio_output.set_stream(std::make_unique<StreamInput>(message.config));
|
||||
} else {
|
||||
audio_output.set_stream(nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
// Initialize audio DMA
|
||||
audio::dma::init_audio_out();
|
||||
|
||||
EventDispatcher event_dispatcher{std::make_unique<SSTVRXProcessor>()};
|
||||
event_dispatcher.run();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (C) 2025 StarVore Labs
|
||||
*
|
||||
* 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_SSTV_RX__
|
||||
#define __PROC_SSTV_RX__
|
||||
|
||||
#include "portapack_shared_memory.hpp"
|
||||
#include "baseband_processor.hpp"
|
||||
#include "baseband_thread.hpp"
|
||||
#include "sstv.hpp"
|
||||
|
||||
#include "dsp_decimate.hpp"
|
||||
#include "dsp_demodulate.hpp"
|
||||
#include "dsp_iir.hpp"
|
||||
#include "audio_output.hpp"
|
||||
|
||||
#include <array>
|
||||
|
||||
using namespace sstv;
|
||||
|
||||
class SSTVRXProcessor : public BasebandProcessor {
|
||||
public:
|
||||
void execute(const buffer_c8_t& buffer) override;
|
||||
void on_message(const Message* const p) override;
|
||||
|
||||
private:
|
||||
enum state_t {
|
||||
STATE_SYNC_SEARCH = 0,
|
||||
STATE_VIS_DECODE,
|
||||
STATE_SEPARATOR, // Wait for separator pulse (1500Hz)
|
||||
STATE_IMAGE_DATA
|
||||
};
|
||||
|
||||
static constexpr uint32_t MAX_SAMPLES_PER_LINE = 4096;
|
||||
static constexpr uint16_t PIXELS_PER_LINE = 320;
|
||||
|
||||
// Frequency ranges for SSTV (in Hz)
|
||||
static constexpr int32_t FREQ_BLACK = 1500;
|
||||
static constexpr int32_t FREQ_WHITE = 2300;
|
||||
static constexpr int32_t FREQ_SYNC = 1200;
|
||||
static constexpr int32_t FREQ_VIS_BIT0 = 1300;
|
||||
static constexpr int32_t FREQ_VIS_BIT1 = 1100;
|
||||
|
||||
state_t state{STATE_SYNC_SEARCH};
|
||||
bool configured{false};
|
||||
uint8_t vis_code{0};
|
||||
const sstv_mode* active_mode{nullptr};
|
||||
uint16_t mode_total_lines{256};
|
||||
|
||||
static constexpr size_t baseband_fs = 3072000;
|
||||
|
||||
// DSP chain components (using NFM-style decimation for SSTV)
|
||||
dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{}; // Decimate by 8 (NFM style)
|
||||
dsp::decimate::FIRC16xR16x32Decim8 decim_1{}; // Decimate by 8
|
||||
dsp::decimate::FIRAndDecimateComplex channel_filter{}; // Decimate by 2 -> 24kHz
|
||||
dsp::demodulate::FM demod{}; // FM demodulator
|
||||
AudioOutput audio_output{};
|
||||
|
||||
// Buffers
|
||||
std::array<complex16_t, 512> dst{};
|
||||
const buffer_c16_t dst_buffer{
|
||||
dst.data(),
|
||||
dst.size()};
|
||||
// work_audio_buffer and dst_buffer use the same data pointer
|
||||
const buffer_s16_t work_audio_buffer{
|
||||
(int16_t*)dst.data(),
|
||||
sizeof(dst) / sizeof(int16_t)};
|
||||
|
||||
// State variables for Goertzel frequency estimation
|
||||
int32_t current_freq{1200}; // Current frequency in Hz
|
||||
|
||||
// Goertzel filters for SSTV frequencies; configured at runtime (48kHz today)
|
||||
// We'll detect 1200Hz, 1500Hz, 1900Hz, and 2300Hz
|
||||
// Larger block size = better frequency discrimination but slower response
|
||||
// At 48kHz: 48 samples ≈ 1ms, a little over one cycle of a 1200Hz tone
|
||||
static constexpr size_t GOERTZEL_N = 48; // Increased from 16 for better accuracy
|
||||
float goertzel_Q1[4]{0, 0, 0, 0};
|
||||
float goertzel_Q2[4]{0, 0, 0, 0};
|
||||
float goertzel_coeff[4]; // Calculated in configure
|
||||
size_t goertzel_count{0};
|
||||
|
||||
// Line decoding state
|
||||
uint8_t line_buffer_r[PIXELS_PER_LINE];
|
||||
uint8_t line_buffer_g[PIXELS_PER_LINE];
|
||||
uint8_t line_buffer_b[PIXELS_PER_LINE];
|
||||
|
||||
uint32_t sample_count{0};
|
||||
uint32_t pixel_index{0};
|
||||
uint32_t channel_index{0};
|
||||
uint8_t channel_count{3};
|
||||
std::array<uint8_t, 3> color_order{{1, 2, 0}};
|
||||
uint16_t current_line{0};
|
||||
bool waiting_for_first_line{true};
|
||||
|
||||
// Pixel accumulation for averaging
|
||||
int32_t pixel_accumulator{0};
|
||||
uint32_t pixel_sample_count{0};
|
||||
|
||||
// Fractional pixel timing for accuracy
|
||||
float pixel_time_frac{0.0f}; // Fractional samples per pixel
|
||||
float pixel_phase{0.0f}; // Accumulated phase for current pixel
|
||||
|
||||
// Phase and slant adjustments
|
||||
int16_t phase_offset{0}; // Horizontal offset in pixels
|
||||
int16_t slant_rate{0}; // Timing adjustment in 0.1% units
|
||||
float slant_factor{1.0f}; // Calculated slant multiplier
|
||||
|
||||
// Timing parameters (will be set based on mode)
|
||||
uint32_t samples_per_pixel{7}; // Integer part for quick checks
|
||||
uint32_t samples_per_sync{216}; // 9ms
|
||||
uint32_t samples_per_gap{36}; // Gap after sync or between channels
|
||||
uint32_t channel_gap_samples{36}; // Separators between color sections
|
||||
uint32_t separator_target{0};
|
||||
|
||||
// Sync detection
|
||||
uint32_t sync_sample_count{0};
|
||||
bool in_sync{false};
|
||||
int32_t sync_freq_sum{0}; // Accumulated frequency during sync pulse
|
||||
uint32_t sync_freq_samples{0}; // Number of samples in sync pulse for averaging
|
||||
|
||||
// Sync pulse timing tracking for auto-calibration
|
||||
static constexpr uint32_t MAX_SYNC_HISTORY = 256; // Track all syncs in image
|
||||
uint32_t sync_positions[MAX_SYNC_HISTORY]; // Sample positions when sync detected
|
||||
uint16_t sync_history_count{0};
|
||||
uint32_t expected_sync_interval{0}; // Expected samples between syncs
|
||||
int32_t accumulated_phase_error{0}; // Accumulated phase offset in samples
|
||||
int32_t accumulated_slant_error{0}; // Accumulated timing drift
|
||||
uint32_t global_sample_count{0}; // Never-reset counter for timing calibration
|
||||
|
||||
// Frequency offset compensation (auto-calibrated from sync pulses)
|
||||
int32_t freq_offset{0};
|
||||
bool freq_offset_calibrated{false};
|
||||
int32_t sync_freq_accumulator{0};
|
||||
uint32_t sync_freq_count{0};
|
||||
|
||||
// Helper functions
|
||||
int32_t freq_to_pixel(int32_t freq);
|
||||
void process_pixel_sample(int32_t freq);
|
||||
void process_line();
|
||||
void detect_sync(int32_t freq);
|
||||
void calculate_calibration();
|
||||
uint32_t compute_nominal_line_interval() const;
|
||||
void estimate_frequency_goertzel(int32_t audio_sample);
|
||||
void capture_config(const CaptureConfigMessage& message);
|
||||
void reset_pixel_state();
|
||||
void start_gap(uint32_t duration);
|
||||
void begin_line_after_sync();
|
||||
void clear_line_buffers();
|
||||
void store_pixel_value(uint32_t channel, uint16_t pixel, uint8_t value);
|
||||
|
||||
RequestSignalMessage sig_message{RequestSignalMessage::Signal::FillRequest};
|
||||
|
||||
/* NB: Threads should be the last members in the class definition. */
|
||||
BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive};
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -141,6 +141,10 @@ class Message {
|
||||
FlexStats = 83,
|
||||
FlexConfigure = 84,
|
||||
FlexDebug = 85,
|
||||
SSTVRXConfigure = 86,
|
||||
SSTVRXProgress = 87,
|
||||
SSTVRXPhaseSlant = 88,
|
||||
SSTVRXCalibration = 89,
|
||||
MAX
|
||||
};
|
||||
|
||||
@@ -1144,6 +1148,62 @@ class SSTVConfigureMessage : public Message {
|
||||
const uint32_t pixel_duration;
|
||||
};
|
||||
|
||||
class SSTVRXConfigureMessage : public Message {
|
||||
public:
|
||||
constexpr SSTVRXConfigureMessage(
|
||||
const uint8_t code)
|
||||
: Message{id : ID::SSTVRXConfigure},
|
||||
code(code) {
|
||||
}
|
||||
|
||||
const uint8_t code;
|
||||
};
|
||||
|
||||
class SSTVRXProgressMessage : public Message {
|
||||
public:
|
||||
constexpr SSTVRXProgressMessage(
|
||||
const uint16_t line,
|
||||
const uint16_t total_lines)
|
||||
: Message{ID::SSTVRXProgress},
|
||||
line(line),
|
||||
total_lines(total_lines) {
|
||||
}
|
||||
|
||||
const uint16_t line;
|
||||
const uint16_t total_lines;
|
||||
};
|
||||
|
||||
class SSTVRXPhaseSlantMessage : public Message {
|
||||
public:
|
||||
constexpr SSTVRXPhaseSlantMessage(
|
||||
const int16_t phase,
|
||||
const int16_t slant)
|
||||
: Message{ID::SSTVRXPhaseSlant},
|
||||
phase(phase),
|
||||
slant(slant) {
|
||||
}
|
||||
|
||||
const int16_t phase;
|
||||
const int16_t slant;
|
||||
};
|
||||
|
||||
class SSTVRXCalibrationMessage : public Message {
|
||||
public:
|
||||
constexpr SSTVRXCalibrationMessage(
|
||||
const int16_t suggested_phase,
|
||||
const int16_t suggested_slant,
|
||||
const uint16_t sync_count)
|
||||
: Message{ID::SSTVRXCalibration},
|
||||
suggested_phase(suggested_phase),
|
||||
suggested_slant(suggested_slant),
|
||||
sync_count(sync_count) {
|
||||
}
|
||||
|
||||
const int16_t suggested_phase; // Suggested phase correction in pixels
|
||||
const int16_t suggested_slant; // Suggested slant correction in 0.1% units
|
||||
const uint16_t sync_count; // Number of syncs analyzed
|
||||
};
|
||||
|
||||
class FSKConfigureMessage : public Message {
|
||||
public:
|
||||
constexpr FSKConfigureMessage(
|
||||
|
||||
@@ -122,6 +122,7 @@ constexpr image_tag_t image_tag_subghzd{'P', 'S', 'G', 'D'};
|
||||
constexpr image_tag_t image_tag_protoview{'P', 'P', 'V', 'W'};
|
||||
constexpr image_tag_t image_tag_wefaxrx{'P', 'W', 'F', 'X'};
|
||||
constexpr image_tag_t image_tag_noaaapt_rx{'P', 'N', 'O', 'A'};
|
||||
constexpr image_tag_t image_tag_sstv_rx{'P', 'S', 'R', 'X'};
|
||||
|
||||
constexpr image_tag_t image_tag_noop{'P', 'N', 'O', 'P'};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user