first
This commit is contained in:
+424
@@ -0,0 +1,424 @@
|
||||
//! 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::<i16>();
|
||||
|
||||
/// 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<PathBuf>,
|
||||
/// 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<String>,
|
||||
/// 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<PathBuf>,
|
||||
/// 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<PathBuf>,
|
||||
/// TLS private key (PEM) matching [`Config::tls_cert`].
|
||||
pub tls_key: Option<PathBuf>,
|
||||
/// 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 <key>` (`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<bool>,
|
||||
/// 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::<u16>() {
|
||||
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 `<audio>.txt` file next to the
|
||||
/// reference WAV.
|
||||
pub fn tts_reference(&self) -> Option<(String, String)> {
|
||||
let audio = self.tts_reference_audio.as_ref()?;
|
||||
let audio_path = audio.to_string_lossy().into_owned();
|
||||
|
||||
// Try the configured reference text first.
|
||||
if let Some(text) = &self.tts_reference_text {
|
||||
let resolved = resolve_text(text);
|
||||
return Some((audio_path, resolved));
|
||||
}
|
||||
|
||||
// Fallback: look for a .txt sibling of the WAV file.
|
||||
let mut txt_path = audio.clone();
|
||||
txt_path.set_extension("txt");
|
||||
if let Ok(content) = std::fs::read_to_string(&txt_path) {
|
||||
return Some((audio_path, content.trim().to_string()));
|
||||
}
|
||||
|
||||
// No text available — send empty string (Fish Audio may infer it).
|
||||
Some((audio_path, String::new()))
|
||||
}
|
||||
}
|
||||
|
||||
/// If `text` is a path to an existing file, return its contents; otherwise
|
||||
/// return the string unchanged.
|
||||
fn resolve_text(text: &str) -> String {
|
||||
if let Ok(content) = std::fs::read_to_string(text) {
|
||||
return content.trim().to_string();
|
||||
}
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
// --- Environment parsing helpers (shared by the config sub-systems) ---------
|
||||
|
||||
/// Parse a boolean env var. Truthy: `1`, `true`, `yes`, `on` (case-insensitive).
|
||||
pub(crate) fn env_bool(name: &str, default: bool) -> bool {
|
||||
match std::env::var(name) {
|
||||
Ok(v) => matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
),
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an optional boolean env var: `None` when unset or malformed.
|
||||
/// Truthy: `1`, `true`, `yes`, `on`; falsy: `0`, `false`, `no`, `off`
|
||||
/// (case-insensitive).
|
||||
pub(crate) fn env_opt_bool(name: &str) -> Option<bool> {
|
||||
match std::env::var(name).ok()?.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an `f32` env var, falling back to `default` when unset or malformed.
|
||||
pub(crate) fn env_f32(name: &str, default: f32) -> f32 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Parse a `u32` env var, falling back to `default` when unset or malformed.
|
||||
pub(crate) fn env_u32(name: &str, default: u32) -> u32 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Parse a `u64` env var, falling back to `default` when unset or malformed.
|
||||
pub(crate) fn env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
Reference in New Issue
Block a user