//! Server configuration: port, SGLang endpoint, model paths, and audio constants. use crate::orchestrator::BackchannelSettings; use crate::vad::VadSettings; use std::path::PathBuf; /// Sample rate of audio sent back to the browser (server -> client). /// Matches Fish Audio Drama 3's native output (44.1 kHz) so no TTS /// resampling is needed — the browser plays the PCM directly. pub const OUTPUT_SAMPLE_RATE: u32 = 44_100; /// Sample rate expected by the Parakeet STT engine. pub const STT_SAMPLE_RATE: u32 = 16_000; /// Frame duration in milliseconds used by the mixer ticker. pub const FRAME_DURATION_MS: u64 = 20; /// Number of samples per output frame (24 kHz * 20 ms). pub const OUTPUT_FRAME_SAMPLES: usize = (OUTPUT_SAMPLE_RATE as u64 * FRAME_DURATION_MS / 1000) as usize; /// Byte length of one output frame (16-bit mono). pub const OUTPUT_FRAME_BYTES: usize = OUTPUT_FRAME_SAMPLES * std::mem::size_of::(); /// EOU (end-of-utterance) silence window in milliseconds. pub const EOU_SILENCE_MS: u64 = 300; /// VAD RMS threshold above which audio is considered voice. pub const VAD_RMS_THRESHOLD: f32 = 0.01; /// Default system prompt for the reasoning LLM. Written for *spoken* output: /// the reply is read aloud by the TTS engine, so formatting is discouraged. /// The model is asked to mirror the user's language (multilingual assistant). pub const DEFAULT_LLM_SYSTEM_PROMPT: &str = "You are a friendly, knowledgeable voice assistant speaking with a human \ through a real-time audio channel. Always answer in the same language the \ user used. Your reply is read aloud by a text-to-speech engine, so write \ plain conversational prose: no markdown, no bullet points, no numbering, \ no emojis, no code blocks and no stage directions. Keep replies short and \ natural — one to three sentences — unless the user explicitly asks for a \ longer explanation. If you do not know something, say so briefly."; /// Appended to the system prompt when `LLM_DIRECT_ANSWERS=true`. Some /// reasoning models (Qwen3 without a vLLM reasoning parser) narrate their /// chain-of-thought directly in `content` instead of a separate field, and the /// TTS would read it aloud. This explicit instruction is the zero-cost, /// server-independent mitigation. pub const DIRECT_ANSWER_DIRECTIVE: &str = "Answer the user directly and never reveal your reasoning: do not output a \ thinking process, an analysis, a plan, numbered steps, a deliberation or \ any preamble before the answer. Reply with the answer itself only."; /// Runtime configuration for the audio agent engine. #[derive(Debug, Clone)] pub struct Config { /// TCP port the Axum server listens on. pub server_port: u16, /// Selected TTS engine plugin. Supported values: `fish` (Fish Audio / /// SGLang Drama 3), `kokoro`. See [`crate::tts::create_tts_engine`]. pub tts_engine: String, /// Base URL of the SGLang-Omni / Fish Audio server (e.g. http://10.8.0.3:8000). pub sglang_url: String, /// Model identifier passed to the SGLang `/v1/audio/speech` endpoint. pub tts_model: String, /// Voice identifier for the TTS engine. pub tts_voice: String, /// Optional reference audio (WAV) path for voice cloning. The path must be /// accessible by the Fish Audio backend server. pub tts_reference_audio: Option, /// Optional reference transcript. If this looks like a path to a `.txt` /// file, its contents are loaded; otherwise the string itself is used. pub tts_reference_text: Option, /// Filesystem directory containing the Parakeet TDT ONNX pipeline /// (`nemo128.onnx`, `encoder-model*.onnx`, `decoder_joint-model.onnx`, /// `vocab.txt`, `config.json`). pub stt_model_path: PathBuf, /// Filesystem path to the Parakeet tokenizer (`tokenizer.model` SentencePiece /// or `tokenizer.json` HuggingFace). Optional: if missing, transcripts are /// emitted as a placeholder string. pub stt_tokenizer_path: Option, /// Directory containing preloaded filler PCM files. pub fillers_dir: PathBuf, /// Whether the end-of-turn filler acknowledgment is used at all /// (`FILLER_ENABLED`). When disabled, no filler clip is ever queued. pub filler_enabled: bool, /// How long (ms) the agent waits for the response audio to start before /// playing the filler (`FILLER_DELAY_MS`). If the reply starts speaking /// within this window the filler is dropped — it is only a latency filler, /// so it must never overlap the beginning of the answer. Sent at most once /// per turn. pub filler_delay_ms: u64, /// TTS jitter buffer (ms) the mixer must fill before the response audio /// starts playing (`TTS_PREBUFFER_MS`, default `900`). Larger values absorb /// uneven TTS chunk arrival (fewer drop-outs) at the cost of added /// time-to-first-audio. The full buffer is only required at the start of a /// reply; after a mid-reply underrun the mixer resumes as soon as any audio /// is queued again. pub tts_prebuffer_ms: u64, /// Maximum number of TTS requests kept in flight at once /// (`TTS_PIPELINE_DEPTH`, default `3`). /// /// The reply is synthesized chunk by chunk. Instead of waiting for a chunk /// to finish streaming before requesting the next one (which leaves an /// audible gap while the TTS engine "warms up" for every request), the /// engine keeps up to this many requests streaming concurrently and plays /// their audio back in the original order. This hides the per-request /// start-up latency behind the previous chunk's audio. A value of `1` /// restores the old strictly serial behaviour. pub tts_pipeline_depth: usize, /// Filler gain applied when TTS is idle. pub filler_gain_idle: f32, /// Filler gain applied while TTS is active (ducking). pub filler_gain_duck: f32, /// Optional TLS certificate (PEM) for serving HTTPS. Set `TLS_CERT` and /// `TLS_KEY` together to enable HTTPS; when unset the server speaks plain /// HTTP. HTTPS is required for browser microphone access from a non- /// localhost origin (WebRTC needs a secure context). pub tls_cert: Option, /// TLS private key (PEM) matching [`Config::tls_cert`]. pub tls_key: Option, /// Voice Activity Detection settings (see [`crate::vad`]). pub vad: VadSettings, /// Backchannel settings (see [`crate::orchestrator::backchannel`]). pub backchannel: BackchannelSettings, /// Master switch for the reasoning LLM (`LLM_ENABLED`). When off the /// pipeline runs STT → TTS with no reasoning step. pub llm_enabled: bool, /// Base URL of the OpenAI-compatible endpoint, e.g. /// `http://10.8.0.4:8000/v1`. The client appends `/chat/completions`. pub llm_base_url: String, /// API key sent as `Authorization: Bearer ` (`LLM_API_KEY`). Servers /// that do not check it (vLLM, SGLang) accept any non-empty value. pub llm_api_key: String, /// Model identifier (`LLM_MODEL`), e.g. `Qwen/Qwen3.6-35B-A3B`. pub llm_model: String, /// Maximum tokens generated per reply (`LLM_MAX_TOKENS`). pub llm_max_tokens: u32, /// Sampling temperature (`LLM_TEMPERATURE`). pub llm_temperature: f32, /// System prompt prepended to every conversation (`LLM_SYSTEM_PROMPT`). pub llm_system_prompt: String, /// Stream the reply token-by-token (`LLM_STREAM`). Streaming starts the TTS /// on the first sentence (lower latency); disabling it waits for the full /// reply before speaking (simpler, more predictable). pub llm_stream: bool, /// Maximum number of past messages kept in the rolling history /// (`LLM_HISTORY_MESSAGES`). pub llm_history_messages: usize, /// Chain-of-thought handling on the request side (`LLM_ENABLE_THINKING`). /// `Some(false)` sends `chat_template_kwargs.enable_thinking = false`, /// which turns Qwen3-style thinking off on vLLM/SGLang (the reply is then /// the answer only). `None` (default) omits the field entirely so the /// request stays valid for strict OpenAI-compatible servers. pub llm_enable_thinking: Option, /// Strip the model's chain-of-thought before it reaches the chat box and /// the TTS (`LLM_STRIP_REASONING`, default `true`). Covers the separate /// `reasoning_content` delta and inline think-tag blocks. When `false` the /// raw output is passed through. pub llm_strip_reasoning: bool, /// Append [`DIRECT_ANSWER_DIRECTIVE`] to the system prompt /// (`LLM_DIRECT_ANSWERS`, default `true`). pub llm_direct_answers: bool, /// Minimum characters the reply must hold before the *first* chunk is /// handed to the TTS (`LLM_STREAM_FIRST_CHUNK_CHARS`, default `20`). Kept /// small so the agent starts speaking as soon as the model has produced a /// few words, without waiting for a whole sentence. `0` disables early /// chunking (chunks then end on sentence punctuation only). pub llm_stream_first_chunk_chars: usize, /// Same threshold for every following chunk (`LLM_STREAM_CHUNK_CHARS`, /// default `120`). Larger chunks give better prosody; smaller ones keep the /// audio flowing while the model is still generating. `0` disables. pub llm_stream_chunk_chars: usize, } impl Default for Config { fn default() -> Self { Self { server_port: 8787, tts_engine: "fish".to_string(), sglang_url: "http://10.8.0.3:8000".to_string(), tts_model: "fishaudio/drama-3".to_string(), tts_voice: "default".to_string(), // Default voice-cloning reference. The transcript is auto-loaded // from the sibling `solis.txt` file when `tts_reference_text` is // left unset. Overridable at runtime from the playground. tts_reference_audio: Some(PathBuf::from("/root/pipecat-voice/voice_profiles/solis.wav")), tts_reference_text: None, stt_model_path: PathBuf::from("models/parakeet"), stt_tokenizer_path: None, fillers_dir: PathBuf::from("assets/fillers"), filler_enabled: true, filler_delay_ms: 2500, tts_prebuffer_ms: 900, tts_pipeline_depth: 3, filler_gain_idle: 1.0, filler_gain_duck: 0.15, tls_cert: None, tls_key: None, vad: VadSettings::default(), backchannel: BackchannelSettings::default(), llm_enabled: false, llm_base_url: String::new(), llm_api_key: "EMPTY".to_string(), llm_model: String::new(), llm_max_tokens: 1024, llm_temperature: 0.7, llm_system_prompt: DEFAULT_LLM_SYSTEM_PROMPT.to_string(), llm_stream: true, llm_history_messages: 16, llm_enable_thinking: None, llm_strip_reasoning: true, llm_direct_answers: true, llm_stream_first_chunk_chars: 20, llm_stream_chunk_chars: 120, } } } impl Config { /// Build a [`Config`] from environment variables, falling back to defaults. pub fn from_env() -> Self { let mut cfg = Self::default(); if let Ok(port) = std::env::var("SERVER_PORT") { if let Ok(p) = port.parse::() { cfg.server_port = p; } } if let Ok(e) = std::env::var("TTS_ENGINE") { if !e.is_empty() { cfg.tts_engine = e; } } if let Ok(url) = std::env::var("SGLANG_URL") { cfg.sglang_url = url; } if let Ok(m) = std::env::var("TTS_MODEL") { cfg.tts_model = m; } if let Ok(v) = std::env::var("TTS_VOICE") { cfg.tts_voice = v; } if let Ok(p) = std::env::var("TTS_REFERENCE_AUDIO") { if !p.is_empty() { cfg.tts_reference_audio = Some(PathBuf::from(p)); } } if let Ok(t) = std::env::var("TTS_REFERENCE_TEXT") { if !t.is_empty() { cfg.tts_reference_text = Some(t); } } if let Ok(p) = std::env::var("STT_MODEL_PATH") { cfg.stt_model_path = PathBuf::from(p); } if let Ok(p) = std::env::var("STT_TOKENIZER_PATH") { if !p.is_empty() { cfg.stt_tokenizer_path = Some(PathBuf::from(p)); } } if let Ok(d) = std::env::var("FILLERS_DIR") { cfg.fillers_dir = PathBuf::from(d); } cfg.filler_enabled = env_bool("FILLER_ENABLED", cfg.filler_enabled); cfg.filler_delay_ms = env_u32("FILLER_DELAY_MS", cfg.filler_delay_ms as u32) as u64; cfg.tts_prebuffer_ms = env_u32("TTS_PREBUFFER_MS", cfg.tts_prebuffer_ms as u32) as u64; cfg.tts_pipeline_depth = env_u32("TTS_PIPELINE_DEPTH", cfg.tts_pipeline_depth as u32).max(1) as usize; if let Ok(p) = std::env::var("TLS_CERT") { if !p.is_empty() { cfg.tls_cert = Some(PathBuf::from(p)); } } if let Ok(p) = std::env::var("TLS_KEY") { if !p.is_empty() { cfg.tls_key = Some(PathBuf::from(p)); } } cfg.vad = VadSettings::from_env(); cfg.backchannel = BackchannelSettings::from_env(); cfg.read_llm_env(); cfg } /// Populate the LLM fields from the environment. Split out of `from_env` /// because the group is large and self-contained. fn read_llm_env(&mut self) { if let Ok(v) = std::env::var("LLM_BASE_URL") { if !v.trim().is_empty() { self.llm_base_url = v.trim().to_string(); } } if let Ok(v) = std::env::var("LLM_API_KEY") { self.llm_api_key = v; } if let Ok(v) = std::env::var("LLM_MODEL") { if !v.trim().is_empty() { self.llm_model = v.trim().to_string(); } } if let Ok(v) = std::env::var("LLM_SYSTEM_PROMPT") { if !v.trim().is_empty() { self.llm_system_prompt = v; } } self.llm_max_tokens = env_u32("LLM_MAX_TOKENS", self.llm_max_tokens); self.llm_temperature = env_f32("LLM_TEMPERATURE", self.llm_temperature); self.llm_stream = env_bool("LLM_STREAM", self.llm_stream); self.llm_history_messages = env_u32("LLM_HISTORY_MESSAGES", self.llm_history_messages as u32) as usize; self.llm_strip_reasoning = env_bool("LLM_STRIP_REASONING", self.llm_strip_reasoning); self.llm_enable_thinking = env_opt_bool("LLM_ENABLE_THINKING"); self.llm_stream_first_chunk_chars = env_u32( "LLM_STREAM_FIRST_CHUNK_CHARS", self.llm_stream_first_chunk_chars as u32, ) as usize; self.llm_stream_chunk_chars = env_u32("LLM_STREAM_CHUNK_CHARS", self.llm_stream_chunk_chars as u32) as usize; // Direct-answer instruction: appended to whichever system prompt is in // use (built-in default or `LLM_SYSTEM_PROMPT` override). self.llm_direct_answers = env_bool("LLM_DIRECT_ANSWERS", self.llm_direct_answers); if self.llm_direct_answers && !self.llm_system_prompt.contains(DIRECT_ANSWER_DIRECTIVE) { self.llm_system_prompt.push_str("\n\n"); self.llm_system_prompt.push_str(DIRECT_ANSWER_DIRECTIVE); } // Enabled by default as soon as an endpoint + model are configured. let configured = !self.llm_base_url.is_empty() && !self.llm_model.is_empty(); self.llm_enabled = env_bool("LLM_ENABLED", configured); } /// TLS certificate path, but only if both cert and key are configured. pub fn tls_enabled(&self) -> Option<(&PathBuf, &PathBuf)> { match (&self.tls_cert, &self.tls_key) { (Some(c), Some(k)) => Some((c, k)), _ => None, } } /// Full URL of the SGLang audio speech endpoint. pub fn speech_endpoint(&self) -> String { format!("{}/v1/audio/speech", self.sglang_url.trim_end_matches('/')) } /// Resolve the voice-cloning reference, if any. /// /// Returns `(audio_path, transcript)`. The transcript resolution rules: /// 1. If `tts_reference_text` is set and points to an existing `.txt` file, /// its contents are used. /// 2. Otherwise the raw `tts_reference_text` string is used. /// 3. If no text is provided, we look for a `