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
|
# 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:
|
|
||||||

|
|
||||||
|
|
||||||
## 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 {
|
||||||
|
|
@ -32,4 +29,10 @@ 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
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 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 {
|
||||||
return;
|
// 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,
|
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,9 +611,19 @@ impl TtsUi {
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
if ui.button("❌ Exit").clicked() {
|
if self.currently_playing_chunk.is_some() && self.audio_control_sender.is_some() {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
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.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));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue