use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::personality::{ResolvedTtsVoice, VoicePersonality};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VoiceConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub mode: VoiceMode,
#[serde(default)]
pub xai: VoiceXaiConfig,
#[serde(default)]
pub audio: AudioConfig,
#[serde(default, alias = "audioRetention")]
pub audio_retention: AudioRetention,
#[serde(default)]
pub stt: SttConfig,
#[serde(default)]
pub tts: TtsConfig,
#[serde(default, alias = "micNodeId", skip_serializing_if = "Option::is_none")]
pub mic_node_id: Option<String>,
#[serde(default)]
pub vad: VadConfig,
#[serde(default)]
pub wake: WakeConfig,
#[serde(default, alias = "cloudFallback")]
pub cloud_fallback: CloudFallbackConfig,
#[serde(default, alias = "personalities")]
pub personalities: HashMap<String, VoicePersonality>,
#[serde(default)]
pub consumer: VoiceConsumerConfig,
#[serde(default)]
pub confirmation: VoiceConfirmationConfig,
#[serde(default, alias = "rateLimit")]
pub rate_limit: VoiceRateLimitConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VoiceMode {
Local,
#[default]
XaiS2s,
XaiHybrid,
}
impl VoiceMode {
pub fn prefers_xai(self) -> bool {
matches!(self, Self::XaiS2s | Self::XaiHybrid)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceXaiConfig {
#[serde(default = "default_xai_api_key_env", alias = "apiKeyEnv")]
pub api_key_env: String,
#[serde(default = "default_xai_realtime_model", alias = "realtimeModel")]
pub realtime_model: String,
#[serde(default = "default_xai_endpoint")]
pub endpoint: String,
}
pub const DEFAULT_XAI_API_KEY_ENV: &str = "XAI_API_KEY";
pub const DEFAULT_XAI_REALTIME_MODEL: &str = "grok-voice-latest";
pub const DEFAULT_XAI_REALTIME_ENDPOINT: &str = "wss://api.x.ai/v1/realtime";
fn default_xai_api_key_env() -> String {
DEFAULT_XAI_API_KEY_ENV.into()
}
fn default_xai_realtime_model() -> String {
DEFAULT_XAI_REALTIME_MODEL.into()
}
fn default_xai_endpoint() -> String {
DEFAULT_XAI_REALTIME_ENDPOINT.into()
}
impl Default for VoiceXaiConfig {
fn default() -> Self {
Self {
api_key_env: default_xai_api_key_env(),
realtime_model: default_xai_realtime_model(),
endpoint: default_xai_endpoint(),
}
}
}
impl VoiceXaiConfig {
pub fn api_key_env_name(&self) -> &str {
if self.api_key_env.trim().is_empty() {
DEFAULT_XAI_API_KEY_ENV
} else {
self.api_key_env.as_str()
}
}
pub fn realtime_url(&self) -> String {
let base = if self.endpoint.trim().is_empty() {
DEFAULT_XAI_REALTIME_ENDPOINT
} else {
self.endpoint.trim()
};
let model = if self.realtime_model.trim().is_empty() {
DEFAULT_XAI_REALTIME_MODEL
} else {
self.realtime_model.trim()
};
if base.contains('?') {
if base.contains("model=") {
base.to_string()
} else {
format!("{base}&model={model}")
}
} else {
format!("{base}?model={model}")
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedVoiceTransport {
Local,
XaiS2s,
XaiHybrid,
}
impl ResolvedVoiceTransport {
pub fn is_xai(self) -> bool {
matches!(self, Self::XaiS2s | Self::XaiHybrid)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceFallbackReason {
NoApiKey,
HealthFailed,
RuntimeFailure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HybridVoiceLeg {
Local,
Xai,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HybridVoiceComposition {
pub stt: HybridVoiceLeg,
pub agent: HybridVoiceLeg,
pub tts: HybridVoiceLeg,
}
impl HybridVoiceComposition {
pub const LOCAL_ALL: Self = Self {
stt: HybridVoiceLeg::Local,
agent: HybridVoiceLeg::Local,
tts: HybridVoiceLeg::Local,
};
pub const HYBRID_DEFAULT: Self = Self {
stt: HybridVoiceLeg::Local,
agent: HybridVoiceLeg::Local,
tts: HybridVoiceLeg::Xai,
};
pub fn uses_xai(self) -> bool {
matches!(self.stt, HybridVoiceLeg::Xai)
|| matches!(self.agent, HybridVoiceLeg::Xai)
|| matches!(self.tts, HybridVoiceLeg::Xai)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VoiceTransportDecision {
pub transport: ResolvedVoiceTransport,
pub notice: Option<&'static str>,
pub fallback_reason: Option<VoiceFallbackReason>,
pub hybrid: HybridVoiceComposition,
}
impl VoiceTransportDecision {
pub fn is_fallback(&self) -> bool {
self.fallback_reason.is_some()
}
pub fn local_ok() -> Self {
Self {
transport: ResolvedVoiceTransport::Local,
notice: None,
fallback_reason: None,
hybrid: HybridVoiceComposition::LOCAL_ALL,
}
}
}
pub const VOICE_NOTICE_XAI_NO_KEY: &str =
"xAI voice requested but API key not found; using local voice pipeline.";
pub const VOICE_NOTICE_XAI_UNHEALTHY: &str =
"xAI voice requested but endpoint health check failed; using local voice pipeline.";
pub const VOICE_NOTICE_XAI_RUNTIME_FALLBACK: &str =
"xAI voice session failed; falling back to local voice pipeline.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XaiHealthProbeResult {
pub healthy: bool,
pub detail: &'static str,
}
impl XaiHealthProbeResult {
pub fn healthy_stub() -> Self {
Self {
healthy: true,
detail: "stub: assumed healthy",
}
}
pub fn unhealthy_stub() -> Self {
Self {
healthy: false,
detail: "stub: injected unhealthy",
}
}
pub fn from_flag(healthy: bool) -> Self {
if healthy {
Self::healthy_stub()
} else {
Self::unhealthy_stub()
}
}
}
pub fn resolve_voice_transport(
mode: VoiceMode,
api_key_present: bool,
xai_healthy: bool,
) -> VoiceTransportDecision {
if mode == VoiceMode::Local {
return VoiceTransportDecision::local_ok();
}
if !api_key_present {
return VoiceTransportDecision {
transport: ResolvedVoiceTransport::Local,
notice: Some(VOICE_NOTICE_XAI_NO_KEY),
fallback_reason: Some(VoiceFallbackReason::NoApiKey),
hybrid: HybridVoiceComposition::LOCAL_ALL,
};
}
if !xai_healthy {
return VoiceTransportDecision {
transport: ResolvedVoiceTransport::Local,
notice: Some(VOICE_NOTICE_XAI_UNHEALTHY),
fallback_reason: Some(VoiceFallbackReason::HealthFailed),
hybrid: HybridVoiceComposition::LOCAL_ALL,
};
}
match mode {
VoiceMode::Local => VoiceTransportDecision::local_ok(),
VoiceMode::XaiS2s => VoiceTransportDecision {
transport: ResolvedVoiceTransport::XaiS2s,
notice: None,
fallback_reason: None,
hybrid: HybridVoiceComposition {
stt: HybridVoiceLeg::Xai,
agent: HybridVoiceLeg::Xai,
tts: HybridVoiceLeg::Xai,
},
},
VoiceMode::XaiHybrid => VoiceTransportDecision {
transport: ResolvedVoiceTransport::XaiHybrid,
notice: None,
fallback_reason: None,
hybrid: HybridVoiceComposition::HYBRID_DEFAULT,
},
}
}
pub fn resolve_voice_transport_with_health(
mode: VoiceMode,
api_key_present: bool,
health: &XaiHealthProbeResult,
) -> VoiceTransportDecision {
resolve_voice_transport(mode, api_key_present, health.healthy)
}
pub fn resolve_voice_transport_runtime_fallback(mode: VoiceMode) -> VoiceTransportDecision {
let _ = mode; VoiceTransportDecision {
transport: ResolvedVoiceTransport::Local,
notice: Some(VOICE_NOTICE_XAI_RUNTIME_FALLBACK),
fallback_reason: Some(VoiceFallbackReason::RuntimeFailure),
hybrid: HybridVoiceComposition::LOCAL_ALL,
}
}
impl VoiceConfig {
pub fn resolve_transport(
&self,
env_lookup: impl FnOnce(&str) -> Option<String>,
xai_healthy: bool,
) -> VoiceTransportDecision {
let key_name = self.xai.api_key_env_name();
let api_key_present = env_lookup(key_name)
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
resolve_voice_transport(self.mode, api_key_present, xai_healthy)
}
pub fn resolve_transport_with_health(
&self,
env_lookup: impl FnOnce(&str) -> Option<String>,
health: &XaiHealthProbeResult,
) -> VoiceTransportDecision {
self.resolve_transport(env_lookup, health.healthy)
}
pub fn resolve_transport_from_env(&self, xai_healthy: bool) -> VoiceTransportDecision {
self.resolve_transport(|name| std::env::var(name).ok(), xai_healthy)
}
pub fn resolve_tts_for_agent(&self, agent_id: &str) -> ResolvedTtsVoice {
if let Some(p) = self.personalities.get(agent_id) {
return ResolvedTtsVoice::from_personality(agent_id, p);
}
let mut resolved = ResolvedTtsVoice::default_for_agent(agent_id);
if !self.tts.voice.trim().is_empty() {
resolved.voice_id = self.tts.voice.clone();
}
resolved.speed = self.tts.speed;
if !self.tts.provider.trim().is_empty() {
resolved.provider = self.tts.provider.clone();
}
resolved
}
pub fn session_greeting_for(&self, agent_id: &str) -> Option<String> {
self.resolve_tts_for_agent(agent_id)
.greeting()
.map(|s| s.to_string())
}
pub fn tts_text_for(&self, agent_id: &str, body: &str, session_start: bool) -> String {
if session_start
&& let Some(g) = self.session_greeting_for(agent_id)
{
let body = body.trim();
if body.is_empty() {
return g;
}
return format!("{g} {body}");
}
body.to_string()
}
pub fn validate_personalities(&self) -> Result<(), String> {
for (agent_id, p) in &self.personalities {
p.validate()
.map_err(|e| format!("personality for agent {agent_id:?}: {e}"))?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AudioRetention {
#[default]
None,
Session,
Persist,
}
impl AudioRetention {
pub fn allows_disk_write(self) -> bool {
matches!(self, Self::Persist)
}
pub fn allows_session_hold(self) -> bool {
matches!(self, Self::Session | Self::Persist)
}
pub fn is_none(self) -> bool {
matches!(self, Self::None)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceConsumerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_transcript_topic", alias = "transcriptTopic")]
pub transcript_topic: String,
#[serde(default = "default_chat_target_agent", alias = "chatTargetAgent")]
pub chat_target_agent: String,
#[serde(default = "default_voice_conv_id", alias = "convId")]
pub conv_id: String,
#[serde(default = "default_command_prefix", alias = "commandPrefix")]
pub command_prefix: String,
#[serde(default)]
pub permissions: VoicePermissionConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoicePermissionConfig {
#[serde(default = "default_voice_default_level", alias = "defaultLevel")]
pub default_level: u8,
#[serde(default, alias = "principalLevels")]
pub principal_levels: HashMap<String, u8>,
#[serde(default = "default_safe_commands", alias = "safeCommands")]
pub safe_commands: Vec<String>,
}
fn default_voice_default_level() -> u8 {
0
}
fn default_safe_commands() -> Vec<String> {
vec!["status".into(), "list".into(), "help".into()]
}
impl Default for VoicePermissionConfig {
fn default() -> Self {
Self {
default_level: default_voice_default_level(),
principal_levels: HashMap::new(),
safe_commands: default_safe_commands(),
}
}
}
fn default_transcript_topic() -> String {
String::new()
}
fn default_chat_target_agent() -> String {
"concierge-bot".into()
}
fn default_voice_conv_id() -> String {
"voice-default".into()
}
fn default_command_prefix() -> String {
"weft ".into()
}
impl Default for VoiceConsumerConfig {
fn default() -> Self {
Self {
enabled: false,
transcript_topic: default_transcript_topic(),
chat_target_agent: default_chat_target_agent(),
conv_id: default_voice_conv_id(),
command_prefix: default_command_prefix(),
permissions: VoicePermissionConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
#[serde(default = "default_sample_rate", alias = "sampleRate")]
pub sample_rate: u32,
#[serde(default = "default_chunk_size", alias = "chunkSize")]
pub chunk_size: u32,
#[serde(default = "default_audio_channels")]
pub channels: u16,
#[serde(default, alias = "inputDevice")]
pub input_device: Option<String>,
#[serde(default, alias = "outputDevice")]
pub output_device: Option<String>,
}
fn default_sample_rate() -> u32 {
16000
}
fn default_chunk_size() -> u32 {
512
}
fn default_audio_channels() -> u16 {
1
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
sample_rate: default_sample_rate(),
chunk_size: default_chunk_size(),
channels: default_audio_channels(),
input_device: None,
output_device: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SttConfig {
#[serde(default = "super::default_true")]
pub enabled: bool,
#[serde(default = "default_stt_model")]
pub model: String,
#[serde(default)]
pub language: String,
}
fn default_stt_model() -> String {
"sherpa-onnx-streaming-zipformer-en-20M".into()
}
impl Default for SttConfig {
fn default() -> Self {
Self {
enabled: true,
model: default_stt_model(),
language: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TtsConfig {
#[serde(default = "super::default_true")]
pub enabled: bool,
#[serde(default = "default_tts_provider")]
pub provider: String,
#[serde(default = "default_tts_model")]
pub model: String,
#[serde(default)]
pub voice: String,
#[serde(default = "default_speed")]
pub speed: f32,
#[serde(default = "default_fast_engine", alias = "fastEngine")]
pub fast_engine: String,
}
pub const DEFAULT_TTS_PROVIDER: &str = "local";
pub mod tts_providers {
pub const LOCAL: &str = "local";
pub const LOCAL_STUB: &str = "local-stub";
pub const BROWSER: &str = "browser";
pub const OPENAI: &str = "openai";
pub const ELEVENLABS: &str = "elevenlabs";
}
pub fn is_local_tts_provider(provider: &str) -> bool {
matches!(
provider,
tts_providers::LOCAL | tts_providers::LOCAL_STUB
)
}
pub fn is_browser_tts_provider(provider: &str) -> bool {
provider == tts_providers::BROWSER
}
pub fn is_cloud_tts_provider(provider: &str) -> bool {
matches!(
provider,
tts_providers::OPENAI | tts_providers::ELEVENLABS
)
}
fn default_tts_provider() -> String {
DEFAULT_TTS_PROVIDER.into()
}
fn default_tts_model() -> String {
"vits-piper-en_US-amy-medium".into()
}
fn default_speed() -> f32 {
1.0
}
pub const DEFAULT_TTS_FAST_ENGINE: &str = "auto";
fn default_fast_engine() -> String {
DEFAULT_TTS_FAST_ENGINE.into()
}
impl Default for TtsConfig {
fn default() -> Self {
Self {
enabled: true,
provider: default_tts_provider(),
model: default_tts_model(),
voice: String::new(),
speed: default_speed(),
fast_engine: default_fast_engine(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VadConfig {
#[serde(default = "default_vad_threshold")]
pub threshold: f32,
#[serde(default = "default_silence_timeout_ms", alias = "silenceTimeoutMs")]
pub silence_timeout_ms: u32,
#[serde(default = "default_min_speech_ms", alias = "minSpeechMs")]
pub min_speech_ms: u32,
#[serde(default, alias = "adaptiveSilence")]
pub adaptive: super::AdaptiveSilenceConfig,
}
fn default_vad_threshold() -> f32 {
0.5
}
fn default_silence_timeout_ms() -> u32 {
1500
}
fn default_min_speech_ms() -> u32 {
250
}
impl Default for VadConfig {
fn default() -> Self {
Self {
threshold: default_vad_threshold(),
silence_timeout_ms: default_silence_timeout_ms(),
min_speech_ms: default_min_speech_ms(),
adaptive: super::AdaptiveSilenceConfig::default(),
}
}
}
impl VadConfig {
pub fn silence_estimator(&self) -> super::AdaptiveSilenceTimeout {
super::AdaptiveSilenceTimeout::new(self.silence_timeout_ms, self.adaptive.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WakeConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_wake_phrase")]
pub phrase: String,
#[serde(
default = "default_wake_sensitivity",
alias = "threshold"
)]
pub sensitivity: f32,
#[serde(default, alias = "modelPath")]
pub model_path: Option<String>,
}
fn default_wake_phrase() -> String {
"hey weft".into()
}
fn default_wake_sensitivity() -> f32 {
0.5
}
impl Default for WakeConfig {
fn default() -> Self {
Self {
enabled: false,
phrase: default_wake_phrase(),
sensitivity: default_wake_sensitivity(),
model_path: None,
}
}
}
impl WakeConfig {
pub fn to_detector_threshold(&self) -> f32 {
(1.0 - self.sensitivity).clamp(0.0, 1.0)
}
pub fn sensitivity_from_detector_threshold(threshold: f32) -> f32 {
(1.0 - threshold).clamp(0.0, 1.0)
}
pub fn model_path_buf(&self) -> Option<std::path::PathBuf> {
self.model_path
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CloudSttProviderKind {
OpenAiWhisper,
}
impl CloudSttProviderKind {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAiWhisper => "whisper",
}
}
pub fn display_name(self) -> &'static str {
match self {
Self::OpenAiWhisper => "OpenAI Whisper API",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CloudTtsProviderKind {
OpenAi,
ElevenLabs,
}
impl CloudTtsProviderKind {
pub fn as_str(self) -> &'static str {
match self {
Self::OpenAi => "openai",
Self::ElevenLabs => "elevenlabs",
}
}
pub fn display_name(self) -> &'static str {
match self {
Self::OpenAi => "OpenAI TTS API",
Self::ElevenLabs => "ElevenLabs API",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloudProviderRouteError {
pub field: &'static str,
pub value: String,
}
impl std::fmt::Display for CloudProviderRouteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unknown cloud {} value {:?}; expected one of: {}",
self.field,
self.value,
match self.field {
"stt_provider" => "whisper | openai | openai-whisper",
"tts_provider" => "openai | openai-tts | elevenlabs",
_ => "see docs",
}
)
}
}
impl std::error::Error for CloudProviderRouteError {}
pub fn parse_cloud_stt_provider(
raw: &str,
) -> Result<Option<CloudSttProviderKind>, CloudProviderRouteError> {
let key = raw.trim().to_ascii_lowercase();
if key.is_empty() {
return Ok(None);
}
match key.as_str() {
"whisper" | "openai" | "openai-whisper" | "openai_whisper" | "openai.whisper" => {
Ok(Some(CloudSttProviderKind::OpenAiWhisper))
}
_ => Err(CloudProviderRouteError {
field: "stt_provider",
value: raw.to_string(),
}),
}
}
pub fn parse_cloud_tts_provider(
raw: &str,
) -> Result<Option<CloudTtsProviderKind>, CloudProviderRouteError> {
let key = raw.trim().to_ascii_lowercase();
if key.is_empty() {
return Ok(None);
}
match key.as_str() {
"openai" | "openai-tts" | "openai_tts" | "openai.tts" => {
Ok(Some(CloudTtsProviderKind::OpenAi))
}
"elevenlabs" | "11labs" | "eleven_labs" | "eleven-labs" => {
Ok(Some(CloudTtsProviderKind::ElevenLabs))
}
_ => Err(CloudProviderRouteError {
field: "tts_provider",
value: raw.to_string(),
}),
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CloudFallbackConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, alias = "sttProvider")]
pub stt_provider: String,
#[serde(default, alias = "ttsProvider")]
pub tts_provider: String,
}
impl CloudFallbackConfig {
pub fn resolve_stt_provider(
&self,
) -> Result<Option<CloudSttProviderKind>, CloudProviderRouteError> {
match parse_cloud_stt_provider(&self.stt_provider)? {
Some(kind) => Ok(Some(kind)),
None if self.enabled => Ok(Some(CloudSttProviderKind::OpenAiWhisper)),
None => Ok(None),
}
}
pub fn resolve_tts_provider(
&self,
) -> Result<Option<CloudTtsProviderKind>, CloudProviderRouteError> {
match parse_cloud_tts_provider(&self.tts_provider)? {
Some(kind) => Ok(Some(kind)),
None if self.enabled => Ok(Some(CloudTtsProviderKind::OpenAi)),
None => Ok(None),
}
}
pub fn validate_providers(&self) -> Result<(), CloudProviderRouteError> {
self.resolve_stt_provider()?;
self.resolve_tts_provider()?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceConfirmationConfig {
#[serde(default = "default_confirmation_timeout_seconds", alias = "timeoutSeconds")]
pub timeout_seconds: u32,
#[serde(default = "default_true", alias = "antiReplayNonce")]
pub anti_replay_nonce: bool,
#[serde(default = "default_true", alias = "transcriptionEcho")]
pub transcription_echo: bool,
}
fn default_confirmation_timeout_seconds() -> u32 {
10
}
fn default_true() -> bool {
true
}
impl Default for VoiceConfirmationConfig {
fn default() -> Self {
Self {
timeout_seconds: default_confirmation_timeout_seconds(),
anti_replay_nonce: true,
transcription_echo: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceRateLimitConfig {
#[serde(
default = "default_commands_per_minute",
alias = "commandsPerMinute"
)]
pub commands_per_minute: u32,
#[serde(
default = "default_wake_activations_per_minute",
alias = "wakeActivationsPerMinute"
)]
pub wake_activations_per_minute: u32,
#[serde(default = "default_fail_threshold", alias = "failThreshold")]
pub fail_threshold: u32,
#[serde(
default = "default_post_fail_cooldown_seconds",
alias = "postFailCooldownSeconds"
)]
pub post_fail_cooldown_seconds: u32,
}
fn default_commands_per_minute() -> u32 {
10
}
fn default_wake_activations_per_minute() -> u32 {
5
}
fn default_fail_threshold() -> u32 {
3
}
fn default_post_fail_cooldown_seconds() -> u32 {
30
}
impl Default for VoiceRateLimitConfig {
fn default() -> Self {
Self {
commands_per_minute: default_commands_per_minute(),
wake_activations_per_minute: default_wake_activations_per_minute(),
fail_threshold: default_fail_threshold(),
post_fail_cooldown_seconds: default_post_fail_cooldown_seconds(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn voice_mode_serde_snake_case() {
assert_eq!(
serde_json::to_string(&VoiceMode::Local).unwrap(),
"\"local\""
);
assert_eq!(
serde_json::to_string(&VoiceMode::XaiS2s).unwrap(),
"\"xai_s2s\""
);
assert_eq!(
serde_json::to_string(&VoiceMode::XaiHybrid).unwrap(),
"\"xai_hybrid\""
);
let m: VoiceMode = serde_json::from_str("\"xai_s2s\"").unwrap();
assert_eq!(m, VoiceMode::XaiS2s);
}
#[test]
fn voice_xai_config_defaults_and_url() {
let xai = VoiceXaiConfig::default();
assert_eq!(xai.api_key_env, DEFAULT_XAI_API_KEY_ENV);
assert_eq!(xai.realtime_model, DEFAULT_XAI_REALTIME_MODEL);
assert_eq!(xai.endpoint, DEFAULT_XAI_REALTIME_ENDPOINT);
assert_eq!(
xai.realtime_url(),
"wss://api.x.ai/v1/realtime?model=grok-voice-latest"
);
let custom = VoiceXaiConfig {
api_key_env: String::new(),
realtime_model: "grok-voice-think-fast-2.0".into(),
endpoint: "wss://api.x.ai/v1/realtime".into(),
};
assert_eq!(custom.api_key_env_name(), DEFAULT_XAI_API_KEY_ENV);
assert_eq!(
custom.realtime_url(),
"wss://api.x.ai/v1/realtime?model=grok-voice-think-fast-2.0"
);
}
#[test]
fn voice_confirmation_and_rate_limit_defaults() {
let cfg: VoiceConfig = serde_json::from_str("{}").unwrap();
assert_eq!(cfg.confirmation.timeout_seconds, 10);
assert!(cfg.confirmation.anti_replay_nonce);
assert!(cfg.confirmation.transcription_echo);
assert_eq!(cfg.rate_limit.commands_per_minute, 10);
assert_eq!(cfg.rate_limit.wake_activations_per_minute, 5);
assert_eq!(cfg.rate_limit.fail_threshold, 3);
assert_eq!(cfg.rate_limit.post_fail_cooldown_seconds, 30);
let custom: VoiceConfig = serde_json::from_str(
r#"{
"confirmation": {"timeoutSeconds": 15, "antiReplayNonce": false},
"rateLimit": {"commandsPerMinute": 20, "wakeActivationsPerMinute": 2}
}"#,
)
.unwrap();
assert_eq!(custom.confirmation.timeout_seconds, 15);
assert!(!custom.confirmation.anti_replay_nonce);
assert!(custom.confirmation.transcription_echo); assert_eq!(custom.rate_limit.commands_per_minute, 20);
assert_eq!(custom.rate_limit.wake_activations_per_minute, 2);
}
#[test]
fn voice_config_parses_mode_and_xai() {
let json = r#"{
"enabled": true,
"mode": "local",
"xai": {
"api_key_env": "MY_XAI_KEY",
"realtime_model": "grok-voice-latest",
"endpoint": "wss://api.x.ai/v1/realtime"
}
}"#;
let cfg: VoiceConfig = serde_json::from_str(json).unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.mode, VoiceMode::Local);
assert_eq!(cfg.xai.api_key_env, "MY_XAI_KEY");
assert_eq!(cfg.xai.realtime_model, "grok-voice-latest");
let camel = r#"{
"mode": "xai_hybrid",
"xai": { "apiKeyEnv": "XAI_API_KEY", "realtimeModel": "grok-voice-latest" }
}"#;
let cfg2: VoiceConfig = serde_json::from_str(camel).unwrap();
assert_eq!(cfg2.mode, VoiceMode::XaiHybrid);
assert_eq!(cfg2.xai.api_key_env, "XAI_API_KEY");
}
#[test]
fn selection_mode_local_always_local() {
let d = resolve_voice_transport(VoiceMode::Local, true, true);
assert_eq!(d.transport, ResolvedVoiceTransport::Local);
assert!(d.notice.is_none());
assert!(d.fallback_reason.is_none());
assert!(!d.is_fallback());
assert_eq!(d.hybrid, HybridVoiceComposition::LOCAL_ALL);
let d2 = resolve_voice_transport(VoiceMode::Local, false, false);
assert_eq!(d2.transport, ResolvedVoiceTransport::Local);
assert!(d2.notice.is_none());
assert!(d2.fallback_reason.is_none());
}
#[test]
fn selection_fail_open_no_key() {
let d = resolve_voice_transport(VoiceMode::XaiS2s, false, true);
assert_eq!(d.transport, ResolvedVoiceTransport::Local);
assert_eq!(d.notice, Some(VOICE_NOTICE_XAI_NO_KEY));
assert_eq!(d.fallback_reason, Some(VoiceFallbackReason::NoApiKey));
assert!(d.is_fallback());
let d2 = resolve_voice_transport(VoiceMode::XaiHybrid, false, true);
assert_eq!(d2.transport, ResolvedVoiceTransport::Local);
assert_eq!(d2.notice, Some(VOICE_NOTICE_XAI_NO_KEY));
assert_eq!(d2.fallback_reason, Some(VoiceFallbackReason::NoApiKey));
assert_eq!(d2.hybrid, HybridVoiceComposition::LOCAL_ALL);
}
#[test]
fn selection_fail_open_unhealthy() {
let d = resolve_voice_transport(VoiceMode::XaiS2s, true, false);
assert_eq!(d.transport, ResolvedVoiceTransport::Local);
assert_eq!(d.notice, Some(VOICE_NOTICE_XAI_UNHEALTHY));
assert_eq!(d.fallback_reason, Some(VoiceFallbackReason::HealthFailed));
}
#[test]
fn hybrid_falls_back_local_when_xai_unhealthy() {
let d = resolve_voice_transport(VoiceMode::XaiHybrid, true, false);
assert_eq!(d.transport, ResolvedVoiceTransport::Local);
assert_eq!(d.notice, Some(VOICE_NOTICE_XAI_UNHEALTHY));
assert_eq!(d.fallback_reason, Some(VoiceFallbackReason::HealthFailed));
assert_eq!(d.hybrid, HybridVoiceComposition::LOCAL_ALL);
assert!(!d.hybrid.uses_xai());
}
#[test]
fn selection_xai_path_when_key_and_healthy() {
let d = resolve_voice_transport(VoiceMode::XaiS2s, true, true);
assert_eq!(d.transport, ResolvedVoiceTransport::XaiS2s);
assert!(d.notice.is_none());
assert!(d.fallback_reason.is_none());
assert!(d.hybrid.uses_xai());
let d2 = resolve_voice_transport(VoiceMode::XaiHybrid, true, true);
assert_eq!(d2.transport, ResolvedVoiceTransport::XaiHybrid);
assert!(d2.notice.is_none());
assert_eq!(d2.hybrid, HybridVoiceComposition::HYBRID_DEFAULT);
assert_eq!(d2.hybrid.stt, HybridVoiceLeg::Local);
assert_eq!(d2.hybrid.agent, HybridVoiceLeg::Local);
assert_eq!(d2.hybrid.tts, HybridVoiceLeg::Xai);
assert!(d2.hybrid.uses_xai());
}
#[test]
fn resolve_with_health_probe_stub() {
let healthy = XaiHealthProbeResult::healthy_stub();
let d = resolve_voice_transport_with_health(VoiceMode::XaiHybrid, true, &healthy);
assert_eq!(d.transport, ResolvedVoiceTransport::XaiHybrid);
let sick = XaiHealthProbeResult::unhealthy_stub();
let d2 = resolve_voice_transport_with_health(VoiceMode::XaiHybrid, true, &sick);
assert_eq!(d2.transport, ResolvedVoiceTransport::Local);
assert_eq!(d2.fallback_reason, Some(VoiceFallbackReason::HealthFailed));
}
#[test]
fn runtime_fallback_always_local_with_notice() {
for mode in [VoiceMode::Local, VoiceMode::XaiS2s, VoiceMode::XaiHybrid] {
let d = resolve_voice_transport_runtime_fallback(mode);
assert_eq!(d.transport, ResolvedVoiceTransport::Local);
assert_eq!(d.notice, Some(VOICE_NOTICE_XAI_RUNTIME_FALLBACK));
assert_eq!(d.fallback_reason, Some(VoiceFallbackReason::RuntimeFailure));
assert_eq!(d.hybrid, HybridVoiceComposition::LOCAL_ALL);
assert!(d.is_fallback());
}
}
#[test]
fn resolve_transport_uses_env_lookup_mock() {
let cfg = VoiceConfig {
mode: VoiceMode::XaiS2s,
xai: VoiceXaiConfig::default(),
..Default::default()
};
let no_key = cfg.resolve_transport(|_| None, true);
assert_eq!(no_key.transport, ResolvedVoiceTransport::Local);
assert_eq!(no_key.notice, Some(VOICE_NOTICE_XAI_NO_KEY));
let empty_key = cfg.resolve_transport(|_| Some(String::new()), true);
assert_eq!(empty_key.transport, ResolvedVoiceTransport::Local);
let present = cfg.resolve_transport(|_| Some("xai-test-key".into()), true);
assert_eq!(present.transport, ResolvedVoiceTransport::XaiS2s);
assert!(present.notice.is_none());
let mut custom = cfg.clone();
custom.xai.api_key_env = "CUSTOM_XAI".into();
let mut saw = String::new();
let d = custom.resolve_transport(
|name| {
saw = name.to_string();
Some("k".into())
},
true,
);
assert_eq!(saw, "CUSTOM_XAI");
assert_eq!(d.transport, ResolvedVoiceTransport::XaiS2s);
let health = XaiHealthProbeResult::from_flag(false);
let sick = cfg.resolve_transport_with_health(|_| Some("k".into()), &health);
assert_eq!(sick.fallback_reason, Some(VoiceFallbackReason::HealthFailed));
}
#[test]
fn voice_config_default_mode_is_xai_s2s() {
let cfg = VoiceConfig::default();
assert_eq!(cfg.mode, VoiceMode::XaiS2s);
assert_eq!(cfg.xai.api_key_env, DEFAULT_XAI_API_KEY_ENV);
}
#[test]
fn tts_fast_engine_defaults_to_auto() {
let cfg = TtsConfig::default();
assert_eq!(cfg.fast_engine, DEFAULT_TTS_FAST_ENGINE);
let json = r#"{"enabled":true}"#;
let parsed: TtsConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.fast_engine, "auto");
let camel = r#"{"fastEngine":"chatterbox"}"#;
let c: TtsConfig = serde_json::from_str(camel).unwrap();
assert_eq!(c.fast_engine, "chatterbox");
}
#[test]
fn resolve_tts_for_agent_two_personalities() {
let mut cfg = VoiceConfig::default();
cfg.personalities.insert(
"agent-alpha".into(),
VoicePersonality {
voice_id: "nova".into(),
provider: "openai".into(),
speed: 1.1,
pitch: 0.2,
greeting_prefix: Some("This is Alpha.".into()),
language: "en".into(),
},
);
cfg.personalities.insert(
"agent-beta".into(),
VoicePersonality {
voice_id: "onyx".into(),
provider: "openai".into(),
speed: 0.9,
pitch: -0.3,
greeting_prefix: Some("Beta online.".into()),
language: "en-GB".into(),
},
);
let alpha = cfg.resolve_tts_for_agent("agent-alpha");
let beta = cfg.resolve_tts_for_agent("agent-beta");
assert!(alpha.matched_agent && beta.matched_agent);
assert_eq!(alpha.voice_id, "nova");
assert_eq!(beta.voice_id, "onyx");
assert!((alpha.speed - 1.1).abs() < f32::EPSILON);
assert!((beta.speed - 0.9).abs() < f32::EPSILON);
assert!((alpha.pitch - 0.2).abs() < f32::EPSILON);
assert!((beta.pitch - (-0.3)).abs() < f32::EPSILON);
assert_ne!(alpha.voice_id, beta.voice_id);
assert_ne!(alpha.pitch, beta.pitch);
assert_eq!(
cfg.session_greeting_for("agent-alpha").as_deref(),
Some("This is Alpha.")
);
assert_eq!(
cfg.session_greeting_for("agent-beta").as_deref(),
Some("Beta online.")
);
assert_eq!(
cfg.tts_text_for("agent-alpha", "Ready.", true),
"This is Alpha. Ready."
);
assert_eq!(cfg.tts_text_for("agent-alpha", "Ready.", false), "Ready.");
assert_eq!(
cfg.tts_text_for("agent-beta", "", true),
"Beta online."
);
cfg.tts.voice = "alloy".into();
cfg.tts.speed = 1.25;
let unknown = cfg.resolve_tts_for_agent("agent-gamma");
assert!(!unknown.matched_agent);
assert_eq!(unknown.voice_id, "alloy");
assert!((unknown.speed - 1.25).abs() < f32::EPSILON);
assert!(cfg.session_greeting_for("agent-gamma").is_none());
assert!(cfg.validate_personalities().is_ok());
}
#[test]
fn validate_personalities_rejects_bad_entry() {
let mut cfg = VoiceConfig::default();
cfg.personalities.insert(
"bad".into(),
VoicePersonality {
speed: 9.0,
..Default::default()
},
);
let err = cfg.validate_personalities().unwrap_err();
assert!(err.contains("bad"));
assert!(err.contains("speed"));
}
#[test]
fn cloud_stt_provider_aliases() {
for alias in ["whisper", "openai", "openai-whisper", "OpenAI_Whisper"] {
assert_eq!(
parse_cloud_stt_provider(alias).unwrap(),
Some(CloudSttProviderKind::OpenAiWhisper)
);
}
assert_eq!(parse_cloud_stt_provider("").unwrap(), None);
assert_eq!(parse_cloud_stt_provider(" ").unwrap(), None);
let err = parse_cloud_stt_provider("azure-speech").unwrap_err();
assert_eq!(err.field, "stt_provider");
assert!(err.to_string().contains("azure-speech"));
}
#[test]
fn cloud_tts_provider_aliases() {
assert_eq!(
parse_cloud_tts_provider("openai").unwrap(),
Some(CloudTtsProviderKind::OpenAi)
);
assert_eq!(
parse_cloud_tts_provider("openai-tts").unwrap(),
Some(CloudTtsProviderKind::OpenAi)
);
assert_eq!(
parse_cloud_tts_provider("elevenlabs").unwrap(),
Some(CloudTtsProviderKind::ElevenLabs)
);
assert_eq!(
parse_cloud_tts_provider("11labs").unwrap(),
Some(CloudTtsProviderKind::ElevenLabs)
);
let err = parse_cloud_tts_provider("polly").unwrap_err();
assert_eq!(err.field, "tts_provider");
}
#[test]
fn cloud_fallback_resolve_defaults_when_enabled() {
let cfg = CloudFallbackConfig {
enabled: true,
stt_provider: String::new(),
tts_provider: String::new(),
};
assert_eq!(
cfg.resolve_stt_provider().unwrap(),
Some(CloudSttProviderKind::OpenAiWhisper)
);
assert_eq!(
cfg.resolve_tts_provider().unwrap(),
Some(CloudTtsProviderKind::OpenAi)
);
assert!(cfg.validate_providers().is_ok());
}
#[test]
fn cloud_fallback_resolve_disabled_empty_is_none() {
let cfg = CloudFallbackConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.resolve_stt_provider().unwrap(), None);
assert_eq!(cfg.resolve_tts_provider().unwrap(), None);
}
#[test]
fn cloud_fallback_unknown_provider_errors_at_startup() {
let cfg = CloudFallbackConfig {
enabled: true,
stt_provider: "not-a-provider".into(),
tts_provider: "openai".into(),
};
assert!(cfg.validate_providers().is_err());
}
#[test]
fn wake_sensitivity_to_detector_threshold_inverts() {
let cfg = WakeConfig {
sensitivity: 0.8,
..Default::default()
};
assert!((cfg.to_detector_threshold() - 0.2).abs() < f32::EPSILON);
assert!(
(WakeConfig::sensitivity_from_detector_threshold(0.2) - 0.8).abs() < f32::EPSILON
);
let mid = WakeConfig::default();
assert!((mid.to_detector_threshold() - 0.5).abs() < f32::EPSILON);
}
#[test]
fn wake_config_threshold_alias_deserializes_as_sensitivity() {
let json = r#"{"enabled": true, "threshold": 0.7}"#;
let cfg: WakeConfig = serde_json::from_str(json).unwrap();
assert!((cfg.sensitivity - 0.7).abs() < f32::EPSILON);
}
#[test]
fn wake_config_model_path_buf_bridge() {
let empty = WakeConfig::default();
assert!(empty.model_path_buf().is_none());
let cfg = WakeConfig {
model_path: Some("models/voice/wake/hey-weft.rpw".into()),
..Default::default()
};
assert_eq!(
cfg.model_path_buf().unwrap(),
std::path::PathBuf::from("models/voice/wake/hey-weft.rpw")
);
}
}