diff --git a/README.md b/README.md index d1ca421..39576e0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,7 @@ # Simple TTS UI My tts ui program for unix -I made this program as an alternative to my bash script for text chunking and sequential streaming - -Screenshot: -![screenshot](./screenshot.png "Screenshot") +Made this program as an alternative to my bash script for text chunking and sequential streaming ## Installation @@ -12,7 +9,7 @@ Dependencies: hexgrad/Kokoro or lucasjinreal/Kokoros and a player aplay/paplay/p Build dependencies: eframe, egui, atty, tempfile -Set path for TTS: (these settings can be changed after build) +Set path for TTS: ```rust impl Default for KokoroConfig { @@ -32,4 +29,10 @@ impl Default for KokoroConfig { Build this app with `cargo build --release` -Then run `echo "Hello, world" | ./target/release/ttsui` \ No newline at end of file +Then run `echo "Hello, world" | ./target/release/ttsui` + +Screenshots: + +main view + +settings \ No newline at end of file diff --git a/assets/screenshot1.png b/assets/screenshot1.png new file mode 100644 index 0000000..8a2ad3f Binary files /dev/null and b/assets/screenshot1.png differ diff --git a/assets/screenshot2.png b/assets/screenshot2.png new file mode 100644 index 0000000..caf23de Binary files /dev/null and b/assets/screenshot2.png differ diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index 5243b41..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/src/main.rs b/src/main.rs index 7c23e15..2283f4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use eframe::egui; use std::io::{self, Read}; -use std::process::{Command, Stdio}; +use std::process::{Command, Stdio, Child}; use std::path::PathBuf; use tempfile::TempDir; use std::thread; use std::sync::mpsc; +use std::sync::{Arc, Mutex}; #[derive(Debug, Clone)] struct ChunkingConfig { @@ -57,12 +58,20 @@ enum ProcessingStatus { Error(String), } +// Audio control for pause/resume +#[derive(Debug, Clone)] +enum AudioControl { + Play, + Pause, + Stop, +} + fn main() -> Result<(), eframe::Error> { let input_text = get_piped_text(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() - .with_inner_size([400.0, 200.0]) + .with_inner_size([400.0, 100.0]) .with_resizable(true) .with_decorations(false), ..Default::default() @@ -187,6 +196,9 @@ struct TtsUi { current_view: CurrentView, viewport_expanded: bool, original_size: egui::Vec2, + // Audio control + audio_control_sender: Option>, + is_paused: bool, } impl TtsUi { @@ -211,6 +223,8 @@ impl TtsUi { current_view: CurrentView::Main, viewport_expanded: false, original_size: egui::Vec2::new(500.0, 200.0), + audio_control_sender: None, + is_paused: false, } } @@ -232,6 +246,19 @@ impl TtsUi { self.viewport_expanded = view != CurrentView::Main; } + fn toggle_pause_play(&mut self) { + if let Some(ref sender) = self.audio_control_sender { + let control = if self.is_paused { + AudioControl::Play + } else { + AudioControl::Pause + }; + + if sender.send(control).is_ok() { + self.is_paused = !self.is_paused; + } + } + } fn start_tts_processing(&mut self) { if self.is_processing || self.cleaned_text.trim().is_empty() { @@ -269,9 +296,13 @@ impl TtsUi { let (sender, receiver) = mpsc::channel(); self.status_receiver = Some(receiver); + // Create audio control channel + let (audio_control_sender, audio_control_receiver) = mpsc::channel(); + self.audio_control_sender = Some(audio_control_sender); + // Process chunks in background thread thread::spawn(move || { - Self::process_chunks_background(temp_path, chunks, config, sender); + Self::process_chunks_background(temp_path, chunks, config, sender, audio_control_receiver); }); self.status = "Starting chunk processing...".to_string(); @@ -282,7 +313,8 @@ impl TtsUi { temp_path: PathBuf, chunks: Vec, config: KokoroConfig, - sender: mpsc::Sender + sender: mpsc::Sender, + audio_control_receiver: mpsc::Receiver, ) { let total_chunks = chunks.len(); @@ -291,13 +323,25 @@ impl TtsUi { // Spawn audio player thread let player_sender = sender.clone(); + let audio_control = Arc::new(Mutex::new(AudioControl::Play)); + let audio_control_clone = audio_control.clone(); + + // Thread to handle audio control messages + thread::spawn(move || { + while let Ok(control) = audio_control_receiver.recv() { + if let Ok(mut audio_ctrl) = audio_control_clone.lock() { + *audio_ctrl = control; + } + } + }); + thread::spawn(move || { let mut chunk_num = 1; // Play audio files as they become available while let Ok(audio_file) = audio_receiver.recv() { let _ = player_sender.send(ProcessingStatus::PlayingChunk(chunk_num, total_chunks)); - Self::play_audio_sync(&audio_file); + Self::play_audio_sync(&audio_file, audio_control.clone()); if chunk_num < total_chunks { let _ = player_sender.send(ProcessingStatus::Idle); } @@ -367,10 +411,13 @@ impl TtsUi { println!("✅ All chunks processed!"); } - fn play_audio_sync(audio_file: &PathBuf) { + fn play_audio_sync(audio_file: &PathBuf, audio_control: Arc>) { let players = ["aplay", "paplay", "play", "ffplay", "mpg123"]; for player in &players { + let mut child_opt: Option = None; + let mut is_paused = false; + let result = Command::new(player) .arg(audio_file) .stdout(Stdio::null()) @@ -378,19 +425,86 @@ impl TtsUi { .spawn(); match result { - Ok(mut child) => { + Ok(child) => { println!("Playing audio with {}...", player); - match child.wait() { - Ok(exit_status) => { - if exit_status.success() { - println!("✅ Chunk played successfully!"); - return; + child_opt = Some(child); + + // Handle pause/play with polling + loop { + // Check audio control state + if let Ok(control) = audio_control.lock() { + match *control { + AudioControl::Pause => { + if !is_paused { + // Kill the current process + if let Some(ref mut c) = child_opt { + let _ = c.kill(); + let _ = c.wait(); // Wait for process to actually die + child_opt = None; + } + is_paused = true; + println!("Audio paused"); + } + } + AudioControl::Play => { + if is_paused && child_opt.is_none() { + // Restart playback from beginning + match Command::new(player) + .arg(audio_file) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() { + Ok(new_child) => { + child_opt = Some(new_child); + is_paused = false; + println!("Audio resumed"); + } + Err(e) => { + eprintln!("Failed to restart audio: {}", e); + return; + } + } + } + } + AudioControl::Stop => { + if let Some(ref mut c) = child_opt { + let _ = c.kill(); + let _ = c.wait(); + } + return; + } } } - Err(e) => { - eprintln!("Error during playback: {}", e); + + // Check if process finished (only if we have an active process) + if let Some(ref mut c) = child_opt { + match c.try_wait() { + Ok(Some(exit_status)) => { + if exit_status.success() && !is_paused { + println!("✅ Chunk played successfully!"); + return; + } else if !exit_status.success() { + eprintln!("Audio process failed"); + break; + } + // If paused, the process died intentionally, continue loop + } + Ok(None) => { + // Still running, continue + } + Err(_) => { + eprintln!("Error checking process status"); + break; + } + } } + + // Small sleep to prevent busy waiting + thread::sleep(std::time::Duration::from_millis(50)); } + + // If we got here and found a working player, don't try others + return; } Err(_) => continue, } @@ -417,15 +531,20 @@ impl eframe::App for TtsUi { self.status = "✅ All chunks completed successfully!".to_string(); self.currently_playing_chunk = None; self.is_processing = false; + self.audio_control_sender = None; + self.is_paused = false; } ProcessingStatus::Error(err) => { self.status = format!("❌ Error: {}", err); self.currently_playing_chunk = None; self.is_processing = false; + self.audio_control_sender = None; + self.is_paused = false; } ProcessingStatus::Idle => { // Reset currently playing chunk when between chunks self.currently_playing_chunk = None; + self.is_paused = false; } _ => {} } @@ -478,7 +597,6 @@ impl TtsUi { .auto_shrink([false; 2]) .show(ui, |ui| { ui.vertical(|ui| { - // Main control buttons ui.horizontal(|ui| { if ui.button("📝").clicked() { @@ -493,9 +611,19 @@ impl TtsUi { ui.separator(); - if ui.button("❌ Exit").clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + if self.currently_playing_chunk.is_some() && self.audio_control_sender.is_some() { + let button_text = if self.is_paused { "▶" } else { "⏸" }; + if ui.button(button_text).clicked() { + self.toggle_pause_play(); + } + ui.separator(); } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("❌").clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); }); ui.separator(); ui.add_space(10.0); @@ -513,7 +641,12 @@ impl TtsUi { ui.add_space(10.0); if let ProcessingStatus::PlayingChunk(current, total) = &self.processing_status { - ui.label(format!("🎵 {}/{}", current, total)); + let status_text = if self.is_paused { + format!("⏸ {}/{}", current, total) + } else { + format!("🎵 {}/{}", current, total) + }; + ui.label(status_text); ui.add_space(5.0); } @@ -521,7 +654,7 @@ impl TtsUi { } else { match &self.processing_status { ProcessingStatus::Completed => { - ui.label("🎉 All audio chunks have been played successfully!"); + ui.label("All audio chunks have been played successfully!"); } ProcessingStatus::Error(err) => { ui.colored_label(egui::Color32::RED, format!("❌ Error: {}", err));