diff --git a/crates/genie-core/src/lib.rs b/crates/genie-core/src/lib.rs index 2192cca2..f97d25d5 100644 --- a/crates/genie-core/src/lib.rs +++ b/crates/genie-core/src/lib.rs @@ -52,6 +52,7 @@ pub mod prompt; pub mod reasoning; pub mod repl; pub mod runtime_contract; +pub mod runtime_mode; pub mod security; pub mod server; pub mod skills; diff --git a/crates/genie-core/src/main.rs b/crates/genie-core/src/main.rs index 99bd1c6d..69aef1b8 100644 --- a/crates/genie-core/src/main.rs +++ b/crates/genie-core/src/main.rs @@ -170,13 +170,25 @@ async fn main() -> Result<()> { let voice_requested = std::env::args().any(|a| a == "--voice") || std::env::var("GENIEPOD_VOICE").unwrap_or_default() == "1" || config.core.voice_enabled; + let wakeword_available = + !config.core.wakeword_script.as_os_str().is_empty() && config.core.wakeword_script.exists(); + let startup_decision = + runtime_mode::decide_startup_mode(voice_requested, interactive, wakeword_available); // Whether the running binary actually has the voice subsystem compiled in. // When voice was requested but the binary is chat-only (issue #41 feature // gate), emit one warning and fall through to the chat path so an existing // voice-tagged `geniepod.toml` still deploys cleanly on a chat-only build. #[cfg(feature = "voice")] - let voice_mode = voice_requested; + let voice_mode = { + if startup_decision.blocked_push_to_talk() { + tracing::warn!( + "push-to-talk voice mode requested without an interactive terminal and no wakeword script is available; \ + running HTTP API only" + ); + } + startup_decision.enters_voice() + }; #[cfg(not(feature = "voice"))] let voice_mode = { if voice_requested { @@ -187,6 +199,7 @@ async fn main() -> Result<()> { enable the voice loop." ); } + let _ = startup_decision; false }; diff --git a/crates/genie-core/src/runtime_mode.rs b/crates/genie-core/src/runtime_mode.rs new file mode 100644 index 00000000..8db2f27a --- /dev/null +++ b/crates/genie-core/src/runtime_mode.rs @@ -0,0 +1,108 @@ +//! Startup-mode selection for genie-core. + +/// High-level interface selected at process startup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartupMode { + /// Run the voice loop. + Voice, + /// Run the HTTP API / daemon path. + HttpOnly, +} + +/// Why startup selected a mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartupReason { + VoiceNotRequested, + InteractivePushToTalk, + WakewordDaemon, + PushToTalkNeedsTerminal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StartupDecision { + pub mode: StartupMode, + pub reason: StartupReason, +} + +impl StartupDecision { + pub fn enters_voice(self) -> bool { + self.mode == StartupMode::Voice + } + + pub fn blocked_push_to_talk(self) -> bool { + self.reason == StartupReason::PushToTalkNeedsTerminal + } +} + +/// Decide whether startup may enter voice mode. +/// +/// Push-to-talk consumes stdin, so it is only valid with an interactive +/// terminal. Wake-word mode owns its own listener process and can run as a +/// daemon under systemd. +pub fn decide_startup_mode( + voice_requested: bool, + stdin_interactive: bool, + wakeword_available: bool, +) -> StartupDecision { + if !voice_requested { + return StartupDecision { + mode: StartupMode::HttpOnly, + reason: StartupReason::VoiceNotRequested, + }; + } + + if wakeword_available { + return StartupDecision { + mode: StartupMode::Voice, + reason: StartupReason::WakewordDaemon, + }; + } + + if stdin_interactive { + return StartupDecision { + mode: StartupMode::Voice, + reason: StartupReason::InteractivePushToTalk, + }; + } + + StartupDecision { + mode: StartupMode::HttpOnly, + reason: StartupReason::PushToTalkNeedsTerminal, + } +} + +#[cfg(test)] +mod tests { + use super::{StartupMode, StartupReason, decide_startup_mode}; + + #[test] + fn systemd_push_to_talk_falls_back_to_http() { + let decision = decide_startup_mode(true, false, false); + assert_eq!(decision.mode, StartupMode::HttpOnly); + assert_eq!(decision.reason, StartupReason::PushToTalkNeedsTerminal); + assert!(decision.blocked_push_to_talk()); + } + + #[test] + fn terminal_push_to_talk_still_runs() { + let decision = decide_startup_mode(true, true, false); + assert_eq!(decision.mode, StartupMode::Voice); + assert_eq!(decision.reason, StartupReason::InteractivePushToTalk); + assert!(decision.enters_voice()); + } + + #[test] + fn wakeword_voice_can_run_without_terminal() { + let decision = decide_startup_mode(true, false, true); + assert_eq!(decision.mode, StartupMode::Voice); + assert_eq!(decision.reason, StartupReason::WakewordDaemon); + assert!(decision.enters_voice()); + } + + #[test] + fn voice_disabled_stays_http_only() { + let decision = decide_startup_mode(false, true, true); + assert_eq!(decision.mode, StartupMode::HttpOnly); + assert_eq!(decision.reason, StartupReason::VoiceNotRequested); + } +}