This commit is contained in:
valenti
2026-10-16 11:36:02 +01:00
commit 236fd509ca
59 changed files with 18858 additions and 0 deletions
+424
View File
@@ -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)
}