This repository has been archived on 2026-04-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
sstvr36e/src/main.rs
2025-07-18 16:36:13 +02:00

689 lines
26 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use eframe::egui;
use robot36_encoder::{Encoder, Robot36Image};
use std::path::PathBuf;
use std::time::Instant;
use std::thread;
use std::sync::mpsc::{self, Receiver, Sender};
use hound::{WavSpec, WavWriter, SampleFormat};
use image::{Rgba, RgbaImage};
use rusttype::{Font, Scale, point};
// Simple 8x8 bitmap font implementation
mod builtin_font {
use image::{Rgba, RgbaImage};
// Each character is 8 bytes (8x8 pixels)
const FONT_DATA: &[u8] = include_bytes!("../font8x8_basic.bin");
pub fn draw_text(
image: &mut RgbaImage,
x: u32,
y: u32,
text: &str,
color: Rgba<u8>,
scale: u32, // scale factor for font size
) {
let char_width = 8u32;
let char_height = 8u32;
for (i, c) in text.chars().enumerate() {
let char_index = c as usize;
if char_index >= 128 {
continue;
}
let font_data = &FONT_DATA[char_index * char_width as usize..(char_index + 1) * char_width as usize];
for row in 0..char_height {
for col in 0..char_width {
if font_data[row as usize] & (1 << (7 - col)) != 0 {
let px = x + (i as u32 * char_width * scale) + col * scale;
let py = y + row * scale;
for dx in 0..scale {
for dy in 0..scale {
let fx = px + dx;
let fy = py + dy;
if fx < image.width() && fy < image.height() {
image.put_pixel(fx, fy, color);
}
}
}
}
}
}
}
}
}
#[derive(Debug, Clone)]
enum EncodingState {
Idle,
Loading,
Encoding { start_time: Instant },
Completed { output_path: PathBuf },
Error(String),
}
#[derive(Debug, Clone)]
enum ProgressMessage {
Progress(f32),
Completed(PathBuf),
Error(String),
}
struct Robot36EncoderApp {
input_file: Option<PathBuf>,
output_file: Option<PathBuf>,
encoding_state: EncodingState,
progress: f32,
sample_rate: u32,
callsign: String,
encoding_thread: Option<thread::JoinHandle<()>>,
progress_receiver: Option<Receiver<ProgressMessage>>,
show_success_dialog: bool,
error_message: Option<String>,
settings_open: bool,
}
impl Default for Robot36EncoderApp {
fn default() -> Self {
Self {
input_file: None,
output_file: None,
encoding_state: EncodingState::Idle,
progress: 0.0,
sample_rate: 48000,
callsign: String::new(),
encoding_thread: None,
progress_receiver: None,
show_success_dialog: false,
error_message: None,
settings_open: false,
}
}
}
impl Robot36EncoderApp {
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
Self::default()
}
fn select_input_file(&mut self) {
if let Some(path) = rfd::FileDialog::new()
.add_filter("Image Files", &["png", "jpg", "jpeg", "bmp", "gif", "tiff", "webp"])
.set_title("Select Input Image")
.pick_file()
{
self.input_file = Some(path.clone());
let mut output_path = path.clone();
output_path.set_extension("wav");
self.output_file = Some(output_path);
self.encoding_state = EncodingState::Idle;
self.progress = 0.0;
self.error_message = None;
}
}
fn select_output_file(&mut self) {
if let Some(path) = rfd::FileDialog::new()
.add_filter("Audio Files", &["wav"])
.set_title("Save Audio Output")
.set_file_name(
self.input_file.as_ref()
.and_then(|p| p.file_stem())
.map(|s| format!("{}.wav", s.to_string_lossy()))
.unwrap_or_else(|| "output.wav".to_string())
)
.save_file()
{
self.output_file = Some(path);
}
}
fn start_encoding(&mut self) {
if let (Some(input_path), Some(output_path)) = (&self.input_file, &self.output_file) {
self.encoding_state = EncodingState::Loading;
self.progress = 0.0;
self.error_message = None;
let (tx, rx) = mpsc::channel();
self.progress_receiver = Some(rx);
let input_path = input_path.clone();
let output_path = output_path.clone();
let sample_rate = self.sample_rate;
let callsign = self.callsign.clone();
self.encoding_thread = Some(thread::spawn(move || {
Self::encode_image_thread(input_path, output_path, sample_rate, callsign, tx);
}));
}
}
fn encode_image_thread(
input_path: PathBuf,
output_path: PathBuf,
sample_rate: u32,
callsign: String,
tx: Sender<ProgressMessage>,
) {
let result = Self::encode_image_impl(input_path, output_path.clone(), sample_rate, callsign, &tx);
match result {
Ok(()) => {
let _ = tx.send(ProgressMessage::Completed(output_path));
}
Err(e) => {
let _ = tx.send(ProgressMessage::Error(e.to_string()));
}
}
}
fn draw_callsign_with_rusttype(image: &mut RgbaImage, text: &str) {
// Path to a common system font found on this system
let font_data = std::fs::read("/usr/share/fonts/TTF/VictorMonoNerdFont-Thin.ttf")
.expect("Failed to load system font");
let font = Font::try_from_vec(font_data).expect("Error constructing Font");
let scale = Scale::uniform(32.0); // Adjust size as needed
let start = point(5.0, 32.0); // x, y baseline
let color = Rgba([0u8, 0u8, 0u8, 255u8]);
// Draw each glyph
let v_metrics = font.v_metrics(scale);
let glyphs: Vec<_> = font.layout(text, scale, start).collect();
for glyph in glyphs {
if let Some(bb) = glyph.pixel_bounding_box() {
glyph.draw(|gx, gy, gv| {
let x = gx as i32 + bb.min.x;
let y = gy as i32 + bb.min.y;
if x >= 0 && y >= 0 && (x as u32) < image.width() && (y as u32) < image.height() {
let alpha = (gv * 255.0) as u8;
let pixel = image.get_pixel_mut(x as u32, y as u32);
*pixel = Rgba([
color[0],
color[1],
color[2],
alpha,
]);
}
});
}
}
}
fn encode_image_impl(
input_path: PathBuf,
output_path: PathBuf,
sample_rate: u32,
callsign: String,
tx: &Sender<ProgressMessage>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _ = tx.send(ProgressMessage::Progress(0.1));
// Load image and convert to RGBA
let mut image = image::open(&input_path)?.to_rgba8();
let _ = tx.send(ProgressMessage::Progress(0.2));
// Add callsign overlay if provided
if !callsign.is_empty() {
Self::draw_callsign_with_rusttype(&mut image, &callsign);
}
// Calculate target size while maintaining aspect ratio
let target_width = 320;
let target_height = 240;
let (width, height) = image.dimensions();
let ratio = width as f32 / height as f32;
let (new_width, new_height) = if ratio > (target_width as f32 / target_height as f32) {
(target_width, (target_width as f32 / ratio) as u32)
} else {
((target_height as f32 * ratio) as u32, target_height)
};
// Resize with padding to maintain 320x240
let mut resized_image = image::DynamicImage::new_rgb8(target_width, target_height);
let temp_image = image::DynamicImage::ImageRgba8(image)
.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3);
// Center the image
let x_offset = (target_width - new_width) / 2;
let y_offset = (target_height - new_height) / 2;
image::imageops::overlay(&mut resized_image, &temp_image, x_offset as i64, y_offset as i64);
let _ = tx.send(ProgressMessage::Progress(0.3));
// Convert to Robot36Image (original encoding logic untouched)
let robot36_image = Robot36Image::from_image(resized_image)?;
let _ = tx.send(ProgressMessage::Progress(0.4));
let encoder = Encoder::new(robot36_image, sample_rate as u64);
let _ = tx.send(ProgressMessage::Progress(0.5));
let samples: Vec<i16> = encoder.encode().collect();
let _ = tx.send(ProgressMessage::Progress(0.8));
let spec = WavSpec {
channels: 1,
sample_rate,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let mut writer = WavWriter::create(&output_path, spec)?;
let total_samples = samples.len();
for (i, sample) in samples.into_iter().enumerate() {
writer.write_sample(sample)?;
if i % 10000 == 0 {
let progress = 0.8 + (i as f32 / total_samples as f32) * 0.2;
let _ = tx.send(ProgressMessage::Progress(progress));
}
}
writer.finalize()?;
let _ = tx.send(ProgressMessage::Progress(1.0));
Ok(())
}
fn stop_encoding(&mut self) {
self.encoding_state = EncodingState::Idle;
self.progress = 0.0;
if let Some(handle) = self.encoding_thread.take() {
handle.join().ok();
}
self.progress_receiver = None;
}
fn update_progress(&mut self) {
let mut should_clear_receiver = false;
let mut completed_path = None;
if let Some(receiver) = &self.progress_receiver {
while let Ok(message) = receiver.try_recv() {
match message {
ProgressMessage::Progress(progress) => {
self.progress = progress;
if progress > 0.4 && matches!(self.encoding_state, EncodingState::Loading) {
self.encoding_state = EncodingState::Encoding {
start_time: Instant::now()
};
}
}
ProgressMessage::Completed(output_path) => {
completed_path = Some(output_path);
self.encoding_state = EncodingState::Completed { output_path: completed_path.clone().unwrap() };
self.progress = 1.0;
self.show_success_dialog = true;
should_clear_receiver = true;
}
ProgressMessage::Error(error) => {
self.encoding_state = EncodingState::Error(error.clone());
self.error_message = Some(error);
self.progress = 0.0;
should_clear_receiver = true;
}
}
}
}
if should_clear_receiver {
self.progress_receiver = None;
if let Some(handle) = self.encoding_thread.take() {
handle.join().ok();
}
}
}
fn get_remaining_time(&self) -> f32 {
match &self.encoding_state {
EncodingState::Encoding { start_time } => {
let _elapsed = start_time.elapsed().as_secs_f32();
let estimated_total = 45.0;
let progress_based_remaining = (1.0 - self.progress) * estimated_total;
progress_based_remaining.max(0.0)
}
_ => 45.0,
}
}
fn is_encoding(&self) -> bool {
matches!(self.encoding_state, EncodingState::Loading | EncodingState::Encoding { .. })
}
fn can_start_encoding(&self) -> bool {
self.input_file.is_some() &&
self.output_file.is_some() &&
!self.is_encoding()
}
fn open_with_vlc(&self, path: &PathBuf) {
let path_str = path.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "vlc", &path_str])
.spawn();
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open")
.args(["-a", "VLC", &path_str])
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("vlc")
.arg(&path_str)
.spawn();
}
}
}
impl eframe::App for Robot36EncoderApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.update_progress();
ctx.set_visuals(egui::Visuals {
dark_mode: true,
override_text_color: Some(egui::Color32::WHITE),
window_fill: egui::Color32::from_rgb(32, 32, 32),
panel_fill: egui::Color32::from_rgb(40, 40, 40),
..egui::Visuals::dark()
});
if self.show_success_dialog {
let output_path = if let EncodingState::Completed { output_path } = &self.encoding_state {
output_path.clone()
} else {
PathBuf::new()
};
egui::Window::new("🎉 Encoding Complete!")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.add_space(10.0);
ui.vertical_centered(|ui| {
ui.heading("✅ Success!");
ui.add_space(5.0);
ui.label("Your image has been encoded to Robot36 audio format");
});
ui.add_space(10.0);
ui.separator();
ui.add_space(10.0);
ui.label(format!("📁 Saved to: {}", output_path.display()));
ui.add_space(15.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
self.show_success_dialog = false;
}
if ui.button("🎵 Open with VLC").clicked() {
self.open_with_vlc(&output_path);
self.show_success_dialog = false;
}
if ui.button("📂 Open Folder").clicked() {
if let Some(parent) = output_path.parent() {
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("explorer")
.arg(parent)
.spawn();
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open")
.arg(parent)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("xdg-open")
.arg(parent)
.spawn();
}
}
self.show_success_dialog = false;
}
});
});
});
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("📡 Callsign:");
ui.add(
egui::TextEdit::singleline(&mut self.callsign)
.hint_text("Enter your callsign (optional)")
.desired_width(150.0)
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("⚙️ Settings").clicked() {
self.settings_open = !self.settings_open;
}
});
});
ui.add_space(10.0);
ui.separator();
ui.add_space(20.0);
ui.vertical_centered(|ui| {
ui.heading("🤖 Robot36 SSTV Encoder");
ui.label("Convert images to SSTV audio format");
});
ui.add_space(30.0);
ui.vertical_centered(|ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgb(48, 48, 48))
.rounding(egui::Rounding::same(12.0))
.inner_margin(egui::Margin::same(20.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.heading("📁 Select Files");
ui.add_space(10.0);
if ui.add_sized([300.0, 40.0], egui::Button::new("📷 Choose Image")).clicked() {
self.select_input_file();
}
if let Some(file_path) = &self.input_file {
ui.add_space(5.0);
ui.label(format!("📄 {}", file_path.file_name().unwrap_or_default().to_string_lossy()));
}
ui.add_space(10.0);
if ui.add_sized([300.0, 40.0], egui::Button::new("💾 Save Audio As")).clicked() {
self.select_output_file();
}
if let Some(file_path) = &self.output_file {
ui.add_space(5.0);
ui.label(format!("🔊 {}", file_path.file_name().unwrap_or_default().to_string_lossy()));
}
});
});
ui.add_space(30.0);
if self.is_encoding() || matches!(self.encoding_state, EncodingState::Completed { .. }) {
egui::Frame::none()
.fill(egui::Color32::from_rgb(48, 48, 48))
.rounding(egui::Rounding::same(12.0))
.inner_margin(egui::Margin::same(20.0))
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.heading("⚡ Encoding Progress");
ui.add_space(15.0);
let progress_bar = egui::ProgressBar::new(self.progress)
.text(format!("{:.1}%", self.progress * 100.0))
.desired_width(300.0)
.desired_height(20.0);
ui.add(progress_bar);
ui.add_space(10.0);
let status_text = match &self.encoding_state {
EncodingState::Loading => "🔄 Loading image...",
EncodingState::Encoding { .. } => "🎵 Encoding to audio...",
EncodingState::Completed { .. } => "✅ Encoding complete!",
_ => "",
};
ui.label(status_text);
if self.is_encoding() {
let remaining = self.get_remaining_time();
ui.label(format!("⏱️ {:.1}s remaining", remaining));
}
});
});
ui.add_space(20.0);
}
ui.horizontal(|ui| {
if self.is_encoding() {
if ui.add_sized([150.0, 50.0], egui::Button::new("⏹️ Stop")).clicked() {
self.stop_encoding();
}
} else {
let start_button = egui::Button::new("🚀 Start Encoding")
.fill(egui::Color32::from_rgb(0, 120, 215));
if ui.add_sized([200.0, 50.0], start_button).clicked() && self.can_start_encoding() {
self.start_encoding();
}
}
});
ui.add_space(20.0);
match &self.encoding_state {
EncodingState::Idle => {
if !self.can_start_encoding() {
ui.colored_label(egui::Color32::GRAY, " Please select input and output files");
}
}
EncodingState::Completed { output_path } => {
ui.colored_label(egui::Color32::GREEN,
format!("✅ Audio saved to: {}", output_path.file_name().unwrap_or_default().to_string_lossy()));
ui.add_space(10.0);
ui.horizontal(|ui| {
if ui.button("🎵 Open with VLC").clicked() {
self.open_with_vlc(output_path);
}
if ui.button("📂 Open Folder").clicked() {
if let Some(parent) = output_path.parent() {
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("explorer")
.arg(parent)
.spawn();
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open")
.arg(parent)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("xdg-open")
.arg(parent)
.spawn();
}
}
}
});
}
EncodingState::Error(msg) => {
ui.colored_label(egui::Color32::RED, format!("❌ Error: {}", msg));
}
_ => {}
}
});
});
if self.settings_open {
egui::Window::new("⚙️ Settings")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::RIGHT_TOP, [-10.0, 60.0])
.show(ctx, |ui| {
ui.label("🎵 Audio Settings");
ui.separator();
ui.horizontal(|ui| {
ui.label("Sample Rate:");
ui.add(
egui::Slider::new(&mut self.sample_rate, 8000..=96000)
.suffix(" Hz")
.logarithmic(true)
.clamp_to_range(true)
);
});
ui.add_space(10.0);
ui.label(" Higher sample rates produce better quality audio but larger files.");
ui.add_space(10.0);
if ui.button("Close").clicked() {
self.settings_open = false;
}
});
}
if self.is_encoding() {
ctx.request_repaint();
}
}
}
fn main() -> eframe::Result<()> {
env_logger::init();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([700.0, 600.0])
.with_min_inner_size([600.0, 500.0])
.with_title("Robot36 SSTV Encoder"),
..Default::default()
};
eframe::run_native(
"Robot36 SSTV Encoder",
options,
Box::new(|cc| Ok(Box::new(Robot36EncoderApp::new(cc)))),
)
}