This commit is contained in:
valenti
2026-10-17 15:49:20 +01:00
parent 430997bd2e
commit bfed42c3b3
124 changed files with 5646 additions and 249 deletions
+206 -18
View File
@@ -1,15 +1,19 @@
//! Startup asset bootstrap.
//!
//! The filler (end-of-utterance) and backchannel (during-utterance) clip banks
//! are plain `*.raw` PCM files. If a clip is missing at startup it is
//! synthesized with the configured TTS engine and written to disk, so a fresh
//! checkout works without a manual generation step. Existing clips are never
//! overwritten.
//! The filler (end-of-utterance), start-filler (turn-start) and backchannel
//! (during-utterance) clip banks are plain `*.raw` PCM files. If a clip is
//! missing at startup it is synthesized with the configured TTS engine and
//! written to disk, so a fresh checkout works without a manual generation step.
//! Existing filler clips are kept, while the user-editable start-filler and
//! backchannel banks are refreshed from their phrase lists (`START_FILLER_TEXTS`
//! / `BACKCHANNEL_TEXTS`) whenever those change (see
//! [`ensure_text_bank_clips`]).
use crate::audio::SampleRingBuffer;
use crate::tts::TtsEngine;
use anyhow::{Context, Result};
use std::path::Path;
use futures_util::StreamExt;
use std::path::{Path, PathBuf};
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
@@ -24,21 +28,24 @@ pub const FILLER_CLIPS: &[(&str, &str)] = &[
("vaya", "Vaya..."),
];
/// Default during-utterance backchannel clips: `(name, text)`.
pub const BACKCHANNEL_CLIPS: &[(&str, &str)] = &[
("mmhmm", "Mm-hmm."),
("aja", "Ajá."),
("claro", "Claro."),
("yaveo", "Ya veo."),
];
/// Capacity (in samples) of the scratch buffer used to capture one synthesized
/// clip. 30 s at 44.1 kHz is far beyond any filler / backchannel length.
const SYNTH_BUFFER_SAMPLES: usize = 30 * 44_100;
/// Number of text-bank clips synthesized concurrently at startup. The lists can
/// hold dozens of phrases; a few parallel requests keep the bootstrap short
/// without flooding the TTS backend.
const TEXT_BANK_CONCURRENCY: usize = 4;
/// Name of the file inside a text-driven clip directory that records the phrase
/// list the current clips were synthesized from (one phrase per line). Used to
/// skip the re-synthesis when the list did not change.
const TEXTS_INDEX_FILE: &str = "texts.txt";
/// Ensure every clip in `clips` exists as `<dir>/<name>.raw`, synthesizing the
/// missing ones with `tts`. Creates `dir` if needed. Returns the number of
/// clips generated. Never fails the caller: individual errors are logged.
/// missing ones with `tts`. Creates `dir` if needed. Existing clips are kept.
/// Returns the number of clips generated. Never fails the caller: individual
/// errors are logged.
pub async fn ensure_clips(
dir: &Path,
clips: &[(&str, &str)],
@@ -66,9 +73,9 @@ pub async fn ensure_clips(
info!(
label,
dir = %dir.display(),
missing = missing.len(),
clips = missing.len(),
engine = tts.name(),
"generating missing clips at startup"
"generating clips at startup"
);
let mut generated = 0usize;
@@ -91,6 +98,187 @@ pub async fn ensure_clips(
generated
}
/// Refresh the *start filler* clip bank so it matches `START_FILLER_TEXTS`.
pub async fn ensure_start_filler_clips(
dir: &Path,
texts: &[String],
tts: &dyn TtsEngine,
) -> usize {
ensure_text_bank_clips(dir, texts, "sf", "start-filler", tts).await
}
/// Refresh the *backchannel* clip bank so it matches `BACKCHANNEL_TEXTS`.
pub async fn ensure_backchannel_clips(
dir: &Path,
texts: &[String],
tts: &dyn TtsEngine,
) -> usize {
ensure_text_bank_clips(dir, texts, "bc", "backchannel", tts).await
}
/// Directory of the clip bank for `lang` under `base` (`<base>/<lang>`, ISO
/// 639-1), see [`crate::lang`].
///
/// Every language — including the configured default — lives in its own
/// subdirectory, so regenerating a language writes and prunes only within that
/// subdirectory and therefore never removes the clips of another language.
pub fn lang_dir(base: &Path, lang: &str) -> PathBuf {
base.join(lang)
}
/// Refresh the *backchannel* clip bank of `lang` (`<base>/<lang>`).
pub async fn ensure_lang_backchannel_clips(
base: &Path,
lang: &str,
texts: &[String],
tts: &dyn TtsEngine,
) -> usize {
ensure_text_bank_clips(&lang_dir(base, lang), texts, "bc", "backchannel", tts).await
}
/// Refresh the end-of-turn *filler* clip bank of `lang` (`<base>/<lang>`).
///
/// Unlike the default filler bank (fixed names, see [`ensure_clips`]) a
/// language bank is driven by its phrase list, so the round-robin order follows
/// the list order.
pub async fn ensure_lang_filler_clips(
base: &Path,
lang: &str,
texts: &[String],
tts: &dyn TtsEngine,
) -> usize {
ensure_text_bank_clips(&lang_dir(base, lang), texts, "fl", "filler", tts).await
}
/// Refresh the *start-filler* clip bank of `lang` (`<base>/<lang>`).
pub async fn ensure_lang_start_filler_clips(
base: &Path,
lang: &str,
texts: &[String],
tts: &dyn TtsEngine,
) -> usize {
ensure_text_bank_clips(&lang_dir(base, lang), texts, "sf", "start-filler", tts).await
}
/// Refresh a *text-driven* clip bank so it matches `texts` exactly.
///
/// The start filler (`START_FILLER_TEXTS`) and the backchannel
/// (`BACKCHANNEL_TEXTS`) are user-editable, so unlike the filler bank they are
/// re-synthesized from their phrase list rather than only when a clip is
/// missing. The list is remembered in [`TEXTS_INDEX_FILE`], so the synthesis is
/// skipped when nothing changed; editing a phrase, adding or dropping one, or
/// losing a clip triggers a full refresh at the next startup, and clips left
/// over from a previous list are removed. Clips are named by position
/// (`<prefix>01`, `<prefix>02`, …); a failed synthesis keeps the previous clip
/// of that position. `label` is only used in log messages (e.g. "start-filler",
/// "backchannel"). Returns the number of clips generated.
async fn ensure_text_bank_clips(
dir: &Path,
texts: &[String],
prefix: &str,
label: &str,
tts: &dyn TtsEngine,
) -> usize {
if texts.is_empty() {
warn!(label, "no {label} texts configured — the cue stays silent");
return 0;
}
if let Err(e) = std::fs::create_dir_all(dir) {
warn!(
label,
dir = %dir.display(),
error = %e,
"cannot create {label} directory — skipping auto-generation"
);
return 0;
}
let names: Vec<String> = (1..=texts.len()).map(|i| format!("{prefix}{i:02}")).collect();
let index = dir.join(TEXTS_INDEX_FILE);
let index_content = texts.join("\n");
// Skip the re-synthesis when the phrase list is unchanged and every clip is
// still on disk — otherwise a long list would add ~1 s per phrase to every
// restart.
let unchanged = std::fs::read_to_string(&index)
.map(|s| s == index_content)
.unwrap_or(false)
&& names.iter().all(|n| dir.join(format!("{n}.raw")).exists());
if unchanged {
info!(
label,
dir = %dir.display(),
clips = texts.len(),
"{label} clips up to date — skipping synthesis"
);
return 0;
}
info!(
label,
dir = %dir.display(),
clips = texts.len(),
engine = tts.name(),
"refreshing {label} clips at startup"
);
// A handful of concurrent requests keeps startup short without flooding the
// TTS backend. Each clip is written to its own file, so order is irrelevant.
let generated = futures_util::stream::iter(names.iter().cloned().zip(texts.iter().cloned()))
.map(|(name, text)| {
let path = dir.join(format!("{name}.raw"));
async move {
match synthesize_to_file(tts, &text, &path).await {
Ok(samples) => {
info!(label, name, samples, path = %path.display(), "generated {label} clip");
true
}
Err(e) => {
warn!(label, name, error = %e, "failed to generate {label} clip");
false
}
}
}
})
.buffer_unordered(TEXT_BANK_CONCURRENCY)
.filter(|ok| std::future::ready(*ok))
.count()
.await;
remove_stale_clips(dir, &names, label);
// Only record the list once every clip was produced: a partial failure
// leaves the index stale so the next startup retries.
if generated == texts.len() {
if let Err(e) = std::fs::write(&index, &index_content) {
warn!(label, path = %index.display(), error = %e, "failed to write {label} list index");
}
}
generated
}
/// Delete every `*.raw` clip in `dir` whose name is not in `keep`, so the bank
/// matches the current phrase list (e.g. after shortening a phrase list).
fn remove_stale_clips(dir: &Path, keep: &[String], label: &str) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("raw") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
if keep.iter().any(|k| k == stem) {
continue;
}
match std::fs::remove_file(&path) {
Ok(()) => info!(label, path = %path.display(), "removed stale {label} clip"),
Err(e) => warn!(label, path = %path.display(), error = %e, "failed to remove stale {label} clip"),
}
}
}
/// Synthesize `text` and write the raw 16-bit LE mono PCM to `path`.
async fn synthesize_to_file(tts: &dyn TtsEngine, text: &str, path: &Path) -> Result<usize> {
let buf = SampleRingBuffer::new(SYNTH_BUFFER_SAMPLES);
+94 -17
View File
@@ -1,9 +1,11 @@
//! 20 ms audio frame mixer.
//!
//! Combines the TTS channel, the filler (end-of-utterance backchannel) channel
//! and the backchannel (during-utterance acknowledgment) channel into a single
//! mono PCM stream at 44.1 kHz / 16-bit. The mixer runs on a strict 20 ms ticker
//! and must never block on network or model I/O.
//! Combines the TTS channel, the filler (end-of-utterance latency mask)
//! channel, the start-filler (immediate turn-start acknowledgment) channel, the
//! backchannel (during-utterance acknowledgment) channel and the external
//! (URL / radio / YouTube) channel into a single mono PCM stream at 44.1 kHz /
//! 16-bit. The mixer runs on a strict 20 ms ticker and must never block on
//! network or model I/O.
//!
//! To absorb the uneven arrival of Fish Audio TTS chunks, the mixer applies a
//! pre-buffer (jitter buffer) on the TTS channel: playback of TTS samples does
@@ -18,7 +20,7 @@
use crate::audio::ring_buffer::SampleRingBuffer;
use crate::config::{OUTPUT_FRAME_BYTES, OUTPUT_FRAME_SAMPLES};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
use tracing::{debug, info};
@@ -32,16 +34,26 @@ use tracing::{debug, info};
/// re-introduces the pre-buffer latency mid-reply.
const TTS_DRAIN_GRACE_FRAMES: usize = 10;
/// Shared audio mixer holding the TTS, filler and backchannel sample queues.
/// Shared audio mixer holding the TTS, filler, start-filler and backchannel
/// sample queues.
#[derive(Clone)]
pub struct AudioMixer {
/// Samples coming from the SGLang Drama 3 TTS stream.
pub tts_queue: SampleRingBuffer,
/// Samples coming from preloaded filler PCM (played at end-of-utterance).
pub filler_queue: SampleRingBuffer,
/// Samples coming from the preloaded start-filler clip (played on its own
/// channel as soon as a user turn begins).
pub start_filler_queue: SampleRingBuffer,
/// Samples coming from preloaded backchannel PCM (played while the user is
/// speaking, so the agent signals it is listening).
pub backchannel_queue: SampleRingBuffer,
/// Samples coming from an external audio source — a direct URL, an
/// Icecast/Shoutcast radio or a YouTube stream (see [`crate::media`]). This
/// is background audio the user injects into the mix; it is *not* cleared by
/// `flush` (a barge-in stops the agent, not the music) and does not count as
/// the agent speaking.
pub external_queue: SampleRingBuffer,
/// Filler gain when TTS is idle.
filler_gain_idle: f32,
/// Filler gain when TTS is active (ducking).
@@ -49,6 +61,10 @@ pub struct AudioMixer {
/// Fixed, low gain applied to the backchannel channel so the cue stays in
/// the background.
backchannel_gain: f32,
/// Gain applied to the external audio channel. Atomic (f32 bits) so the
/// client can adjust the background level at runtime without rebuilding the
/// mixer.
external_gain: Arc<AtomicU32>,
/// Whether the TTS pre-buffer has been reached and TTS is being played out.
tts_primed: Arc<AtomicBool>,
/// Count of consecutive frames where the TTS queue was empty while primed.
@@ -66,33 +82,45 @@ pub struct AudioMixer {
tts_stream_active: Arc<AtomicBool>,
/// Set the first time the pre-buffer is reached in a turn, i.e. when the
/// response audio actually starts playing. Reset by `mark_stream_start`
/// and `flush`. Used to decide whether a latency filler is still useful.
/// and `flush`. Only used to bypass the pre-buffer on subsequent
/// playbacks within the same turn.
tts_playback_started: Arc<AtomicBool>,
/// Set as soon as the first TTS samples of the turn are queued, i.e. when
/// the response's first audio chunk has *arrived* — possibly still inside
/// the pre-buffer window, before playback starts. Reset by
/// `mark_stream_start` and `flush`. Used to decide whether a turn-start
/// filler is still useful.
tts_first_chunk: Arc<AtomicBool>,
}
impl AudioMixer {
/// Create a new mixer with the given queue capacities (in samples), filler
/// gain parameters and backchannel gain.
/// gain parameters, backchannel gain and external-audio gain.
pub fn new(
queue_capacity: usize,
tts_prebuffer_samples: usize,
filler_gain_idle: f32,
filler_gain_duck: f32,
backchannel_gain: f32,
external_gain: f32,
) -> Self {
Self {
tts_queue: SampleRingBuffer::new(queue_capacity),
filler_queue: SampleRingBuffer::new(queue_capacity),
start_filler_queue: SampleRingBuffer::new(queue_capacity),
backchannel_queue: SampleRingBuffer::new(queue_capacity),
external_queue: SampleRingBuffer::new(queue_capacity),
filler_gain_idle,
filler_gain_duck,
backchannel_gain,
external_gain: Arc::new(AtomicU32::new(external_gain.to_bits())),
tts_prebuffer_samples,
tts_primed: Arc::new(AtomicBool::new(false)),
tts_drain_frames: Arc::new(AtomicUsize::new(0)),
tts_stream_end: Arc::new(AtomicBool::new(false)),
tts_stream_active: Arc::new(AtomicBool::new(false)),
tts_playback_started: Arc::new(AtomicBool::new(false)),
tts_first_chunk: Arc::new(AtomicBool::new(false)),
}
}
@@ -103,14 +131,26 @@ impl AudioMixer {
/// bytes long and contains little-endian 16-bit mono PCM.
///
/// Mixing formula:
/// `mixed = tts + filler * filler_gain + backchannel * backchannel_gain`.
/// `mixed = tts + (filler + start_filler) * filler_gain
/// + backchannel * backchannel_gain + external * external_gain`.
/// The filler gain is dynamically attenuated to `filler_gain_duck` while
/// TTS is being played out, otherwise it stays at `filler_gain_idle`; the
/// backchannel gain is fixed and low so the cue stays in the background.
/// The start-filler channel shares the filler gain, so the immediate
/// acknowledgment ducks out of the way once the reply starts. The external
/// channel is a user-injected background source with its own (runtime
/// adjustable) gain.
pub fn pull_frame(&self) -> Option<Vec<u8>> {
// TTS pre-buffer / priming logic.
let primed = self.tts_primed.load(Ordering::Relaxed);
let tts_len = self.tts_queue.len();
// The first audio chunk of the turn has arrived as soon as anything is
// queued, even if the pre-buffer is not full yet and playback has not
// started. Latched here (20 ms ticker) so callers can tell "no audio
// yet" apart from "audio arrived but still buffering".
if tts_len > 0 {
self.tts_first_chunk.store(true, Ordering::Relaxed);
}
if !primed {
// Prime when the pre-buffer is reached, or when the TTS stream has
// already ended and anything is queued. The latter handles short
@@ -171,7 +211,9 @@ impl AudioMixer {
// Scratch buffers for one frame — allocation-free hot path.
let mut tts_buf = [0i16; OUTPUT_FRAME_SAMPLES];
let mut filler_buf = [0i16; OUTPUT_FRAME_SAMPLES];
let mut start_filler_buf = [0i16; OUTPUT_FRAME_SAMPLES];
let mut backchannel_buf = [0i16; OUTPUT_FRAME_SAMPLES];
let mut external_buf = [0i16; OUTPUT_FRAME_SAMPLES];
// Only consume TTS when primed (pre-buffer reached). This prevents
// partial frames that cause audible drop-outs.
@@ -181,13 +223,21 @@ impl AudioMixer {
0
};
let filler_n = self.filler_queue.pop_into(&mut filler_buf);
let start_filler_n = self.start_filler_queue.pop_into(&mut start_filler_buf);
let backchannel_n = self.backchannel_queue.pop_into(&mut backchannel_buf);
let external_n = self.external_queue.pop_into(&mut external_buf);
// If no channel produced anything, skip sending a frame.
if tts_n == 0 && filler_n == 0 && backchannel_n == 0 {
if tts_n == 0
&& filler_n == 0
&& start_filler_n == 0
&& backchannel_n == 0
&& external_n == 0
{
return None;
}
let external_gain = f32::from_bits(self.external_gain.load(Ordering::Relaxed));
let mut out = vec![0u8; OUTPUT_FRAME_BYTES];
// Mix into the output byte buffer directly to avoid an extra copy.
let bytes = out.as_mut_slice();
@@ -195,9 +245,13 @@ impl AudioMixer {
let tts = tts_buf[i] as i32;
// Apply filler / backchannel gains in floating point then round back.
let filler_scaled = (filler_buf[i] as f32 * filler_gain).round() as i32;
let start_filler_scaled =
(start_filler_buf[i] as f32 * filler_gain).round() as i32;
let backchannel_scaled =
(backchannel_buf[i] as f32 * self.backchannel_gain).round() as i32;
let mixed = tts + filler_scaled + backchannel_scaled;
let external_scaled = (external_buf[i] as f32 * external_gain).round() as i32;
let mixed =
tts + filler_scaled + start_filler_scaled + backchannel_scaled + external_scaled;
// Clamp to i16 range to prevent overflow / audible pops.
let clamped = mixed.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
let lo = (clamped & 0xFF) as u8;
@@ -215,6 +269,7 @@ impl AudioMixer {
self.tts_stream_end.store(false, Ordering::Relaxed);
self.tts_stream_active.store(true, Ordering::Relaxed);
self.tts_playback_started.store(false, Ordering::Relaxed);
self.tts_first_chunk.store(false, Ordering::Relaxed);
self.tts_primed.store(false, Ordering::Relaxed);
self.tts_drain_frames.store(0, Ordering::Relaxed);
}
@@ -228,14 +283,15 @@ impl AudioMixer {
}
/// Whether the agent is producing or playing audio: a TTS request is in
/// flight, TTS samples are queued (or being played out), or a filler is
/// still queued. Used to gate VAD-driven barge-in, so silence from the user
/// does not flush an idle mixer.
/// flight, TTS samples are queued (or being played out), or a filler /
/// start-filler is still queued. Used to gate VAD-driven barge-in, so
/// silence from the user does not flush an idle mixer.
pub fn is_active(&self) -> bool {
self.tts_stream_active.load(Ordering::Relaxed)
|| self.tts_primed.load(Ordering::Relaxed)
|| self.tts_queue.len() > 0
|| self.filler_queue.len() > 0
|| self.start_filler_queue.len() > 0
}
/// Whether the response audio has started playing since the last
@@ -246,17 +302,38 @@ impl AudioMixer {
self.tts_playback_started.load(Ordering::Relaxed)
}
/// Instantly clear all queues. Used for barge-in / interruption so that
/// stale TTS, filler and backchannel audio is not played after the user
/// speaks.
/// Whether the first response audio chunk has reached the mixer since the
/// last `mark_stream_start`. Unlike [`Self::tts_playback_started`] this is
/// latched on *arrival*, so it is already `true` while the first samples
/// are still sitting in the pre-buffer. `false` means no response audio has
/// been produced yet, i.e. a turn-start filler is still worth playing.
pub fn tts_audio_arrived(&self) -> bool {
self.tts_first_chunk.load(Ordering::Relaxed)
}
/// Instantly clear all *agent* queues. Used for barge-in / interruption so
/// that stale TTS, filler, start-filler and backchannel audio is not played
/// after the user speaks.
///
/// The [`Self::external_queue`] is deliberately left untouched: a barge-in
/// stops the agent, not the background audio the user injected.
pub fn flush(&self) {
self.tts_queue.clear();
self.filler_queue.clear();
self.start_filler_queue.clear();
self.backchannel_queue.clear();
self.tts_primed.store(false, Ordering::Relaxed);
self.tts_drain_frames.store(0, Ordering::Relaxed);
self.tts_stream_end.store(false, Ordering::Relaxed);
self.tts_stream_active.store(false, Ordering::Relaxed);
self.tts_playback_started.store(false, Ordering::Relaxed);
self.tts_first_chunk.store(false, Ordering::Relaxed);
}
/// Set the gain applied to the external audio channel. Used by the client's
/// volume control; takes effect on the next mixed frame.
pub fn set_external_gain(&self, gain: f32) {
self.external_gain
.store(gain.max(0.0).to_bits(), Ordering::Relaxed);
}
}
+295
View File
@@ -0,0 +1,295 @@
//! Per-session audit log: a structured JSONL record of a whole WebSocket
//! session.
//!
//! Each session can be written to its own file (`<dir>/<id>.jsonl`), one JSON
//! object per line. Every record carries a wall-clock timestamp, the elapsed
//! time since the session started, an event discriminator and a payload:
//!
//! ```text
//! {"ts":1729170000000,"elapsed_ms":12,"event":"session_start","data":{…}}
//! {"ts":1729170000123,"elapsed_ms":135,"event":"control","data":{"type":"user_text",…}}
//! {"ts":1729170000456,"elapsed_ms":468,"event":"out","data":{"type":"assistant_delta",…}}
//! {"ts":1729170009000,"elapsed_ms":9012,"event":"session_end","data":{…}}
//! ```
//!
//! The log captures what the client sent (control messages), what the agent
//! sent back (every WebSocket event, tapped on the sender path), the cue
//! emissions, the language switches, the barge-ins and interrupts, plus a
//! configuration snapshot at the start and an event-count summary at the end.
//! It is meant for post-mortem analysis and for tuning latency knobs
//! (`FILLER_DELAY_MS`, `START_FILLER_GO_DELAY`, …).
//!
//! Recording is **best-effort**: when the sink is disabled, or the file cannot
//! be created, or a write fails, the session runs unchanged (failures are
//! logged at most once and then the sink goes quiet). Nothing on this path ever
//! aborts or delays a session.
use crate::config::Config;
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tracing::{info, warn};
/// Process-wide sequence number, so two sessions that start in the same
/// millisecond still get distinct ids.
static SESSION_SEQ: AtomicU64 = AtomicU64::new(0);
/// Wall-clock milliseconds since the Unix epoch (0 if the clock predates it).
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Mutable state behind the sink's lock.
struct Inner {
/// Buffered file writer; flushed after every record so the log survives a
/// crash or a `pm2 restart`.
writer: BufWriter<File>,
/// Event counts by discriminator, for the end-of-session summary.
counts: BTreeMap<String, u64>,
/// Set after a write error, so we stop retrying on every event.
failed: bool,
}
/// A per-session JSONL audit sink. Shared across the session tasks through an
/// `Arc`; safe to call from any task. A disabled sink makes every method a
/// no-op.
pub struct SessionAudit {
/// Unique session id (also the file stem), e.g. `s-1729170000000-0001`.
id: String,
/// The file being written (for logging and the summary).
path: Option<PathBuf>,
/// `None` when disabled or when the file could not be opened.
inner: Option<Mutex<Inner>>,
/// Session start instant, the origin of every `elapsed_ms`.
start: Instant,
}
impl SessionAudit {
/// Create the sink for a new session. `SESSION_AUDIT_ENABLED=false`
/// disables it; a failure to create the directory or the file (logged as a
/// warning) also yields a disabled sink.
pub fn new(config: &Config) -> Self {
let start = Instant::now();
if !config.session_audit_enabled {
return Self::disabled(start);
}
let seq = SESSION_SEQ.fetch_add(1, Ordering::Relaxed);
let id = format!("s-{}-{:04}", now_ms(), seq);
let path = config.session_audit_dir.join(format!("{id}.jsonl"));
if let Err(e) = std::fs::create_dir_all(&config.session_audit_dir) {
warn!(
dir = %config.session_audit_dir.display(),
error = %e,
"session audit: cannot create directory — logging disabled"
);
return Self::disabled(start);
}
let file = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
warn!(
path = %path.display(),
error = %e,
"session audit: cannot open file — logging disabled"
);
return Self::disabled(start);
}
};
info!(id = %id, path = %path.display(), "session audit log opened");
Self {
id,
path: Some(path),
inner: Some(Mutex::new(Inner {
writer: BufWriter::new(file),
counts: BTreeMap::new(),
failed: false,
})),
start,
}
}
/// An inert sink (nothing is written).
fn disabled(start: Instant) -> Self {
Self {
id: String::new(),
path: None,
inner: None,
start,
}
}
/// Append one record. `kind` is the discriminator (e.g. `session_start`,
/// `control`, `out`, `barge_in`); `data` is the payload. Best-effort: a
/// write failure disables the sink for the rest of the session.
pub fn event(&self, kind: &str, data: Value) {
let Some(inner) = &self.inner else {
return;
};
let mut inner = inner.lock();
if inner.failed {
return;
}
let record = json!({
"ts": now_ms(),
"elapsed_ms": self.start.elapsed().as_millis() as u64,
"event": kind,
"data": data,
});
let mut line = record.to_string();
line.push('\n');
if let Err(e) = inner
.writer
.write_all(line.as_bytes())
.and_then(|_| inner.writer.flush())
{
warn!(
error = %e,
"session audit: write failed — logging disabled for this session"
);
inner.failed = true;
return;
}
*inner.counts.entry(kind.to_string()).or_insert(0) += 1;
}
/// Write the opening record: the full configuration snapshot plus the
/// runtime capabilities that are not part of [`Config`] (whether STT, VAD,
/// tokenizer and LLM are actually active for this process).
pub fn log_start(&self, config: &Config, runtime: Value) {
self.event(
"session_start",
json!({ "config": config_snapshot(config), "runtime": runtime }),
);
}
/// Write the closing record with the event counts and the session duration.
pub fn summary(&self, reason: &str) {
let Some(inner) = &self.inner else {
return;
};
let counts = inner.lock().counts.clone();
self.event(
"session_end",
json!({
"reason": reason,
"duration_ms": self.start.elapsed().as_millis() as u64,
"session_id": self.id,
"file": self.path.as_ref().map(|p| p.display().to_string()),
"counts": counts,
}),
);
}
}
/// A compact, serializable view of everything that shapes a session's
/// observable behaviour. Secrets (API keys) are deliberately omitted.
fn config_snapshot(c: &Config) -> Value {
json!({
"server_port": c.server_port,
"tls": c.tls_cert.is_some() && c.tls_key.is_some(),
"tts": {
"engine": c.tts_engine,
"sglang_url": c.sglang_url,
"model": c.tts_model,
"voice": c.tts_voice,
"reference_audio": c.tts_reference_audio.as_ref().map(|p| p.display().to_string()),
"temperature": c.tts_temperature,
"top_p": c.tts_top_p,
"top_k": c.tts_top_k,
"style_tag": c.tts_style_tag,
"prebuffer_ms": c.tts_prebuffer_ms,
"pipeline_depth": c.tts_pipeline_depth,
},
"stt": {
"model_path": c.stt_model_path.display().to_string(),
"tokenizer_path": c.stt_tokenizer_path.as_ref().map(|p| p.display().to_string()),
},
"filler": {
"enabled": c.filler_enabled,
"dir": c.fillers_dir.display().to_string(),
"delay_ms": c.filler_delay_ms,
"gain_idle": c.filler_gain_idle,
"gain_duck": c.filler_gain_duck,
},
"start_filler": {
"enabled": c.start_filler_enabled,
"dir": c.start_filler_dir.display().to_string(),
"go_delay_ms": c.start_filler_go_delay_ms,
"texts": c.start_filler_texts,
},
"backchannel": {
"enabled": c.backchannel.enabled,
"dir": c.backchannel.dir.display().to_string(),
"gain": c.backchannel.gain,
"min_speech_ms": c.backchannel.min_speech_ms,
"interval_ms": c.backchannel.interval_ms,
"jitter_ms": c.backchannel.jitter_ms,
"pause_min_ms": c.backchannel.pause_min_ms,
"pause_max_ms": c.backchannel.pause_max_ms,
"prob": c.backchannel.prob,
"volume_jitter": c.backchannel.volume_jitter,
"max_per_turn": c.backchannel.max_per_turn,
"texts": c.backchannel.texts,
"categories": c.backchannel.categories,
},
"external_audio": {
"gain": c.external_audio_gain,
"ffmpeg_bin": c.media_ffmpeg_bin,
"ytdlp_bin": c.media_ytdlp_bin,
"ytdlp_args": c.media_ytdlp_args,
},
"vad": {
"enabled": c.vad.enabled,
"model_path": c.vad.model_path.display().to_string(),
"threshold": c.vad.threshold,
"neg_threshold": c.vad.neg_threshold,
"min_speech_ms": c.vad.min_speech_ms,
"min_silence_ms": c.vad.min_silence_ms,
"speech_pad_ms": c.vad.speech_pad_ms,
"barge_in_min_speech_ms": c.vad.barge_in_min_speech_ms,
"segment_stt": c.vad.segment_stt,
"barge_in": c.vad.barge_in,
"debug": c.vad.debug,
},
"lang": {
"default": c.lang_default,
"detect_enabled": c.lang_detect_enabled,
"min_chars": c.lang_min_chars,
"redetect_every": c.lang_redetect_every,
"llm_fallback": c.lang_llm_fallback_enabled,
},
"llm": {
"enabled": c.llm_enabled,
"base_url": c.llm_base_url,
"model": c.llm_model,
"max_tokens": c.llm_max_tokens,
"temperature": c.llm_temperature,
"stream": c.llm_stream,
"history_messages": c.llm_history_messages,
"enable_thinking": c.llm_enable_thinking,
"strip_reasoning": c.llm_strip_reasoning,
"direct_answers": c.llm_direct_answers,
"stream_first_chunk_chars": c.llm_stream_first_chunk_chars,
"stream_chunk_chars": c.llm_stream_chunk_chars,
"system_prompt": c.llm_system_prompt,
"tools": {
"enabled": c.llm_tools_enabled,
"file": c.llm_tools_file,
"max_iterations": c.llm_tool_max_iterations,
"timeout_secs": c.llm_tool_timeout_secs,
"result_max_chars": c.llm_tool_result_max_chars,
},
},
})
}
+230 -2
View File
@@ -64,6 +64,26 @@ pub struct Config {
/// 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>,
/// Sampling temperature sent to the SGLang speech endpoint
/// (`TTS_TEMPERATURE`, default `0.45`). `None` omits the field, falling
/// back to the server default. Lower values reduce the "theatrical"
/// over-articulation of Drama 3.
pub tts_temperature: Option<f32>,
/// Nucleus sampling top-p sent to SGLang (`TTS_TOP_P`, default `0.75`).
/// `None` omits the field. Trims the tail of the acoustic token
/// distribution, where exaggerated intonations live.
pub tts_top_p: Option<f32>,
/// Top-k sampling sent to SGLang (`TTS_TOP_K`, default unset/omitted).
pub tts_top_k: Option<u32>,
/// Neutral style tag prepended to every synthesis request that does not
/// already start with one (`TTS_STYLE_TAG`, default
/// `[conversational, casual]`). Empty disables it.
///
/// Drama 3 replies are plain LLM prose (the system prompt forbids stage
/// directions), so this is the only way to steer the *reply* away from a
/// recitative register; clip banks that carry their own tag are left
/// untouched.
pub tts_style_tag: String,
/// Filesystem directory containing the Parakeet TDT ONNX pipeline
/// (`nemo128.onnx`, `encoder-model*.onnx`, `decoder_joint-model.onnx`,
/// `vocab.txt`, `config.json`).
@@ -84,7 +104,7 @@ pub struct Config {
/// 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 `450`). Larger values absorb
/// starts playing (`TTS_PREBUFFER_MS`, default `170`). 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
@@ -105,6 +125,45 @@ pub struct Config {
pub filler_gain_idle: f32,
/// Filler gain applied while TTS is active (ducking).
pub filler_gain_duck: f32,
/// Fixed gain applied to the external audio channel (`EXTERNAL_AUDIO_GAIN`,
/// default `0.6`) — the mixer channel used to inject an audio stream coming
/// from a URL / radio / YouTube source (see [`crate::media`]). Kept below
/// `1.0` so background audio sits under the agent's voice.
pub external_audio_gain: f32,
/// Binary used to fetch and decode external audio sources
/// (`MEDIA_FFMPEG_BIN`, default `ffmpeg`). ffmpeg handles direct media URLs,
/// Icecast/Shoutcast radio and HLS streams.
pub media_ffmpeg_bin: String,
/// Binary used to resolve YouTube URLs to a decodable stream
/// (`MEDIA_YTDLP_BIN`, default `yt-dlp`). Only needed for YouTube sources.
pub media_ytdlp_bin: String,
/// Extra arguments appended to the yt-dlp invocation
/// (`MEDIA_YTDLP_ARGS`, space-separated). Useful to pass cookies or a
/// JavaScript runtime, e.g. `--cookies /path/cookies.txt`.
pub media_ytdlp_args: Vec<String>,
/// Whether the immediate *start filler* acknowledgment is used at all
/// (`START_FILLER_ENABLED`). The cue is queued on its own mixer channel
/// whenever the response audio is late, so the agent acknowledges the turn
/// while the reply is still being generated (see
/// [`Config::start_filler_go_delay_ms`]).
pub start_filler_enabled: bool,
/// How long (ms) the agent waits for the response audio to start before
/// playing the start filler (`START_FILLER_GO_DELAY`, default `1200`). Same
/// idea as [`Config::filler_delay_ms`] — a late reply is masked with a
/// short acknowledgment, an on-time one is not — but on the dedicated
/// start-filler channel and with its own (shorter) delay. If playback has
/// already begun within this window the cue is dropped.
pub start_filler_go_delay_ms: u64,
/// Directory holding the pre-generated start-filler clips
/// (`START_FILLER_DIR`). One clip per entry in
/// [`Config::start_filler_texts`] is synthesized at startup.
pub start_filler_dir: PathBuf,
/// Phrases spoken by the start filler (`START_FILLER_TEXTS`), one per line
/// (a `|` separator also works), e.g. `"[calm] Un momento..."`. One clip is
/// synthesized per phrase and a random one is played at each turn start, so
/// the agent's acknowledgment varies. The clips are re-synthesized on every
/// startup, so editing this list takes effect at the next restart.
pub start_filler_texts: Vec<String>,
/// 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-
@@ -116,6 +175,29 @@ pub struct Config {
pub vad: VadSettings,
/// Backchannel settings (see [`crate::orchestrator::backchannel`]).
pub backchannel: BackchannelSettings,
/// Language of the shipped clip banks (`LANG_DEFAULT`, default `es`).
///
/// The base directories (`BACKCHANNEL_DIR`, `FILLERS_DIR`) hold the clips
/// of this language; the clips of any other language are generated on
/// demand into a subdirectory named after the ISO 639-1 code. See
/// [`crate::lang`].
pub lang_default: String,
/// Detect the user's language with a dedicated LLM call and switch the
/// backchannel / end-of-turn filler clip banks to it when it differs from
/// the active one (`LANG_DETECT_ENABLED`, default `true`).
pub lang_detect_enabled: bool,
/// Minimum transcript length (chars) before a sample is sent to the LLM
/// (`LANG_MIN_CHARS`, default `12`): a shorter text is not a reliable
/// language sample.
pub lang_min_chars: usize,
/// Re-detect the language every N user turns (`LANG_REDETECT_EVERY`,
/// default `5`). The first turn is always checked, so a language switch
/// mid-conversation is picked up without a detection call on every turn.
pub lang_redetect_every: u64,
/// Generate the phrase list with the LLM for languages without a built-in
/// table (`LANG_LLM_FALLBACK_ENABLED`, default `true`). See
/// [`crate::lang::BACKCHANNEL_TEXTS`].
pub lang_llm_fallback_enabled: bool,
/// Master switch for the reasoning LLM (`LLM_ENABLED`). When off the
/// pipeline runs STT → TTS with no reasoning step.
pub llm_enabled: bool,
@@ -164,6 +246,31 @@ pub struct Config {
/// 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,
/// Master switch for LLM tool calling (`LLM_TOOLS_ENABLED`). Defaults to
/// enabled when `LLM_TOOLS_FILE` points to a loadable file.
pub llm_tools_enabled: bool,
/// Path to the JSON file listing the HTTP tools
/// (`LLM_TOOLS_FILE`, see [`crate::tools`]). Empty disables tool calling.
pub llm_tools_file: String,
/// Maximum tool-call round trips per user turn
/// (`LLM_TOOL_MAX_ITERATIONS`, default `4`). Bounds a runaway loop.
pub llm_tool_max_iterations: usize,
/// Per-request timeout (seconds) for a tool HTTP call
/// (`LLM_TOOL_TIMEOUT_SECS`, default `15`).
pub llm_tool_timeout_secs: u64,
/// Maximum characters of a tool response handed back to the model
/// (`LLM_TOOL_RESULT_MAX_CHARS`, default `2000`); longer responses are
/// truncated to protect the context window.
pub llm_tool_result_max_chars: usize,
/// Write a structured per-session audit log to disk
/// (`SESSION_AUDIT_ENABLED`, default `true`). One JSONL file per session,
/// holding the configuration snapshot, the control messages, every outbound
/// event, the cues, the language switches, the barge-ins and a summary — see
/// [`crate::audit`].
pub session_audit_enabled: bool,
/// Directory holding the per-session audit files (`SESSION_AUDIT_DIR`,
/// default `logs/sessions`).
pub session_audit_dir: PathBuf,
}
impl Default for Config {
@@ -179,19 +286,36 @@ impl Default for Config {
// 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,
tts_temperature: Some(0.45),
tts_top_p: Some(0.75),
tts_top_k: None,
tts_style_tag: "[conversational, casual]".to_string(),
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: 450,
tts_prebuffer_ms: 170,
tts_pipeline_depth: 3,
filler_gain_idle: 1.0,
filler_gain_duck: 0.15,
external_audio_gain: 0.6,
media_ffmpeg_bin: "ffmpeg".to_string(),
media_ytdlp_bin: "yt-dlp".to_string(),
media_ytdlp_args: Vec::new(),
start_filler_enabled: true,
start_filler_go_delay_ms: 1200,
start_filler_dir: PathBuf::from("assets/start_filler"),
start_filler_texts: vec!["[calm] Un momento...".to_string()],
tls_cert: None,
tls_key: None,
vad: VadSettings::default(),
backchannel: BackchannelSettings::default(),
lang_default: "es".to_string(),
lang_detect_enabled: true,
lang_min_chars: 12,
lang_redetect_every: 5,
lang_llm_fallback_enabled: true,
llm_enabled: false,
llm_base_url: String::new(),
llm_api_key: "EMPTY".to_string(),
@@ -206,6 +330,13 @@ impl Default for Config {
llm_direct_answers: true,
llm_stream_first_chunk_chars: 20,
llm_stream_chunk_chars: 120,
llm_tools_enabled: false,
llm_tools_file: String::new(),
llm_tool_max_iterations: 4,
llm_tool_timeout_secs: 15,
llm_tool_result_max_chars: 2000,
session_audit_enabled: true,
session_audit_dir: PathBuf::from("logs/sessions"),
}
}
}
@@ -243,6 +374,15 @@ impl Config {
cfg.tts_reference_text = Some(t);
}
}
// Optional sampling overrides. Setting a variable to an empty value
// disables the field (the server default then applies), so the
// de-emphasis can be turned off without editing code.
cfg.tts_temperature = env_opt_f32("TTS_TEMPERATURE", cfg.tts_temperature);
cfg.tts_top_p = env_opt_f32("TTS_TOP_P", cfg.tts_top_p);
cfg.tts_top_k = env_opt_u32("TTS_TOP_K", cfg.tts_top_k);
if let Ok(v) = std::env::var("TTS_STYLE_TAG") {
cfg.tts_style_tag = v.trim().to_string();
}
if let Ok(p) = std::env::var("STT_MODEL_PATH") {
cfg.stt_model_path = PathBuf::from(p);
}
@@ -256,6 +396,26 @@ impl Config {
}
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.start_filler_enabled = env_bool("START_FILLER_ENABLED", cfg.start_filler_enabled);
cfg.start_filler_go_delay_ms =
env_u32("START_FILLER_GO_DELAY", cfg.start_filler_go_delay_ms as u32) as u64;
if let Ok(d) = std::env::var("START_FILLER_DIR") {
if !d.is_empty() {
cfg.start_filler_dir = PathBuf::from(d);
}
}
if let Ok(t) = std::env::var("START_FILLER_TEXTS") {
// One phrase per line; a `|` separator is also accepted so the list
// can live on a single line. Blank entries are dropped.
let texts: Vec<String> = t
.split(['\n', '|'])
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !texts.is_empty() {
cfg.start_filler_texts = texts;
}
}
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;
@@ -269,8 +429,42 @@ impl Config {
cfg.tls_key = Some(PathBuf::from(p));
}
}
// External audio source (URL / radio / YouTube) — see `crate::media`.
cfg.external_audio_gain = env_f32("EXTERNAL_AUDIO_GAIN", cfg.external_audio_gain);
if let Ok(v) = std::env::var("MEDIA_FFMPEG_BIN") {
if !v.trim().is_empty() {
cfg.media_ffmpeg_bin = v.trim().to_string();
}
}
if let Ok(v) = std::env::var("MEDIA_YTDLP_BIN") {
if !v.trim().is_empty() {
cfg.media_ytdlp_bin = v.trim().to_string();
}
}
if let Ok(v) = std::env::var("MEDIA_YTDLP_ARGS") {
cfg.media_ytdlp_args = v.split_whitespace().map(str::to_string).collect();
}
cfg.vad = VadSettings::from_env();
cfg.backchannel = BackchannelSettings::from_env();
// Multilingual clip banks (see `crate::lang`): the language of the
// shipped banks plus the runtime detection / regeneration knobs.
if let Ok(v) = std::env::var("LANG_DEFAULT") {
if !v.trim().is_empty() {
cfg.lang_default = v.trim().to_lowercase();
}
}
cfg.lang_detect_enabled = env_bool("LANG_DETECT_ENABLED", cfg.lang_detect_enabled);
cfg.lang_min_chars = env_u32("LANG_MIN_CHARS", cfg.lang_min_chars as u32) as usize;
cfg.lang_redetect_every = env_u64("LANG_REDETECT_EVERY", cfg.lang_redetect_every).max(1);
cfg.lang_llm_fallback_enabled =
env_bool("LANG_LLM_FALLBACK_ENABLED", cfg.lang_llm_fallback_enabled);
// Per-session audit log (see `crate::audit`).
if let Ok(d) = std::env::var("SESSION_AUDIT_DIR") {
if !d.trim().is_empty() {
cfg.session_audit_dir = PathBuf::from(d.trim());
}
}
cfg.session_audit_enabled = env_bool("SESSION_AUDIT_ENABLED", cfg.session_audit_enabled);
cfg.read_llm_env();
cfg
}
@@ -310,6 +504,21 @@ impl Config {
self.llm_stream_chunk_chars =
env_u32("LLM_STREAM_CHUNK_CHARS", self.llm_stream_chunk_chars as u32) as usize;
// Tool calling (configurable HTTP tools). Enabled by default as soon as
// a tools file is configured; see `crate::tools`.
if let Ok(v) = std::env::var("LLM_TOOLS_FILE") {
if !v.trim().is_empty() {
self.llm_tools_file = v.trim().to_string();
}
}
self.llm_tool_max_iterations =
env_u32("LLM_TOOL_MAX_ITERATIONS", self.llm_tool_max_iterations as u32).max(1) as usize;
self.llm_tool_timeout_secs = env_u64("LLM_TOOL_TIMEOUT_SECS", self.llm_tool_timeout_secs);
self.llm_tool_result_max_chars =
env_u32("LLM_TOOL_RESULT_MAX_CHARS", self.llm_tool_result_max_chars as u32) as usize;
self.llm_tools_enabled =
env_bool("LLM_TOOLS_ENABLED", !self.llm_tools_file.is_empty());
// 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);
@@ -407,6 +616,25 @@ pub(crate) fn env_f32(name: &str, default: f32) -> f32 {
.unwrap_or(default)
}
/// Parse an optional `f32` sampling knob: `fallback` when unset or malformed,
/// `None` when set but empty (disables the field), `Some(v)` otherwise.
pub(crate) fn env_opt_f32(name: &str, fallback: Option<f32>) -> Option<f32> {
match std::env::var(name) {
Ok(v) if v.trim().is_empty() => None,
Ok(v) => v.trim().parse().ok().or(fallback),
Err(_) => fallback,
}
}
/// Parse an optional `u32` sampling knob (see [`env_opt_f32`]).
pub(crate) fn env_opt_u32(name: &str, fallback: Option<u32>) -> Option<u32> {
match std::env::var(name) {
Ok(v) if v.trim().is_empty() => None,
Ok(v) => v.trim().parse().ok().or(fallback),
Err(_) => fallback,
}
}
/// 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)
+433
View File
@@ -0,0 +1,433 @@
//! Multilingual clip banks: language detection and per-language phrase tables.
//!
//! The shipped clip banks (backchannel, end-of-turn filler and start filler,
//! built in [`crate::assets`]) are pre-synthesized in a single language — the
//! one configured by `LANG_DEFAULT` (Spanish by default), which lives in the
//! base directories (`BACKCHANNEL_DIR`, `FILLERS_DIR`, `START_FILLER_DIR`).
//! When the user is detected speaking a different language, the banks for that
//! language are generated on demand into a per-language subdirectory
//! (`<base>/<lang>/`, ISO 639-1) and the session switches to them. Every
//! language has its own directory, so regenerating one never removes the clips
//! of another.
//!
//! Detection is a dedicated LLM call (`LANG_DETECT_ENABLED`) returning the ISO
//! 639-1 code of the transcript. The phrase lists come from the built-in tables
//! below for the common languages, and from the LLM
//! (`LANG_LLM_FALLBACK_ENABLED`) for any other language.
use crate::llm::LlmClient;
use crate::orchestrator::FillerBank;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
/// Maximum number of phrases accepted from an LLM-generated list.
const LLM_TEXT_LIMIT: usize = 12;
/// Backchannel phrases per language, as `(category, phrase)` pairs.
///
/// The category drives the progression through a turn (an acknowledgment first,
/// agreement later — see [`crate::orchestrator::backchannel`]); the Drama-3
/// style tag steers the delivery and is passed straight to the TTS. Spanish
/// matches [`DEFAULT_BACKCHANNEL_TEXTS`](crate::orchestrator::backchannel::DEFAULT_BACKCHANNEL_TEXTS),
/// the language of the shipped clip bank.
pub const BACKCHANNEL_TEXTS: &[(&str, &[(&str, &str)])] = &[
(
"es",
&[
("ack", "[calm] Mm-hmm..."),
("ack", "[attentive] Ajá..."),
("agree", "[agreeing] Claro..."),
("ack", "[listening] Ya veo..."),
("ack", "[attentive] Sí, sí..."),
("ack", "[calm] Entiendo..."),
("agree", "[relaxed] Vale..."),
("ack", "[thoughtful] Ah, ya..."),
],
),
(
"en",
&[
("ack", "[calm] Mm-hmm..."),
("ack", "[attentive] Uh-huh..."),
("agree", "[agreeing] Right..."),
("ack", "[listening] I see..."),
("ack", "[attentive] Yeah, yeah..."),
("ack", "[calm] Got it..."),
("agree", "[relaxed] Okay..."),
("ack", "[thoughtful] Oh, I see..."),
],
),
(
"it",
&[
("ack", "[calm] Mmm..."),
("ack", "[attentive] Eh già..."),
("agree", "[agreeing] Certo..."),
("ack", "[listening] Capisco..."),
("ack", "[attentive] Sì, sì..."),
("ack", "[calm] Ho capito..."),
("agree", "[relaxed] Va bene..."),
("ack", "[thoughtful] Ah, ecco..."),
],
),
(
"fr",
&[
("ack", "[calm] Mm-hmm..."),
("ack", "[attentive] Hum, oui..."),
("agree", "[agreeing] Bien sûr..."),
("ack", "[listening] Je vois..."),
("ack", "[attentive] Oui, oui..."),
("ack", "[calm] D'accord..."),
("agree", "[relaxed] Très bien..."),
("ack", "[thoughtful] Ah, je vois..."),
],
),
(
"de",
&[
("ack", "[calm] Mhm..."),
("ack", "[attentive] Aha..."),
("agree", "[agreeing] Genau..."),
("ack", "[listening] Ich verstehe..."),
("ack", "[attentive] Ja, ja..."),
("ack", "[calm] Verstehe..."),
("agree", "[relaxed] In Ordnung..."),
("ack", "[thoughtful] Ach so..."),
],
),
(
"pt",
&[
("ack", "[calm] Mm-hmm..."),
("ack", "[attentive] Aham..."),
("agree", "[agreeing] Claro..."),
("ack", "[listening] Entendo..."),
("ack", "[attentive] Sim, sim..."),
("ack", "[calm] Entendi..."),
("agree", "[relaxed] Tá bom..."),
("ack", "[thoughtful] Ah, entendi..."),
],
),
];
/// End-of-turn filler interjections per language, played round-robin. Spanish
/// matches [`FILLER_CLIPS`](crate::assets::FILLER_CLIPS), the language of the
/// shipped filler bank.
pub const FILLER_TEXTS: &[(&str, &[&str])] = &[
("es", &["Uhm...", "Eh...", "Mmm...", "A ver...", "Bueno...", "Pues...", "Vaya..."]),
("en", &["Uhm...", "Hmm...", "Mmm...", "Well...", "Uh, let's see...", "Okay...", "Right..."]),
("it", &["Uhm...", "Eh...", "Mmm...", "Vediamo...", "Allora...", "Beh...", "Dunque..."]),
("fr", &["Hum...", "Euh...", "Mmm...", "Voyons...", "Alors...", "Ben...", "Bref..."]),
("de", &["Ähm...", "Hm...", "Mmm...", "Mal sehen...", "Also...", "Naja...", "Tja..."]),
("pt", &["Hã...", "É...", "Mmm...", "Vejamos...", "Então...", "Bom...", "Pois..."]),
];
/// Start-filler phrases per language: the turn-start acknowledgment played when
/// the reply audio is late (see
/// [`TurnManager::schedule_start_filler`](crate::orchestrator::turn_manager::TurnManager)).
/// Each phrase carries a Drama-3 style tag. Spanish matches the shipped default
/// `START_FILLER_TEXTS`, the language of the base clip bank.
pub const START_FILLER_TEXTS: &[(&str, &[&str])] = &[
(
"es",
&["[calm] Un momento...", "[thoughtful] A ver, veamos...", "[sigh] Vale, ya voy..."],
),
(
"en",
&["[calm] One moment...", "[thoughtful] Let me see...", "[sigh] Okay, coming up..."],
),
(
"it",
&["[calm] Un momento...", "[thoughtful] Vediamo un po'...", "[sigh] Va bene, arrivo..."],
),
(
"fr",
&["[calm] Un instant...", "[thoughtful] Voyons voir...", "[sigh] Bon, voilà..."],
),
(
"de",
&["[calm] Einen Moment...", "[thoughtful] Mal sehen...", "[sigh] Okay, gleich..."],
),
(
"pt",
&["[calm] Um momento...", "[thoughtful] Vejamos...", "[sigh] Tá bom, já vai..."],
),
];
/// Backchannel phrases of `lang` as `(category, phrase)` pairs, or `None` when
/// the language is not in the built-in table.
pub fn backchannel_texts(lang: &str) -> Option<Vec<(String, String)>> {
BACKCHANNEL_TEXTS
.iter()
.find(|(l, _)| *l == lang)
.map(|(_, list)| {
list.iter()
.map(|(c, p)| (c.to_string(), p.to_string()))
.collect()
})
}
/// End-of-turn filler phrases of `lang`, or `None` when the language is not in
/// the built-in table.
pub fn filler_texts(lang: &str) -> Option<Vec<String>> {
FILLER_TEXTS
.iter()
.find(|(l, _)| *l == lang)
.map(|(_, list)| list.iter().map(|s| s.to_string()).collect())
}
/// Start-filler phrases of `lang`, or `None` when the language is not in the
/// built-in table.
pub fn start_filler_texts(lang: &str) -> Option<Vec<String>> {
START_FILLER_TEXTS
.iter()
.find(|(l, _)| *l == lang)
.map(|(_, list)| list.iter().map(|s| s.to_string()).collect())
}
/// The clip banks of one language, published together so the filler path
/// (orchestrator) and the backchannel path (mic task) always agree on which
/// language is active.
#[derive(Clone)]
pub struct LangBanks {
/// ISO 639-1 code of this bank.
pub lang: String,
/// Backchannel clips for this language.
pub backchannel: Arc<FillerBank>,
/// Spoken phrases of [`Self::backchannel`], in clip order.
pub backchannel_texts: Vec<String>,
/// Category of each phrase in [`Self::backchannel_texts`] (parallel array).
pub backchannel_categories: Vec<String>,
/// End-of-turn filler clips for this language.
pub filler: Arc<FillerBank>,
/// Start-filler clips for this language.
pub start_filler: Arc<FillerBank>,
}
/// Per-session language state, shared between the turn manager (end-of-turn and
/// start filler) and the microphone task (backchannel).
pub struct LanguageState {
/// The banks of the configured default language (`LANG_DEFAULT`), i.e. the
/// base directories. Kept so switching back needs no regeneration.
base: Arc<LangBanks>,
/// Currently active banks.
current: parking_lot::RwLock<Arc<LangBanks>>,
/// Bumped on every switch so the mic task can notice a change with a single
/// atomic load per audio frame.
generation: AtomicU64,
}
impl LanguageState {
pub fn new(
lang: String,
backchannel: Arc<FillerBank>,
backchannel_texts: Vec<String>,
backchannel_categories: Vec<String>,
filler: Arc<FillerBank>,
start_filler: Arc<FillerBank>,
) -> Self {
let base = Arc::new(LangBanks {
lang,
backchannel,
backchannel_texts,
backchannel_categories,
filler,
start_filler,
});
Self {
current: parking_lot::RwLock::new(base.clone()),
base,
generation: AtomicU64::new(0),
}
}
/// The banks currently in use.
pub fn current(&self) -> Arc<LangBanks> {
self.current.read().clone()
}
/// The banks of the default language (base directories).
pub fn base(&self) -> Arc<LangBanks> {
self.base.clone()
}
/// Current generation; changes whenever the active language changes.
pub fn generation(&self) -> u64 {
self.generation.load(Ordering::Acquire)
}
/// Publish a new set of banks and bump the generation.
pub fn set(&self, banks: LangBanks) {
*self.current.write() = Arc::new(banks);
self.generation.fetch_add(1, Ordering::AcqRel);
}
}
/// System prompt of the language-identification call. Written to yield *only*
/// the ISO code, so the reply can be parsed without a JSON or function-call
/// round trip (this deployment runs no server-side tool parser).
const DETECT_SYSTEM_PROMPT: &str = "You are a language identification tool. Read the user's \
text and reply with only the ISO 639-1 two-letter code of the language it is written in \
(for example: en, es, it, fr, de, pt). No punctuation, no quotes, no explanation. Reply \
with und if you cannot tell.";
/// Ask the LLM for the ISO 639-1 code of `sample`.
///
/// Best-effort: `None` on any request failure or when the reply does not carry
/// a plausible code, in which case the currently active language simply stays.
pub async fn detect_language(llm: &LlmClient, sample: &str) -> Option<String> {
let cancel = CancellationToken::new();
let reply = match llm
.simple_chat(DETECT_SYSTEM_PROMPT, sample, 8, 0.0, &cancel)
.await
{
Ok(r) => r,
Err(e) => {
warn!(error = %e, "language detection call failed");
return None;
}
};
parse_lang_code(&reply)
}
/// The first run of ASCII letters in `raw`, lowercased, kept only when it looks
/// like an ISO 639-1 code (`"es."` → `"es"`, `"Spanish"` → `None`,
/// `"und"` → `None`).
fn parse_lang_code(raw: &str) -> Option<String> {
let mut run = String::new();
let mut started = false;
for c in raw.trim().chars() {
if c.is_ascii_alphabetic() {
started = true;
run.push(c.to_ascii_lowercase());
} else if started {
break;
}
}
match run.len() {
2 | 3 if run != "und" => Some(run),
_ => None,
}
}
const BACKCHANNEL_GEN_PROMPT: &str = "You write backchannel cues: the very short \
acknowledgments a listener says while someone else is speaking, like \"mm-hmm\", \
\"I see\" or \"right\". Write eight different cues in the language with ISO 639-1 code \
{lang}. Each cue is at most three words, ends with an ellipsis and is never a question. \
Reply with one cue per line and nothing else: no numbering, no quotes, no explanations.";
const FILLER_GEN_PROMPT: &str = "You write hesitation fillers: the short sounds a speaker \
makes to buy a moment while thinking, like \"uhm\", \"well\" or \"let me see\". Write \
seven different fillers in the language with ISO 639-1 code {lang}. Each filler is one \
or two words ending with an ellipsis. Reply with one filler per line and nothing else: \
no numbering, no quotes, no explanations.";
const START_FILLER_GEN_PROMPT: &str = "You write turn-start acknowledgments: the very short \
phrases a speaker says to buy a moment right before answering, like \"one moment\", \
\"let me see\" or \"okay, coming up\". Write three different phrases in the language \
with ISO 639-1 code {lang}. Each phrase is two to four words and ends with an ellipsis. \
Reply with one phrase per line and nothing else: no numbering, no quotes, no \
explanations.";
/// Generate a backchannel phrase list with the LLM (fallback for languages
/// without a built-in table). `None` when the call fails or yields nothing
/// usable.
pub async fn generate_backchannel_texts(llm: &LlmClient, lang: &str) -> Option<Vec<String>> {
let prompt = BACKCHANNEL_GEN_PROMPT.replace("{lang}", lang);
generate_texts(llm, &prompt, "backchannel").await
}
/// Generate an end-of-turn filler phrase list with the LLM.
pub async fn generate_filler_texts(llm: &LlmClient, lang: &str) -> Option<Vec<String>> {
let prompt = FILLER_GEN_PROMPT.replace("{lang}", lang);
generate_texts(llm, &prompt, "filler").await
}
/// Generate a start-filler phrase list with the LLM (fallback for languages
/// without a built-in table).
pub async fn generate_start_filler_texts(llm: &LlmClient, lang: &str) -> Option<Vec<String>> {
let prompt = START_FILLER_GEN_PROMPT.replace("{lang}", lang);
generate_texts(llm, &prompt, "start-filler").await
}
async fn generate_texts(llm: &LlmClient, prompt: &str, kind: &str) -> Option<Vec<String>> {
let cancel = CancellationToken::new();
let reply = match llm
.simple_chat(prompt, "Generate the list now.", 256, 0.8, &cancel)
.await
{
Ok(r) => r,
Err(e) => {
warn!(kind, error = %e, "phrase-list generation failed");
return None;
}
};
let texts = parse_lines(&reply, LLM_TEXT_LIMIT);
if texts.len() < 2 {
warn!(kind, lines = texts.len(), "phrase-list generation returned too few usable lines");
return None;
}
info!(kind, phrases = texts.len(), "generated phrase list via LLM");
Some(texts)
}
/// Split an LLM list reply into clean phrases: one per line, bullets/numbering
/// and surrounding quotes removed, over-long lines dropped.
fn parse_lines(raw: &str, max: usize) -> Vec<String> {
raw.lines()
.map(|l| {
l.trim()
.trim_start_matches(|c: char| {
c.is_ascii_digit() || matches!(c, '-' | '*' | '.' | ')' | ':' | ' ')
})
.trim_matches(|c| matches!(c, '"' | '\'' | '`'))
.trim()
})
.filter(|l| !l.is_empty() && l.len() <= 60)
.take(max)
.map(|l| l.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_iso_code_from_various_replies() {
assert_eq!(parse_lang_code("es"), Some("es".to_string()));
assert_eq!(parse_lang_code("EN."), Some("en".to_string()));
assert_eq!(parse_lang_code(" it\n"), Some("it".to_string()));
assert_eq!(parse_lang_code("und"), None);
assert_eq!(parse_lang_code("Spanish"), None);
assert_eq!(parse_lang_code(""), None);
}
#[test]
fn strips_bullets_and_numbering_from_generated_lists() {
let raw = "1. Mmm...\n- Certo...\n\"Va bene...\"\n\n* Capisco...";
assert_eq!(
parse_lines(raw, 10),
vec![
"Mmm...".to_string(),
"Certo...".to_string(),
"Va bene...".to_string(),
"Capisco...".to_string(),
]
);
}
#[test]
fn builtin_tables_cover_the_base_languages() {
for lang in ["es", "en", "it", "fr", "de", "pt"] {
assert!(backchannel_texts(lang).is_some(), "missing backchannel table for {lang}");
assert!(filler_texts(lang).is_some(), "missing filler table for {lang}");
assert!(start_filler_texts(lang).is_some(), "missing start-filler table for {lang}");
}
assert!(backchannel_texts("xx").is_none());
assert!(start_filler_texts("xx").is_none());
}
}
+100 -2
View File
@@ -23,6 +23,7 @@
//! - [`LlmClient::chat`] — single blocking request (simplest, no partial output).
use crate::config::Config;
use crate::tools::ToolRegistry;
use anyhow::{anyhow, Result};
use futures_util::StreamExt;
use reqwest::Client;
@@ -53,6 +54,11 @@ impl ChatMessage {
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: "assistant".into(), content: content.into() }
}
/// A tool result handed back to the model after a tool call.
pub fn tool(content: impl Into<String>) -> Self {
Self { role: "tool".into(), content: content.into() }
}
}
/// Client for an OpenAI-compatible `/chat/completions` endpoint.
@@ -72,9 +78,14 @@ pub struct LlmClient {
/// Strip the chain-of-thought (`reasoning_content` / inline think blocks)
/// from the reply before it is spoken and shown in the chat box.
strip_reasoning: bool,
/// Optional tool registry. When set, every request advertises the tools and
/// the orchestrator runs the call → execute → re-prompt loop. See
/// [`crate::tools`].
tools: Option<Arc<ToolRegistry>>,
}
impl LlmClient {
#[allow(clippy::too_many_arguments)]
pub fn new(
base_url: &str,
api_key: &str,
@@ -83,6 +94,7 @@ impl LlmClient {
temperature: f32,
enable_thinking: Option<bool>,
strip_reasoning: bool,
tools: Option<Arc<ToolRegistry>>,
) -> Self {
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));
// No global request timeout: an SSE stream can legitimately stay open
@@ -100,6 +112,7 @@ impl LlmClient {
temperature,
enable_thinking,
strip_reasoning,
tools,
}
}
@@ -123,6 +136,12 @@ impl LlmClient {
self.strip_reasoning
}
/// The tool registry, if tool calling is configured. Used by the
/// orchestrator to execute parsed calls.
pub fn tools(&self) -> Option<&Arc<ToolRegistry>> {
self.tools.as_ref()
}
fn request_body(&self, messages: &[ChatMessage], stream: bool) -> serde_json::Value {
let mut body = json!({
"model": self.model,
@@ -134,6 +153,15 @@ impl LlmClient {
if let Some(enable) = self.enable_thinking {
body["chat_template_kwargs"] = json!({ "enable_thinking": enable });
}
// Advertise the configured tools so the model can call them. The
// server has no tool parser, so the call comes back as text in the
// native `<tool_call>` format — parsed client-side in `crate::tools`.
if let Some(reg) = &self.tools {
if !reg.is_empty() {
body["tools"] = reg.openai_tools();
body["tool_choice"] = json!("auto");
}
}
body
}
@@ -190,6 +218,52 @@ impl LlmClient {
Ok(self.clean(&self.delta_text(message)))
}
/// One-off non-streaming completion with an explicit system/user pair, its
/// own token budget and temperature, and **no tools advertised**.
///
/// Used by auxiliary calls that must not go through the conversational
/// prompt or the tool loop — language detection and phrase-list generation
/// (see [`crate::lang`]).
pub async fn simple_chat(
&self,
system: &str,
user: &str,
max_tokens: u32,
temperature: f32,
cancel: &CancellationToken,
) -> Result<String> {
let mut body = json!({
"model": self.model,
"messages": [ChatMessage::system(system), ChatMessage::user(user)],
"max_tokens": max_tokens,
"temperature": temperature,
"stream": false,
});
if let Some(enable) = self.enable_thinking {
body["chat_template_kwargs"] = json!({ "enable_thinking": enable });
}
let req = self
.http
.post(&self.endpoint)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&body)
.send();
let resp = tokio::select! {
r = req => r?,
_ = cancel.cancelled() => return Ok(String::new()),
};
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(anyhow!("LLM HTTP {}: {}", status, text.trim()));
}
let value: serde_json::Value = resp.json().await?;
let message = &value["choices"][0]["message"];
Ok(self.clean(&self.delta_text(message)))
}
/// Streaming request. Every clean (think-stripped) text delta is pushed to
/// `delta_tx` as it arrives; the full clean reply is returned at the end.
///
@@ -304,9 +378,31 @@ pub fn create_llm_client(config: &Config) -> Option<Arc<LlmClient>> {
config.llm_temperature,
config.llm_enable_thinking,
config.llm_strip_reasoning,
load_tools(config),
)))
}
/// Load the tool registry from `LLM_TOOLS_FILE` when tool calling is enabled.
/// A missing or invalid file is logged and tool calling is disabled — the
/// pipeline keeps working without tools.
fn load_tools(config: &Config) -> Option<Arc<ToolRegistry>> {
if !config.llm_tools_enabled || config.llm_tools_file.trim().is_empty() {
return None;
}
let path = std::path::Path::new(config.llm_tools_file.trim());
match ToolRegistry::load(path, config.llm_tool_timeout_secs, config.llm_tool_result_max_chars) {
Ok(reg) if reg.is_empty() => {
warn!("tools file present but empty — tool calling disabled");
None
}
Ok(reg) => Some(Arc::new(reg)),
Err(e) => {
warn!(error = %e, "failed to load tools file — tool calling disabled");
None
}
}
}
// --- Chain-of-thought stripping --------------------------------------------
// Opening / closing chain-of-thought markers emitted by Qwen3-style reasoning
@@ -414,13 +510,15 @@ mod tests {
fn request_body_thinking_is_opt_in() {
let msgs = vec![ChatMessage::user("hi")];
// Unset: the vendor-specific field must not be sent.
let plain = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, None, true);
let plain = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, None, true, None);
assert!(plain.request_body(&msgs, true).get("chat_template_kwargs").is_none());
// Explicit: forwarded verbatim.
let off = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, Some(false), true);
let off = LlmClient::new("http://x/v1", "k", "m", 16, 0.5, Some(false), true, None);
let body = off.request_body(&msgs, false);
assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false);
assert_eq!(body["stream"], false);
// Without a registry no `tools` field is advertised.
assert!(body.get("tools").is_none());
}
#[test]
+68 -10
View File
@@ -5,12 +5,16 @@
mod assets;
mod audio;
mod audit;
mod config;
mod lang;
mod llm;
mod media;
mod onnx;
mod orchestrator;
mod stt;
mod tls;
mod tools;
mod tts;
mod vad;
mod web;
@@ -90,33 +94,66 @@ async fn main() -> anyhow::Result<()> {
// backchannel clips can be synthesized with the configured voice).
let tts = create_tts_engine(&config);
// Ensure the filler / backchannel clip banks exist, generating any missing
// clip with the TTS engine (see `crate::assets`).
// Every clip bank lives in a per-language subdirectory `<dir>/<lang>`
// (see `crate::lang`) — including the default language. The base banks of
// `LANG_DEFAULT` are generated and loaded from `<dir>/<lang_default>/`, so
// the filler, start-filler and backchannel share the exact same layout for
// every language and no language is special-cased to the top level.
let fillers_dir = assets::lang_dir(&config.fillers_dir, &config.lang_default);
let start_filler_dir = assets::lang_dir(&config.start_filler_dir, &config.lang_default);
let backchannel_dir = assets::lang_dir(&config.backchannel.dir, &config.lang_default);
// Ensure the filler clip bank exists, generating any missing clip with the
// TTS engine (see `crate::assets`). Existing clips are kept.
let mut generated = assets::ensure_clips(
&config.fillers_dir,
&fillers_dir,
assets::FILLER_CLIPS,
tts.as_ref(),
"filler",
)
.await;
if config.backchannel.enabled {
generated += assets::ensure_clips(
&config.backchannel.dir,
assets::BACKCHANNEL_CLIPS,
// Like the start filler, the backchannel bank follows its phrase list
// (`BACKCHANNEL_TEXTS`): one clip per phrase, re-synthesized only when
// the list changes, and one is picked at random when the cue fires.
generated += assets::ensure_backchannel_clips(
&backchannel_dir,
&config.backchannel.texts,
tts.as_ref(),
)
.await;
}
if config.start_filler_enabled {
// The start-filler bank (one clip per START_FILLER_TEXTS phrase) is
// re-synthesized whenever the phrase list changes, so editing it takes
// effect without deleting the clips by hand; an unchanged list is
// skipped to keep startup fast, and a failed synthesis keeps the
// previous clip. One clip is picked at random at each turn start.
generated += assets::ensure_start_filler_clips(
&start_filler_dir,
&config.start_filler_texts,
tts.as_ref(),
"backchannel",
)
.await;
}
// Preload filler PCM files (played at end-of-utterance).
let fillers = Arc::new(FillerBank::load(&config.fillers_dir, "filler"));
let fillers = Arc::new(FillerBank::load(&fillers_dir, "filler"));
// Preload the start-filler clip (played immediately at turn start).
let start_filler = Arc::new(FillerBank::load(&start_filler_dir, "start-filler"));
if config.start_filler_enabled && start_filler.is_empty() {
tracing::warn!(
dir = %start_filler_dir.display(),
"start filler enabled but no clip found — the cue stays silent"
);
}
// Preload backchannel PCM files (played while the user is speaking).
let backchannel = Arc::new(FillerBank::load(&config.backchannel.dir, "backchannel"));
let backchannel = Arc::new(FillerBank::load(&backchannel_dir, "backchannel"));
if config.backchannel.enabled && backchannel.is_empty() {
tracing::warn!(
dir = %config.backchannel.dir.display(),
dir = %backchannel_dir.display(),
"backchannel enabled but no clips found — the cue stays silent"
);
}
@@ -155,15 +192,36 @@ async fn main() -> anyhow::Result<()> {
thinking = ?client.enable_thinking(),
strip_reasoning = client.strip_reasoning(),
direct_answers = config.llm_direct_answers,
tools = client.tools().is_some(),
max_tool_iterations = config.llm_tool_max_iterations,
"LLM reasoning enabled"
),
None => info!("LLM reasoning disabled (LLM_ENABLED=false or unconfigured)"),
}
// Multilingual clip banks: the backchannel and the end-of-turn filler are
// re-generated on demand in the user's language (see `crate::lang`).
if config.lang_detect_enabled {
match &llm {
Some(_) => info!(
default = %config.lang_default,
min_chars = config.lang_min_chars,
redetect_every = config.lang_redetect_every,
llm_fallback = config.lang_llm_fallback_enabled,
"language detection enabled (clip banks switch with the user's language)"
),
None => tracing::warn!(
"LANG_DETECT_ENABLED=true but the LLM is disabled — the clip banks stay in {}",
config.lang_default
),
}
}
let app_state = AppState {
config: config.clone(),
tts,
fillers,
start_filler,
backchannel,
stt,
tokenizer,
+353
View File
@@ -0,0 +1,353 @@
//! External audio source injection (URL / radio / YouTube).
//!
//! Decodes an external audio stream into the mixer's dedicated `external`
//! channel so it is mixed together with the agent's voice. Fetching and
//! decoding are delegated to external tools, which keeps the engine free of
//! heavyweight codec dependencies:
//!
//! - **`ffmpeg`** decodes direct media URLs, Icecast/Shoutcast radio and HLS
//! streams to raw 16-bit LE mono PCM at the mixer's output rate.
//! - **`yt-dlp`** resolves a YouTube URL to a directly decodable stream URL
//! first (ffmpeg cannot resolve YouTube on its own).
//!
//! The decoding task writes into the external queue respecting its free space,
//! so a VOD source is throttled to real time by the mixer's drain rate instead
//! of flooding — and dropping — the buffer. The channel is independent of the
//! agent audio: `flush` (barge-in) never touches it and it does not count as
//! the agent speaking.
use crate::audio::SampleRingBuffer;
use crate::config::OUTPUT_SAMPLE_RATE;
use parking_lot::Mutex;
use serde_json::json;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info};
/// Size of the byte buffer used to read ffmpeg's PCM stdout (≈ 93 ms).
const READ_CHUNK_BYTES: usize = 8192;
/// Manages a single external audio source feeding the mixer's `external`
/// channel. One instance per WebSocket session.
pub struct MediaSource {
/// Destination queue (the mixer's external channel).
queue: SampleRingBuffer,
/// Monotonic id of the current playback, so a task that finishes late does
/// not clear the state of a newer source that replaced it.
generation: AtomicU64,
/// Id + cancellation token of the currently active source.
current: Mutex<Option<(u64, CancellationToken)>>,
/// ffmpeg binary (see `MEDIA_FFMPEG_BIN`).
ffmpeg_bin: String,
/// yt-dlp binary (see `MEDIA_YTDLP_BIN`); only used for YouTube URLs.
ytdlp_bin: String,
/// Extra yt-dlp arguments (see `MEDIA_YTDLP_ARGS`).
ytdlp_args: Vec<String>,
/// Outbound text-event channel used to report state to the client.
out_tx: Option<mpsc::UnboundedSender<String>>,
}
impl MediaSource {
/// Create a media source writing into `queue`.
pub fn new(
queue: SampleRingBuffer,
ffmpeg_bin: String,
ytdlp_bin: String,
ytdlp_args: Vec<String>,
out_tx: Option<mpsc::UnboundedSender<String>>,
) -> Arc<Self> {
Arc::new(Self {
queue,
generation: AtomicU64::new(0),
current: Mutex::new(None),
ffmpeg_bin,
ytdlp_bin,
ytdlp_args,
out_tx,
})
}
/// Start playing `url`, replacing any source already playing. Returns
/// immediately: resolution and decoding run in a background task.
pub fn play(self: &Arc<Self>, url: &str) {
let url = url.trim().to_string();
if url.is_empty() {
self.emit_error("", "empty URL");
return;
}
// Stop whatever is playing and clear the channel for a clean start.
self.stop();
let id = self.generation.fetch_add(1, Ordering::Relaxed) + 1;
let cancel = CancellationToken::new();
*self.current.lock() = Some((id, cancel.clone()));
let this = self.clone();
tokio::spawn(async move { this.run(id, url, cancel).await });
}
/// Stop the current source and clear the external channel.
pub fn stop(&self) {
if let Some((_, token)) = self.current.lock().take() {
token.cancel();
}
self.queue.clear();
}
/// Resolve + decode loop for one source. Owns the state machine.
async fn run(self: Arc<Self>, id: u64, url: String, cancel: CancellationToken) {
self.emit(json!({ "type": "audio_source", "state": "loading", "url": url }));
// YouTube needs yt-dlp to turn the page URL into a media URL.
let source = if is_youtube_url(&url) {
info!(url = %url, "resolving YouTube source via yt-dlp");
match self.resolve_youtube(&url, &cancel).await {
Ok(direct) => direct,
Err(e) => {
if !cancel.is_cancelled() {
error!(error = %e, url = %url, "YouTube resolution failed");
self.emit_error(&url, &e);
}
self.finish(id);
return;
}
}
} else {
url.clone()
};
if cancel.is_cancelled() {
self.finish(id);
return;
}
info!(url = %url, "external audio source started");
self.emit(json!({ "type": "audio_source", "state": "playing", "url": url }));
match self.decode_into_queue(&source, &cancel).await {
Ok(()) => info!(url = %url, "external audio source ended"),
Err(e) => {
if !cancel.is_cancelled() {
error!(error = %e, url = %url, "external audio decode failed");
self.emit_error(&url, &e);
}
}
}
if !cancel.is_cancelled() {
self.emit(json!({ "type": "audio_source", "state": "stopped", "url": url }));
}
self.finish(id);
}
/// Clear the "currently active" marker if it still belongs to `id`.
fn finish(&self, id: u64) {
let mut guard = self.current.lock();
if guard.as_ref().map(|(g, _)| *g == id).unwrap_or(false) {
*guard = None;
}
}
/// Resolve a YouTube URL to a directly decodable stream URL via yt-dlp.
async fn resolve_youtube(
&self,
url: &str,
cancel: &CancellationToken,
) -> Result<String, String> {
let mut cmd = Command::new(&self.ytdlp_bin);
cmd.arg("-f")
.arg("bestaudio/best")
.arg("-g")
.arg("--no-playlist")
.arg("--no-warnings")
.args(&self.ytdlp_args)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn yt-dlp ({}): {e}", self.ytdlp_bin))?;
// `wait_with_output` consumes the child; if the cancel branch wins the
// future is dropped and `kill_on_drop` terminates the process.
let output = tokio::select! {
_ = cancel.cancelled() => return Err("cancelled".to_string()),
out = child.wait_with_output() => {
out.map_err(|e| format!("yt-dlp wait failed: {e}"))?
}
};
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"yt-dlp exited with {}: {}",
output.status,
err.trim().lines().last().unwrap_or("")
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.map(str::trim)
.find(|l| l.starts_with("http"))
.map(str::to_string)
.ok_or_else(|| "yt-dlp returned no stream URL".to_string())
}
/// Decode `source` to raw PCM and stream it into the external queue.
async fn decode_into_queue(
&self,
source: &str,
cancel: &CancellationToken,
) -> Result<(), String> {
let mut cmd = Command::new(&self.ffmpeg_bin);
cmd.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-nostdin")
// HTTP input resilience for live streams.
.arg("-reconnect")
.arg("1")
.arg("-reconnect_streamed")
.arg("1")
.arg("-reconnect_delay_max")
.arg("5")
.arg("-i")
.arg(source)
.arg("-vn")
.arg("-f")
.arg("s16le")
.arg("-acodec")
.arg("pcm_s16le")
.arg("-ac")
.arg("1")
.arg("-ar")
.arg(OUTPUT_SAMPLE_RATE.to_string())
.arg("pipe:1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn ffmpeg ({}): {e}", self.ffmpeg_bin))?;
let mut stdout = child.stdout.take().ok_or("ffmpeg stdout unavailable")?;
// Drain stderr in the background so a chatty decoder cannot block on a
// full pipe; the text is only surfaced if the decode produced no audio.
let stderr = child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut s = String::new();
if let Some(mut e) = stderr {
let _ = e.read_to_string(&mut s).await;
}
s
});
let mut buf = vec![0u8; READ_CHUNK_BYTES];
let mut scratch = vec![0i16; READ_CHUNK_BYTES / 2];
let mut total_samples: u64 = 0;
loop {
let n = tokio::select! {
_ = cancel.cancelled() => {
let _ = child.kill().await;
let _ = stderr_task.await;
return Ok(());
}
r = stdout.read(&mut buf) => r.map_err(|e| format!("ffmpeg read error: {e}"))?,
};
if n == 0 {
break;
}
// 16-bit LE mono -> i16 samples.
let ns = n / 2;
for i in 0..ns {
scratch[i] = i16::from_le_bytes([buf[i * 2], buf[i * 2 + 1]]);
}
total_samples += self.push_samples(&scratch[..ns], cancel).await as u64;
if cancel.is_cancelled() {
let _ = child.kill().await;
let _ = stderr_task.await;
return Ok(());
}
}
let status = child
.wait()
.await
.map_err(|e| format!("ffmpeg wait failed: {e}"))?;
let err_text = stderr_task.await.unwrap_or_default();
debug!(samples = total_samples, "ffmpeg decode finished");
if !status.success() && total_samples == 0 {
let msg = err_text.trim();
return Err(if msg.is_empty() {
format!("ffmpeg exited with {status}")
} else {
msg.to_string()
});
}
Ok(())
}
/// Push `samples` into the external queue, respecting its free space so a
/// fast (VOD) source is throttled to real time by the mixer's drain rate
/// instead of dropping audio. Returns the number of samples accepted.
async fn push_samples(&self, samples: &[i16], cancel: &CancellationToken) -> usize {
let mut off = 0;
while off < samples.len() {
if cancel.is_cancelled() {
break;
}
let free = self.queue.remaining_capacity();
if free == 0 {
tokio::select! {
_ = cancel.cancelled() => break,
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
continue;
}
let take = free.min(samples.len() - off);
let n = self.queue.push(&samples[off..off + take]);
if n == 0 {
break;
}
off += n;
}
off
}
/// Send a JSON state event to the client, if a channel is attached.
fn emit(&self, value: serde_json::Value) {
if let Some(tx) = &self.out_tx {
let _ = tx.send(value.to_string());
}
}
/// Report an error to the client.
fn emit_error(&self, url: &str, msg: &str) {
self.emit(json!({ "type": "audio_source", "state": "error", "url": url, "text": msg }));
}
}
/// Whether `url` looks like a YouTube link that needs yt-dlp resolution.
fn is_youtube_url(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
lower.contains("youtube.com/")
|| lower.contains("youtu.be/")
|| lower.contains("youtube-nocookie.com/")
}
+417 -26
View File
@@ -4,8 +4,15 @@
//! which is played once the user stops, at end-of-utterance. A backchannel is a
//! short "mm-hmm" / "ajá" mixed in at a reduced gain to signal that the agent is
//! listening, without taking the turn. It is emitted only while the agent itself
//! is silent (otherwise the user is being interrupted) and never more than
//! `max_per_turn` times.
//! is silent (otherwise the user is being interrupted).
//!
//! To sound natural rather than metronomic, a cue is played only when the user
//! is in a **micro-pause** ([`VadDetector::silence_ms`](crate::vad::VadDetector::silence_ms)),
//! at a **random depth** inside that pause, its timing is randomized, a
//! probabilistic gate skips some qualifying pauses,
//! recently used clips are not repeated, the volume drifts slightly, and the
//! phrases are grouped in **categories** that follow a progression through the
//! turn (an acknowledgment first, agreement later).
//!
//! Every behavioural knob is configured from the environment
//! ([`BackchannelSettings`]), so the feature can be tuned or disabled without a
@@ -13,9 +20,14 @@
use crate::config::{env_bool, env_f32, env_u32, env_u64};
use crate::orchestrator::FillerBank;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::info;
/// Category applied to phrases written without an explicit `category::` prefix.
pub(crate) const DEFAULT_CATEGORY: &str = "ack";
/// Runtime configuration for the backchannel subsystem, read from `.env`.
#[derive(Debug, Clone)]
@@ -24,31 +36,110 @@ pub struct BackchannelSettings {
pub enabled: bool,
/// Directory of `*.raw` PCM clips (`BACKCHANNEL_DIR`).
pub dir: PathBuf,
/// Phrases spoken by the backchannel bank (`BACKCHANNEL_TEXTS`), one per
/// line, with the optional `category::` prefix already stripped. One clip is
/// synthesized per phrase at startup. Drama-3 style tags (`[calm]`, …) are
/// passed straight through to the TTS.
pub texts: Vec<String>,
/// Category of each phrase in [`Self::texts`] (parallel array). Lines
/// written as `category::phrase` set it; plain lines default to `ack`.
pub categories: Vec<String>,
/// Mix gain applied to backchannel clips (`BACKCHANNEL_GAIN`). Kept low so
/// the cue stays in the background.
pub gain: f32,
/// Continuous speech required before the first backchannel
/// Confirmed speech required before the first cue may be considered
/// (`BACKCHANNEL_MIN_SPEECH_MS`).
pub min_speech_ms: u64,
/// Minimum spacing between two backchannels (`BACKCHANNEL_INTERVAL_MS`).
/// Base spacing between two cues of the same turn
/// (`BACKCHANNEL_INTERVAL_MS`); randomized by ±`jitter_ms`/2.
pub interval_ms: u64,
/// Maximum backchannels per user turn (`BACKCHANNEL_MAX_PER_TURN`).
/// Timing jitter (`BACKCHANNEL_JITTER_MS`): delays the first cue by
/// `0..jitter_ms` after `min_speech_ms` and spreads the spacing.
pub jitter_ms: u64,
/// Lower bound of the micro-pause window (`BACKCHANNEL_PAUSE_MIN_MS`): the
/// earliest depth, inside a pause, at which the user is likely done with the
/// current word. The actual reaction depth is drawn at random in
/// `[pause_min_ms, pause_max_ms]`.
pub pause_min_ms: u64,
/// Upper bound of the micro-pause window (`BACKCHANNEL_PAUSE_MAX_MS`): the
/// latest depth a cue may react at. Above this the utterance is about to
/// close, so a cue would land on the reply.
pub pause_max_ms: u64,
/// Probability of cueing when a micro-pause qualifies (`BACKCHANNEL_PROB`).
pub prob: f32,
/// Random per-cue volume scaling as a ± fraction
/// (`BACKCHANNEL_VOLUME_JITTER`).
pub volume_jitter: f32,
/// Maximum cues per user turn (`BACKCHANNEL_MAX_PER_TURN`).
pub max_per_turn: u32,
}
impl Default for BackchannelSettings {
fn default() -> Self {
let (texts, categories) = parse_texts(&DEFAULT_BACKCHANNEL_TEXTS.join("\n"));
Self {
enabled: true,
dir: PathBuf::from("assets/backchannel"),
texts,
categories,
gain: 0.35,
min_speech_ms: 1_500,
interval_ms: 3_000,
max_per_turn: 2,
jitter_ms: 1_200,
pause_min_ms: 96,
pause_max_ms: 260,
prob: 0.65,
volume_jitter: 0.2,
max_per_turn: 3,
}
}
}
/// Default backchannel phrases (Spanish, matching the shipped clip set), with
/// Drama-3 style tags and a category prefix. Used when `BACKCHANNEL_TEXTS` is
/// unset.
pub const DEFAULT_BACKCHANNEL_TEXTS: &[&str] = &[
"ack::[calm] Mm-hmm...",
"ack::[attentive] Ajá...",
"agree::[agreeing] Claro...",
"ack::[listening] Ya veo...",
"ack::[attentive] Sí, sí...",
"ack::[calm] Entiendo...",
"agree::[relaxed] Vale...",
"ack::[thoughtful] Ah, ya...",
];
/// Split a raw `*_TEXTS` value into `(phrases, categories)`.
///
/// One phrase per line; `|` is also accepted as a separator so the list can
/// live on a single line. A line may carry a `category::` prefix (e.g.
/// `agree::[agreeing] Claro...`) used by the scheduler to vary the kind of
/// acknowledgment through the turn; without it the phrase is an `ack`.
fn parse_texts(raw: &str) -> (Vec<String>, Vec<String>) {
let mut texts = Vec::new();
let mut categories = Vec::new();
for line in raw.split(['\n', '|']) {
let line = line.trim();
if line.is_empty() {
continue;
}
let (category, phrase) = match line.split_once("::") {
Some((c, p)) => (c.trim(), p.trim()),
None => (DEFAULT_CATEGORY, line),
};
if phrase.is_empty() {
continue;
}
texts.push(phrase.to_string());
categories.push(if category.is_empty() {
DEFAULT_CATEGORY.to_string()
} else {
category.to_string()
});
}
(texts, categories)
}
impl BackchannelSettings {
/// Build the settings from environment variables, falling back to defaults.
pub fn from_env() -> Self {
@@ -58,19 +149,99 @@ impl BackchannelSettings {
s.dir = PathBuf::from(p);
}
}
if let Ok(t) = std::env::var("BACKCHANNEL_TEXTS") {
let (texts, categories) = parse_texts(&t);
if !texts.is_empty() {
s.texts = texts;
s.categories = categories;
}
}
s.enabled = env_bool("BACKCHANNEL_ENABLED", s.enabled);
s.gain = env_f32("BACKCHANNEL_GAIN", s.gain);
s.min_speech_ms = env_u64("BACKCHANNEL_MIN_SPEECH_MS", s.min_speech_ms);
s.interval_ms = env_u64("BACKCHANNEL_INTERVAL_MS", s.interval_ms);
s.jitter_ms = env_u64("BACKCHANNEL_JITTER_MS", s.jitter_ms);
s.pause_min_ms = env_u64("BACKCHANNEL_PAUSE_MIN_MS", s.pause_min_ms);
s.pause_max_ms = env_u64("BACKCHANNEL_PAUSE_MAX_MS", s.pause_max_ms);
s.prob = env_f32("BACKCHANNEL_PROB", s.prob);
s.volume_jitter = env_f32("BACKCHANNEL_VOLUME_JITTER", s.volume_jitter);
s.max_per_turn = env_u32("BACKCHANNEL_MAX_PER_TURN", s.max_per_turn);
s.gain = s.gain.clamp(0.0, 1.0);
// Never cue before the turn has meaningfully started.
s.interval_ms = s.interval_ms.max(s.min_speech_ms);
s.prob = s.prob.clamp(0.0, 1.0);
s.volume_jitter = s.volume_jitter.clamp(0.0, 1.0);
// Keep the spacing and the micro-pause window coherent.
s.interval_ms = s.interval_ms.max(500);
if s.pause_max_ms < s.pause_min_ms {
s.pause_max_ms = s.pause_min_ms;
}
s
}
}
/// Tiny xorshift64 generator for timing jitter, the probabilistic gate and the
/// volume drift. The backchannel path is not hot enough to warrant more.
struct XorShift64(u64);
impl XorShift64 {
fn new(seed: u64) -> Self {
// OR 1 so the state is never zero (which would make xorshift degenerate).
Self(seed | 1)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
/// Uniform in `[0, 1)`.
fn frac(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
}
/// Uniform integer in `[lo, hi]` inclusive (`lo` when `hi <= lo`).
fn range(&mut self, lo: u64, hi: u64) -> u64 {
if hi <= lo {
lo
} else {
lo + self.next_u64() % (hi - lo + 1)
}
}
}
/// One synthesized clip, with the category of its source phrase.
struct ClipRef {
name: String,
category: String,
}
/// Map a phrase list (and its categories) onto the clip names of `bank`.
///
/// Clips are named `{prefix}{index}` in list order and `FillerBank` keeps that
/// order, so the i-th clip matches the i-th phrase — and thus the i-th
/// category. A bank that does not match the list (custom clips) leaves the
/// mapping short; the scheduler then falls back to a plain random pick.
fn map_clips(bank: &FillerBank, texts: &[String], categories: &[String]) -> Vec<ClipRef> {
let names = bank.names();
texts
.iter()
.enumerate()
.filter_map(|(i, _)| {
names.get(i).map(|name| ClipRef {
name: name.clone(),
category: categories
.get(i)
.cloned()
.unwrap_or_else(|| DEFAULT_CATEGORY.to_string()),
})
})
.collect()
}
/// Per-session backchannel decision logic.
///
/// Fed by the VAD: [`on_speech_start`](Self::on_speech_start) when a user turn
@@ -79,33 +250,112 @@ impl BackchannelSettings {
pub struct BackchannelScheduler {
bank: Arc<FillerBank>,
settings: BackchannelSettings,
/// Round-robin cursor over the clip bank.
next_index: usize,
/// Clip name + category per bank entry, in the order of `settings.texts`.
clips: Vec<ClipRef>,
/// VAD end-of-utterance silence window (`VAD_MIN_SILENCE_MS`), used to cap
/// the micro-pause window so a cue never lands on the pause that closes the
/// turn.
vad_min_silence_ms: u32,
/// When the current user turn started (only set while speaking).
turn_start: Option<Instant>,
/// When the last backchannel was emitted in this turn.
/// When the last cue was emitted in this turn.
last_emitted: Option<Instant>,
/// Backchannels emitted in the current turn.
/// Cues emitted in the current turn.
emitted: u32,
/// Earliest instant the first cue of this turn may be played (randomized).
first_cue_at: Option<Instant>,
/// Randomized gap to wait after the previous cue before the next one.
next_gap_ms: u64,
/// Whether the current micro-pause was already evaluated (cue or skip).
pause_settled: bool,
/// Random depth (ms) at which to react inside the current micro-pause, so
/// cues do not all land at exactly `pause_min_ms`. Drawn once per pause.
pause_target_ms: Option<u64>,
/// Categories already used in this turn, for a natural progression.
used_categories: Vec<String>,
/// Category of the last cue (avoid chaining two cues of the same kind).
last_category: Option<String>,
/// Names of the most recently used clips (anti-repetition), kept across
/// turns so the same cue never plays twice in a row.
recent: VecDeque<String>,
/// RNG for jitter, the probabilistic gate and the volume drift.
rng: XorShift64,
}
impl BackchannelScheduler {
pub fn new(bank: Arc<FillerBank>, settings: BackchannelSettings) -> Self {
/// Create a scheduler for one session. `vad_min_silence_ms` is the VAD EOU
/// window (`VAD_MIN_SILENCE_MS`); it bounds the micro-pause window.
pub fn new(
bank: Arc<FillerBank>,
settings: BackchannelSettings,
vad_min_silence_ms: u32,
) -> Self {
let clips = map_clips(&bank, &settings.texts, &settings.categories);
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15);
// One line so the effective policy (and the parsed categories) is
// visible in the log — the knobs are numerous and tuned by ear.
let categories: Vec<&str> = clips.iter().map(|c| c.category.as_str()).collect();
info!(
clips = clips.len(),
categories = ?categories,
pause_ms = format!("{}..{}", settings.pause_min_ms, settings.pause_max_ms),
first_cue_ms = settings.min_speech_ms,
interval_ms = settings.interval_ms,
jitter_ms = settings.jitter_ms,
prob = settings.prob,
volume_jitter = settings.volume_jitter,
max_per_turn = settings.max_per_turn,
"backchannel scheduler ready"
);
Self {
bank,
settings,
next_index: 0,
clips,
vad_min_silence_ms,
turn_start: None,
last_emitted: None,
emitted: 0,
first_cue_at: None,
next_gap_ms: 0,
pause_settled: false,
pause_target_ms: None,
used_categories: Vec::new(),
last_category: None,
recent: VecDeque::new(),
rng: XorShift64::new(seed),
}
}
/// Point the scheduler at another language's bank: replace the clip list and
/// the phrase/category mapping. Called between turns when the detected user
/// language changes (see [`crate::lang`]). The per-turn counters are left as
/// they are — the next VAD speech start resets them anyway.
pub fn retarget(&mut self, bank: Arc<FillerBank>, texts: &[String], categories: &[String]) {
self.clips = map_clips(&bank, texts, categories);
self.bank = bank;
}
/// Begin a new user turn (VAD speech start).
pub fn on_speech_start(&mut self) {
self.turn_start = Some(Instant::now());
let now = Instant::now();
self.turn_start = Some(now);
self.last_emitted = None;
self.emitted = 0;
self.pause_settled = false;
self.pause_target_ms = None;
self.used_categories.clear();
self.last_category = None;
// Randomize when the first cue becomes eligible: base delay plus a
// random 0..jitter offset, so turns don't all cue at the same instant.
let jitter = self.rng.range(0, self.settings.jitter_ms);
self.first_cue_at = Some(now + Duration::from_millis(self.settings.min_speech_ms + jitter));
self.next_gap_ms = self.settings.interval_ms;
}
/// Close the current user turn (VAD speech end).
@@ -113,35 +363,176 @@ impl BackchannelScheduler {
self.turn_start = None;
self.last_emitted = None;
self.emitted = 0;
self.first_cue_at = None;
self.pause_settled = false;
self.pause_target_ms = None;
self.used_categories.clear();
self.last_category = None;
}
/// Emit a backchannel into `mixer` if the policy allows it right now.
///
/// Must be called while the user is speaking and the agent is silent.
/// Returns the clip name when one was emitted.
pub fn maybe_emit(&mut self, mixer: &crate::audio::AudioMixer) -> Option<String> {
/// `pause_ms` is the length of the micro-pause the user is currently in
/// ([`VadDetector::silence_ms`](crate::vad::VadDetector::silence_ms), 0
/// while voice is ongoing). Returns the clip `(name, category)` when one
/// was emitted.
pub fn maybe_emit(
&mut self,
mixer: &crate::audio::AudioMixer,
pause_ms: u64,
) -> Option<(String, String)> {
if !self.settings.enabled {
return None;
}
let start = self.turn_start?;
if self.emitted >= self.settings.max_per_turn {
if self.turn_start.is_none() || self.emitted >= self.settings.max_per_turn {
return None;
}
if start.elapsed() < Duration::from_millis(self.settings.min_speech_ms) {
// Cue only inside a micro-pause: never mid-word, and never on the pause
// that is long enough to close the utterance (that one belongs to the
// reply). A zero-length pause means voice is ongoing: re-arm so the
// *next* micro-pause is evaluated.
if pause_ms == 0 {
self.pause_settled = false;
self.pause_target_ms = None;
return None;
}
let max = self.pause_max();
if pause_ms > max {
// The pause ran past the window (it is about to close the turn):
// abandon it without firing.
self.pause_settled = true;
return None;
}
if self.pause_settled {
return None;
}
// At the start of a new micro-pause pick the depth to react at
// uniformly inside the window, so the cue does not always land at
// exactly `pause_min_ms` — the reaction point varies pause to pause.
let target = match self.pause_target_ms {
Some(t) => t,
None => {
let lo = self.settings.pause_min_ms.min(max);
let t = self.rng.range(lo, max);
self.pause_target_ms = Some(t);
t
}
};
if pause_ms < target {
return None;
}
// Randomized turn timing: hold off until the first-cue instant.
if let Some(first) = self.first_cue_at {
if Instant::now() < first {
return None;
}
}
// Randomized spacing since the previous cue.
if let Some(last) = self.last_emitted {
if last.elapsed() < Duration::from_millis(self.settings.interval_ms) {
if last.elapsed() < Duration::from_millis(self.next_gap_ms) {
return None;
}
}
let (name, samples) = self.bank.nth(self.next_index)?;
let name = name.to_string();
mixer.backchannel_queue.push(samples);
self.next_index = self.next_index.wrapping_add(1);
// Probabilistic gate: stay silent for some qualifying pauses, so the
// cadence matches how a listener nods along instead of a metronome.
if self.rng.frac() > self.settings.prob {
self.pause_settled = true;
return None;
}
let (name, category, samples) = self.select()?;
let samples = self.apply_volume(&samples);
mixer.backchannel_queue.push(&samples);
self.pause_settled = true;
self.last_emitted = Some(Instant::now());
self.emitted += 1;
Some(name)
self.used_categories.push(category.clone());
self.last_category = Some(category.clone());
self.recent.push_back(name.clone());
while self.recent.len() > 2 {
self.recent.pop_front();
}
// Randomize the next gap around the configured interval.
let half = self.settings.jitter_ms / 2;
let lo = self.settings.interval_ms.saturating_sub(half).max(500);
let hi = self.settings.interval_ms + half;
self.next_gap_ms = self.rng.range(lo, hi);
Some((name, category))
}
/// Upper bound of the micro-pause window: the configured maximum, capped so
/// it stays below the VAD end-of-utterance window (minus one analysis
/// window) — otherwise the cue would land on the pause that ends the turn.
fn pause_max(&self) -> u64 {
let vad_cap = (self.vad_min_silence_ms.saturating_sub(32) as u64).max(1);
self.settings.pause_max_ms.min(vad_cap)
}
/// Pick a clip honouring the category progression and anti-repetition, then
/// falling back to a plain random clip when the bank does not match the
/// phrase list. Returns `(name, category, samples)`.
fn select(&mut self) -> Option<(String, String, Vec<i16>)> {
if let Some(idx) = self.select_index() {
let clip = &self.clips[idx];
if let Some(samples) = self.bank.get(&clip.name) {
return Some((clip.name.clone(), clip.category.clone(), samples.clone()));
}
}
self.bank
.random()
.map(|(n, s)| (n.to_string(), DEFAULT_CATEGORY.to_string(), s.clone()))
}
/// Index of the clip to play: prefer a category other than the last one,
/// then categories not yet used this turn (a progression from "listening"
/// to "agreeing"), then clips not used recently, and pick uniformly at
/// random among whatever remains.
fn select_index(&mut self) -> Option<usize> {
if self.clips.is_empty() {
return None;
}
let all: Vec<usize> = (0..self.clips.len()).collect();
let not_last: Vec<usize> = all
.iter()
.copied()
.filter(|&i| Some(&self.clips[i].category) != self.last_category.as_ref())
.collect();
let pool = if not_last.is_empty() { all } else { not_last };
let fresh: Vec<usize> = pool
.iter()
.copied()
.filter(|&i| !self.used_categories.contains(&self.clips[i].category))
.collect();
let pool = if fresh.is_empty() { pool } else { fresh };
let unrepeated: Vec<usize> = pool
.iter()
.copied()
.filter(|&i| !self.recent.contains(&self.clips[i].name))
.collect();
let pool = if unrepeated.is_empty() { pool } else { unrepeated };
Some(pool[(self.rng.next_u64() % pool.len() as u64) as usize])
}
/// Scale a clip by a random ±`volume_jitter` factor so the cues do not all
/// sit at exactly the same level.
fn apply_volume(&mut self, samples: &[i16]) -> Vec<i16> {
let jitter = self.settings.volume_jitter;
if jitter <= 0.0 {
return samples.to_vec();
}
let scale = 1.0 + (self.rng.frac() * 2.0 - 1.0) * jitter;
samples
.iter()
.map(|&s| ((s as f32) * scale).clamp(i16::MIN as f32, i16::MAX as f32) as i16)
.collect()
}
}
+440 -65
View File
@@ -9,13 +9,17 @@
//! - **Without LLM**: transcript → TTS (the original pass-through behaviour).
use crate::audio::{AudioMixer, SampleRingBuffer};
use crate::audit::SessionAudit;
use crate::config::Config;
use crate::lang::{self, LangBanks, LanguageState};
use crate::llm::{ChatMessage, LlmClient};
use crate::stt::{Tokenizer, Transcript};
use crate::tools::ToolCallStripper;
use crate::tts::TtsEngine;
use crate::{assets, orchestrator::backchannel::DEFAULT_CATEGORY};
use serde_json::json;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
@@ -62,12 +66,15 @@ fn pump(src: &SampleRingBuffer, dst: &SampleRingBuffer) -> usize {
}
/// Preloaded PCM samples keyed by name (e.g. "uhm"). Used for the filler bank
/// injected at end-of-utterance (played round-robin) and, from a different
/// directory, for the backchannel clips played while the user speaks.
/// injected at end-of-utterance (played round-robin), for the backchannel clips
/// played while the user speaks, and for the start-filler bank (both played in
/// random order).
pub struct FillerBank {
samples: HashMap<String, Vec<i16>>,
/// Names in a stable order, for round-robin selection.
names: Vec<String>,
/// xorshift64 state for [`Self::random`] (seeded from the clock at load).
rng: AtomicU64,
}
impl FillerBank {
@@ -107,17 +114,37 @@ impl FillerBank {
let mut names: Vec<String> = samples.keys().cloned().collect();
names.sort();
Self { samples, names }
// Seed the xorshift64 state from the wall clock (OR 1 so it is never
// zero, which would make xorshift degenerate).
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E37_79B9_7F4A_7C15)
| 1;
Self {
samples,
names,
rng: AtomicU64::new(seed),
}
}
/// Get the samples for a clip by name. Kept for reference/debugging.
#[allow(dead_code)]
/// Get the samples for a clip by name. Used by the backchannel to play a
/// specific (category-selected) clip instead of a uniform random one.
pub fn get(&self, name: &str) -> Option<&Vec<i16>> {
self.samples.get(name)
}
/// Clip names in the stable (sorted) order used by [`Self::nth`]. The
/// backchannel uses it to map the phrase list (and its categories) onto the
/// synthesized clips, since clips are named `{prefix}{index}` in list order.
pub fn names(&self) -> &[String] {
&self.names
}
/// Clip at position `index` (wrapping), with its name. Used for the
/// round-robin filler and backchannel selection.
/// round-robin end-of-utterance filler.
pub fn nth(&self, index: usize) -> Option<(&str, &Vec<i16>)> {
if self.names.is_empty() {
return None;
@@ -126,6 +153,23 @@ impl FillerBank {
self.samples.get(name).map(|s| (name.as_str(), s))
}
/// Clip chosen uniformly at random, with its name. Used by the start filler
/// and the backchannel so the cue varies from turn to turn.
pub fn random(&self) -> Option<(&str, &Vec<i16>)> {
if self.names.is_empty() {
return None;
}
// xorshift64 step over the shared state.
let mut x = self.rng.load(Ordering::Relaxed);
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.rng.store(x, Ordering::Relaxed);
let name = &self.names[(x % self.names.len() as u64) as usize];
self.samples.get(name).map(|s| (name.as_str(), s))
}
/// Whether the bank holds no clips.
pub fn is_empty(&self) -> bool {
self.names.is_empty()
@@ -136,7 +180,10 @@ impl FillerBank {
pub struct TurnManager {
mixer: AudioMixer,
tts: Arc<dyn TtsEngine>,
fillers: Arc<FillerBank>,
/// Active-language clip banks, shared with the mic task (backchannel). The
/// end-of-turn and start fillers are read from here so all three cues
/// follow the same language — see [`crate::lang`].
langs: Arc<LanguageState>,
/// Optional tokenizer to decode STT token IDs into text. If `None`, a
/// placeholder string is sent to the TTS (useful for testing the pipeline
/// without a real tokenizer).
@@ -154,22 +201,31 @@ pub struct TurnManager {
/// Outbound text-event channel (JSON strings) consumed by the session
/// sender task and forwarded to the browser (chat box).
out_tx: Option<mpsc::UnboundedSender<String>>,
/// User turns seen so far, used to schedule the periodic language check.
lang_turns: AtomicU64,
/// Whether a language-detection call is currently in flight (one at a time).
lang_detecting: Arc<AtomicBool>,
/// Per-session audit sink: the language switches are recorded here (every
/// other event reaches the log through the outbound tap in the session
/// sender — see [`crate::audit`]).
audit: Arc<SessionAudit>,
}
impl TurnManager {
pub fn new(
mixer: AudioMixer,
tts: Arc<dyn TtsEngine>,
fillers: Arc<FillerBank>,
langs: Arc<LanguageState>,
tokenizer: Option<Tokenizer>,
llm: Option<Arc<LlmClient>>,
config: Arc<Config>,
out_tx: Option<mpsc::UnboundedSender<String>>,
audit: Arc<SessionAudit>,
) -> Self {
Self {
mixer,
tts,
fillers,
langs,
tokenizer,
cancel: Arc::new(parking_lot::Mutex::new(None)),
filler_cursor: AtomicUsize::new(0),
@@ -177,13 +233,76 @@ impl TurnManager {
config,
history: parking_lot::Mutex::new(Vec::new()),
out_tx,
lang_turns: AtomicU64::new(0),
lang_detecting: Arc::new(AtomicBool::new(false)),
audit,
}
}
/// Pick the next filler clip round-robin from the bank.
/// Pick the next filler clip round-robin from the bank of the active
/// language.
fn next_filler(&self) -> Option<(String, Vec<i16>)> {
let banks = self.langs.current();
let i = self.filler_cursor.fetch_add(1, Ordering::Relaxed);
self.fillers.nth(i).map(|(name, s)| (name.to_string(), s.clone()))
banks
.filler
.nth(i)
.map(|(name, s)| (name.to_string(), s.clone()))
}
/// Schedule the *start filler* acknowledgment on the mixer's dedicated
/// start-filler channel.
///
/// Like [`Self::schedule_filler`] this is a latency mask, but with its own
/// (shorter) delay, `START_FILLER_GO_DELAY`: the cue is only queued when the
/// first response audio is still **late** after that window — if the first
/// audio chunk has already reached the mixer (even if it is still inside the
/// pre-buffer and playback has not started), the agent is already talking
/// and the cue is dropped. One phrase is picked **at random** from the bank
/// (built from `START_FILLER_TEXTS`), so the cue varies turn to turn.
/// Disabled by `START_FILLER_ENABLED=false`; a barge-in (`cancel`) or a new
/// turn drops the pending cue, and `mixer.flush` clears it once queued.
fn schedule_start_filler(&self, cancel: &CancellationToken) {
if !self.config.start_filler_enabled {
return;
}
// Own the clip: the spawned task must be `'static` (same as the
// round-robin filler above).
let banks = self.langs.current();
let Some((name, samples)) = banks
.start_filler
.random()
.map(|(n, s)| (n.to_string(), s.clone()))
else {
debug!("start filler enabled but no clip loaded — skipping");
return;
};
let delay_ms = self.config.start_filler_go_delay_ms;
let mixer = self.mixer.clone();
let cancel = cancel.clone();
let out_tx = self.out_tx.clone();
tokio::spawn(async move {
tokio::select! {
_ = tokio::time::sleep(Duration::from_millis(delay_ms)) => {}
// Barge-in / new turn: never play a stale acknowledgment.
_ = cancel.cancelled() => return,
}
if mixer.tts_audio_arrived() {
debug!(
filler = %name,
delay_ms, "first response audio already arrived — start filler skipped"
);
return;
}
let queued = mixer.start_filler_queue.push(&samples);
debug!(filler = %name, samples = queued, delay_ms, "queued start filler");
if let Some(tx) = &out_tx {
let _ = tx.send(
json!({ "type": "cue", "kind": "start_filler", "name": name }).to_string(),
);
}
});
}
/// Schedule the single end-of-turn filler acknowledgment.
@@ -207,6 +326,7 @@ impl TurnManager {
let delay_ms = self.config.filler_delay_ms;
let mixer = self.mixer.clone();
let cancel = cancel.clone();
let out_tx = self.out_tx.clone();
let samples_len = samples.len();
tokio::spawn(async move {
tokio::select! {
@@ -223,6 +343,11 @@ impl TurnManager {
}
mixer.filler_queue.push(&samples);
debug!(filler = %name, samples = samples_len, delay_ms, "pushed filler (delayed)");
if let Some(tx) = &out_tx {
let _ = tx.send(
json!({ "type": "cue", "kind": "filler", "name": name }).to_string(),
);
}
});
}
@@ -274,12 +399,14 @@ impl TurnManager {
/// 2. Cancel any in-flight reply (barge-in within the turn).
/// 3. Run the LLM (if configured) or send the text straight to the TTS.
///
/// The filler is not queued here: it is scheduled once the response is
/// dispatched, so it only plays if the audio is late (see
/// [`Self::schedule_filler`]).
/// The latency masks are not queued here: both the start filler and the
/// end-of-turn filler are *scheduled* once the response is dispatched, so
/// they only play if the audio is late — see [`Self::schedule_filler`] and
/// [`Self::schedule_start_filler`].
async fn handle_user_turn(&self, text: String) {
self.emit(json!({ "type": "user", "text": text }));
self.maybe_detect_language(&text);
self.cancel_current_tts();
match &self.llm {
@@ -288,11 +415,58 @@ impl TurnManager {
}
}
/// Kick off a language check for `text` when the detection schedule allows
/// it. Runs in the background so the reply is never delayed: the switch (if
/// any) takes effect from the next turn. See [`crate::lang`].
///
/// The first turn is always checked; afterwards a sample is sent only every
/// `LANG_REDETECT_EVERY` turns, so a language change mid-conversation is
/// picked up without paying for a detection call on every turn.
fn maybe_detect_language(&self, text: &str) {
if !self.config.lang_detect_enabled {
return;
}
let Some(llm) = self.llm.clone() else {
return;
};
// Only the backchannel, the end-of-turn filler and the start filler are
// language-aware; with all of them disabled there is nothing to switch.
if !self.config.backchannel.enabled
&& !self.config.filler_enabled
&& !self.config.start_filler_enabled
{
return;
}
if text.chars().count() < self.config.lang_min_chars {
return;
}
let turn = self.lang_turns.fetch_add(1, Ordering::Relaxed);
if turn % self.config.lang_redetect_every != 0 {
return;
}
// One detection at a time: a slow LLM call must not pile up.
if self.lang_detecting.swap(true, Ordering::AcqRel) {
return;
}
let langs = self.langs.clone();
let tts = self.tts.clone();
let config = self.config.clone();
let detecting = self.lang_detecting.clone();
let audit = self.audit.clone();
let text = text.to_string();
tokio::spawn(async move {
switch_language_if_needed(langs, llm, tts, config, &text, audit).await;
detecting.store(false, Ordering::Release);
});
}
/// Run one full LLM turn: stream the reply, chunk it into sentences and
/// synthesize each sentence as soon as it is complete.
async fn run_llm_turn(&self, llm: Arc<LlmClient>, user_text: String) {
// Build the message list from the rolling history.
let messages = {
// Build the message list from the rolling history. It grows across
// tool rounds (assistant call + tool results) but only the *spoken*
// text is written back to the rolling history.
let mut messages = {
let mut hist = self.history.lock();
hist.push(ChatMessage::user(&user_text));
let max = self.config.llm_history_messages.max(2);
@@ -313,6 +487,7 @@ impl TurnManager {
*self.cancel.lock() = Some(cancel.clone());
self.mixer.mark_stream_start();
self.schedule_filler(&cancel);
self.schedule_start_filler(&cancel);
debug!(
endpoint = llm.endpoint(),
model = llm.model(),
@@ -414,63 +589,129 @@ impl TurnManager {
}
});
// LLM producer: streams clean text deltas into a channel.
let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
let stream_mode = self.config.llm_stream;
let llm_cancel = cancel.clone();
let llm_task = tokio::spawn(async move {
if stream_mode {
llm.chat_stream(&messages, &llm_cancel, delta_tx).await
} else {
let res = llm.chat(&messages, &llm_cancel).await;
if let Ok(text) = &res {
let _ = delta_tx.send(text.clone());
}
res
}
});
// Tool-calling loop. Each round streams one reply; tool call blocks are
// stripped from the spoken/displayed text (the model emits them as
// text — see `crate::tools`), the calls run over HTTP, and the results
// are handed back for the next round. Bounded by
// `LLM_TOOL_MAX_ITERATIONS`. With no tools configured the loop runs
// exactly once.
let registry = llm.tools().cloned();
let max_rounds = self.config.llm_tool_max_iterations.max(1);
let mut spoken = String::new();
let mut round = 0usize;
loop {
// LLM producer: streams clean text deltas into a channel.
let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
let stream_mode = self.config.llm_stream;
let llm_cancel = cancel.clone();
let llm_round = llm.clone();
let round_messages = messages.clone();
let llm_task = tokio::spawn(async move {
if stream_mode {
llm_round.chat_stream(&round_messages, &llm_cancel, delta_tx).await
} else {
let res = llm_round.chat(&round_messages, &llm_cancel).await;
if let Ok(text) = &res {
let _ = delta_tx.send(text.clone());
}
res
}
});
// Consume deltas: emit `assistant_delta` events and feed the
// splitter, so the TTS starts as soon as the first few words are
// available. The stripper holds back tool-call blocks so they are
// never spoken nor shown.
let mut splitter = SentenceSplitter::new(
self.config.llm_stream_first_chunk_chars,
self.config.llm_stream_chunk_chars,
);
let mut stripper = ToolCallStripper::new();
let mut raw = String::new();
while let Some(delta) = delta_rx.recv().await {
if cancel.is_cancelled() {
break;
}
raw.push_str(&delta);
let visible = stripper.push(&delta);
if visible.is_empty() {
continue;
}
self.emit(json!({ "type": "assistant_delta", "text": visible }));
spoken.push_str(&visible);
for sentence in splitter.push(&visible) {
if sentence_tx.send(sentence).is_err() {
break;
}
}
}
if !cancel.is_cancelled() {
let tail = stripper.finish();
if !tail.is_empty() {
self.emit(json!({ "type": "assistant_delta", "text": tail }));
for sentence in splitter.push(&tail) {
if sentence_tx.send(sentence).is_err() {
break;
}
}
spoken.push_str(&tail);
}
if let Some(rest) = splitter.finish() {
let _ = sentence_tx.send(rest);
}
}
match llm_task.await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
error!(error = %e, "LLM request failed");
self.emit(json!({ "type": "error", "text": e.to_string() }));
}
Err(e) => {
error!(error = %e, "LLM task join failed");
self.emit(json!({ "type": "error", "text": format!("LLM task failed: {e}") }));
}
}
// Consume deltas: emit `assistant_delta` events and feed the splitter,
// so the TTS starts as soon as the first few words are available.
let mut splitter = SentenceSplitter::new(
self.config.llm_stream_first_chunk_chars,
self.config.llm_stream_chunk_chars,
);
let mut full = String::new();
while let Some(delta) = delta_rx.recv().await {
if cancel.is_cancelled() {
break;
}
self.emit(json!({ "type": "assistant_delta", "text": delta }));
full.push_str(&delta);
for sentence in splitter.push(&delta) {
if sentence_tx.send(sentence).is_err() {
break;
}
}
}
if !cancel.is_cancelled() {
if let Some(rest) = splitter.finish() {
let _ = sentence_tx.send(rest);
let calls = stripper.take_calls();
if calls.is_empty() {
break;
}
let Some(registry) = registry.as_ref() else {
warn!("model emitted a tool call but no tools are configured — ignoring");
break;
};
if round + 1 >= max_rounds {
warn!(max_rounds, "tool-call iteration limit reached — stopping");
break;
}
round += 1;
// Record the model's own call followed by each result, so the next
// round sees the exchange (standard OpenAI tool round-trip).
messages.push(ChatMessage::assistant(raw.clone()));
for call in &calls {
info!(tool = %call.name, round, "executing tool call");
self.emit(json!({
"type": "tool_call",
"name": &call.name,
"arguments": &call.arguments,
}));
let result = registry.execute(call).await;
messages.push(ChatMessage::tool(result));
}
}
drop(sentence_tx);
let _ = tts_task.await;
match llm_task.await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
error!(error = %e, "LLM request failed");
self.emit(json!({ "type": "error", "text": e.to_string() }));
}
Err(e) => {
error!(error = %e, "LLM task join failed");
self.emit(json!({ "type": "error", "text": format!("LLM task failed: {e}") }));
}
}
if !cancel.is_cancelled() && !full.trim().is_empty() {
self.history.lock().push(ChatMessage::assistant(full.clone()));
if !cancel.is_cancelled() && !spoken.trim().is_empty() {
self.history.lock().push(ChatMessage::assistant(spoken.clone()));
self.emit(json!({ "type": "assistant_end" }));
}
@@ -500,6 +741,12 @@ impl TurnManager {
self.tts.set_reference(audio_path, text);
}
/// Set the gain of the mixer's external audio channel (URL / radio /
/// YouTube background source). See [`crate::media`].
pub fn set_external_gain(&self, gain: f32) {
self.mixer.set_external_gain(gain);
}
/// Synthesize `text` directly with the TTS engine, bypassing the LLM.
/// Used by the playground's "Speak" box to test the TTS pipeline.
pub async fn speak(&self, text: &str) {
@@ -529,6 +776,7 @@ impl TurnManager {
let text_owned = text.to_string();
self.mixer.mark_stream_start();
self.schedule_filler(&cancel);
self.schedule_start_filler(&cancel);
debug!(
engine = tts.name(),
sample_rate = tts.sample_rate(),
@@ -560,6 +808,133 @@ impl TurnManager {
}
}
/// Detect the language of `text` and, when it differs from the active one,
/// build and publish the clip banks for it.
///
/// The phrase list comes from the built-in tables ([`crate::lang`]) for the
/// common languages, or from the LLM when `LANG_LLM_FALLBACK_ENABLED` is set.
/// Clips are synthesized only when missing — [`assets::ensure_lang_backchannel_clips`]
/// keeps a per-language index — and live in their own subdirectory, so no other
/// language is touched. Switching back to the default language restores the
/// base banks and needs no synthesis at all.
async fn switch_language_if_needed(
langs: Arc<LanguageState>,
llm: Arc<LlmClient>,
tts: Arc<dyn TtsEngine>,
config: Arc<Config>,
text: &str,
audit: Arc<SessionAudit>,
) {
let Some(lang) = lang::detect_language(&llm, text).await else {
return;
};
let current = langs.current();
if lang == current.lang {
return;
}
if lang == config.lang_default {
info!(lang = %lang, "user language back to the default — restoring the base clip banks");
audit.event(
"language_switch",
json!({ "from": current.lang, "to": lang, "generated": false }),
);
langs.set((*langs.base()).clone());
return;
}
// Phrase lists: built-in table first, LLM fallback otherwise.
let backchannel = match lang::backchannel_texts(&lang) {
Some(pairs) => pairs,
None if config.lang_llm_fallback_enabled => {
match lang::generate_backchannel_texts(&llm, &lang).await {
Some(phrases) => phrases
.into_iter()
.map(|p| (DEFAULT_CATEGORY.to_string(), p))
.collect(),
None => return,
}
}
None => return,
};
let filler = match lang::filler_texts(&lang) {
Some(texts) => texts,
None if config.lang_llm_fallback_enabled => {
match lang::generate_filler_texts(&llm, &lang).await {
Some(texts) => texts,
None => return,
}
}
None => return,
};
// Start filler is optional: when the language has no phrase list (and the
// LLM fallback is unavailable) the cue simply stays silent for it, without
// preventing the switch of the banks above.
let start_filler = if !config.start_filler_enabled {
None
} else {
match lang::start_filler_texts(&lang) {
Some(texts) => Some(texts),
None if config.lang_llm_fallback_enabled => {
lang::generate_start_filler_texts(&llm, &lang).await
}
None => None,
}
};
info!(
from = %current.lang,
to = %lang,
backchannel = backchannel.len(),
filler = filler.len(),
start_filler = start_filler.as_ref().map(|t| t.len()).unwrap_or(0),
"user language changed — generating the clip banks for it"
);
let phrases: Vec<String> = backchannel.iter().map(|(_, p)| p.clone()).collect();
let categories: Vec<String> = backchannel.iter().map(|(c, _)| c.clone()).collect();
// Idempotent: an unchanged phrase list is skipped via the per-directory
// `texts.txt` index, so an already-generated language is just reloaded.
assets::ensure_lang_backchannel_clips(&config.backchannel.dir, &lang, &phrases, tts.as_ref())
.await;
assets::ensure_lang_filler_clips(&config.fillers_dir, &lang, &filler, tts.as_ref()).await;
if let Some(texts) = &start_filler {
assets::ensure_lang_start_filler_clips(
&config.start_filler_dir,
&lang,
texts,
tts.as_ref(),
)
.await;
}
let backchannel_bank = Arc::new(FillerBank::load(
&assets::lang_dir(&config.backchannel.dir, &lang),
"backchannel",
));
let filler_bank = Arc::new(FillerBank::load(
&assets::lang_dir(&config.fillers_dir, &lang),
"filler",
));
let start_filler_bank = Arc::new(FillerBank::load(
&assets::lang_dir(&config.start_filler_dir, &lang),
"start-filler",
));
langs.set(LangBanks {
lang,
backchannel: backchannel_bank,
backchannel_texts: phrases,
backchannel_categories: categories,
filler: filler_bank,
start_filler: start_filler_bank,
});
audit.event(
"language_switch",
json!({ "from": current.lang, "to": langs.current().lang, "generated": true }),
);
}
/// Spawn an orchestrator loop that consumes transcripts from `rx` and drives
/// the turn manager.
pub fn spawn_orchestrator(
+475
View File
@@ -0,0 +1,475 @@
//! Configurable HTTP tool calling for the LLM reasoning stage.
//!
//! The deployment's OpenAI-compatible server (vLLM) runs **without** a
//! server-side tool parser (`--enable-auto-tool-choice --tool-call-parser …`),
//! so it never returns structured `tool_calls` / `finish_reason: "tool_calls"`.
//! The Qwen model still knows how to call tools and emits the call **as text**
//! in its native format:
//!
//! ```text
//! <tool_call>
//! <function=get_weather>
//! <parameter=city>
//! Paris
//! </parameter>
//! </function>
//! </tool_call>
//! ```
//!
//! This module therefore parses that native format client-side, executes the
//! matching HTTP tool and hands the result back to the model on the next
//! completion (as a `tool` role message). It works with any server, with no
//! server-side reconfiguration.
//!
//! Tool definitions are loaded from a JSON file (`LLM_TOOLS_FILE`), which keeps
//! verbose JSON schemas out of `.env`:
//!
//! ```json
//! [
//! {
//! "name": "get_weather",
//! "description": "Current weather for a city",
//! "parameters": {
//! "type": "object",
//! "properties": { "city": { "type": "string" } },
//! "required": ["city"]
//! },
//! "url": "https://wttr.in/{city}?format=3",
//! "method": "GET",
//! "headers": { "X-Api-Key": "…" }
//! }
//! ]
//! ```
//!
//! - `url` may contain `{arg}` placeholders substituted from the call arguments.
//! - `GET` sends the remaining arguments as query parameters; `POST` (default)
//! sends them as a JSON body.
//! - `headers` are optional and sent verbatim (API keys, etc.).
use anyhow::{Context, Result};
use reqwest::Client;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info, warn};
/// Opening / closing markers of a native tool call. Written with `\u{..}`
/// escapes so the literal tags never appear as markup in this file.
const TOOL_CALL_OPEN: &str = "\u{3C}tool_call\u{3E}";
const TOOL_CALL_CLOSE: &str = "\u{3C}/tool_call\u{3E}";
/// One tool definition loaded from `LLM_TOOLS_FILE`.
#[derive(Debug, Clone, Deserialize)]
pub struct ToolSpec {
/// Function name the model calls (the `name` in the request's `tools`).
pub name: String,
/// Human/LLM-readable description — this is what the model uses to decide
/// when to call the tool, so write it carefully.
#[serde(default)]
pub description: String,
/// JSON Schema for the arguments. Defaults to an empty object schema.
#[serde(default)]
pub parameters: Value,
/// Target URL. May contain `{arg}` placeholders.
pub url: String,
/// HTTP method (`GET` or `POST`; default `POST`).
#[serde(default = "default_method")]
pub method: String,
/// Extra headers sent with the request (API keys, content type, …).
#[serde(default)]
pub headers: HashMap<String, String>,
}
fn default_method() -> String {
"POST".to_string()
}
/// A tool call parsed from the model's native output.
#[derive(Debug, Clone, PartialEq)]
pub struct ToolCall {
pub name: String,
/// Parsed arguments object (may be empty).
pub arguments: Value,
}
/// Loaded tool definitions plus the HTTP client used to execute them.
pub struct ToolRegistry {
specs: Vec<ToolSpec>,
http: Client,
max_result_chars: usize,
}
impl ToolRegistry {
/// Load the tool list from a JSON file. Returns an error when the file is
/// missing, unreadable or not valid JSON.
pub fn load(path: &Path, timeout_secs: u64, max_result_chars: usize) -> Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading tools file {}", path.display()))?;
let specs: Vec<ToolSpec> =
serde_json::from_str(&raw).with_context(|| format!("parsing tools file {}", path.display()))?;
if specs.is_empty() {
warn!(path = %path.display(), "tools file contains no tool definitions");
}
for spec in &specs {
if spec.name.trim().is_empty() || spec.url.trim().is_empty() {
warn!(name = %spec.name, "tool definition with empty name/url ignored");
}
}
let http = Client::builder()
.timeout(Duration::from_secs(timeout_secs.max(1)))
.build()
.unwrap_or_default();
info!(
path = %path.display(),
tools = specs.len(),
names = ?specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
"tool calling enabled"
);
Ok(Self { specs, http, max_result_chars })
}
/// Whether any tool is defined.
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
/// The OpenAI `tools` array forwarded in every chat-completions request, so
/// the model knows which functions it may call.
pub fn openai_tools(&self) -> Value {
Value::Array(
self.specs
.iter()
.map(|s| {
let params = if s.parameters.is_null() {
json!({ "type": "object", "properties": {} })
} else {
s.parameters.clone()
};
json!({
"type": "function",
"function": {
"name": s.name,
"description": s.description,
"parameters": params,
}
})
})
.collect(),
)
}
/// Execute a parsed call and return the text handed back to the model.
/// Never fails: an unknown tool or a transport error becomes a textual
/// error the model can read and recover from.
pub async fn execute(&self, call: &ToolCall) -> String {
let Some(spec) = self.specs.iter().find(|s| s.name == call.name) else {
warn!(tool = %call.name, "model called an unknown tool");
return format!("Error: unknown tool '{}'", call.name);
};
let args = call.arguments.as_object().cloned().unwrap_or_default();
let method = spec.method.trim().to_ascii_uppercase();
let url = substitute_placeholders(&spec.url, &args);
let mut request = match method.as_str() {
"GET" => {
let mut req = self.http.get(&url);
// Append arguments that were not already consumed by a `{arg}`
// placeholder in the URL.
let mut used: Vec<String> = Vec::new();
for key in args.keys() {
if spec.url.contains(&format!("{{{key}}}")) {
used.push(key.clone());
}
}
for (key, value) in &args {
if !used.contains(key) {
req = req.query(&[(key, scalar_to_string(value))]);
}
}
req
}
_ => {
let body = Value::Object(args.clone());
self.http.post(&url).json(&body)
}
};
for (key, value) in &spec.headers {
request = request.header(key, substitute_placeholders(value, &args));
}
debug!(tool = %call.name, method = %method, url = %url, "executing tool");
match request.send().await {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let text = truncate(&body, self.max_result_chars);
if !status.is_success() {
warn!(tool = %call.name, status = %status, "tool returned a non-success status");
format!("Error: HTTP {} — {}", status, text.trim())
} else {
text.trim().to_string()
}
}
Err(e) => {
warn!(tool = %call.name, error = %e, "tool request failed");
format!("Error: tool request failed: {e}")
}
}
}
}
/// Replace every `{arg}` placeholder in `template` with the matching argument.
fn substitute_placeholders(template: &str, args: &Map<String, Value>) -> String {
let mut out = template.to_string();
for (key, value) in args {
out = out.replace(&format!("{{{key}}}"), &scalar_to_string(value));
}
out
}
/// Render a JSON scalar as a string (numbers/booleans without quotes).
fn scalar_to_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
/// Truncate to at most `max` characters on a char boundary.
fn truncate(s: &str, max: usize) -> String {
if max == 0 || s.len() <= max {
return s.to_string();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}… [truncated]", &s[..end])
}
// --- Native-format parsing --------------------------------------------------
/// Remove every complete `<tool_call>…</tool_call>` block from `text` and return
/// the visible remainder plus the parsed calls.
///
/// A block that is still incomplete (no closing marker yet) — and any trailing
/// partial opening marker — is **held back**, so a tool call never leaks into
/// the spoken/displayed text while it is still streaming in.
pub fn split_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
let mut visible = String::with_capacity(text.len());
let mut calls = Vec::new();
let mut rest = text;
loop {
match rest.find(TOOL_CALL_OPEN) {
Some(i) => {
visible.push_str(&rest[..i]);
let after = &rest[i + TOOL_CALL_OPEN.len()..];
match after.find(TOOL_CALL_CLOSE) {
Some(j) => {
if let Some(call) = parse_block(&after[..j]) {
calls.push(call);
}
rest = &after[j + TOOL_CALL_CLOSE.len()..];
}
// Incomplete block: hold the remainder back for the next push.
None => return (visible, calls),
}
}
None => {
// Hold back a trailing partial opening marker (e.g. `<tool_c`).
let hold = partial_marker_tail(rest);
visible.push_str(&rest[..rest.len() - hold]);
return (visible, calls);
}
}
}
}
/// Number of trailing bytes of `s` that are a proper prefix of the opening
/// marker and therefore must not be emitted yet.
fn partial_marker_tail(s: &str) -> usize {
let marker = TOOL_CALL_OPEN.as_bytes();
let max = marker.len().min(s.len());
for k in (1..=max).rev() {
if s.as_bytes()[s.len() - k..] == marker[..k] {
return k;
}
}
0
}
/// Parse the body of a `<tool_call>` block: a `<function=NAME>` element holding
/// one or more `<parameter=KEY>VALUE</parameter>` elements.
fn parse_block(body: &str) -> Option<ToolCall> {
let name = element_value(body, "\u{3C}function=", "\u{3E}")?;
let mut args = Map::new();
let mut rest = body;
while let Some(start) = rest.find("\u{3C}parameter=") {
let after = &rest[start + "\u{3C}parameter=".len()..];
let Some(gt) = after.find('\u{3E}') else { break };
let key = after[..gt].trim().to_string();
let after = &after[gt + 1..];
let Some(end) = after.find("\u{3C}/parameter\u{3E}") else { break };
let raw = after[..end].trim();
args.insert(key, coerce_value(raw));
rest = &after[end + "\u{3C}/parameter\u{3E}".len()..];
}
Some(ToolCall { name: name.trim().to_string(), arguments: Value::Object(args) })
}
/// Read the text between `open` and `close` (e.g. `<function=` … `>`).
fn element_value(s: &str, open: &str, close: &str) -> Option<String> {
let start = s.find(open)? + open.len();
let after = &s[start..];
let end = after.find(close)?;
Some(after[..end].to_string())
}
/// Coerce a parameter value: valid JSON (number/bool/array/object) is parsed,
/// anything else stays a string.
fn coerce_value(raw: &str) -> Value {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Value::String(String::new());
}
match serde_json::from_str::<Value>(trimmed) {
Ok(v) if !v.is_string() || trimmed.starts_with('"') => v,
_ => Value::String(trimmed.to_string()),
}
}
// --- Streaming stripper -----------------------------------------------------
/// Incremental filter that turns a stream of raw model deltas into visible
/// text, collecting every complete tool call as a side effect.
///
/// Like the chain-of-thought stripper in [`crate::llm`], it recomputes the
/// clean text from the accumulated raw text on each push, so a `<tool_call>`
/// block split across deltas is removed as a whole.
pub struct ToolCallStripper {
raw: String,
/// Bytes of visible text already returned to the caller.
emitted: usize,
/// Tool calls found so far (recomputed on every push).
calls: Vec<ToolCall>,
}
impl ToolCallStripper {
pub fn new() -> Self {
Self { raw: String::new(), emitted: 0, calls: Vec::new() }
}
/// Feed a delta, returning the newly visible (non-tool-call) text.
pub fn push(&mut self, delta: &str) -> String {
self.raw.push_str(delta);
let (visible, calls) = split_tool_calls(&self.raw);
self.calls = calls;
if visible.len() <= self.emitted {
return String::new();
}
let out = visible[self.emitted..].to_string();
self.emitted = visible.len();
out
}
/// Flush whatever visible text is left once the stream has ended. A
/// never-completed tool-call block is dropped (it was truncated).
pub fn finish(&mut self) -> String {
let (visible, _) = split_tool_calls(&self.raw);
if visible.len() <= self.emitted {
return String::new();
}
let out = visible[self.emitted..].to_string();
self.emitted = visible.len();
out
}
/// Take the collected tool calls.
pub fn take_calls(&mut self) -> Vec<ToolCall> {
std::mem::take(&mut self.calls)
}
}
impl Default for ToolCallStripper {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(name: &str, params: &[(&str, &str)]) -> String {
let mut s = format!("{TOOL_CALL_OPEN}\n<function={name}>\n");
for (k, v) in params {
s.push_str(&format!("<parameter={k}>\n{v}\n</parameter>\n"));
}
s.push_str(&format!("</function>\n{TOOL_CALL_CLOSE}"));
s
}
#[test]
fn parses_native_call() {
let text = format!("Let me check.{}", block("get_weather", &[("city", "Paris")]));
let (visible, calls) = split_tool_calls(&text);
assert_eq!(visible, "Let me check.");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments["city"], "Paris");
}
#[test]
fn parses_multiple_calls_and_typed_args() {
let text = format!(
"{}{}",
block("a", &[("n", "3")]),
block("b", &[("flag", "true")])
);
let (visible, calls) = split_tool_calls(&text);
assert!(visible.is_empty());
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].arguments["n"], 3);
assert_eq!(calls[1].arguments["flag"], true);
}
#[test]
fn stripper_holds_back_partial_block() {
let mut s = ToolCallStripper::new();
assert_eq!(s.push("Ciao "), "Ciao ");
// Partial opening marker must not leak.
assert_eq!(s.push("<tool_ca"), "");
assert_eq!(s.push("ll>\n<function=f>\n"), "");
assert!(s.take_calls().is_empty());
let tail = format!("</function>\n{TOOL_CALL_CLOSE}");
assert_eq!(s.push(&tail), "");
let calls = s.take_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "f");
}
#[test]
fn stripper_is_stable_across_deltas() {
let text = format!("Sole a Roma.{}", block("get_weather", &[("city", "Rome")]));
let mut s = ToolCallStripper::new();
let mut out = String::new();
for chunk in text.as_bytes().chunks(3) {
out.push_str(&s.push(&String::from_utf8_lossy(chunk)));
}
out.push_str(&s.finish());
assert_eq!(out, "Sole a Roma.");
let calls = s.take_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].arguments["city"], "Rome");
}
#[test]
fn plain_text_passes_through_untouched() {
let mut s = ToolCallStripper::new();
assert_eq!(s.push("Nessun tool qui."), "Nessun tool qui.");
assert!(s.take_calls().is_empty());
}
}
+57 -2
View File
@@ -5,6 +5,12 @@
//! mixer's output rate, so samples are pushed directly into the TTS queue
//! without resampling. A [`CancellationToken`] allows immediate interruption
//! (barge-in).
//!
//! Drama 3 can sound overly theatrical on some references. Two mitigations are
//! applied here, both configurable from the environment: optional sampling
//! knobs (`temperature` / `top_p` / `top_k`) that trim the tails of the
//! acoustic token distribution, and a neutral style tag prepended to untagged
//! requests so plain LLM prose is read in a conversational register.
use crate::audio::SampleRingBuffer;
use crate::config::Config;
@@ -26,6 +32,11 @@ struct Reference {
}
/// Request body for the SGLang `/v1/audio/speech` endpoint.
///
/// Sampling knobs are optional and only serialized when set. Drama 3 accepts
/// `temperature`, `top_p` and `top_k` at this level; `repetition_penalty` is
/// deliberately never sent because this server rejects any value other than
/// `1.0` (`Drama 3 request: repetition_penalty must be 1.0`).
#[derive(Debug, Serialize)]
struct SpeechRequest<'a> {
model: &'a str,
@@ -35,6 +46,12 @@ struct SpeechRequest<'a> {
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
references: Option<Vec<Reference>>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
top_k: Option<u32>,
}
/// Streaming TTS client backed by Fish Audio / SGLang-Omni Drama 3.
@@ -46,6 +63,12 @@ pub struct FishAudioTts {
voice: String,
/// Current voice-cloning reference (config default, overridable at runtime).
reference: Arc<parking_lot::RwLock<Option<Reference>>>,
/// Optional sampling overrides (de-emphasis of the recitative register).
temperature: Option<f32>,
top_p: Option<f32>,
top_k: Option<u32>,
/// Neutral style tag prepended to untagged synthesis requests.
style_tag: String,
}
impl FishAudioTts {
@@ -61,6 +84,13 @@ impl FishAudioTts {
"TTS voice-cloning reference configured"
);
}
info!(
temperature = ?config.tts_temperature,
top_p = ?config.tts_top_p,
top_k = ?config.tts_top_k,
style_tag = %config.tts_style_tag,
"TTS de-emphasis settings"
);
Self {
http: Client::builder()
.build()
@@ -69,6 +99,23 @@ impl FishAudioTts {
model: config.tts_model.clone(),
voice: config.tts_voice.clone(),
reference: Arc::new(parking_lot::RwLock::new(reference)),
temperature: config.tts_temperature,
top_p: config.tts_top_p,
top_k: config.tts_top_k,
style_tag: config.tts_style_tag.clone(),
}
}
/// The text actually sent to the model: the configured neutral style tag is
/// prepended unless the text already opens with a style tag or the tag is
/// disabled. LLM replies carry no tags, so they get de-emphasized; clip
/// banks that start with their own tag are left untouched.
fn styled<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
let tag = self.style_tag.trim();
if tag.is_empty() || text.trim_start().starts_with('[') {
std::borrow::Cow::Borrowed(text)
} else {
std::borrow::Cow::Owned(format!("{tag} {text}"))
}
}
@@ -107,14 +154,18 @@ impl TtsEngine for FishAudioTts {
cancel: &CancellationToken,
) -> Result<()> {
let references = self.reference.read().clone().map(|r| vec![r]);
let input = self.styled(text);
let body = SpeechRequest {
model: &self.model,
input: text,
input: &input,
voice: &self.voice,
response_format: "pcm",
stream: true,
references,
temperature: self.temperature,
top_p: self.top_p,
top_k: self.top_k,
};
info!(model = %self.model, text_len = text.len(), "SGLang TTS request");
@@ -125,7 +176,11 @@ impl TtsEngine for FishAudioTts {
response_format = "pcm",
stream = true,
has_reference = body.references.is_some(),
text = %text,
temperature = ?self.temperature,
top_p = ?self.top_p,
top_k = ?self.top_k,
styled = input.len() != text.len(),
text = %input,
"TTS request dispatched"
);
+11
View File
@@ -257,6 +257,17 @@ impl VadDetector {
self.in_speech
}
/// Milliseconds of consecutive silence accumulated *inside* the current
/// utterance — i.e. the length of the micro-pause the user is currently in.
/// Zero while voice is ongoing, and zero outside a confirmed utterance.
///
/// Exposed so the backchannel can cue on a natural pause (between words /
/// phrases) instead of dropping a cue mid-word. Measured in [`WINDOW_MS`]
/// steps, so it is a multiple of 32 ms.
pub fn silence_ms(&self) -> u32 {
self.silence_run_ms
}
/// Feed 16 kHz mono PCM and collect the events it triggers.
///
/// Any number of samples may be pushed; internally they are buffered into
+368
View File
@@ -100,6 +100,83 @@
.meter { height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden; flex: 1; }
.meter > div { height: 100%; width: 0%; background: linear-gradient(90deg, var(--ok), var(--warn), var(--err)); transition: width .05s; }
.meters span { font-size: 11px; color: var(--muted); white-space: nowrap; }
/* Cue indicators: flash + counter for filler / start filler / backchannel. */
.cues { display: flex; gap: 8px; margin-bottom: 10px; }
.cue {
--cue: var(--accent);
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
padding: 6px 9px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 11px;
color: var(--muted);
transition: border-color .25s, box-shadow .25s;
}
.cue[data-kind="filler"] { --cue: var(--ok); }
.cue[data-kind="start_filler"] { --cue: var(--warn); }
.cue[data-kind="backchannel"] { --cue: var(--accent); }
.cue .cue-dot {
width: 9px; height: 9px; border-radius: 50%; flex: none;
background: var(--cue); opacity: .25;
transition: opacity .15s, box-shadow .15s, transform .15s;
}
.cue.active { border-color: var(--cue); box-shadow: 0 0 10px -2px var(--cue); }
.cue.active .cue-dot { opacity: 1; box-shadow: 0 0 8px var(--cue); transform: scale(1.3); }
.cue .cue-label { text-transform: uppercase; letter-spacing: .4px; white-space: nowrap; }
.cue .cue-last {
flex: 1 1 auto; min-width: 0; text-align: right;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cue .cue-count { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
/* Cue methods: collapsible panel with the live policy of each cue. */
.cuemethods {
margin-bottom: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
font-size: 12px;
}
.cuemethods > summary {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; cursor: pointer; list-style: none; color: var(--muted);
}
.cuemethods > summary::-webkit-details-marker { display: none; }
.cuemethods > summary::before {
content: '\25B8'; font-size: 10px; transition: transform .15s;
}
.cuemethods[open] > summary::before { transform: rotate(90deg); }
.cuemethods .cm-title { text-transform: uppercase; letter-spacing: .4px; color: var(--text); font-weight: 600; }
.cuemethods .cm-hint { margin-left: auto; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cm-cards {
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px; padding: 0 10px 10px; max-height: 246px; overflow-y: auto;
}
.cm-empty { color: var(--muted); padding: 2px 0 4px; }
.cm-card { --cue: var(--accent); border: 1px solid var(--border); border-left: 3px solid var(--cue); border-radius: 8px; padding: 8px 10px; }
.cm-card[data-kind="filler"] { --cue: var(--ok); }
.cm-card[data-kind="start_filler"] { --cue: var(--warn); }
.cm-card[data-kind="backchannel"] { --cue: var(--accent); }
.cm-head { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
.cm-head .cm-name { text-transform: uppercase; letter-spacing: .4px; font-size: 11px; color: var(--cue); font-weight: 600; }
.cm-head .cm-badge {
margin-left: auto; font-size: 10px; padding: 1px 7px; border-radius: 999px;
border: 1px solid var(--border); color: var(--muted);
}
.cm-head .cm-badge.on { border-color: var(--cue); color: var(--cue); }
.cm-head .cm-badge.off { border-color: var(--err); color: var(--err); }
.cm-desc { color: var(--muted); line-height: 1.45; margin-bottom: 6px; }
.cm-params { display: flex; flex-wrap: wrap; gap: 4px 6px; }
.cm-param {
font-size: 11px; color: var(--text);
background: var(--panel); border: 1px solid var(--border);
border-radius: 6px; padding: 1px 6px;
}
.cm-param b { color: var(--muted); font-weight: 600; }
.composer { display: flex; gap: 8px; }
.composer input {
flex: 1;
@@ -110,6 +187,30 @@
color: var(--text);
font-size: 14px;
}
.mediabar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.mediabar input[type="text"] {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
}
.mediabar input[type="range"] { width: 88px; accent-color: var(--accent); }
.mediabar .state {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
min-width: 72px;
text-align: right;
}
#log {
display: none;
max-height: 110px;
@@ -147,6 +248,42 @@
<span>🎤</span><div class="meter"><div id="micMeter"></div></div>
<span>🔊</span><div class="meter"><div id="outMeter"></div></div>
</div>
<div class="cues">
<div class="cue" data-kind="filler" id="cueFiller" title="End-of-turn filler (latency mask)">
<span class="cue-dot"></span>
<span class="cue-label">Filler</span>
<span class="cue-last">—</span>
<span class="cue-count">0</span>
</div>
<div class="cue" data-kind="start_filler" id="cueStartFiller" title="Turn-start acknowledgment (latency mask)">
<span class="cue-dot"></span>
<span class="cue-label">Start filler</span>
<span class="cue-last">—</span>
<span class="cue-count">0</span>
</div>
<div class="cue" data-kind="backchannel" id="cueBackchannel" title="Backchannel cue while the user speaks">
<span class="cue-dot"></span>
<span class="cue-label">Backchannel</span>
<span class="cue-last">—</span>
<span class="cue-count">0</span>
</div>
</div>
<details class="cuemethods" id="cueMethods">
<summary>
<span class="cm-title">Cue methods</span>
<span class="cm-hint" id="cueMethodsHint">connect to load the live policy</span>
</summary>
<div class="cm-cards" id="cueCards">
<div class="cm-empty">Connect to load the live cue policy from the server.</div>
</div>
</details>
<div class="mediabar">
<input type="text" id="mediaUrl" placeholder="🎵 Audio URL / radio / YouTube…" disabled autocomplete="off" />
<button type="button" id="btnMediaPlay" disabled>Play</button>
<button type="button" id="btnMediaStop" class="danger" disabled>Stop</button>
<input type="range" id="mediaVol" min="0" max="150" value="60" title="External audio volume" disabled />
<span class="state" id="mediaState">idle</span>
</div>
<form class="composer" id="composer">
<input type="text" id="textInput" placeholder="Or type a message…" disabled autocomplete="off" />
<button type="submit" id="btnSend" disabled>Send</button>
@@ -167,6 +304,12 @@
// {"type":"assistant_delta","text":"…"} incremental assistant reply
// {"type":"assistant_end"} assistant finished
// {"type":"error","text":"…"} pipeline error
// {"type":"audio_source","state":"…", …} external audio source status
// {"type":"cue","kind":"…","name":"…"} filler / start filler / backchannel emitted
// {"type":"cues","lang":"…","config":{…}} live cue policy (sent at start)
//
// The session closes itself after IDLE_TIMEOUT_MS without any traffic in
// either direction (see the idle guard below).
// ---------------------------------------------------------------------------
const SAMPLE_RATE_OUT = 44100; // server <-> client (matches the TTS rate)
@@ -174,6 +317,10 @@ const SAMPLE_RATE_IN = 44100;
const FRAME_MS = 20;
const FRAME_SAMPLES = SAMPLE_RATE_IN * FRAME_MS / 1000; // 882
// Disconnect the session (releasing the microphone) after this much time
// without activity.
const IDLE_TIMEOUT_MS = 2 * 60 * 1000;
const $ = (id) => document.getElementById(id);
const wsDot = $('connDot');
const wsText = $('connText');
@@ -186,7 +333,20 @@ const emptyHint = $('emptyHint');
const composer = $('composer');
const textInput = $('textInput');
const btnSend = $('btnSend');
const mediaUrl = $('mediaUrl');
const btnMediaPlay = $('btnMediaPlay');
const btnMediaStop = $('btnMediaStop');
const mediaVol = $('mediaVol');
const mediaState = $('mediaState');
const logEl = $('log');
// Cue indicators: one tile per filler type, flashed on each server "cue" event.
const cueEls = {
filler: $('cueFiller'),
start_filler: $('cueStartFiller'),
backchannel: $('cueBackchannel'),
};
const cueCounts = { filler: 0, start_filler: 0, backchannel: 0 };
const cueTimers = { filler: 0, start_filler: 0, backchannel: 0 };
let ws = null;
let audioCtx = null;
@@ -496,6 +656,128 @@ function feedPlayback(arrayBuffer) {
// --- WebSocket -------------------------------------------------------------
// Cue indicators: flash the matching tile and bump its counter. `kind` is one
// of "filler" | "start_filler" | "backchannel"; the detail line shows the
// backchannel category or the clip name.
function updateCue(ev) {
const el = cueEls[ev.kind];
if (!el) return;
cueCounts[ev.kind] += 1;
el.querySelector('.cue-count').textContent = cueCounts[ev.kind];
el.querySelector('.cue-last').textContent = ev.category || ev.name || '';
el.classList.add('active');
clearTimeout(cueTimers[ev.kind]);
cueTimers[ev.kind] = setTimeout(() => el.classList.remove('active'), 400);
}
function resetCues() {
for (const kind of Object.keys(cueCounts)) {
cueCounts[kind] = 0;
clearTimeout(cueTimers[kind]);
cueEls[kind].classList.remove('active');
cueEls[kind].querySelector('.cue-count').textContent = '0';
cueEls[kind].querySelector('.cue-last').textContent = '—';
}
}
// --- Cue methods panel -----------------------------------------------------
// The method (how each cue decides to fire) plus the live parameters reported
// by the server in the "cues" event at session start. `params` turns the
// server payload into [label, value] chips.
const fmt2 = (v) => (typeof v === 'number' ? v.toFixed(2) : '—');
const CUE_METHODS = {
filler: {
name: 'Filler',
desc: 'End-of-turn latency mask. At most one clip per turn, and only when the '
+ 'reply audio has not started within the delay — otherwise it is dropped, so '
+ 'it never talks over the answer. Picked round-robin from the active bank.',
params: (c) => [
['delay', c.delay_ms + ' ms'],
['gain idle', fmt2(c.gain_idle)],
['gain duck', fmt2(c.gain_duck)],
['clips', c.clips],
['bank', c.bank],
],
},
start_filler: {
name: 'Start filler',
desc: 'Turn-start acknowledgment. Fires only if the first audio chunk of the '
+ 'reply is still missing after its (shorter) delay; when audio arrives in '
+ 'time the cue is dropped. A clip is picked at random from the bank.',
params: (c) => [
['go delay', c.go_delay_ms + ' ms'],
['clips', c.clips],
['bank', c.bank],
],
},
backchannel: {
name: 'Backchannel',
desc: 'While the user keeps speaking (and the agent is silent): a short ack '
+ 'mixed at low gain. Fires only inside a natural micro-pause, at a random '
+ 'depth in the pause window, through a probabilistic gate, with randomized '
+ 'spacing and volume and a per-turn cap. Categories progress ack → agree.',
params: (c) => [
['gain', fmt2(c.gain)],
['min speech', c.min_speech_ms + ' ms'],
['interval', c.interval_ms + ' ms'],
['jitter', c.jitter_ms + ' ms'],
['pause', c.pause_min_ms + '–' + c.pause_max_ms + ' ms'],
['prob', fmt2(c.prob)],
['vol jitter', fmt2(c.volume_jitter)],
['max/turn', c.max_per_turn],
['categories', (c.categories || []).join(', ')],
['clips', c.clips],
],
},
};
// Render the three cards from the server "cues" payload (live params + method).
function renderCues(lang, cfg) {
const host = $('cueCards');
if (!host || !cfg) return;
host.textContent = '';
for (const kind of ['filler', 'start_filler', 'backchannel']) {
const m = CUE_METHODS[kind];
const c = cfg[kind];
if (!m || !c) continue;
const card = document.createElement('div');
card.className = 'cm-card';
card.dataset.kind = kind;
const head = document.createElement('div');
head.className = 'cm-head';
const name = document.createElement('span');
name.className = 'cm-name';
name.textContent = m.name;
const badge = document.createElement('span');
badge.className = 'cm-badge ' + (c.enabled ? 'on' : 'off');
badge.textContent = c.enabled ? 'on' : 'off';
head.append(name, badge);
const desc = document.createElement('div');
desc.className = 'cm-desc';
desc.textContent = m.desc;
const params = document.createElement('div');
params.className = 'cm-params';
for (const [k, v] of m.params(c)) {
const chip = document.createElement('span');
chip.className = 'cm-param';
const b = document.createElement('b');
b.textContent = k + ' ';
chip.append(b, document.createTextNode(String(v)));
params.append(chip);
}
card.append(head, desc, params);
host.append(card);
}
const hint = $('cueMethodsHint');
if (hint) hint.textContent = (lang ? 'lang ' + lang + ' · ' : '') + 'filler · start filler · backchannel';
}
function handleServerText(text) {
let event = null;
try { event = JSON.parse(text); } catch (e) { log('server: ' + text); return; }
@@ -515,11 +797,65 @@ function handleServerText(text) {
addMessage('error', event.text, 'error');
log('error: ' + event.text);
break;
case 'audio_source':
updateMediaState(event);
break;
case 'cue':
updateCue(event);
break;
case 'cues':
renderCues(event.lang, event.config);
break;
default:
log('server: ' + text);
}
}
// External audio source status → status label + button states.
function updateMediaState(event) {
const st = event.state || '';
mediaState.textContent =
st === 'playing' ? 'playing' : st === 'loading' ? 'loading…' : st === 'error' ? 'error' : 'idle';
btnMediaStop.disabled = !(st === 'playing' || st === 'loading');
if (st === 'error') {
const msg = event.text || 'unknown error';
log('audio source error: ' + msg);
addMessage('error', 'Audio source error: ' + msg, 'error');
}
}
function setMediaControlsEnabled(on) {
mediaUrl.disabled = !on;
mediaVol.disabled = !on;
btnMediaPlay.disabled = !on;
if (!on) btnMediaStop.disabled = true;
}
// --- Idle session guard ----------------------------------------------------
// Reset on any traffic (server audio / events) and on every user action. When
// it expires the session is closed, which stops the microphone and re-enables
// the Connect button. A playing external source keeps sending audio frames, so
// it counts as activity too.
let idleTimer = 0;
function touchActivity() {
if (!ws) return;
clearTimeout(idleTimer);
idleTimer = setTimeout(onIdleTimeout, IDLE_TIMEOUT_MS);
}
function stopIdleWatch() {
clearTimeout(idleTimer);
idleTimer = 0;
}
function onIdleTimeout() {
if (!ws) return;
log(`idle timeout — no activity for ${IDLE_TIMEOUT_MS / 60000} min, disconnecting`);
addMessage('error', 'Session closed after 2 minutes of inactivity. Press Connect to resume.', 'error');
disconnect();
}
async function connect() {
if (!navigator.mediaDevices?.getUserMedia) {
const why = window.isSecureContext
@@ -546,12 +882,19 @@ async function connect() {
ws.onopen = () => {
setConn('on', 'connected');
log('connected');
resetCues();
btnConnect.disabled = true;
btnInterrupt.disabled = false;
textInput.disabled = false;
btnSend.disabled = false;
setMediaControlsEnabled(true);
mediaState.textContent = 'idle';
touchActivity();
// Sync the server's external-audio gain with the slider.
sendJson({ type: 'audio_source', action: 'volume', gain: mediaVol.value / 100 });
};
ws.onmessage = (e) => {
touchActivity();
if (e.data instanceof ArrayBuffer) {
feedPlayback(e.data);
} else {
@@ -560,12 +903,16 @@ async function connect() {
};
ws.onerror = () => { log('websocket error'); setConn('err', 'error'); };
ws.onclose = () => {
stopIdleWatch();
ws = null; // allow reconnecting from the Connect button
log('disconnected');
setConn('', 'disconnected');
btnConnect.disabled = false;
btnInterrupt.disabled = true;
textInput.disabled = true;
btnSend.disabled = true;
setMediaControlsEnabled(false);
mediaState.textContent = 'idle';
endAgentMessage();
stopAudio();
};
@@ -579,6 +926,7 @@ function disconnect() {
function sendJson(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
touchActivity();
return true;
}
return false;
@@ -592,6 +940,26 @@ btnInterrupt.onclick = () => {
}
};
// External audio source (URL / radio / YouTube) controls.
btnMediaPlay.onclick = () => {
const url = mediaUrl.value.trim();
if (!url) return;
if (sendJson({ type: 'audio_source', action: 'play', url })) {
mediaState.textContent = 'loading…';
btnMediaStop.disabled = false;
log('→ audio_source play ' + url);
}
};
btnMediaStop.onclick = () => {
if (sendJson({ type: 'audio_source', action: 'stop' })) log('→ audio_source stop');
};
mediaUrl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); btnMediaPlay.click(); }
});
mediaVol.oninput = () => {
sendJson({ type: 'audio_source', action: 'volume', gain: mediaVol.value / 100 });
};
composer.onsubmit = (e) => {
e.preventDefault();
const text = textInput.value.trim();
+3
View File
@@ -18,6 +18,9 @@ pub struct AppState {
pub config: Arc<Config>,
pub tts: Arc<dyn TtsEngine>,
pub fillers: Arc<FillerBank>,
/// Preloaded start-filler clip, played immediately at turn start (see
/// [`crate::orchestrator::TurnManager`]). Empty => no immediate cue.
pub start_filler: Arc<FillerBank>,
/// Preloaded backchannel clips, played while the user is speaking (see
/// [`crate::orchestrator::backchannel`]).
pub backchannel: Arc<FillerBank>,
+232 -12
View File
@@ -1,14 +1,21 @@
//! Per-session audio loop: receives microphone PCM, feeds the STT worker, and
//! streams mixed 20 ms frames back to the browser on a strict ticker.
use crate::assets::lang_dir;
use crate::audio::AudioMixer;
use crate::config::{FRAME_DURATION_MS, OUTPUT_FRAME_BYTES, OUTPUT_SAMPLE_RATE, STT_SAMPLE_RATE};
use crate::audit::SessionAudit;
use crate::config::{
Config, FRAME_DURATION_MS, OUTPUT_FRAME_BYTES, OUTPUT_SAMPLE_RATE, STT_SAMPLE_RATE,
};
use crate::lang::{LangBanks, LanguageState};
use crate::media::MediaSource;
use crate::orchestrator::{spawn_orchestrator, BackchannelScheduler, TurnManager};
use crate::stt::{SttWorker, Transcript};
use crate::vad::{VadDetector, VadEvent};
use crate::ws::handler::AppState;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use serde_json::json;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::time::{interval, MissedTickBehavior};
@@ -24,6 +31,22 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
let (mut ws_sender, mut ws_receiver) = socket.split();
// Structured per-session audit log (see `crate::audit`). Best-effort: a
// disabled or unwritable sink leaves the session running unchanged. The
// opening record snapshots the configuration plus the runtime capabilities
// that are not part of `Config`.
let audit = Arc::new(SessionAudit::new(&state.config));
audit.log_start(
&state.config,
json!({
"stt": state.stt.is_some(),
"vad": state.vad.is_some(),
"tokenizer": state.tokenizer.is_some(),
"llm": state.llm.is_some(),
"llm_model": state.llm.as_ref().map(|l| l.model().to_string()),
}),
);
// Per-session mixer and turn manager.
let mixer = AudioMixer::new(
MIXER_QUEUE_CAPACITY,
@@ -31,20 +54,61 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
state.config.filler_gain_idle,
state.config.filler_gain_duck,
state.config.backchannel.gain,
state.config.external_audio_gain,
);
// Outbound text events (chat box): the turn manager pushes JSON strings,
// the sender task forwards them on the same WebSocket as the audio frames.
let (out_tx, out_rx) = mpsc::unbounded_channel::<String>();
// Active-language clip banks, shared between the turn manager (end-of-turn
// and start filler) and the mic task (backchannel). The session starts on
// the configured default language (the base clip directories) and switches
// when the user's language is detected — see `crate::lang`.
let langs = Arc::new(LanguageState::new(
state.config.lang_default.clone(),
state.backchannel.clone(),
state.config.backchannel.texts.clone(),
state.config.backchannel.categories.clone(),
state.fillers.clone(),
state.start_filler.clone(),
));
// Tell the client how the three cues (filler, start filler, backchannel)
// work and with which parameters, so the playground can show the live
// policy instead of hard-coded values. Sent on the outbound text channel;
// the payload mirrors the runtime behaviour in `turn_manager` and
// `orchestrator::backchannel`.
{
let banks = langs.current();
let _ = out_tx.send(
json!({
"type": "cues",
"lang": banks.lang,
"config": cues_config(&state.config, &banks),
})
.to_string(),
);
}
let turn_manager = Arc::new(TurnManager::new(
mixer.clone(),
state.tts.clone(),
state.fillers.clone(),
langs.clone(),
state.tokenizer.clone(),
state.llm.clone(),
state.config.clone(),
Some(out_tx),
Some(out_tx.clone()),
audit.clone(),
));
// External audio source (URL / radio / YouTube) feeding the mixer's own
// channel. It reports state events on the same outbound text channel as the
// chat box.
let media = MediaSource::new(
mixer.external_queue.clone(),
state.config.media_ffmpeg_bin.clone(),
state.config.media_ytdlp_bin.clone(),
state.config.media_ytdlp_args.clone(),
Some(out_tx.clone()),
);
let vad_settings = state.config.vad.clone();
let mut vad_detector = state.vad.as_ref().map(|model| {
info!(
@@ -74,6 +138,8 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
// --- Task 1: receiver loop (mic frames + control text) ---
let tm_for_interrupt = turn_manager.clone();
let media_for_control = media.clone();
let audit_for_recv = audit.clone();
let receiver_handle = tokio::spawn(async move {
while let Some(msg) = ws_receiver.next().await {
match msg {
@@ -91,12 +157,19 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
let _ = mic_tx.send(resampled);
}
Ok(Message::Text(text)) => {
handle_control_message(&text, &tm_for_interrupt).await;
handle_control_message(
&text,
&tm_for_interrupt,
&media_for_control,
&audit_for_recv,
)
.await;
}
Ok(Message::Close(_)) => break,
Ok(_) => {}
Err(e) => {
error!(error = %e, "websocket recv error");
audit_for_recv.event("error", json!({ "where": "receiver", "text": e.to_string() }));
break;
}
}
@@ -111,19 +184,38 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
let settings = vad_settings.clone();
// Backchannel scheduler: emits short acknowledgments while the user
// speaks (see `crate::orchestrator::backchannel`).
let langs_for_mic = langs.clone();
let mut backchannel = BackchannelScheduler::new(
state.backchannel.clone(),
langs.current().backchannel.clone(),
state.config.backchannel.clone(),
settings.min_silence_ms,
);
let mixer_for_backchannel = mixer.clone();
let out_tx_for_mic = out_tx.clone();
let audit_for_mic = audit.clone();
tokio::spawn(async move {
let mut frames: u64 = 0;
let mut samples: u64 = 0;
let mut lang_generation = langs_for_mic.generation();
while let Some(frame) = mic_rx.recv().await {
frames += 1;
samples += frame.len() as u64;
// Language switch (see `crate::lang`): repoint the backchannel
// at the new language's clip bank. One atomic load per frame.
let generation = langs_for_mic.generation();
if generation != lang_generation {
lang_generation = generation;
let banks = langs_for_mic.current();
backchannel.retarget(
banks.backchannel.clone(),
&banks.backchannel_texts,
&banks.backchannel_categories,
);
info!(lang = %banks.lang, "backchannel switched language");
}
let Some(detector) = vad_detector.as_mut() else {
// No VAD: the STT worker segments with its RMS detector; if
// STT is disabled we only report mic activity, one line per
@@ -150,6 +242,10 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
prob = format!("{prob:.3}"),
"VAD barge-in — interrupting agent"
);
audit_for_mic.event(
"barge_in",
json!({ "source": "vad", "prob": prob }),
);
tm_for_vad.interrupt();
}
}
@@ -158,6 +254,8 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
prob = format!("{prob:.3}"),
"VAD speech confirmed (min speech reached)"
);
audit_for_mic
.event("vad_speech_start", json!({ "prob": prob }));
backchannel.on_speech_start();
}
VadEvent::SpeechEnd {
@@ -165,6 +263,10 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
duration_ms,
avg_prob,
} => {
audit_for_mic.event(
"vad_speech_end",
json!({ "duration_ms": duration_ms, "avg_prob": avg_prob }),
);
backchannel.on_speech_end();
if settings.segment_stt {
match &worker_for_mic {
@@ -188,12 +290,25 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
}
// While the user keeps speaking and the agent is silent, emit a
// brief acknowledgment at most at the configured intervals. The
// `!is_speaking()` gate avoids cuing on top of the agent's own
// TTS (that would be an interruption, not a backchannel).
// brief acknowledgment — but only on a natural micro-pause, so
// the cue never lands mid-word. The `!is_speaking()` gate avoids
// cuing on top of the agent's own TTS (that would be an
// interruption, not a backchannel).
if detector.in_speech() && !tm_for_vad.is_speaking() {
if let Some(name) = backchannel.maybe_emit(&mixer_for_backchannel) {
info!(clip = %name, "backchannel emitted during user speech");
let pause_ms = detector.silence_ms() as u64;
if let Some((name, category)) =
backchannel.maybe_emit(&mixer_for_backchannel, pause_ms)
{
info!(clip = %name, category = %category, pause_ms, "backchannel emitted during user speech");
let _ = out_tx_for_mic.send(
json!({
"type": "cue",
"kind": "backchannel",
"name": name,
"category": category,
})
.to_string(),
);
}
}
@@ -209,6 +324,7 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
};
// --- Task 3: 20 ms sender ticker (mixed audio -> browser) ---
let audit_for_send = audit.clone();
let sender_handle = tokio::spawn(async move {
let mut ticker = interval(std::time::Duration::from_millis(FRAME_DURATION_MS));
// Burst: if ticks are missed (event loop busy), catch up by sending
@@ -235,6 +351,11 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
event = out_rx.recv(), if events_open => {
match event {
Some(text) => {
// Audit every outbound event (chat text, assistant
// deltas, cues, tool calls, media state, errors) on
// its way out — this is the single tap point, so
// nothing sent to the client is missed.
audit_for_send.event("out", parse_event(&text));
if ws_sender.send(Message::Text(text)).await.is_err() {
break;
}
@@ -250,12 +371,17 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
// Wait for the receiver to finish (client disconnect), then clean up.
let _ = receiver_handle.await;
// Cancel any in-flight TTS and flush audio so the synthesis task stops
// consuming resources after the client disconnects.
// consuming resources after the client disconnects. Also stop the external
// audio source (kills the ffmpeg/yt-dlp processes).
turn_manager.interrupt();
media.stop();
// Dropping the sender will close the socket; abort the other tasks.
sender_handle.abort();
mic_feed_handle.abort();
// Closing audit record: event counts + session duration.
audit.summary("client_disconnected");
info!("session ended");
}
@@ -264,13 +390,22 @@ pub async fn run_session(socket: WebSocket, state: AppState) {
/// JSON control messages (used by the `/mivocero` chat client):
/// - `{"type":"user_text","text":"…"}`: run a user turn through the LLM.
/// - `{"type":"interrupt"}`: flush audio and cancel the in-flight reply.
/// - `{"type":"audio_source","action":"play","url":"…"}`: inject an external
/// audio source (URL / radio / YouTube) into the mixer's external channel.
/// - `{"type":"audio_source","action":"stop"}`: stop the external source.
/// - `{"type":"audio_source","action":"volume","gain":0.6}`: set its gain.
///
/// Legacy plain-text controls (used by the playground):
/// - `INTERRUPT` / `BAARGEIN`: flush audio and cancel TTS (barge-in).
/// - `SET_REFERENCE:<path>|<text>`: set the voice-cloning reference.
/// - `CLEAR_REFERENCE`: remove the voice-cloning reference.
/// - Any other text: synthesize it as speech (TTS pipeline test).
async fn handle_control_message(text: &str, turn_manager: &Arc<TurnManager>) {
async fn handle_control_message(
text: &str,
turn_manager: &Arc<TurnManager>,
media: &Arc<MediaSource>,
audit: &SessionAudit,
) {
let trimmed = text.trim();
if trimmed.starts_with('{') {
@@ -279,6 +414,10 @@ async fn handle_control_message(text: &str, turn_manager: &Arc<TurnManager>) {
Some("user_text") => {
if let Some(user_text) = value["text"].as_str() {
info!(text = user_text, "client text turn");
audit.event(
"control",
json!({ "type": "user_text", "text": user_text }),
);
// Run the turn in its own task: awaiting it here would
// block the receive loop for the whole LLM reply and
// make the session deaf to `interrupt` messages.
@@ -292,9 +431,37 @@ async fn handle_control_message(text: &str, turn_manager: &Arc<TurnManager>) {
}
Some("interrupt") => {
info!("client requested interrupt (json)");
audit.event("interrupt", json!({ "source": "client" }));
turn_manager.interrupt();
return;
}
Some("audio_source") => {
match value["action"].as_str().unwrap_or("") {
"play" => {
if let Some(url) = value["url"].as_str() {
info!(url, "client requested external audio source");
audit.event("media", json!({ "action": "play", "url": url }));
media.play(url);
}
}
"stop" => {
info!("client stopped external audio source");
audit.event("media", json!({ "action": "stop" }));
media.stop();
}
"volume" => {
if let Some(gain) = value["gain"].as_f64() {
let gain = gain as f32;
debug!(gain, "client set external audio gain");
audit
.event("media", json!({ "action": "volume", "gain": gain }));
turn_manager.set_external_gain(gain);
}
}
other => debug!(action = other, "unknown audio_source action"),
}
return;
}
_ => {}
}
}
@@ -302,11 +469,13 @@ async fn handle_control_message(text: &str, turn_manager: &Arc<TurnManager>) {
if trimmed == "INTERRUPT" || trimmed == "interrupt" || trimmed == "BAARGEIN" || trimmed == "bargein" {
info!("client requested interrupt");
audit.event("interrupt", json!({ "source": "client_legacy" }));
turn_manager.interrupt();
return;
}
if trimmed == "CLEAR_REFERENCE" || trimmed == "clear_reference" {
info!("client cleared voice reference");
audit.event("reference", json!({ "action": "clear" }));
turn_manager.set_reference(None, None);
return;
}
@@ -316,19 +485,70 @@ async fn handle_control_message(text: &str, turn_manager: &Arc<TurnManager>) {
None => (rest.trim().to_string(), None),
};
if path.is_empty() {
audit.event("reference", json!({ "action": "clear" }));
turn_manager.set_reference(None, None);
} else {
info!(audio_path = %path, "client set voice reference");
audit.event("reference", json!({ "action": "set", "audio": path }));
turn_manager.set_reference(Some(path), text);
}
return;
}
if !trimmed.is_empty() {
info!(text = trimmed, "client text-to-speech request");
audit.event("speak", json!({ "text": trimmed }));
turn_manager.speak(trimmed).await;
}
}
/// Parse an outbound text event into a JSON value for the audit log. Falls back
/// to a raw wrapper if the payload is not valid JSON (it always is, but the log
/// must never fail on it).
fn parse_event(text: &str) -> serde_json::Value {
serde_json::from_str(text).unwrap_or_else(|_| json!({ "raw": text }))
}
/// Snapshot of the three cue policies (filler, start filler, backchannel) for
/// the playground, sent once as the `cues` event at session start. The values
/// are the effective runtime parameters; the clip counts are those of the
/// active language's banks. `backchannel.pause_max_ms` is already capped to the
/// VAD end-of-utterance window, exactly like
/// [`BackchannelScheduler::pause_max`](crate::orchestrator::BackchannelScheduler).
fn cues_config(config: &Config, banks: &LangBanks) -> serde_json::Value {
let bc = &config.backchannel;
let pause_cap = (config.vad.min_silence_ms.saturating_sub(32) as u64).max(1);
json!({
"filler": {
"enabled": config.filler_enabled,
"delay_ms": config.filler_delay_ms,
"gain_idle": config.filler_gain_idle,
"gain_duck": config.filler_gain_duck,
"bank": lang_dir(&config.fillers_dir, &banks.lang).display().to_string(),
"clips": banks.filler.names().len(),
},
"start_filler": {
"enabled": config.start_filler_enabled,
"go_delay_ms": config.start_filler_go_delay_ms,
"bank": lang_dir(&config.start_filler_dir, &banks.lang).display().to_string(),
"clips": banks.start_filler.names().len(),
},
"backchannel": {
"enabled": bc.enabled,
"gain": bc.gain,
"min_speech_ms": bc.min_speech_ms,
"interval_ms": bc.interval_ms,
"jitter_ms": bc.jitter_ms,
"pause_min_ms": bc.pause_min_ms,
"pause_max_ms": bc.pause_max_ms.min(pause_cap),
"prob": bc.prob,
"volume_jitter": bc.volume_jitter,
"max_per_turn": bc.max_per_turn,
"categories": banks.backchannel_categories,
"clips": banks.backchannel.names().len(),
},
})
}
/// Resample a mono `i16` PCM buffer from `input_rate` to 16 kHz.
///
/// Uses linear interpolation. Handles the common 16 kHz and 24 kHz cases