use crate::audio::capture::CpalRecorder;
use crate::audio::wav::TempWav;
use crate::state::{AppEvent, RecordingState};
use super::VoiceApp;
struct SendWhisperPtr(*const crate::transcribe::engine::WhisperEngine);
unsafe impl Send for SendWhisperPtr {}
impl SendWhisperPtr {
unsafe fn as_ref(&self) -> &crate::transcribe::engine::WhisperEngine {
&*self.0
}
}
const MIN_RECORDING_SECS: f64 = 0.5;
const MIN_SAMPLES: usize = 8_000;
pub(crate) async fn handle_toggle(app: &mut VoiceApp) {
match app.state {
RecordingState::Idle | RecordingState::ApprovalPending => {
app.state = RecordingState::Recording;
app.current_level = None;
app.render_display();
}
RecordingState::Recording => {
app.state = RecordingState::Transcribing;
app.current_level = None;
app.render_display();
}
_ => {
}
}
}
pub(crate) async fn handle_push_to_talk_start(app: &mut VoiceApp) {
if app.state != RecordingState::Idle && app.state != RecordingState::ApprovalPending {
return;
}
let device = app.audio_config.device.as_deref();
let mut recorder = match CpalRecorder::new(device) {
Ok(r) => r,
Err(e) => {
app.handle_error(&format!("Failed to open audio device: {}", e));
return;
}
};
let energy_rx = match recorder.start() {
Ok(rx) => rx,
Err(e) => {
app.handle_error(&format!("Failed to start recording: {}", e));
return;
}
};
let event_tx = app.event_tx.clone();
let mut energy_rx = energy_rx;
tokio::spawn(async move {
while let Some(rms_energy) = energy_rx.recv().await {
if event_tx
.send(AppEvent::AudioChunk { rms_energy })
.is_err()
{
break; }
}
});
app.recorder = Some(recorder);
{
let name = app.recorder.as_ref()
.and_then(|r| r.device_name())
.unwrap_or("unknown");
app.debug_log(format_args!("recording started device: {}", name));
}
app.state = RecordingState::Recording;
app.current_level = None;
app.render_display();
}
pub(crate) async fn handle_push_to_talk_stop(app: &mut VoiceApp) {
if app.state != RecordingState::Recording {
return;
}
let mut recorder = match app.recorder.take() {
Some(r) => r,
None => {
return_to_idle_or_approval(app);
return;
}
};
let duration = recorder.duration();
let samples = match recorder.stop() {
Ok(s) => s,
Err(e) => {
app.handle_error(&format!("Failed to stop recording: {}", e));
return;
}
};
app.debug_log(format_args!("recording stopped duration: {:.2}s samples: {}", duration, samples.len()));
if duration < MIN_RECORDING_SECS || samples.len() < MIN_SAMPLES {
app.display.log(&format!(
"[voice] Recording too short ({:.2}s, {} samples) — discarded.",
duration,
samples.len()
));
return_to_idle_or_approval(app);
return;
}
app.state = RecordingState::Transcribing;
app.current_level = None;
app.render_display();
let wav = TempWav::new();
if let Err(e) = wav.write(&samples, &app.audio_config) {
app.handle_error(&format!("Failed to write WAV file: {}", e));
return;
}
let wav_path = wav.into_path();
let transcript = match &app.whisper {
None => {
let _ = std::fs::remove_file(&wav_path);
app.handle_error("Whisper model not loaded. Run 'opencode-voice setup'.");
return;
}
Some(_) => {
let path_for_task = wav_path.clone();
let engine_ptr = SendWhisperPtr(
app.whisper.as_ref().unwrap() as *const crate::transcribe::engine::WhisperEngine,
);
let result = tokio::task::spawn_blocking(move || {
let engine = unsafe { engine_ptr.as_ref() };
engine.transcribe(&path_for_task)
})
.await;
let _ = std::fs::remove_file(&wav_path);
match result {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
app.handle_error(&format!("Transcription failed: {}", e));
return;
}
Err(e) => {
app.handle_error(&format!("Transcription task panicked: {}", e));
return;
}
}
}
};
let text = transcript.text.trim().to_string();
if app.config.debug {
if text.is_empty() {
app.debug_log(format_args!("transcript: (empty)"));
} else {
app.debug_log(format_args!("transcript: {}", text));
}
app.last_transcript = if text.is_empty() { None } else { Some(text) };
return_to_idle_or_approval(app);
return;
}
if text.is_empty() {
return_to_idle_or_approval(app);
return;
}
app.last_transcript = Some(text.clone());
if app.approval_queue.has_pending() {
let handled = try_handle_approval(app, &text).await;
if handled {
return_to_idle_or_approval(app);
return;
}
}
inject_text(app, &text).await;
}
async fn inject_text(app: &mut VoiceApp, text: &str) {
app.state = RecordingState::Injecting;
app.render_display();
if let Err(e) = app.bridge.append_prompt(text, None, None).await {
app.handle_error(&format!("Failed to inject text: {}", e));
return;
}
if app.config.auto_submit {
if let Err(e) = app.bridge.submit_prompt().await {
app.handle_error(&format!("Failed to submit prompt: {}", e));
return;
}
}
return_to_idle_or_approval(app);
}
pub(crate) fn return_to_idle_or_approval(app: &mut VoiceApp) {
if app.approval_queue.has_pending() {
app.state = RecordingState::ApprovalPending;
} else {
app.state = RecordingState::Idle;
}
app.current_level = None;
app.render_display();
}
pub(crate) async fn try_handle_approval(app: &mut VoiceApp, text: &str) -> bool {
use crate::approval::matcher::{match_permission_command, match_question_answer, MatchResult};
use crate::approval::types::PendingApproval;
let pending = match app.approval_queue.peek() {
Some(p) => p.clone(),
None => return false,
};
match &pending {
PendingApproval::Permission(_req) => {
let result = match_permission_command(text);
match result {
MatchResult::PermissionReply { reply, message } => {
let id = pending.id().to_string();
let msg_ref = message.as_deref();
if let Err(e) = app.bridge.reply_permission(&id, reply, msg_ref).await {
app.handle_error(&format!("Failed to reply to permission: {}", e));
}
app.approval_queue.remove(&id);
super::approval::refresh_approval_display(app);
true
}
MatchResult::NoMatch => false,
_ => false,
}
}
PendingApproval::Question(req) => {
let req_clone = req.clone();
let result = match_question_answer(text, &req_clone);
match result {
MatchResult::QuestionAnswer { answers } => {
let id = pending.id().to_string();
if let Err(e) = app.bridge.reply_question(&id, answers).await {
app.handle_error(&format!("Failed to reply to question: {}", e));
}
app.approval_queue.remove(&id);
super::approval::refresh_approval_display(app);
true
}
MatchResult::QuestionReject => {
let id = pending.id().to_string();
if let Err(e) = app.bridge.reject_question(&id).await {
app.handle_error(&format!("Failed to reject question: {}", e));
}
app.approval_queue.remove(&id);
super::approval::refresh_approval_display(app);
true
}
MatchResult::NoMatch => false,
_ => false,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::VoiceApp;
use crate::config::{AppConfig, ModelSize};
use std::path::PathBuf;
fn test_config() -> AppConfig {
AppConfig {
whisper_model_path: PathBuf::from("/nonexistent/model.bin"),
opencode_port: 4096,
toggle_key: ' ',
model_size: ModelSize::TinyEn,
auto_submit: true,
server_password: None,
data_dir: PathBuf::from("/nonexistent/data"),
audio_device: None,
use_global_hotkey: false,
global_hotkey: "right_option".to_string(),
push_to_talk: false,
handle_prompts: false,
debug: false,
}
}
#[tokio::test]
async fn test_handle_toggle_idle_to_recording() {
let mut app = VoiceApp::new(test_config()).unwrap();
assert_eq!(app.state, RecordingState::Idle);
handle_toggle(&mut app).await;
assert_eq!(app.state, RecordingState::Recording);
}
#[tokio::test]
async fn test_handle_toggle_recording_to_transcribing() {
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::Recording;
handle_toggle(&mut app).await;
assert_eq!(app.state, RecordingState::Transcribing);
}
#[tokio::test]
async fn test_handle_toggle_approval_pending_to_recording() {
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::ApprovalPending;
handle_toggle(&mut app).await;
assert_eq!(app.state, RecordingState::Recording);
}
#[tokio::test]
async fn test_handle_toggle_ignores_transcribing_state() {
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::Transcribing;
handle_toggle(&mut app).await;
assert_eq!(app.state, RecordingState::Transcribing);
}
#[tokio::test]
async fn test_handle_push_to_talk_start_ignores_transcribing() {
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::Transcribing;
handle_push_to_talk_start(&mut app).await;
assert_eq!(app.state, RecordingState::Transcribing);
}
#[tokio::test]
async fn test_handle_push_to_talk_start_ignores_recording() {
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::Recording;
handle_push_to_talk_start(&mut app).await;
assert_eq!(app.state, RecordingState::Recording);
}
#[tokio::test]
async fn test_handle_push_to_talk_stop_ignores_idle() {
let mut app = VoiceApp::new(test_config()).unwrap();
handle_push_to_talk_stop(&mut app).await;
assert_eq!(app.state, RecordingState::Idle);
}
#[tokio::test]
async fn test_handle_push_to_talk_stop_no_recorder_returns_to_idle() {
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::Recording;
handle_push_to_talk_stop(&mut app).await;
assert_eq!(app.state, RecordingState::Idle);
}
#[test]
fn test_return_to_idle_when_no_pending() {
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::Injecting;
return_to_idle_or_approval(&mut app);
assert_eq!(app.state, RecordingState::Idle);
}
#[test]
fn test_return_to_approval_pending_when_queue_has_items() {
use crate::approval::types::PermissionRequest;
let mut app = VoiceApp::new(test_config()).unwrap();
app.state = RecordingState::Injecting;
app.approval_queue.add_permission(PermissionRequest {
id: "p1".to_string(),
permission: "bash".to_string(),
metadata: serde_json::Value::Null,
});
return_to_idle_or_approval(&mut app);
assert_eq!(app.state, RecordingState::ApprovalPending);
}
#[tokio::test]
async fn test_try_handle_approval_empty_queue_returns_false() {
let mut app = VoiceApp::new(test_config()).unwrap();
let result = try_handle_approval(&mut app, "yes").await;
assert!(!result, "empty queue should return false");
}
#[tokio::test]
async fn test_try_handle_approval_permission_no_match_returns_false() {
use crate::approval::types::PermissionRequest;
let mut app = VoiceApp::new(test_config()).unwrap();
app.approval_queue.add_permission(PermissionRequest {
id: "p1".to_string(),
permission: "bash".to_string(),
metadata: serde_json::Value::Null,
});
let result = try_handle_approval(&mut app, "hello world").await;
assert!(!result, "unrecognised text should return false");
assert!(app.approval_queue.has_pending());
}
#[tokio::test]
async fn test_try_handle_approval_question_no_match_returns_false() {
use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
let mut app = VoiceApp::new(test_config()).unwrap();
app.approval_queue.add_question(QuestionRequest {
id: "q1".to_string(),
questions: vec![QuestionInfo {
question: "Pick one".to_string(),
options: vec![
QuestionOption {
label: "Alpha".to_string(),
},
QuestionOption {
label: "Beta".to_string(),
},
],
custom: false, }],
});
let result = try_handle_approval(&mut app, "gamma").await;
assert!(!result, "unrecognised question answer should return false");
assert!(app.approval_queue.has_pending());
}
#[tokio::test]
async fn test_try_handle_approval_permission_match_removes_item_and_returns_true() {
use crate::approval::types::PermissionRequest;
let mut app = VoiceApp::new(test_config()).unwrap();
app.approval_queue.add_permission(PermissionRequest {
id: "p1".to_string(),
permission: "bash".to_string(),
metadata: serde_json::Value::Null,
});
app.state = RecordingState::ApprovalPending;
let result = try_handle_approval(&mut app, "yes").await;
assert!(result, "matched permission should return true");
assert!(
!app.approval_queue.has_pending(),
"item should be removed from queue after match"
);
}
#[tokio::test]
async fn test_try_handle_approval_question_match_removes_item_and_returns_true() {
use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
let mut app = VoiceApp::new(test_config()).unwrap();
app.approval_queue.add_question(QuestionRequest {
id: "q1".to_string(),
questions: vec![QuestionInfo {
question: "Pick one".to_string(),
options: vec![
QuestionOption {
label: "Alpha".to_string(),
},
QuestionOption {
label: "Beta".to_string(),
},
],
custom: false,
}],
});
app.state = RecordingState::ApprovalPending;
let result = try_handle_approval(&mut app, "alpha").await;
assert!(result, "matched question answer should return true");
assert!(
!app.approval_queue.has_pending(),
"item should be removed from queue after match"
);
}
#[tokio::test]
async fn test_try_handle_approval_question_reject_removes_item_and_returns_true() {
use crate::approval::types::{QuestionInfo, QuestionOption, QuestionRequest};
let mut app = VoiceApp::new(test_config()).unwrap();
app.approval_queue.add_question(QuestionRequest {
id: "q2".to_string(),
questions: vec![QuestionInfo {
question: "Pick one".to_string(),
options: vec![QuestionOption {
label: "Yes".to_string(),
}],
custom: false,
}],
});
app.state = RecordingState::ApprovalPending;
let result = try_handle_approval(&mut app, "skip").await;
assert!(result, "question rejection should return true");
assert!(
!app.approval_queue.has_pending(),
"item should be removed from queue after rejection"
);
}
}