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
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 {
@ -33,3 +30,9 @@ impl Default for KokoroConfig {
Build this app with `cargo build --release`
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 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<mpsc::Sender<AudioControl>>,
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<String>,
config: KokoroConfig,
sender: mpsc::Sender<ProcessingStatus>
sender: mpsc::Sender<ProcessingStatus>,
audio_control_receiver: mpsc::Receiver<AudioControl>,
) {
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<Mutex<AudioControl>>) {
let players = ["aplay", "paplay", "play", "ffplay", "mpg123"];
for player in &players {
let mut child_opt: Option<Child> = 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!");
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;
}
}
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,
}
@ -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,10 +611,20 @@ impl TtsUi {
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);
}
});
});
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));