diff --git a/firmware/application/baseband_api.cpp b/firmware/application/baseband_api.cpp index c5fdd1dd5..7152b7430 100644 --- a/firmware/application/baseband_api.cpp +++ b/firmware/application/baseband_api.cpp @@ -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, diff --git a/firmware/application/baseband_api.hpp b/firmware/application/baseband_api.hpp index c95b612c3..86d06604c 100644 --- a/firmware/application/baseband_api.hpp +++ b/firmware/application/baseband_api.hpp @@ -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); diff --git a/firmware/application/external/external.cmake b/firmware/application/external/external.cmake index f7789d7b8..32a404141 100644 --- a/firmware/application/external/external.cmake +++ b/firmware/application/external/external.cmake @@ -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 diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index 946086db7..a6f681ed5 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -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 } diff --git a/firmware/application/external/sstvrx/main.cpp b/firmware/application/external/sstvrx/main.cpp new file mode 100644 index 000000000..ffcdb459a --- /dev/null +++ b/firmware/application/external/sstvrx/main.cpp @@ -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(); +} + +} // 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" \ No newline at end of file diff --git a/firmware/application/external/sstvrx/ui_sstvrx.cpp b/firmware/application/external/sstvrx/ui_sstvrx.cpp new file mode 100644 index 000000000..165d25822 --- /dev/null +++ b/firmware/application/external/sstvrx/ui_sstvrx.cpp @@ -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 +#include +#include +#include +#include + +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; + using options_t = std::vector; + 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(); + 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(&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(&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(&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 chunk{}; + memcpy(chunk.data(), shared_memory.bb_data.data, chunk.size()); + *reinterpret_cast(&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(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 \ No newline at end of file diff --git a/firmware/application/external/sstvrx/ui_sstvrx.hpp b/firmware/application/external/sstvrx/ui_sstvrx.hpp new file mode 100644 index 000000000..6c7ccfbfb --- /dev/null +++ b/firmware/application/external/sstvrx/ui_sstvrx.hpp @@ -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 +#include + +#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 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 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 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(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(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__ \ No newline at end of file diff --git a/firmware/baseband/CMakeLists.txt b/firmware/baseband/CMakeLists.txt index 9c8cb2bed..5d967f1fb 100644 --- a/firmware/baseband/CMakeLists.txt +++ b/firmware/baseband/CMakeLists.txt @@ -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 diff --git a/firmware/baseband/proc_sstvrx.cpp b/firmware/baseband/proc_sstvrx.cpp new file mode 100644 index 000000000..5e97e5118 --- /dev/null +++ b/firmware/baseband/proc_sstvrx.cpp @@ -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 +#include +#include +#include +#include + +#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(&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 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(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(PIXELS_PER_LINE); + const float rounded_channel = std::round(samples_per_channel_f); + const uint32_t samples_per_channel = static_cast(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(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(current_line * 2), 0, first_chunk_pixels); + + if (remaining_pixels) { + write_chunk(static_cast(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(msg)); + break; + + case Message::ID::SSTVRXPhaseSlant: { + const auto message = *reinterpret_cast(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(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(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(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(SSTV_SAMPLERATE); + pixel_time_frac = static_cast(active_mode->samples_per_pixel) * conversion; + if (pixel_time_frac < 1.0f) { + pixel_time_frac = 1.0f; + } + samples_per_pixel = static_cast(pixel_time_frac + 0.5f); + + const auto convert_interval = [conversion](uint32_t value) -> uint32_t { + const float samples = static_cast(value) * conversion; + const float rounded = std::round(samples); + const float clamped = std::max(1.0f, rounded); + return static_cast(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(message.config)); + } else { + audio_output.set_stream(nullptr); + } +} + +int main() { + // Initialize audio DMA + audio::dma::init_audio_out(); + + EventDispatcher event_dispatcher{std::make_unique()}; + event_dispatcher.run(); + return 0; +} \ No newline at end of file diff --git a/firmware/baseband/proc_sstvrx.hpp b/firmware/baseband/proc_sstvrx.hpp new file mode 100644 index 000000000..8483547fe --- /dev/null +++ b/firmware/baseband/proc_sstvrx.hpp @@ -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 + +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 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 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 \ No newline at end of file diff --git a/firmware/common/message.hpp b/firmware/common/message.hpp index 1301c27a2..c277d50cc 100644 --- a/firmware/common/message.hpp +++ b/firmware/common/message.hpp @@ -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( diff --git a/firmware/common/spi_image.hpp b/firmware/common/spi_image.hpp index 1b5591e92..6e10a87c8 100644 --- a/firmware/common/spi_image.hpp +++ b/firmware/common/spi_image.hpp @@ -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'};