first
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user