This commit is contained in:
valenti
2026-10-16 11:36:02 +01:00
commit 236fd509ca
59 changed files with 18858 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
//! Backchannel: brief acknowledgments emitted *while the user is speaking*.
//!
//! This is distinct from a filler (see [`TurnManager`](crate::orchestrator::TurnManager)),
//! 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.
//!
//! Every behavioural knob is configured from the environment
//! ([`BackchannelSettings`]), so the feature can be tuned or disabled without a
//! rebuild.
use crate::config::{env_bool, env_f32, env_u32, env_u64};
use crate::orchestrator::FillerBank;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Runtime configuration for the backchannel subsystem, read from `.env`.
#[derive(Debug, Clone)]
pub struct BackchannelSettings {
/// Master switch (`BACKCHANNEL_ENABLED`).
pub enabled: bool,
/// Directory of `*.raw` PCM clips (`BACKCHANNEL_DIR`).
pub dir: PathBuf,
/// 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
/// (`BACKCHANNEL_MIN_SPEECH_MS`).
pub min_speech_ms: u64,
/// Minimum spacing between two backchannels (`BACKCHANNEL_INTERVAL_MS`).
pub interval_ms: u64,
/// Maximum backchannels per user turn (`BACKCHANNEL_MAX_PER_TURN`).
pub max_per_turn: u32,
}
impl Default for BackchannelSettings {
fn default() -> Self {
Self {
enabled: true,
dir: PathBuf::from("assets/backchannel"),
gain: 0.35,
min_speech_ms: 1_500,
interval_ms: 3_000,
max_per_turn: 2,
}
}
}
impl BackchannelSettings {
/// Build the settings from environment variables, falling back to defaults.
pub fn from_env() -> Self {
let mut s = Self::default();
if let Ok(p) = std::env::var("BACKCHANNEL_DIR") {
if !p.is_empty() {
s.dir = PathBuf::from(p);
}
}
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.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
}
}
/// Per-session backchannel decision logic.
///
/// Fed by the VAD: [`on_speech_start`](Self::on_speech_start) when a user turn
/// begins, [`on_speech_end`](Self::on_speech_end) when it closes, and
/// [`maybe_emit`](Self::maybe_emit) on every frame while the user is speaking.
pub struct BackchannelScheduler {
bank: Arc<FillerBank>,
settings: BackchannelSettings,
/// Round-robin cursor over the clip bank.
next_index: usize,
/// When the current user turn started (only set while speaking).
turn_start: Option<Instant>,
/// When the last backchannel was emitted in this turn.
last_emitted: Option<Instant>,
/// Backchannels emitted in the current turn.
emitted: u32,
}
impl BackchannelScheduler {
pub fn new(bank: Arc<FillerBank>, settings: BackchannelSettings) -> Self {
Self {
bank,
settings,
next_index: 0,
turn_start: None,
last_emitted: None,
emitted: 0,
}
}
/// Begin a new user turn (VAD speech start).
pub fn on_speech_start(&mut self) {
self.turn_start = Some(Instant::now());
self.last_emitted = None;
self.emitted = 0;
}
/// Close the current user turn (VAD speech end).
pub fn on_speech_end(&mut self) {
self.turn_start = None;
self.last_emitted = None;
self.emitted = 0;
}
/// 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> {
if !self.settings.enabled {
return None;
}
let start = self.turn_start?;
if self.emitted >= self.settings.max_per_turn {
return None;
}
if start.elapsed() < Duration::from_millis(self.settings.min_speech_ms) {
return None;
}
if let Some(last) = self.last_emitted {
if last.elapsed() < Duration::from_millis(self.settings.interval_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);
self.last_emitted = Some(Instant::now());
self.emitted += 1;
Some(name)
}
}