Skip to main content

omni_dev/cli/
voice.rs

1//! Voice-related CLI commands.
2//!
3//! Provider-namespaced (`capture`, `transcribe` today; later: `listen`,
4//! `review`). Per-subcommand argument structs live in submodules to keep
5//! help text and parse logic local to each command.
6
7pub mod capture;
8pub mod enroll;
9pub mod install_model;
10pub mod reflect;
11pub mod review;
12pub mod transcribe;
13
14use anyhow::Result;
15use clap::{Parser, Subcommand};
16
17/// Voice capture and processing operations.
18#[derive(Parser)]
19pub struct VoiceCommand {
20    /// The voice subcommand to execute.
21    #[command(subcommand)]
22    pub command: VoiceSubcommands,
23}
24
25/// Voice subcommands.
26#[derive(Subcommand)]
27pub enum VoiceSubcommands {
28    /// Captures audio from a microphone to a 16 kHz mono WAV file.
29    Capture(capture::CaptureCommand),
30    /// Transcribes a 16 kHz mono WAV file to JSONL or markdown.
31    Transcribe(transcribe::TranscribeCommand),
32    /// Reflects on a transcript and emits reflection events.
33    Reflect(reflect::ReflectCommand),
34    /// Reconciles a session's events.jsonl into materialized markdown.
35    Review(review::ReviewCommand),
36    /// Downloads the model files for a chosen variant (Whisper tiny.en
37    /// for the `whisper-candle` backend, or wespeaker for speaker
38    /// embedding) into `~/.omni-dev/voice/models/<variant>/`.
39    InstallModel(install_model::InstallModelCommand),
40    /// Captures a microphone sample and persists a speaker embedding to
41    /// `~/.omni-dev/voice/speakers/<name>.json`.
42    Enroll(enroll::EnrollCommand),
43}
44
45impl VoiceCommand {
46    /// Dispatches to the selected voice subcommand.
47    ///
48    /// Async because `reflect` invokes Claude via an async
49    /// [`crate::claude::ai::AiClient`]. Sync arms just `.await` an
50    /// immediately-ready value via `async {…}`.
51    pub async fn execute(self) -> Result<()> {
52        match self.command {
53            VoiceSubcommands::Capture(cmd) => cmd.execute(),
54            VoiceSubcommands::Transcribe(cmd) => cmd.execute(),
55            VoiceSubcommands::Reflect(cmd) => cmd.execute().await,
56            VoiceSubcommands::Review(cmd) => cmd.execute(),
57            VoiceSubcommands::InstallModel(cmd) => cmd.execute(),
58            VoiceSubcommands::Enroll(cmd) => cmd.execute(),
59        }
60    }
61}
62
63#[cfg(test)]
64#[allow(clippy::unwrap_used, clippy::expect_used)]
65mod tests {
66    use super::*;
67
68    use std::path::PathBuf;
69
70    #[test]
71    fn voice_subcommands_capture_variant() {
72        let cmd = VoiceCommand {
73            command: VoiceSubcommands::Capture(capture::CaptureCommand {
74                idle_after: 5,
75                output: None,
76                device: None,
77            }),
78        };
79        assert!(matches!(cmd.command, VoiceSubcommands::Capture(_)));
80    }
81
82    #[test]
83    fn voice_subcommands_transcribe_variant() {
84        let cmd = VoiceCommand {
85            command: VoiceSubcommands::Transcribe(transcribe::TranscribeCommand {
86                wav: PathBuf::from("/tmp/x.wav"),
87                backend: None,
88                model: None,
89                format: None,
90                speaker: None,
91                threshold: None,
92                speaker_model: None,
93            }),
94        };
95        assert!(matches!(cmd.command, VoiceSubcommands::Transcribe(_)));
96    }
97
98    #[test]
99    fn voice_subcommands_reflect_variant() {
100        let cmd = VoiceCommand {
101            command: VoiceSubcommands::Reflect(reflect::ReflectCommand {
102                transcript: Some(PathBuf::from("/tmp/t.jsonl")),
103                session: None,
104            }),
105        };
106        assert!(matches!(cmd.command, VoiceSubcommands::Reflect(_)));
107    }
108
109    #[test]
110    fn voice_subcommands_install_model_variant() {
111        let cmd = VoiceCommand {
112            command: VoiceSubcommands::InstallModel(install_model::InstallModelCommand {
113                dest: None,
114                force: false,
115                variant: install_model::Variant::WhisperTinyEn,
116            }),
117        };
118        assert!(matches!(cmd.command, VoiceSubcommands::InstallModel(_)));
119    }
120
121    #[tokio::test]
122    async fn voice_command_dispatches_install_model_via_execute() {
123        // Drives VoiceCommand::execute through the InstallModel arm
124        // end-to-end: covers the async dispatch in cli/voice.rs and the
125        // stderr-locking wrapper in install_model::execute. Uses a
126        // pre-staged tempdir so the idempotent early-return path keeps
127        // the test off the network.
128        use crate::voice::models::REQUIRED_FILES;
129
130        let tmp = tempfile::TempDir::new().unwrap();
131        for f in REQUIRED_FILES {
132            std::fs::write(tmp.path().join(f), b"placeholder").unwrap();
133        }
134
135        let cmd = VoiceCommand {
136            command: VoiceSubcommands::InstallModel(install_model::InstallModelCommand {
137                dest: Some(tmp.path().to_path_buf()),
138                force: false,
139                variant: install_model::Variant::WhisperTinyEn,
140            }),
141        };
142        cmd.execute()
143            .await
144            .expect("install-model dispatch should succeed on pre-staged dir");
145    }
146}