This commit is contained in:
2025-07-18 16:19:00 +02:00
parent c7711f075c
commit 33f577718e
4 changed files with 1327 additions and 239 deletions

935
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,3 +11,6 @@ robot36-encoder = { version = "0.2", features = ["image"] }
image = "0.24"
hound = "3.5"
env_logger = "0.10"
rodio = "0.21.1"
imageproc = "0.25.0"
rusttype = "0.9.3"

16
font8x8_basic.bin Normal file
View File

@@ -0,0 +1,16 @@
00000000 00 00 00 00 00 00 00 00 18 18 18 18 18 00 18 00
00000010 6c 6c 6c 00 00 00 00 00 6c 6c fe 6c fe 6c 6c 00
00000020 18 3e 60 3c 06 7c 18 00 00 c6 cc 18 30 66 c6 00
00000030 38 6c 38 76 dc cc 76 00 18 18 30 00 00 00 00 00
00000040 0c 18 30 30 30 18 0c 00 30 18 0c 0c 0c 18 30 00
00000050 00 66 3c ff 3c 66 00 00 00 18 18 7e 18 18 00 00
00000060 00 00 00 00 18 18 30 00 00 00 00 7e 00 00 00 00
00000070 00 00 00 00 00 18 18 00 06 0c 18 30 60 c0 80 00
00000080 38 6c c6 c6 c6 6c 38 00 18 38 18 18 18 18 7e 00
00000090 7c c6 06 1c 30 66 fe 00 7c c6 06 3c 06 c6 7c 00
000000a0 1c 3c 6c cc fe 0c 1e 00 fe c0 fc 06 06 c6 7c 00
000000b0 38 60 c0 fc c6 c6 7c 00 fe c6 0c 18 30 30 30 00
000000c0 7c c6 c6 7c c6 c6 7c 00 7c c6 c6 7e 06 0c 78 00
000000d0 00 18 18 00 00 18 18 00 00 18 18 00 00 18 18 30
000000e0 0c 18 30 60 30 18 0c 00 00 00 7e 00 7e 00 00 00
000000f0 30 18 0c 06 0c 18 30 00 7c c6 0c 18 18 00 18 00

View File

