voicepeak_cli/
voicepeak.rs1use 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 if let Some(parent) = lock_path.parent() {
16 create_dir_all(parent)?;
17 }
18
19 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 {
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 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 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 let lock_file = get_lock_file()?;
225 lock_file.lock_exclusive()?;
226 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 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 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}