Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/genie-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion crates/genie-core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -187,6 +199,7 @@ async fn main() -> Result<()> {
enable the voice loop."
);
}
let _ = startup_decision;
false
};

Expand Down
108 changes: 108 additions & 0 deletions crates/genie-core/src/runtime_mode.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading