initial
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
4581
Cargo.lock
generated
Normal file
4581
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "sstvr36e"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = "0.28"
|
||||||
|
egui = "0.28"
|
||||||
|
rfd = "0.14"
|
||||||
|
robot36-encoder = { version = "0.2", features = ["image"] }
|
||||||
|
image = "0.24"
|
||||||
|
hound = "3.5"
|
||||||
|
env_logger = "0.10"
|
||||||
510
src/main.rs
Normal file
510
src/main.rs
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
use robot36_encoder::{Encoder, Robot36Image};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use std::thread;
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use hound::{WavSpec, WavWriter, SampleFormat};
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
// File paths
|
||||||
|
input_file: Option<PathBuf>,
|
||||||
|
output_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
// Encoding state
|
||||||
|
encoding_state: EncodingState,
|
||||||
|
progress: f32,
|
||||||
|
|
||||||
|
// Audio settings
|
||||||
|
sample_rate: u32,
|
||||||
|
|
||||||
|
// Threading
|
||||||
|
encoding_thread: Option<thread::JoinHandle<()>>,
|
||||||
|
progress_receiver: Option<Receiver<ProgressMessage>>,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
show_success_dialog: bool,
|
||||||
|
error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Robot36EncoderApp {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
input_file: None,
|
||||||
|
output_file: None,
|
||||||
|
encoding_state: EncodingState::Idle,
|
||||||
|
progress: 0.0,
|
||||||
|
sample_rate: 48000,
|
||||||
|
encoding_thread: None,
|
||||||
|
progress_receiver: None,
|
||||||
|
show_success_dialog: false,
|
||||||
|
error_message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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("output.wav")
|
||||||
|
.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;
|
||||||
|
|
||||||
|
self.encoding_thread = Some(thread::spawn(move || {
|
||||||
|
Self::encode_image_thread(input_path, output_path, sample_rate, tx);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_image_thread(
|
||||||
|
input_path: PathBuf,
|
||||||
|
output_path: PathBuf,
|
||||||
|
sample_rate: u32,
|
||||||
|
tx: Sender<ProgressMessage>,
|
||||||
|
) {
|
||||||
|
let result = Self::encode_image_impl(input_path, output_path.clone(), sample_rate, &tx);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = tx.send(ProgressMessage::Completed(output_path));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(ProgressMessage::Error(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_image_impl(
|
||||||
|
input_path: PathBuf,
|
||||||
|
output_path: PathBuf,
|
||||||
|
sample_rate: u32,
|
||||||
|
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)?;
|
||||||
|
let _ = tx.send(ProgressMessage::Progress(0.2));
|
||||||
|
|
||||||
|
// Resize image to Robot36 specifications (320x240)
|
||||||
|
let resized_image = image.resize(320, 240, image::imageops::FilterType::Lanczos3);
|
||||||
|
let _ = tx.send(ProgressMessage::Progress(0.3));
|
||||||
|
|
||||||
|
// Convert to Robot36Image
|
||||||
|
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,
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
// Note: In a production app, you'd want to implement proper cancellation
|
||||||
|
// For now, we'll just detach the thread
|
||||||
|
handle.join().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.progress_receiver = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_progress(&mut self) {
|
||||||
|
let mut should_clear_receiver = false;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
self.encoding_state = EncodingState::Completed { output_path };
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_clear_receiver {
|
||||||
|
self.progress_receiver = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for Robot36EncoderApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.update_progress();
|
||||||
|
|
||||||
|
// Success dialog
|
||||||
|
if self.show_success_dialog {
|
||||||
|
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!");
|
||||||
|
|
||||||
|
if let EncodingState::Completed { output_path } = &self.encoding_state {
|
||||||
|
ui.label(format!("Output saved to: {}", output_path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
// Status messages
|
||||||
|
match &self.encoding_state {
|
||||||
|
EncodingState::Idle => {
|
||||||
|
if self.can_start_encoding() {
|
||||||
|
ui.colored_label(egui::Color32::BLUE, "Ready to encode");
|
||||||
|
} else {
|
||||||
|
ui.colored_label(egui::Color32::GRAY, "Select input and output files to begin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request repaint for smooth updates
|
||||||
|
if self.is_encoding() {
|
||||||
|
ctx.request_repaint_after(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([600.0, 500.0])
|
||||||
|
.with_min_inner_size([500.0, 400.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"Robot36 Image Encoder",
|
||||||
|
options,
|
||||||
|
Box::new(|cc| Ok(Box::new(Robot36EncoderApp::new(cc)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user