@@ -1,10 +1,49 @@
use eframe::egui;
use robot36_encoder::{Encoder, Robot36Image};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::time::Instant;
use std::thread;
use std::sync::mpsc::{self, Receiver, Sender};
use hound::{WavSpec, WavWriter, SampleFormat};
use image::{Rgba, RgbaImage};
// Simple 8x8 bitmap font implementation
mod builtin_font {
// 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>,
) {
let char_width = 8;
let char_height = 8;
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..(char_index + 1) * char_width];
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) + col;
let py = y + row;
if px < image.width() && py < image.height() {
image.put_pixel(px, py, color);
}
}
}
}
}
}
}
#[derive(Debug, Clone)]
enum EncodingState {
@@ -23,24 +62,17 @@ enum ProgressMessage {
}
struct Robot36EncoderApp {
// File paths
input_file: Option<PathBuf>,
output_file: Option<PathBuf>,
// Encoding state
encoding_state: EncodingState,
progress: f32,
// Audio settings
sample_rate: u32,
// Threading
callsign: String,
encoding_thread: Option<thread::JoinHandle<()>>,
progress_receiver: Option<Receiver<ProgressMessage>>,
// UI state
show_success_dialog: bool,
error_message: Option<String>,
settings_open: bool,
}
impl Default for Robot36EncoderApp {
@@ -51,10 +83,12 @@ impl Default for Robot36EncoderApp {
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,
}
}
}
@@ -71,13 +105,9 @@ impl Robot36EncoderApp {
.pick_file()
{
self.input_file = Some(path.clone());
// Auto-generate output filename
let mut output_path = path.clone();
output_path.set_extension("wav");
self.output_file = Some(output_path);
// Reset state
self.encoding_state = EncodingState::Idle;
self.progress = 0.0;
self.error_message = None;
@@ -88,7 +118,12 @@ impl Robot36EncoderApp {
if let Some(path) = rfd::FileDialog::new()
.add_filter("Audio Files", &["wav"])
.set_title("Save Audio Output")
.set_file_name("output.wav")
.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);
@@ -107,9 +142,10 @@ impl Robot36EncoderApp {
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, tx);
Self::encode_image_thread(input_path, output_path, sample_rate, callsign, tx);
}));
}
}
@@ -118,9 +154,10 @@ impl Robot36EncoderApp {
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, &tx);
let result = Self::encode_image_impl(input_path, output_path.clone(), sample_rate, callsign, &tx);
match result {
Ok(()) => {
@@ -136,31 +173,70 @@ impl Robot36EncoderApp {
input_path: PathBuf,
output_path: PathBuf,
sample_rate: u32,
callsign: String,
tx: &Sender<ProgressMessage>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Load and process image
let _ = tx.send(ProgressMessage::Progress(0.1));
let image = image::open(&input_path)?;
// Load image and convert to RGBA
let mut image = image::open(&input_path)?.to_rgba8();
let _ = tx.send(ProgressMessage::Progress(0.2));
// Resize image to Robot36 specifications (320x240)
let resized_image = image.resize(320, 240, image::imageops::FilterType::Lanczos3);
// Add callsign overlay if provided
if !callsign.is_empty() {
// Draw black background for text
for y in 0..16 {
for x in 0..(callsign.len() as u32 * 8).min(image.width()) {
if x < image.width() && y < image.height() {
image.put_pixel(x, y, Rgba([0u8, 0u8, 0u8, 200u8]));
}
}
}
// Draw text
builtin_font::draw_text(
&mut image,
5, 5, // x, y position
&callsign,
Rgba([255u8, 255u8, 255u8, 255u8]), // white text
);
}
// 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
// Convert to Robot36Image (original encoding logic untouched)
let robot36_image = Robot36Image::from_image(resized_image)?;
let _ = tx.send(ProgressMessage::Progress(0.4));
// Create encoder
let encoder = Encoder::new(robot36_image, sample_rate as u64);
let _ = tx.send(ProgressMessage::Progress(0.5));
// Encode to audio samples
let samples: Vec<i16> = encoder.encode().collect();
let _ = tx.send(ProgressMessage::Progress(0.8));
// Write to WAV file
let spec = WavSpec {
channels: 1,
sample_rate,
@@ -174,7 +250,6 @@ impl Robot36EncoderApp {
for (i, sample) in samples.into_iter().enumerate() {
writer.write_sample(sample)?;
// Update progress periodically
if i % 10000 == 0 {
let progress = 0.8 + (i as f32 / total_samples as f32) * 0.2;
let _ = tx.send(ProgressMessage::Progress(progress));
@@ -192,8 +267,6 @@ impl Robot36EncoderApp {
self.progress = 0.0;
if let Some(handle) = self.encoding_thread.take() {
// Note: In a production app, you'd want to implement proper cancellation
// For now, we'll just detach the thread
handle.join().ok();
}
@@ -202,6 +275,7 @@ impl Robot36EncoderApp {
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() {
@@ -215,22 +289,17 @@ impl Robot36EncoderApp {
}
}
ProgressMessage::Completed(output_path) => {
self.encoding_state = EncodingState::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;
if let Some(handle) = self.encoding_thread.take() {
handle.join().ok();
}
}
ProgressMessage::Error(error) => {
self.encoding_state = EncodingState::Error(error.clone());
self.error_message = Some(error);
self.progress = 0.0;
should_clear_receiver = true;
if let Some(handle) = self.encoding_thread.take() {
handle.join().ok();
}
}
}
}
@@ -238,13 +307,15 @@ impl Robot36EncoderApp {
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 } => {
// Robot36 encoding typically takes about 45 seconds
let _elapsed = start_time.elapsed().as_secs_f32();
let estimated_total = 45.0;
let progress_based_remaining = (1.0 - self.progress) * estimated_total;
@@ -263,231 +334,311 @@ impl Robot36EncoderApp {
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();
// Success dialog
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 {
egui::Window::new("Encoding Complete")
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.label("Image successfully encoded to Robot36 audio!");
ui.add_space(10.0);
if let EncodingState::Completed { output_path } = &self.encoding_state {
ui.label(format!("Output saved to: {}", output_path.display()));
}
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| {
if ui.button("OK").clicked() {
self.show_success_dialog = false;
}
if let EncodingState::Completed { output_path } = &self.encoding_state {
if ui.button("Open Folder").clicked() {
if let Some(parent) = output_path.parent() {
let _ = std::process::Command::new("xdg-open")
.arg(parent)
.spawn();
}
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.heading("Robot36 Image Encoder");
ui.label("Convert images to SSTV Robot36 audio format");
ui.separator();
// File selection section
ui.group(|ui| {
ui.label("File Selection");
ui.separator();
// Input file
ui.horizontal(|ui| {
if ui.button("Select Input Image").clicked() {
self.select_input_file();
}
if let Some(file_path) = &self.input_file {
ui.label(format!("Input: {}",
file_path.file_name()
.unwrap_or_default()
.to_string_lossy()));
} else {
ui.colored_label(egui::Color32::GRAY, "No input file selected");
}
});
// Output file
ui.horizontal(|ui| {
if ui.button("Select Output Audio").clicked() {
self.select_output_file();
}
if let Some(file_path) = &self.output_file {
ui.label(format!("Output: {}",
file_path.file_name()
.unwrap_or_default()
.to_string_lossy()));
} else {
ui.colored_label(egui::Color32::GRAY, "No output file selected");
}
});
});
ui.add_space(10.0);
// Audio settings
ui.group(|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(false));
});
ui.label("Robot36 will encode to 320x240 resolution");
});
ui.add_space(10.0);
// Progress section
ui.group(|ui| {
ui.label("Encoding Progress");
ui.separator();
// Progress bar
let progress_text = match &self.encoding_state {
EncodingState::Idle => "Ready".to_string(),
EncodingState::Loading => "Loading image...".to_string(),
EncodingState::Encoding { .. } => format!("Encoding... {:.1}%", self.progress * 100.0),
EncodingState::Completed { .. } => "Completed".to_string(),
EncodingState::Error(_) => "Error".to_string(),
};
let progress_bar = egui::ProgressBar::new(self.progress)
.text(progress_text)
.show_percentage();
ui.add(progress_bar);
ui.add_space(10.0);
// Time remaining display
if matches!(self.encoding_state, EncodingState::Encoding { .. }) {
let remaining_time = self.get_remaining_time();
ui.horizontal(|ui| {
ui.label("Estimated time remaining:");
ui.monospace(format!("{:.2} seconds", remaining_time));
});
}
// Large LCD display
ui.separator();
ui.vertical_centered(|ui| {
let remaining_time = self.get_remaining_time();
let lcd_text = if self.is_encoding() {
format!("{:05.2}", remaining_time)
} else {
"45.00".to_string()
};
let lcd_display = egui::Label::new(
egui::RichText::new(lcd_text)
.size(48.0)
.monospace()
.color(if self.is_encoding() {
egui::Color32::from_rgb(0, 255, 0)
} else {
egui::Color32::from_rgb(100, 100, 100)
})
);
// Add background
let response = ui.add(lcd_display);
let rect = response.rect;
ui.painter().rect_filled(
rect.expand(10.0),
egui::Rounding::same(5.0),
egui::Color32::from_rgb(20, 20, 20),
);
});
});
ui.add_space(10.0);
// Control buttons
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| {
let stop_button = ui.add_enabled(
self.is_encoding(),
egui::Button::new("Stop")
);
if stop_button.clicked() {
self.stop_encoding();
}
let start_button = ui.add_enabled(
self.can_start_encoding(),
egui::Button::new("Start Encoding")
);
if start_button.clicked() {
self.start_encoding();
if ui.button("⚙️ Settings").clicked() {
self.settings_open = !self.settings_open;
}
});
});
ui.add_space(10.0);
ui.separator();
ui.add_space(20.0);
// Status messages
match &self.encoding_state {
EncodingState::Idle => {
if self.can_start_encoding() {
ui.colored_label(egui::Color32::BLUE, "Ready to encode");
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 {
ui.colored_label(egui::Color32::GRAY, "Select input and output files to begin");
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();
}
}
}
EncodingState::Loading => {
ui.colored_label(egui::Color32::YELLOW, "Loading and processing image...");
}
EncodingState::Encoding { .. } => {
ui.colored_label(egui::Color32::YELLOW, "Encoding to Robot36 audio format...");
}
EncodingState::Completed { output_path } => {
ui.colored_label(egui::Color32::GREEN,
format!("Successfully encoded to {}", output_path.display()));
}
EncodingState::Error(msg) => {
ui.colored_label(egui::Color32::RED, format!("Error: {}", msg));
}
}
// Error details
if let Some(error) = &self.error_message {
ui.add_space(5.0);
ui.group(|ui| {
ui.colored_label(egui::Color32::RED, "Error Details:");
ui.monospace(error);
});
}
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));
}
_ => {}
}
});
});
// Request repaint for smooth updates
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_after(Duration::from_millis(50));
ctx.request_repaint();
}
}
}
@@ -497,13 +648,14 @@ fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([600.0, 500.0])
.with_min_inner_size([500.0, 400.0]),
.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 Image Encoder",
"Robot36 SSTV Encoder",
options,
Box::new(|cc| Ok(Box::new(Robot36EncoderApp::new(cc)))),
)