use std::io::{BufRead, Write};
use std::path::Path;
use tokio::sync::{broadcast, mpsc};
use tokio_util::sync::CancellationToken;
use crate::audio::{AudioOutputConfig, Playback, PlaybackHandle};
use crate::config::{Config, OutputMode};
use crate::error::Result;
use crate::llm::client::{CLAUSE_MAX_LEN, CLAUSE_MIN_LEN};
use crate::llm::{ClauseSplitter, LlmBackend, LlmClient};
use crate::pipeline::{write_wav16, Pipeline};
use crate::stt::SttEngine;
use crate::tts::{build_engine, concat_clip_samples, TtsClip, TtsEngine, TTS_SAMPLE_RATE};
pub(crate) const EVENT_CAP: usize = 64;
const TEXT_TURN_CLAUSE_CAP: usize = 16;
#[derive(Clone, Debug)]
pub enum AgentEvent {
Listening,
SpeechStart,
Transcript(String),
Clause(String),
ToolCall {
name: String,
arguments: String,
},
ReplyDone,
TurnCancelled,
StageLatency {
stt_ms: u64,
llm_ms: u64,
tts_ms: u64,
playback_ms: u64,
total_ms: u64,
},
Error(String),
}
pub struct Agent {
config: Config,
stt: Option<Box<dyn SttEngine>>,
llm: Option<Box<dyn LlmBackend>>,
tts: Option<Box<dyn TtsEngine>>,
events: broadcast::Sender<AgentEvent>,
shutdown: CancellationToken,
playback: Option<(Playback, PlaybackHandle)>,
}
pub struct AgentBuilder {
config: Config,
stt: Option<Box<dyn SttEngine>>,
llm: Option<Box<dyn LlmBackend>>,
tts: Option<Box<dyn TtsEngine>>,
}
impl Agent {
pub fn builder() -> AgentBuilder {
AgentBuilder {
config: Config::default(),
stt: None,
llm: None,
tts: None,
}
}
pub fn events(&self) -> broadcast::Receiver<AgentEvent> {
self.events.subscribe()
}
pub fn run(mut self) -> Result<()> {
Pipeline::from_parts(
self.config.clone(),
self.shutdown.clone(),
self.events.clone(),
self.stt.take(),
self.llm.take(),
self.tts.take(),
)
.run()
}
pub fn text_turn(&mut self, input: &str) -> Result<String> {
let Agent {
llm,
tts,
events,
config,
playback,
..
} = self;
let llm = ensure_llm(llm, config);
let tts: Option<&mut dyn TtsEngine> = match config.output {
OutputMode::Audio => Some(ensure_tts(tts, config)?),
OutputMode::Text => None,
};
let playback = if tts.is_some() {
Some(lazy_playback(playback, config)?.1.clone())
} else {
None
};
let events = events.clone();
run_scoped(move || async move {
let mut tts = tts;
text_turn_async(llm, &mut tts, playback, &events, input, |_| Ok(())).await
})
}
pub fn repl(
&mut self,
input: impl BufRead + Send,
mut output: impl Write + Send,
) -> Result<()> {
let events = self.events.clone();
let llm = ensure_llm(&mut self.llm, &self.config);
writeln!(output, "skadoosh repl — type a line, /quit to exit").map_err(repl_io_error)?;
run_scoped(move || async move {
for line in input.lines() {
let line = line.map_err(repl_io_error)?;
let text = line.trim();
if text == "/quit" {
break;
}
if text.is_empty() {
continue;
}
write!(output, "bot> ").map_err(repl_io_error)?;
output.flush().map_err(repl_io_error)?;
let mut first_clause = true;
text_turn_async(&mut *llm, &mut None, None, &events, text, |clause| {
if !first_clause {
write!(output, " ").map_err(repl_io_error)?;
}
first_clause = false;
write!(output, "{}", clause.trim()).map_err(repl_io_error)?;
output.flush().map_err(repl_io_error)
})
.await?;
writeln!(output).map_err(repl_io_error)?;
}
writeln!(output, "bye").map_err(repl_io_error)?;
Ok(())
})
}
pub fn say(&mut self, text: &str) -> Result<()> {
let clips = self.synthesize_clips(text)?;
let handle = lazy_playback(&mut self.playback, &self.config)?.1.clone();
run_scoped(move || async move {
for clip in clips {
handle.queue_clip(clip).await?;
}
handle.wait_drained().await;
Ok(())
})
}
pub fn say_to_wav(&mut self, text: &str, path: &Path) -> Result<()> {
let clips = self.synthesize_clips(text)?;
write_wav16(path, &concat_clip_samples(&clips), TTS_SAMPLE_RATE)
}
pub fn shutdown(&self) {
self.shutdown.cancel();
}
pub fn shutdown_token(&self) -> CancellationToken {
self.shutdown.clone()
}
fn synthesize_clips(&mut self, text: &str) -> Result<Vec<TtsClip>> {
let engine = ensure_tts(&mut self.tts, &self.config)?;
let mut splitter = ClauseSplitter::new(CLAUSE_MIN_LEN, CLAUSE_MAX_LEN);
let mut clips = Vec::new();
for clause in splitter.push(text).into_iter().chain(splitter.flush()) {
clips.push(engine.synthesize(&clause)?);
}
if clips.is_empty() {
return Err(anyhow::anyhow!("the text produced no speakable clauses").into());
}
Ok(clips)
}
}
impl AgentBuilder {
pub fn config(mut self, config: Config) -> Self {
self.config = config;
self
}
pub fn stt(mut self, engine: Box<dyn SttEngine>) -> Self {
self.stt = Some(engine);
self
}
pub fn llm(mut self, backend: Box<dyn LlmBackend>) -> Self {
self.llm = Some(backend);
self
}
pub fn tts(mut self, engine: Box<dyn TtsEngine>) -> Self {
self.tts = Some(engine);
self
}
pub fn build(self) -> Result<Agent> {
let (events, _) = broadcast::channel(EVENT_CAP);
Ok(Agent {
config: self.config,
stt: self.stt,
llm: self.llm,
tts: self.tts,
events,
shutdown: CancellationToken::new(),
playback: None,
})
}
}
impl Drop for Agent {
fn drop(&mut self) {
if let Some((playback, _)) = self.playback.take() {
playback.stop();
}
}
}
fn ensure_llm<'a>(
slot: &'a mut Option<Box<dyn LlmBackend>>,
config: &Config,
) -> &'a mut dyn LlmBackend {
if slot.is_none() {
*slot = Some(Box::new(LlmClient::from_config(config)));
}
&mut **slot.as_mut().expect("filled above")
}
fn ensure_tts<'a>(
slot: &'a mut Option<Box<dyn TtsEngine>>,
config: &Config,
) -> Result<&'a mut dyn TtsEngine> {
if slot.is_none() {
*slot = Some(build_engine(config)?);
}
Ok(&mut **slot.as_mut().expect("filled above"))
}
fn lazy_playback<'a>(
slot: &'a mut Option<(Playback, PlaybackHandle)>,
config: &Config,
) -> Result<&'a (Playback, PlaybackHandle)> {
if slot.is_none() {
*slot = Some(Playback::start(&AudioOutputConfig {
device_name: config.output_device.clone(),
})?);
}
Ok(slot.as_ref().expect("filled above"))
}
async fn text_turn_async(
llm: &mut dyn LlmBackend,
tts: &mut Option<&mut dyn TtsEngine>,
playback: Option<PlaybackHandle>,
events: &broadcast::Sender<AgentEvent>,
input: &str,
mut on_clause: impl FnMut(&str) -> Result<()>,
) -> Result<String> {
let (clause_tx, mut clause_rx) = mpsc::channel::<String>(TEXT_TURN_CLAUSE_CAP);
let token = CancellationToken::new();
let turn = llm.stream_reply(input, clause_tx, token);
tokio::pin!(turn);
let mut reply = String::new();
let mut stream_result: Option<Result<()>> = None;
loop {
tokio::select! {
biased;
result = &mut turn => {
stream_result = Some(result);
while let Ok(clause) = clause_rx.try_recv() {
handle_clause(
&mut reply, clause, events, &mut on_clause, tts, &playback,
).await?;
}
break;
}
clause = clause_rx.recv() => {
match clause {
Some(clause) => handle_clause(
&mut reply, clause, events, &mut on_clause, tts, &playback,
).await?,
None => break,
}
}
}
}
match stream_result {
Some(Ok(())) | None => {
let _ = events.send(AgentEvent::ReplyDone);
if let Some(handle) = &playback {
handle.wait_buffered().await;
}
Ok(reply)
}
Some(Err(err)) => Err(err),
}
}
async fn handle_clause(
reply: &mut String,
clause: String,
events: &broadcast::Sender<AgentEvent>,
on_clause: &mut impl FnMut(&str) -> Result<()>,
tts: &mut Option<&mut dyn TtsEngine>,
playback: &Option<PlaybackHandle>,
) -> Result<()> {
reply.push_str(&clause);
let _ = events.send(AgentEvent::Clause(clause.clone()));
on_clause(&clause)?;
if let (Some(engine), Some(handle)) = (tts.as_deref_mut(), playback) {
let clip = engine.synthesize(&clause)?;
handle.queue_clip(clip).await?;
}
Ok(())
}
fn run_scoped<F, Fut, T>(f: F) -> Result<T>
where
F: FnOnce() -> Fut + Send,
Fut: std::future::Future<Output = Result<T>>,
T: Send,
{
std::thread::scope(|scope| {
let handle = scope.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| anyhow::anyhow!("failed to start tokio runtime: {err}"))?;
runtime.block_on(f())
});
match handle.join() {
Ok(result) => result,
Err(panic) => std::panic::resume_unwind(panic),
}
})
}
fn repl_io_error(err: std::io::Error) -> crate::error::SkadooshError {
anyhow::anyhow!("repl I/O failed: {err}").into()
}