add play pause

This commit is contained in:
jrosh 2025-06-03 15:35:17 +02:00
commit ef58ed1c24
No known key found for this signature in database
GPG key ID: A4D68DCA6C9CCD2D
5 changed files with 161 additions and 25 deletions

View file

@ -1,10 +1,7 @@
# Simple TTS UI # Simple TTS UI
My tts ui program for unix My tts ui program for unix
I made this program as an alternative to my bash script for text chunking and sequential streaming Made this program as an alternative to my bash script for text chunking and sequential streaming
Screenshot:
![screenshot](./screenshot.png "Screenshot")
## Installation ## Installation
@ -12,7 +9,7 @@ Dependencies: hexgrad/Kokoro or lucasjinreal/Kokoros and a player aplay/paplay/p
Build dependencies: eframe, egui, atty, tempfile Build dependencies: eframe, egui, atty, tempfile
Set path for TTS: (these settings can be changed after build) Set path for TTS:
```rust ```rust
impl Default for KokoroConfig { impl Default for KokoroConfig {
@ -33,3 +30,9 @@ impl Default for KokoroConfig {
Build this app with `cargo build --release` Build this app with `cargo build --release`
Then run `echo "Hello, world" | ./target/release/ttsui` Then run `echo "Hello, world" | ./target/release/ttsui`
Screenshots:
<img src="./assets/screenshot1.png" alt="main view" title="main view" width="497" height="114">
<img src="./assets/screenshot2.png" alt="settings" title="settings" width="464" height="338">

BIN
assets/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
assets/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View file

@ -1,10 +1,11 @@
use eframe::egui; use eframe::egui;
use std::io::{self, Read}; use std::io::{self, Read};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio, Child};
use std::path::PathBuf; use std::path::PathBuf;
use tempfile::TempDir; use tempfile::TempDir;
use std::thread; use std::thread;
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct ChunkingConfig { struct ChunkingConfig {
@ -57,12 +58,20 @@ enum ProcessingStatus {
Error(String), Error(String),
} }
// Audio control for pause/resume
#[derive(Debug, Clone)]
enum AudioControl {
Play,
Pause,
Stop,
}
fn main() -> Result<(), eframe::Error> { fn main() -> Result<(), eframe::Error> {
let input_text = get_piped_text(); let input_text = get_piped_text();
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 200.0]) .with_inner_size([400.0, 100.0])
.with_resizable(true) .with_resizable(true)
.with_decorations(false), .with_decorations(false),
..Default::default() ..Default::default()
@ -187,6 +196,9 @@ struct TtsUi {
current_view: CurrentView, current_view: CurrentView,
viewport_expanded: bool, viewport_expanded: bool,
original_size: egui::Vec2, original_size: egui::Vec2,
// Audio control
audio_control_sender: Option<mpsc::Sender<AudioControl>>,
is_paused: bool,
} }
impl TtsUi { impl TtsUi {
@ -211,6 +223,8 @@ impl TtsUi {
current_view: CurrentView::Main, current_view: CurrentView::Main,
viewport_expanded: false, viewport_expanded: false,
original_size: egui::Vec2::new(500.0, 200.0), 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; 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) { fn start_tts_processing(&mut self) {
if self.is_processing || self.cleaned_text.trim().is_empty() { if self.is_processing || self.cleaned_text.trim().is_empty() {
@ -269,9 +296,13 @@ impl TtsUi {
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
self.status_receiver = Some(receiver); 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 // Process chunks in background thread
thread::spawn(move || { 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(); self.status = "Starting chunk processing...".to_string();
@ -282,7 +313,8 @@ impl TtsUi {
temp_path: PathBuf, temp_path: PathBuf,
chunks: Vec<String>, chunks: Vec<String>,
config: KokoroConfig, config: KokoroConfig,
sender: mpsc::Sender<ProcessingStatus> sender: mpsc::Sender<ProcessingStatus>,
audio_control_receiver: mpsc::Receiver<AudioControl>,
) { ) {
let total_chunks = chunks.len(); let total_chunks = chunks.len();
@ -291,13 +323,25 @@ impl TtsUi {
// Spawn audio player thread // Spawn audio player thread
let player_sender = sender.clone(); 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 || { thread::spawn(move || {
let mut chunk_num = 1; let mut chunk_num = 1;
// Play audio files as they become available // Play audio files as they become available
while let Ok(audio_file) = audio_receiver.recv() { while let Ok(audio_file) = audio_receiver.recv() {
let _ = player_sender.send(ProcessingStatus::PlayingChunk(chunk_num, total_chunks)); 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 { if chunk_num < total_chunks {
let _ = player_sender.send(ProcessingStatus::Idle); let _ = player_sender.send(ProcessingStatus::Idle);
} }
@ -367,10 +411,13 @@ impl TtsUi {
println!("✅ All chunks processed!"); println!("✅ All chunks processed!");
} }
fn play_audio_sync(audio_file: &PathBuf) { fn play_audio_sync(audio_file: &PathBuf, audio_control: Arc<Mutex<AudioControl>>) {
let players = ["aplay", "paplay", "play", "ffplay", "mpg123"]; let players = ["aplay", "paplay", "play", "ffplay", "mpg123"];
for player in &players { for player in &players {
let mut child_opt: Option<Child> = None;
let mut is_paused = false;
let result = Command::new(player) let result = Command::new(player)
.arg(audio_file) .arg(audio_file)
.stdout(Stdio::null()) .stdout(Stdio::null())
@ -378,19 +425,86 @@ impl TtsUi {
.spawn(); .spawn();
match result { match result {
Ok(mut child) => { Ok(child) => {
println!("Playing audio with {}...", player); println!("Playing audio with {}...", player);
match child.wait() { child_opt = Some(child);
Ok(exit_status) => {
if exit_status.success() { // Handle pause/play with polling
println!("✅ Chunk played successfully!"); 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; return;
} }
} }
Err(e) => {
eprintln!("Error during playback: {}", e);
} }
} }
AudioControl::Stop => {
if let Some(ref mut c) = child_opt {
let _ = c.kill();
let _ = c.wait();
}
return;
}
}
}
// 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, Err(_) => continue,
} }
@ -417,15 +531,20 @@ impl eframe::App for TtsUi {
self.status = "✅ All chunks completed successfully!".to_string(); self.status = "✅ All chunks completed successfully!".to_string();
self.currently_playing_chunk = None; self.currently_playing_chunk = None;
self.is_processing = false; self.is_processing = false;
self.audio_control_sender = None;
self.is_paused = false;
} }
ProcessingStatus::Error(err) => { ProcessingStatus::Error(err) => {
self.status = format!("❌ Error: {}", err); self.status = format!("❌ Error: {}", err);
self.currently_playing_chunk = None; self.currently_playing_chunk = None;
self.is_processing = false; self.is_processing = false;
self.audio_control_sender = None;
self.is_paused = false;
} }
ProcessingStatus::Idle => { ProcessingStatus::Idle => {
// Reset currently playing chunk when between chunks // Reset currently playing chunk when between chunks
self.currently_playing_chunk = None; self.currently_playing_chunk = None;
self.is_paused = false;
} }
_ => {} _ => {}
} }
@ -478,7 +597,6 @@ impl TtsUi {
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
// Main control buttons // Main control buttons
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("📝").clicked() { if ui.button("📝").clicked() {
@ -493,10 +611,20 @@ impl TtsUi {
ui.separator(); ui.separator();
if ui.button("❌ Exit").clicked() { 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); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} }
}); });
});
ui.separator(); ui.separator();
ui.add_space(10.0); ui.add_space(10.0);
@ -513,7 +641,12 @@ impl TtsUi {
ui.add_space(10.0); ui.add_space(10.0);
if let ProcessingStatus::PlayingChunk(current, total) = &self.processing_status { 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); ui.add_space(5.0);
} }
@ -521,7 +654,7 @@ impl TtsUi {
} else { } else {
match &self.processing_status { match &self.processing_status {
ProcessingStatus::Completed => { ProcessingStatus::Completed => {
ui.label("🎉 All audio chunks have been played successfully!"); ui.label("All audio chunks have been played successfully!");
} }
ProcessingStatus::Error(err) => { ProcessingStatus::Error(err) => {
ui.colored_label(egui::Color32::RED, format!("❌ Error: {}", err)); ui.colored_label(egui::Color32::RED, format!("❌ Error: {}", err));