add play pause
This commit is contained in:
parent
303f1a9c66
commit
ef58ed1c24
5 changed files with 161 additions and 25 deletions
15
README.md
15
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:
|
||||

|
||||
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`
|
||||
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
BIN
assets/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
assets/screenshot2.png
Normal file
BIN
assets/screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB |
171
src/main.rs
171
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<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!");
|
||||
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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue