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, 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, output_file: Option, encoding_state: EncodingState, progress: f32, sample_rate: u32, callsign: String, encoding_thread: Option>, progress_receiver: Option>, show_success_dialog: bool, error_message: Option, 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, ) { 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, ) -> Result<(), Box> { 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 = 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)))), ) }