1use crate::audio::capture::CpalRecorder;
17use crate::audio::wav::TempWav;
18use crate::state::{AppEvent, RecordingState};
19
20use super::VoiceApp;
21
22struct SendWhisperPtr(*const crate::transcribe::engine::WhisperEngine);
35
36unsafe impl Send for SendWhisperPtr {}
38
39impl SendWhisperPtr {
40 unsafe fn as_ref(&self) -> &crate::transcribe::engine::WhisperEngine {
47 &*self.0
48 }
49}
50
51const MIN_RECORDING_SECS: f64 = 0.5;
54
55const MIN_SAMPLES: usize = 8_000;
58
59pub(crate) async fn handle_toggle(app: &mut VoiceApp) {
66 match app.state {
67 RecordingState::Idle => {
68 app.state = RecordingState::Recording;
69 app.current_level = None;
70 app.render_display();
71 }
72 RecordingState::Recording => {
73 app.state = RecordingState::Transcribing;
74 app.current_level = None;
75 app.render_display();
76 }
77 _ => {
78 }
80 }
81}
82
83
84pub(crate) async fn handle_push_to_talk_start(app: &mut VoiceApp) {
94 if app.state != RecordingState::Idle {
95 return;
96 }
97
98 let device = app.audio_config.device.as_deref();
99
100 let mut recorder = match CpalRecorder::new(device) {
102 Ok(r) => r,
103 Err(e) => {
104 app.handle_error(&format!("Failed to open audio device: {}", e));
105 return;
106 }
107 };
108
109 let energy_rx = match recorder.start() {
110 Ok(rx) => rx,
111 Err(e) => {
112 app.handle_error(&format!("Failed to start recording: {}", e));
113 return;
114 }
115 };
116
117 let event_tx = app.event_tx.clone();
120 let mut energy_rx = energy_rx;
121 tokio::spawn(async move {
122 while let Some(rms_energy) = energy_rx.recv().await {
123 if event_tx
124 .send(AppEvent::AudioChunk { rms_energy })
125 .is_err()
126 {
127 break; }
129 }
130 });
131
132 app.recorder = Some(recorder);
134
135 app.state = RecordingState::Recording;
136 app.current_level = None;
137 app.render_display();
138}
139
140pub(crate) async fn handle_push_to_talk_stop(app: &mut VoiceApp) {
149 if app.state != RecordingState::Recording {
150 return;
151 }
152
153 let mut recorder = match app.recorder.take() {
155 Some(r) => r,
156 None => {
157 return_to_idle_or_approval(app);
159 return;
160 }
161 };
162
163 let duration = recorder.duration();
165
166 let samples = match recorder.stop() {
168 Ok(s) => s,
169 Err(e) => {
170 app.handle_error(&format!("Failed to stop recording: {}", e));
171 return;
172 }
173 };
174
175 if duration < MIN_RECORDING_SECS || samples.len() < MIN_SAMPLES {
177 app.display.log(&format!(
178 "[voice] Recording too short ({:.2}s, {} samples) — discarded.",
179 duration,
180 samples.len()
181 ));
182 return_to_idle_or_approval(app);
183 return;
184 }
185
186 app.state = RecordingState::Transcribing;
188 app.current_level = None;
189 app.render_display();
190
191 let wav = TempWav::new();
195 if let Err(e) = wav.write(&samples, &app.audio_config) {
196 app.handle_error(&format!("Failed to write WAV file: {}", e));
197 return;
198 }
199
200 let wav_path = wav.into_path();
202
203 let transcript = match &app.whisper {
205 None => {
206 let _ = std::fs::remove_file(&wav_path);
208 app.handle_error("Whisper model not loaded. Run 'opencode-voice setup'.");
209 return;
210 }
211 Some(_) => {
212 let path_for_task = wav_path.clone();
214
215 let engine_ptr = SendWhisperPtr(
222 app.whisper.as_ref().unwrap() as *const crate::transcribe::engine::WhisperEngine,
223 );
224
225 let result = tokio::task::spawn_blocking(move || {
226 let engine = unsafe { engine_ptr.as_ref() };
228 engine.transcribe(&path_for_task)
229 })
230 .await;
231
232 let _ = std::fs::remove_file(&wav_path);
234
235 match result {
236 Ok(Ok(r)) => r,
237 Ok(Err(e)) => {
238 app.handle_error(&format!("Transcription failed: {}", e));
239 return;
240 }
241 Err(e) => {
242 app.handle_error(&format!("Transcription task panicked: {}", e));
243 return;
244 }
245 }
246 }
247 };
248
249 let text = transcript.text.trim().to_string();
250
251 if text.is_empty() {
252 return_to_idle_or_approval(app);
254 return;
255 }
256
257 app.last_transcript = Some(text.clone());
259
260 if app.approval_queue.has_pending() {
262 let handled = try_handle_approval(app, &text).await;
263 if handled {
264 return_to_idle_or_approval(app);
265 return;
266 }
267 }
268
269 inject_text(app, &text).await;
271}
272
273async fn inject_text(app: &mut VoiceApp, text: &str) {
280 app.state = RecordingState::Injecting;
281 app.render_display();
282
283 if let Err(e) = app.bridge.append_prompt(text, None, None).await {
284 app.handle_error(&format!("Failed to inject text: {}", e));
285 return;
286 }
287
288 if app.config.auto_submit {
289 if let Err(e) = app.bridge.submit_prompt().await {
290 app.handle_error(&format!("Failed to submit prompt: {}", e));
291 return;
292 }
293 }
294
295 return_to_idle_or_approval(app);
296}
297
298pub(crate) fn return_to_idle_or_approval(app: &mut VoiceApp) {
301 if app.approval_queue.has_pending() {
302 app.state = RecordingState::ApprovalPending;
303 } else {
304 app.state = RecordingState::Idle;
305 }
306 app.current_level = None;
307 app.render_display();
308}
309
310pub(crate) async fn try_handle_approval(app: &mut VoiceApp, text: &str) -> bool {
331 use crate::approval::matcher::{match_permission_command, match_question_answer, MatchResult};
332 use crate::approval::types::PendingApproval;
333
334 let pending = match app.approval_queue.peek() {
337 Some(p) => p.clone(),
338 None => return false,
339 };
340
341 match &pending {
342 PendingApproval::Permission(_req) => {
343 let result = match_permission_command(text);
344 match result {
345 MatchResult::PermissionReply { reply, message } => {
346 let id = pending.id().to_string();
347 let msg_ref = message.as_deref();
348 if let Err(e) = app.bridge.reply_permission(&id, reply, msg_ref).await {
349 app.handle_error(&format!("Failed to reply to permission: {}", e));
350 }
351 app.approval_queue.remove(&id);
352 super::approval::refresh_approval_display(app);
353 true
354 }
355 MatchResult::NoMatch => false,
356 _ => false,
359 }
360 }
361
362 PendingApproval::Question(req) => {
363 let req_clone = req.clone();
366 let result = match_question_answer(text, &req_clone);
367 match result {
368 MatchResult::QuestionAnswer { answers } => {
369 let id = pending.id().to_string();
370 if let Err(e) = app.bridge.reply_question(&id, answers).await {
371 app.handle_error(&format!("Failed to reply to question: {}", e));
372 }
373 app.approval_queue.remove(&id);
374 super::approval::refresh_approval_display(app);
375 true
376 }
377 MatchResult::QuestionReject => {
378 let id = pending.id().to_string();
379 if let Err(e) = app.bridge.reject_question(&id).await {
380 app.handle_error(&format!("Failed to reject question: {}", e));
381 }
382 app.approval_queue.remove(&id);
383 super::approval::refresh_approval_display(app);
384 true
385 }
386 MatchResult::NoMatch => false,
387 _ => false,
389 }
390 }
391 }
392}
393
394#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::app::VoiceApp;
400 use crate::config::{AppConfig, ModelSize};
401 use std::path::PathBuf;
402
403 fn test_config() -> AppConfig {
404 AppConfig {
405 whisper_model_path: PathBuf::from("/nonexistent/model.bin"),
406 opencode_port: 4096,
407 toggle_key: ' ',
408 model_size: ModelSize::TinyEn,
409 auto_submit: true,
410 server_password: None,
411 data_dir: PathBuf::from("/nonexistent/data"),
412 audio_device: None,
413 use_global_hotkey: false,
414 global_hotkey: "right_option".to_string(),
415 push_to_talk: false,
416 approval_mode: false,
417 }
418 }
419
420 #[tokio::test]
423 async fn test_handle_toggle_idle_to_recording() {
424 let mut app = VoiceApp::new(test_config()).unwrap();
425 assert_eq!(app.state, RecordingState::Idle);
426 handle_toggle(&mut app).await;
427 assert_eq!(app.state, RecordingState::Recording);
428 }
429
430 #[tokio::test]
431 async fn test_handle_toggle_recording_to_transcribing() {
432 let mut app = VoiceApp::new(test_config()).unwrap();
433 app.state = RecordingState::Recording;
434 handle_toggle(&mut app).await;
435 assert_eq!(app.state, RecordingState::Transcribing);
436 }
437
438 #[tokio::test]
439 async fn test_handle_toggle_ignores_transcribing_state() {
440 let mut app = VoiceApp::new(test_config()).unwrap();
441 app.state = RecordingState::Transcribing;
442 handle_toggle(&mut app).await;
443 assert_eq!(app.state, RecordingState::Transcribing);
444 }
445
446 #[tokio::test]
449 async fn test_handle_push_to_talk_stop_ignores_idle() {
450 let mut app = VoiceApp::new(test_config()).unwrap();
451 handle_push_to_talk_stop(&mut app).await;
453 assert_eq!(app.state, RecordingState::Idle);
454 }
455
456 #[tokio::test]
457 async fn test_handle_push_to_talk_stop_no_recorder_returns_to_idle() {
458 let mut app = VoiceApp::new(test_config()).unwrap();
459 app.state = RecordingState::Recording;
461 handle_push_to_talk_stop(&mut app).await;
462 assert_eq!(app.state, RecordingState::Idle);
464 }
465
466 #[test]
469 fn test_return_to_idle_when_no_pending() {
470 let mut app = VoiceApp::new(test_config()).unwrap();
471 app.state = RecordingState::Injecting;
472 return_to_idle_or_approval(&mut app);
473 assert_eq!(app.state, RecordingState::Idle);
474 }
475
476 #[test]
477 fn test_return_to_approval_pending_when_queue_has_items() {
478 use crate::approval::types::PermissionRequest;
479
480 let mut app = VoiceApp::new(test_config()).unwrap();
481 app.state = RecordingState::Injecting;
482
483 app.approval_queue.add_permission(PermissionRequest {
485 id: "p1".to_string(),
486 permission: "bash".to_string(),
487 metadata: serde_json::Value::Null,
488 });
489
490 return_to_idle_or_approval(&mut app);
491 assert_eq!(app.state, RecordingState::ApprovalPending);
492 }
493
494 #[tokio::test]
498 async fn test_try_handle_approval_empty_queue_returns_false() {
499 let mut app = VoiceApp::new(test_config()).unwrap();
500 let result = try_handle_approval(&mut app, "yes").await;
502 assert!(!result, "empty queue should return false");
503 }
504
505 #[tokio::test]
507 async fn test_try_handle_approval_permission_no_match_returns_false() {
508 use crate::approval::types::PermissionRequest;
509
510 let mut app = VoiceApp::new(test_config()).unwrap();
511 app.approval_queue.add_permission(PermissionRequest {
512 id: "p1".to_string(),
513 permission: "bash".to_string(),
514 metadata: serde_json::Value::Null,
515 });
516
517 let result = try_handle_approval(&mut app, "hello world").await;
519 assert!(!result, "unrecognised text should return false");
520 assert!(app.approval_queue.has_pending());
522 }
523
524 #[tokio::test]
526 async fn test_try_handle_approval_question_no_match_returns_false() {
527 use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
528
529 let mut app = VoiceApp::new(test_config()).unwrap();
530 app.approval_queue.add_question(QuestionRequest {
531 id: "q1".to_string(),
532 questions: vec![QuestionInfo {
533 question: "Pick one".to_string(),
534 options: vec![
535 QuestionOption {
536 label: "Alpha".to_string(),
537 },
538 QuestionOption {
539 label: "Beta".to_string(),
540 },
541 ],
542 custom: false, }],
544 });
545
546 let result = try_handle_approval(&mut app, "gamma").await;
548 assert!(!result, "unrecognised question answer should return false");
549 assert!(app.approval_queue.has_pending());
550 }
551
552 #[tokio::test]
556 async fn test_try_handle_approval_permission_match_removes_item_and_returns_true() {
557 use crate::approval::types::PermissionRequest;
558
559 let mut app = VoiceApp::new(test_config()).unwrap();
560 app.approval_queue.add_permission(PermissionRequest {
561 id: "p1".to_string(),
562 permission: "bash".to_string(),
563 metadata: serde_json::Value::Null,
564 });
565 app.state = RecordingState::ApprovalPending;
566
567 let result = try_handle_approval(&mut app, "yes").await;
570 assert!(result, "matched permission should return true");
571 assert!(
572 !app.approval_queue.has_pending(),
573 "item should be removed from queue after match"
574 );
575 }
576
577 #[tokio::test]
580 async fn test_try_handle_approval_question_match_removes_item_and_returns_true() {
581 use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
582
583 let mut app = VoiceApp::new(test_config()).unwrap();
584 app.approval_queue.add_question(QuestionRequest {
585 id: "q1".to_string(),
586 questions: vec![QuestionInfo {
587 question: "Pick one".to_string(),
588 options: vec![
589 QuestionOption {
590 label: "Alpha".to_string(),
591 },
592 QuestionOption {
593 label: "Beta".to_string(),
594 },
595 ],
596 custom: false,
597 }],
598 });
599 app.state = RecordingState::ApprovalPending;
600
601 let result = try_handle_approval(&mut app, "alpha").await;
603 assert!(result, "matched question answer should return true");
604 assert!(
605 !app.approval_queue.has_pending(),
606 "item should be removed from queue after match"
607 );
608 }
609
610 #[tokio::test]
613 async fn test_try_handle_approval_question_reject_removes_item_and_returns_true() {
614 use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
615
616 let mut app = VoiceApp::new(test_config()).unwrap();
617 app.approval_queue.add_question(QuestionRequest {
618 id: "q2".to_string(),
619 questions: vec![QuestionInfo {
620 question: "Pick one".to_string(),
621 options: vec![QuestionOption {
622 label: "Yes".to_string(),
623 }],
624 custom: false,
625 }],
626 });
627 app.state = RecordingState::ApprovalPending;
628
629 let result = try_handle_approval(&mut app, "skip").await;
631 assert!(result, "question rejection should return true");
632 assert!(
633 !app.approval_queue.has_pending(),
634 "item should be removed from queue after rejection"
635 );
636 }
637
638}