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
130fn kill_all_voicepeak_processes() {
131    use std::process::Command;
132    let _ = Command::new("pkill").arg("-f").arg("voicepeak").output();
133}
134
135impl VoicepeakCommand {
136    pub fn new() -> Self {
137        Self {
138            args: CommandArgs {
139                text: None,
140                narrator: None,
141                emotion: None,
142                output: None,
143                speed: None,
144                pitch: None,
145            },
146        }
147    }
148
149    pub fn text(mut self, text: &str) -> Self {
150        self.args.text = Some(text.to_string());
151        self
152    }
153
154    pub fn narrator(mut self, narrator: &str) -> Self {
155        self.args.narrator = Some(narrator.to_string());
156        self
157    }
158
159    pub fn emotion(mut self, emotion: &str) -> Self {
160        if !emotion.is_empty() {
161            self.args.emotion = Some(emotion.to_string());
162        }
163        self
164    }
165
166    pub fn output(mut self, path: &std::path::Path) -> Self {
167        self.args.output = Some(path.to_path_buf());
168        self
169    }
170
171    pub fn speed(mut self, speed: &str) -> Self {
172        self.args.speed = Some(speed.to_string());
173        self
174    }
175
176    pub fn pitch(mut self, pitch: &str) -> Self {
177        self.args.pitch = Some(pitch.to_string());
178        self
179    }
180
181    fn build_command(&self) -> ProcessCommand {
182        let mut command = ProcessCommand::new(VOICEPEAK_PATH);
183
184        if let Some(ref text) = self.args.text {
185            command.arg("-s").arg(text);
186        }
187        if let Some(ref narrator) = self.args.narrator {
188            command.arg("-n").arg(narrator);
189        }
190        if let Some(ref emotion) = self.args.emotion {
191            command.arg("-e").arg(emotion);
192        }
193        if let Some(ref output) = self.args.output {
194            command.arg("-o").arg(output);
195        }
196        if let Some(ref speed) = self.args.speed {
197            command.arg("--speed").arg(speed);
198        }
199        if let Some(ref pitch) = self.args.pitch {
200            command.arg("--pitch").arg(pitch);
201        }
202
203        command
204    }
205
206    pub fn execute(self) -> Result<(), Box<dyn std::error::Error>> {
207        self.execute_with_verbose(false)
208    }
209
210    pub fn execute_with_verbose(self, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
211        self.execute_with_retry(verbose, 10)
212    }
213
214    fn execute_with_retry(
215        &self,
216        verbose: bool,
217        max_retries: usize,
218    ) -> Result<(), Box<dyn std::error::Error>> {
219        // Acquire exclusive lock to prevent concurrent VOICEPEAK execution
220        let lock_file = get_lock_file()?;
221        lock_file.lock_exclusive()?;
222        // Lock will be automatically released when lock_file is dropped
223
224        let mut last_error: Option<Box<dyn std::error::Error>> = None;
225
226        for attempt in 1..=max_retries {
227            let command = self.build_command();
228
229            let output = execute_command_with_timeout(command, 15);
230            let result = match output {
231                Ok(output) => {
232                    if verbose {
233                        // Print stdout and stderr in verbose mode
234                        if !output.stdout.is_empty() {
235                            print!("{}", String::from_utf8_lossy(&output.stdout));
236                        }
237                        if !output.stderr.is_empty() {
238                            eprint!("{}", String::from_utf8_lossy(&output.stderr));
239                        }
240                    }
241                    if output.status.success() {
242                        Ok(())
243                    } else {
244                        Err("voicepeak command failed".into())
245                    }
246                }
247                Err(e) => Err(e),
248            };
249
250            match result {
251                Ok(()) => return Ok(()),
252                Err(e) => {
253                    last_error = Some(e);
254                    if attempt < max_retries {
255                        eprintln!(
256                            "VOICEPEAK command failed (attempt {}/{}), retrying in 5 seconds...",
257                            attempt, max_retries
258                        );
259                        // Kill any remaining VOICEPEAK processes before retry
260                        kill_all_voicepeak_processes();
261                        thread::sleep(Duration::from_secs(5));
262                    }
263                }
264            }
265        }
266
267        Err(format!(
268            "VOICEPEAK command failed after {} attempts: {}",
269            max_retries,
270            last_error.unwrap_or_else(|| "unknown error".into())
271        )
272        .into())
273    }
274}
275
276impl Default for VoicepeakCommand {
277    fn default() -> Self {
278        Self::new()
279    }
280}