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 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 let lock_file = get_lock_file()?;
221 lock_file.lock_exclusive()?;
222 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 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_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}