Skip to main content

voicepeak_cli/
voicepeak.rs

1use fs2::FileExt;
2use std::fs::{create_dir_all, File, OpenOptions};
3use std::path::PathBuf;
4use std::process::{Command as ProcessCommand, Output, Stdio};
5use std::sync::{mpsc, Arc, Mutex};
6use std::thread;
7use std::time::Duration;
8
9const VOICEPEAK_PATH: &str = "/Applications/voicepeak.app/Contents/MacOS/voicepeak";
10
11fn get_lock_file() -> Result<File, Box<dyn std::error::Error>> {
12    let lock_path = get_lock_file_path()?;
13
14    // Ensure parent directory exists
15    if let Some(parent) = lock_path.parent() {
16        create_dir_all(parent)?;
17    }
18
19    // Open or create lock file
20    let file = OpenOptions::new()
21        .read(true)
22        .write(true)
23        .create(true)
24        .truncate(true)
25        .open(&lock_path)?;
26
27    Ok(file)
28}
29
30fn get_lock_file_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
31    let config_dir = dirs::config_dir().ok_or("Could not determine config directory")?;
32    Ok(config_dir.join("vp").join("vp.lock"))
33}
34
35pub fn list_narrator() {
36    let output = ProcessCommand::new(VOICEPEAK_PATH)
37        .arg("--list-narrator")
38        .output();
39
40    match output {
41        Ok(output) => {
42            print!("{}", String::from_utf8_lossy(&output.stdout));
43        }
44        Err(e) => {
45            eprintln!("Failed to execute voicepeak: {}", e);
46        }
47    }
48}
49
50pub fn list_emotion(narrator: &str) {
51    let output = ProcessCommand::new(VOICEPEAK_PATH)
52        .arg("--list-emotion")
53        .arg(narrator)
54        .output();
55
56    match output {
57        Ok(output) => {
58            print!("{}", String::from_utf8_lossy(&output.stdout));
59        }
60        Err(e) => {
61            eprintln!("Failed to execute voicepeak: {}", e);
62        }
63    }
64}
65
66#[derive(Debug, Clone)]
67struct CommandArgs {
68    text: Option<String>,
69    narrator: Option<String>,
70    emotion: Option<String>,
71    output: Option<std::path::PathBuf>,
72    speed: Option<String>,
73    pitch: Option<String>,
74}
75
76pub struct VoicepeakCommand {
77    args: CommandArgs,
78}
79
80fn execute_command_with_timeout(
81    mut command: ProcessCommand,
82    timeout_secs: u64,
83) -> Result<Output, Box<dyn std::error::Error>> {
84    let (tx, rx) = mpsc::channel();
85    let child_id = Arc::new(Mutex::new(None::<u32>));
86    let child_id_clone = Arc::clone(&child_id);
87
88    thread::spawn(move || {
89        let child = match command
90            .stdout(Stdio::piped())
91            .stderr(Stdio::piped())
92            .spawn()
93        {
94            Ok(child) => child,
95            Err(e) => {
96                let _ = tx.send(Err(e));
97                return;
98            }
99        };
100
101        // Store child process ID for potential killing
102        {
103            let mut id_guard = child_id_clone.lock().unwrap();
104            *id_guard = Some(child.id());
105        }
106
107        let result = child.wait_with_output();
108        let _ = tx.send(result);
109    });
110
111    match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
112        Ok(result) => result.map_err(|e| e.into()),
113        Err(_) => {
114            // Kill the process on timeout
115            if let Ok(id_guard) = child_id.lock() {
116                if let Some(pid) = *id_guard {
117                    kill_process_by_id(pid);
118                }
119            }
120            Err("Command timed out after 15 seconds".into())
121        }
122    }
123}
124
125fn kill_process_by_id(pid: u32) {
126    use std::process::Command;
127    let _ = Command::new("kill").arg("-9").arg(pid.to_string()).output();
128}
129
130/// Exponential backoff: 0.5s, 1s, 2s, 4s, then capped at 5s.
131/// VOICEPEAK usually crashes right after launch, so a short first wait is enough.
132fn retry_delay(attempt: usize) -> Duration {
133    const BASE_MS: u64 = 500;
134    const MAX_MS: u64 = 5000;
135    let exponent = attempt.saturating_sub(1).min(16) as u32;
136    Duration::from_millis((BASE_MS << exponent).min(MAX_MS))
137}
138
139impl VoicepeakCommand {
140    pub fn new() -> Self {
141        Self {
142            args: CommandArgs {
143                text: None,
144                narrator: None,
145                emotion: None,
146                output: None,
147                speed: None,
148                pitch: None,
149            },
150        }
151    }
152
153    pub fn text(mut self, text: &str) -> Self {
154        self.args.text = Some(text.to_string());
155        self
156    }
157
158    pub fn narrator(mut self, narrator: &str) -> Self {
159        self.args.narrator = Some(narrator.to_string());
160        self
161    }
162
163    pub fn emotion(mut self, emotion: &str) -> Self {
164        if !emotion.is_empty() {
165            self.args.emotion = Some(emotion.to_string());
166        }
167        self
168    }
169
170    pub fn output(mut self, path: &std::path::Path) -> Self {
171        self.args.output = Some(path.to_path_buf());
172        self
173    }
174
175    pub fn speed(mut self, speed: &str) -> Self {
176        self.args.speed = Some(speed.to_string());
177        self
178    }
179
180    pub fn pitch(mut self, pitch: &str) -> Self {
181        self.args.pitch = Some(pitch.to_string());
182        self
183    }
184
185    fn build_command(&self) -> ProcessCommand {
186        let mut command = ProcessCommand::new(VOICEPEAK_PATH);
187
188        if let Some(ref text) = self.args.text {
189            command.arg("-s").arg(text);
190        }
191        if let Some(ref narrator) = self.args.narrator {
192            command.arg("-n").arg(narrator);
193        }
194        if let Some(ref emotion) = self.args.emotion {
195            command.arg("-e").arg(emotion);
196        }
197        if let Some(ref output) = self.args.output {
198            command.arg("-o").arg(output);
199        }
200        if let Some(ref speed) = self.args.speed {
201            command.arg("--speed").arg(speed);
202        }
203        if let Some(ref pitch) = self.args.pitch {
204            command.arg("--pitch").arg(pitch);
205        }
206
207        command
208    }
209
210    pub fn execute(self) -> Result<(), Box<dyn std::error::Error>> {
211        self.execute_with_verbose(false)
212    }
213
214    pub fn execute_with_verbose(self, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
215        self.execute_with_retry(verbose, 10)
216    }
217
218    fn execute_with_retry(
219        &self,
220        verbose: bool,
221        max_retries: usize,
222    ) -> Result<(), Box<dyn std::error::Error>> {
223        // Acquire exclusive lock to prevent concurrent VOICEPEAK execution
224        let lock_file = get_lock_file()?;
225        lock_file.lock_exclusive()?;
226        // Lock will be automatically released when lock_file is dropped
227
228        let mut last_error: Option<Box<dyn std::error::Error>> = None;
229
230        for attempt in 1..=max_retries {
231            let command = self.build_command();
232
233            let output = execute_command_with_timeout(command, 15);
234            let result = match output {
235                Ok(output) => {
236                    if verbose {
237                        // Print stdout and stderr in verbose mode
238                        if !output.stdout.is_empty() {
239                            print!("{}", String::from_utf8_lossy(&output.stdout));
240                        }
241                        if !output.stderr.is_empty() {
242                            eprint!("{}", String::from_utf8_lossy(&output.stderr));
243                        }
244                    }
245                    if output.status.success() {
246                        Ok(())
247                    } else {
248                        Err("voicepeak command failed".into())
249                    }
250                }
251                Err(e) => Err(e),
252            };
253
254            match result {
255                Ok(()) => return Ok(()),
256                Err(e) => {
257                    last_error = Some(e);
258                    if attempt < max_retries {
259                        // A timed-out process has already been killed by PID,
260                        // so no further cleanup is needed before retrying
261                        let delay = retry_delay(attempt);
262                        eprintln!(
263                            "VOICEPEAK command failed (attempt {}/{}), retrying in {:.1} seconds...",
264                            attempt,
265                            max_retries,
266                            delay.as_secs_f64()
267                        );
268                        thread::sleep(delay);
269                    }
270                }
271            }
272        }
273
274        Err(format!(
275            "VOICEPEAK command failed after {} attempts: {}",
276            max_retries,
277            last_error.unwrap_or_else(|| "unknown error".into())
278        )
279        .into())
280    }
281}
282
283impl Default for VoicepeakCommand {
284    fn default() -> Self {
285        Self::new()
286    }
287}