Skip to main content

voice_echo/
config.rs

1use serde::Deserialize;
2use std::path::PathBuf;
3
4#[derive(Debug, Deserialize, Clone)]
5pub struct Config {
6    pub server: ServerConfig,
7    pub twilio: TwilioConfig,
8    pub groq: GroqConfig,
9    pub inworld: InworldConfig,
10    #[serde(alias = "claude")]
11    pub llm: LlmConfig,
12    pub vad: VadConfig,
13    #[serde(default)]
14    pub api: ApiConfig,
15    #[serde(default)]
16    pub hold_music: Option<HoldMusicConfig>,
17    #[serde(default)]
18    pub identity: IdentityConfig,
19    #[serde(default)]
20    pub greetings: GreetingsConfig,
21}
22
23#[derive(Debug, Deserialize, Clone)]
24pub struct ServerConfig {
25    pub host: String,
26    pub port: u16,
27    pub external_url: String,
28}
29
30#[derive(Debug, Deserialize, Clone)]
31pub struct TwilioConfig {
32    pub account_sid: String,
33    pub auth_token: String,
34    pub phone_number: String,
35}
36
37#[derive(Debug, Deserialize, Clone)]
38pub struct GroqConfig {
39    pub api_key: String,
40    #[serde(default = "default_groq_model")]
41    pub model: String,
42}
43
44fn default_groq_model() -> String {
45    "whisper-large-v3-turbo".to_string()
46}
47
48#[derive(Debug, Deserialize, Clone)]
49pub struct InworldConfig {
50    pub api_key: String,
51    #[serde(default = "default_voice_id")]
52    pub voice_id: String,
53    #[serde(default = "default_inworld_model")]
54    pub model: String,
55}
56
57fn default_voice_id() -> String {
58    "Olivia".to_string()
59}
60
61fn default_inworld_model() -> String {
62    "inworld-tts-1.5-max".to_string()
63}
64
65#[derive(Debug, Deserialize, Clone)]
66pub struct LlmConfig {
67    #[serde(default = "default_session_timeout")]
68    pub session_timeout_secs: u64,
69    #[serde(default)]
70    pub greeting: String,
71    #[serde(default = "default_name")]
72    pub name: String,
73    #[serde(default)]
74    pub self_path: Option<String>,
75    /// URL of bridge-echo multiplexer. When set, voice-echo forwards
76    /// transcripts to bridge-echo instead of spawning its own Claude Code process.
77    #[serde(default)]
78    pub bridge_url: Option<String>,
79    /// Max tokens for LLM responses. Short for voice (default: 1024).
80    #[serde(default = "default_max_response_tokens")]
81    pub max_response_tokens: u32,
82}
83
84fn default_max_response_tokens() -> u32 {
85    1024
86}
87
88fn default_session_timeout() -> u64 {
89    300
90}
91
92fn default_name() -> String {
93    "Echo".to_string()
94}
95
96#[derive(Debug, Deserialize, Clone)]
97pub struct VadConfig {
98    #[serde(default = "default_silence_threshold")]
99    pub silence_threshold_ms: u64,
100    #[serde(default = "default_energy_threshold")]
101    pub energy_threshold: u16,
102    #[serde(default)]
103    pub adaptive_threshold: bool,
104    #[serde(default = "default_noise_floor_multiplier")]
105    pub noise_floor_multiplier: f64,
106    #[serde(default = "default_noise_floor_decay")]
107    pub noise_floor_decay: f64,
108    #[serde(default)]
109    pub max_utterance_secs: Option<u64>,
110}
111
112fn default_silence_threshold() -> u64 {
113    1500
114}
115
116fn default_energy_threshold() -> u16 {
117    50
118}
119
120fn default_noise_floor_multiplier() -> f64 {
121    3.0
122}
123
124fn default_noise_floor_decay() -> f64 {
125    0.995
126}
127
128#[derive(Debug, Deserialize, Clone, Default)]
129pub struct ApiConfig {
130    /// Bearer token required for /api/* endpoints. If empty, all requests are rejected.
131    #[serde(default)]
132    pub token: String,
133}
134
135#[derive(Debug, Deserialize, Clone)]
136pub struct HoldMusicConfig {
137    pub file: String,
138    #[serde(default = "default_hold_music_volume")]
139    pub volume: f32,
140}
141
142fn default_hold_music_volume() -> f32 {
143    0.3
144}
145
146#[derive(Debug, Deserialize, Clone)]
147pub struct IdentityConfig {
148    #[serde(default = "default_identity_name")]
149    pub name: String,
150    #[serde(default = "default_caller_name")]
151    pub caller_name: String,
152}
153
154impl Default for IdentityConfig {
155    fn default() -> Self {
156        Self {
157            name: default_identity_name(),
158            caller_name: default_caller_name(),
159        }
160    }
161}
162
163fn default_identity_name() -> String {
164    "Echo".to_string()
165}
166
167fn default_caller_name() -> String {
168    "User".to_string()
169}
170
171#[derive(Debug, Deserialize, Clone)]
172pub struct GreetingsConfig {
173    #[serde(default = "default_inbound_greetings")]
174    pub inbound: Vec<String>,
175    #[serde(default = "default_outbound_template")]
176    pub outbound_template: String,
177    #[serde(default = "default_outbound_fallback")]
178    pub outbound_fallback: String,
179}
180
181impl Default for GreetingsConfig {
182    fn default() -> Self {
183        Self {
184            inbound: default_inbound_greetings(),
185            outbound_template: default_outbound_template(),
186            outbound_fallback: default_outbound_fallback(),
187        }
188    }
189}
190
191fn default_inbound_greetings() -> Vec<String> {
192    vec!["Hello, this is {name}".to_string()]
193}
194
195fn default_outbound_template() -> String {
196    "Hey {caller}, {reason}".to_string()
197}
198
199fn default_outbound_fallback() -> String {
200    "Hey {caller}, I wanted to talk to you about something".to_string()
201}
202
203impl Config {
204    pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
205        // Load .env file from same directory as config.toml
206        let env_path = config_dir().join(".env");
207        match dotenvy::from_path(&env_path) {
208            Ok(()) => tracing::info!("Loaded .env from {}", env_path.display()),
209            Err(dotenvy::Error::Io(_)) => {
210                tracing::debug!(
211                    "No .env file at {}, using environment only",
212                    env_path.display()
213                );
214            }
215            Err(e) => tracing::warn!("Failed to parse .env: {e}"),
216        }
217
218        let path = config_path();
219        tracing::info!("Loading config from {}", path.display());
220
221        let contents = std::fs::read_to_string(&path).map_err(|e| {
222            format!(
223                "Failed to read config at {}: {}. Copy config.example.toml to {}",
224                path.display(),
225                e,
226                path.display()
227            )
228        })?;
229
230        let mut config: Config = toml::from_str(&contents)?;
231
232        // Allow env var overrides for secrets
233        if let Ok(v) = std::env::var("TWILIO_ACCOUNT_SID") {
234            config.twilio.account_sid = v;
235        }
236        if let Ok(v) = std::env::var("TWILIO_AUTH_TOKEN") {
237            config.twilio.auth_token = v;
238        }
239        if let Ok(v) = std::env::var("GROQ_API_KEY") {
240            config.groq.api_key = v;
241        }
242        if let Ok(v) = std::env::var("INWORLD_API_KEY") {
243            config.inworld.api_key = v;
244        }
245        if let Ok(v) = std::env::var("ECHO_API_TOKEN") {
246            config.api.token = v;
247        }
248        if let Ok(v) = std::env::var("SERVER_EXTERNAL_URL") {
249            config.server.external_url = v;
250        }
251
252        // Backward compat: if [greetings] was not set but [llm].greeting was
253        // customized, use it as the sole inbound greeting template.
254        if config.greetings.inbound == default_inbound_greetings()
255            && !config.llm.greeting.is_empty()
256        {
257            config.greetings.inbound = vec![config.llm.greeting.clone()];
258        }
259
260        Ok(config)
261    }
262}
263
264fn config_dir() -> PathBuf {
265    if let Ok(p) = std::env::var("ECHO_CONFIG") {
266        // If pointing to a file, use its parent directory
267        let path = PathBuf::from(p);
268        return path.parent().map(|p| p.to_path_buf()).unwrap_or(path);
269    }
270
271    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
272    PathBuf::from(home).join(".voice-echo")
273}
274
275fn config_path() -> PathBuf {
276    if let Ok(p) = std::env::var("ECHO_CONFIG") {
277        return PathBuf::from(p);
278    }
279
280    config_dir().join("config.toml")
281}