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 #[serde(default)]
78 pub bridge_url: Option<String>,
79 #[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 #[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 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 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 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 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}