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) {
67 match app.state {
68 RecordingState::Idle | RecordingState::ApprovalPending => {
69 app.state = RecordingState::Recording;
70 app.current_level = None;
71 app.render_display();
72 }
73 RecordingState::Recording => {
74 app.state = RecordingState::Transcribing;
75 app.current_level = None;
76 app.render_display();
77 }
78 _ => {
79 }
81 }
82}
83
84
85pub(crate) async fn handle_push_to_talk_start(app: &mut VoiceApp) {
100 if app.state != RecordingState::Idle && app.state != RecordingState::ApprovalPending {
101 return;
102 }
103
104 let device = app.audio_config.device.as_deref();
105
106 let mut recorder = match CpalRecorder::new(device) {
108 Ok(r) => r,
109 Err(e) => {
110 app.handle_error(&format!("Failed to open audio device: {}", e));
111 return;
112 }
113 };
114
115 let energy_rx = match recorder.start() {
116 Ok(rx) => rx,
117 Err(e) => {
118 app.handle_error(&format!("Failed to start recording: {}", e));
119 return;
120 }
121 };
122
123 let event_tx = app.event_tx.clone();
126 let mut energy_rx = energy_rx;
127 tokio::spawn(async move {
128 while let Some(rms_energy) = energy_rx.recv().await {
129 if event_tx
130 .send(AppEvent::AudioChunk { rms_energy })
131 .is_err()
132 {
133 break; }
135 }
136 });
137
138 app.recorder = Some(recorder);
140
141 {
142 let name = app.recorder.as_ref()
143 .and_then(|r| r.device_name())
144 .unwrap_or("unknown");
145 app.debug_log(format_args!("recording started device: {}", name));
146 }
147
148 app.state = RecordingState::Recording;
149 app.current_level = None;
150 app.render_display();
151}
152
153pub(crate) async fn handle_push_to_talk_stop(app: &mut VoiceApp) {
162 if app.state != RecordingState::Recording {
163 return;
164 }
165
166 let mut recorder = match app.recorder.take() {
168 Some(r) => r,
169 None => {
170 return_to_idle_or_approval(app);
172 return;
173 }
174 };
175
176 let duration = recorder.duration();
178
179 let samples = match recorder.stop() {
181 Ok(s) => s,
182 Err(e) => {
183 app.handle_error(&format!("Failed to stop recording: {}", e));
184 return;
185 }
186 };
187
188 app.debug_log(format_args!("recording stopped duration: {:.2}s samples: {}", duration, samples.len()));
189
190 if duration < MIN_RECORDING_SECS || samples.len() < MIN_SAMPLES {
192 app.display.log(&format!(
193 "[voice] Recording too short ({:.2}s, {} samples) — discarded.",
194 duration,
195 samples.len()
196 ));
197 return_to_idle_or_approval(app);
198 return;
199 }
200
201 app.state = RecordingState::Transcribing;
203 app.current_level = None;
204 app.render_display();
205
206 let wav = TempWav::new();
210 if let Err(e) = wav.write(&samples, &app.audio_config) {
211 app.handle_error(&format!("Failed to write WAV file: {}", e));
212 return;
213 }
214
215 let wav_path = wav.into_path();
217
218 let transcript = match &app.whisper {
220 None => {
221 let _ = std::fs::remove_file(&wav_path);
223 app.handle_error("Whisper model not loaded. Run 'opencode-voice setup'.");
224 return;
225 }
226 Some(_) => {
227 let path_for_task = wav_path.clone();
229
230 let engine_ptr = SendWhisperPtr(
237 app.whisper.as_ref().unwrap() as *const crate::transcribe::engine::WhisperEngine,
238 );
239
240 let result = tokio::task::spawn_blocking(move || {
241 let engine = unsafe { engine_ptr.as_ref() };
243 engine.transcribe(&path_for_task)
244 })
245 .await;
246
247 let _ = std::fs::remove_file(&wav_path);
249
250 match result {
251 Ok(Ok(r)) => r,
252 Ok(Err(e)) => {
253 app.handle_error(&format!("Transcription failed: {}", e));
254 return;
255 }
256 Err(e) => {
257 app.handle_error(&format!("Transcription task panicked: {}", e));
258 return;
259 }
260 }
261 }
262 };
263
264 let text = transcript.text.trim().to_string();
265
266 if app.config.debug {
267 if text.is_empty() {
268 app.debug_log(format_args!("transcript: (empty)"));
269 } else {
270 app.debug_log(format_args!("transcript: {}", text));
271 }
272 app.last_transcript = if text.is_empty() { None } else { Some(text) };
274 return_to_idle_or_approval(app);
275 return;
276 }
277
278 if text.is_empty() {
279 return_to_idle_or_approval(app);
281 return;
282 }
283
284 app.last_transcript = Some(text.clone());
286
287 if app.approval_queue.has_pending() {
289 let handled = try_handle_approval(app, &text).await;
290 if handled {
291 return_to_idle_or_approval(app);
292 return;
293 }
294 }
295
296 inject_text(app, &text).await;
298}
299
300async fn inject_text(app: &mut VoiceApp, text: &str) {
307 app.state = RecordingState::Injecting;
308 app.render_display();
309
310 if let Err(e) = app.bridge.append_prompt(text, None, None).await {
311 app.handle_error(&format!("Failed to inject text: {}", e));
312 return;
313 }
314
315 if app.config.auto_submit {
316 if let Err(e) = app.bridge.submit_prompt().await {
317 app.handle_error(&format!("Failed to submit prompt: {}", e));
318 return;
319 }
320 }
321
322 return_to_idle_or_approval(app);
323}
324
325pub(crate) fn return_to_idle_or_approval(app: &mut VoiceApp) {
328 if app.approval_queue.has_pending() {
329 app.state = RecordingState::ApprovalPending;
330 } else {
331 app.state = RecordingState::Idle;
332 }
333 app.current_level = None;
334 app.render_display();
335}
336
337pub(crate) async fn try_handle_approval(app: &mut VoiceApp, text: &str) -> bool {
358 use crate::approval::matcher::{match_permission_command, match_question_answer, MatchResult};
359 use crate::approval::types::PendingApproval;
360
361 let pending = match app.approval_queue.peek() {
364 Some(p) => p.clone(),
365 None => return false,
366 };
367
368 match &pending {
369 PendingApproval::Permission(_req) => {
370 let result = match_permission_command(text);
371 match result {
372 MatchResult::PermissionReply { reply, message } => {
373 let id = pending.id().to_string();
374 let msg_ref = message.as_deref();
375 if let Err(e) = app.bridge.reply_permission(&id, reply, msg_ref).await {
376 app.handle_error(&format!("Failed to reply to permission: {}", e));
377 }
378 app.approval_queue.remove(&id);
379 super::approval::refresh_approval_display(app);
380 true
381 }
382 MatchResult::NoMatch => false,
383 _ => false,
386 }
387 }
388
389 PendingApproval::Question(req) => {
390 let req_clone = req.clone();
393 let result = match_question_answer(text, &req_clone);
394 match result {
395 MatchResult::QuestionAnswer { answers } => {
396 let id = pending.id().to_string();
397 if let Err(e) = app.bridge.reply_question(&id, answers).await {
398 app.handle_error(&format!("Failed to reply to question: {}", e));
399 }
400 app.approval_queue.remove(&id);
401 super::approval::refresh_approval_display(app);
402 true
403 }
404 MatchResult::QuestionReject => {
405 let id = pending.id().to_string();
406 if let Err(e) = app.bridge.reject_question(&id).await {
407 app.handle_error(&format!("Failed to reject question: {}", e));
408 }
409 app.approval_queue.remove(&id);
410 super::approval::refresh_approval_display(app);
411 true
412 }
413 MatchResult::NoMatch => false,
414 _ => false,
416 }
417 }
418 }
419}
420
421#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::app::VoiceApp;
427 use crate::config::{AppConfig, ModelSize};
428 use std::path::PathBuf;
429
430 fn test_config() -> AppConfig {
431 AppConfig {
432 whisper_model_path: PathBuf::from("/nonexistent/model.bin"),
433 opencode_port: 4096,
434 toggle_key: ' ',
435 model_size: ModelSize::TinyEn,
436 auto_submit: true,
437 server_password: None,
438 data_dir: PathBuf::from("/nonexistent/data"),
439 audio_device: None,
440 use_global_hotkey: false,
441 global_hotkey: "right_option".to_string(),
442 push_to_talk: false,
443 handle_prompts: false,
444 debug: false,
445 }
446 }
447
448 #[tokio::test]
451 async fn test_handle_toggle_idle_to_recording() {
452 let mut app = VoiceApp::new(test_config()).unwrap();
453 assert_eq!(app.state, RecordingState::Idle);
454 handle_toggle(&mut app).await;
455 assert_eq!(app.state, RecordingState::Recording);
456 }
457
458 #[tokio::test]
459 async fn test_handle_toggle_recording_to_transcribing() {
460 let mut app = VoiceApp::new(test_config()).unwrap();
461 app.state = RecordingState::Recording;
462 handle_toggle(&mut app).await;
463 assert_eq!(app.state, RecordingState::Transcribing);
464 }
465
466 #[tokio::test]
467 async fn test_handle_toggle_approval_pending_to_recording() {
468 let mut app = VoiceApp::new(test_config()).unwrap();
469 app.state = RecordingState::ApprovalPending;
470 handle_toggle(&mut app).await;
471 assert_eq!(app.state, RecordingState::Recording);
472 }
473
474 #[tokio::test]
475 async fn test_handle_toggle_ignores_transcribing_state() {
476 let mut app = VoiceApp::new(test_config()).unwrap();
477 app.state = RecordingState::Transcribing;
478 handle_toggle(&mut app).await;
479 assert_eq!(app.state, RecordingState::Transcribing);
480 }
481
482 #[tokio::test]
485 async fn test_handle_push_to_talk_start_ignores_transcribing() {
486 let mut app = VoiceApp::new(test_config()).unwrap();
487 app.state = RecordingState::Transcribing;
488 handle_push_to_talk_start(&mut app).await;
489 assert_eq!(app.state, RecordingState::Transcribing);
491 }
492
493 #[tokio::test]
494 async fn test_handle_push_to_talk_start_ignores_recording() {
495 let mut app = VoiceApp::new(test_config()).unwrap();
496 app.state = RecordingState::Recording;
497 handle_push_to_talk_start(&mut app).await;
498 assert_eq!(app.state, RecordingState::Recording);
500 }
501
502 #[tokio::test]
503 async fn test_handle_push_to_talk_stop_ignores_idle() {
504 let mut app = VoiceApp::new(test_config()).unwrap();
505 handle_push_to_talk_stop(&mut app).await;
507 assert_eq!(app.state, RecordingState::Idle);
508 }
509
510 #[tokio::test]
511 async fn test_handle_push_to_talk_stop_no_recorder_returns_to_idle() {
512 let mut app = VoiceApp::new(test_config()).unwrap();
513 app.state = RecordingState::Recording;
515 handle_push_to_talk_stop(&mut app).await;
516 assert_eq!(app.state, RecordingState::Idle);
518 }
519
520 #[test]
523 fn test_return_to_idle_when_no_pending() {
524 let mut app = VoiceApp::new(test_config()).unwrap();
525 app.state = RecordingState::Injecting;
526 return_to_idle_or_approval(&mut app);
527 assert_eq!(app.state, RecordingState::Idle);
528 }
529
530 #[test]
531 fn test_return_to_approval_pending_when_queue_has_items() {
532 use crate::approval::types::PermissionRequest;
533
534 let mut app = VoiceApp::new(test_config()).unwrap();
535 app.state = RecordingState::Injecting;
536
537 app.approval_queue.add_permission(PermissionRequest {
539 id: "p1".to_string(),
540 permission: "bash".to_string(),
541 metadata: serde_json::Value::Null,
542 });
543
544 return_to_idle_or_approval(&mut app);
545 assert_eq!(app.state, RecordingState::ApprovalPending);
546 }
547
548 #[tokio::test]
552 async fn test_try_handle_approval_empty_queue_returns_false() {
553 let mut app = VoiceApp::new(test_config()).unwrap();
554 let result = try_handle_approval(&mut app, "yes").await;
556 assert!(!result, "empty queue should return false");
557 }
558
559 #[tokio::test]
561 async fn test_try_handle_approval_permission_no_match_returns_false() {
562 use crate::approval::types::PermissionRequest;
563
564 let mut app = VoiceApp::new(test_config()).unwrap();
565 app.approval_queue.add_permission(PermissionRequest {
566 id: "p1".to_string(),
567 permission: "bash".to_string(),
568 metadata: serde_json::Value::Null,
569 });
570
571 let result = try_handle_approval(&mut app, "hello world").await;
573 assert!(!result, "unrecognised text should return false");
574 assert!(app.approval_queue.has_pending());
576 }
577
578 #[tokio::test]
580 async fn test_try_handle_approval_question_no_match_returns_false() {
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, }],
598 });
599
600 let result = try_handle_approval(&mut app, "gamma").await;
602 assert!(!result, "unrecognised question answer should return false");
603 assert!(app.approval_queue.has_pending());
604 }
605
606 #[tokio::test]
610 async fn test_try_handle_approval_permission_match_removes_item_and_returns_true() {
611 use crate::approval::types::PermissionRequest;
612
613 let mut app = VoiceApp::new(test_config()).unwrap();
614 app.approval_queue.add_permission(PermissionRequest {
615 id: "p1".to_string(),
616 permission: "bash".to_string(),
617 metadata: serde_json::Value::Null,
618 });
619 app.state = RecordingState::ApprovalPending;
620
621 let result = try_handle_approval(&mut app, "yes").await;
624 assert!(result, "matched permission should return true");
625 assert!(
626 !app.approval_queue.has_pending(),
627 "item should be removed from queue after match"
628 );
629 }
630
631 #[tokio::test]
634 async fn test_try_handle_approval_question_match_removes_item_and_returns_true() {
635 use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
636
637 let mut app = VoiceApp::new(test_config()).unwrap();
638 app.approval_queue.add_question(QuestionRequest {
639 id: "q1".to_string(),
640 questions: vec![QuestionInfo {
641 question: "Pick one".to_string(),
642 options: vec![
643 QuestionOption {
644 label: "Alpha".to_string(),
645 },
646 QuestionOption {
647 label: "Beta".to_string(),
648 },
649 ],
650 custom: false,
651 }],
652 });
653 app.state = RecordingState::ApprovalPending;
654
655 let result = try_handle_approval(&mut app, "alpha").await;
657 assert!(result, "matched question answer should return true");
658 assert!(
659 !app.approval_queue.has_pending(),
660 "item should be removed from queue after match"
661 );
662 }
663
664 #[tokio::test]
667 async fn test_try_handle_approval_question_reject_removes_item_and_returns_true() {
668 use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
669
670 let mut app = VoiceApp::new(test_config()).unwrap();
671 app.approval_queue.add_question(QuestionRequest {
672 id: "q2".to_string(),
673 questions: vec![QuestionInfo {
674 question: "Pick one".to_string(),
675 options: vec![QuestionOption {
676 label: "Yes".to_string(),
677 }],
678 custom: false,
679 }],
680 });
681 app.state = RecordingState::ApprovalPending;
682
683 let result = try_handle_approval(&mut app, "skip").await;
685 assert!(result, "question rejection should return true");
686 assert!(
687 !app.approval_queue.has_pending(),
688 "item should be removed from queue after rejection"
689 );
690 }
691
692}