use std::time::{Duration, Instant};
use super::*;
use crate::features::tts_command::TtsScope;
use crate::shared::api::catalogue::{self, CatalogueRequest, CatalogueShape};
use crate::shared::config::{CloudProvider, TtsCloudSettings, TtsMode};
use crate::shared::i18n::{Lang, locale};
const ASKED: &str = "What is the weather like?";
const ANSWERED: &str = "Testing, one two three. The weather is fine today.";
fn key() -> Option<String> {
let key = std::env::var("MINDFORK_OPENROUTER_KEY")
.ok()
.filter(|k| !k.trim().is_empty());
if key.is_none() {
eprintln!("skip: MINDFORK_OPENROUTER_KEY not set");
}
key
}
fn model(var: &str, measured: &str) -> String {
std::env::var(var)
.ok()
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| measured.to_string())
}
async fn voices(key: &str, model: &str) -> Vec<String> {
let request = CatalogueRequest {
shape: CatalogueShape::OpenRouterSpeech,
base: CloudProvider::OpenRouter.chat_base_url().to_string(),
key: Some(key.to_string()),
attribution: true,
};
let listed = catalogue::fetch(&request).await.expect("the speech list");
let entry = listed.into_iter().find(|m| m.id == model);
entry
.unwrap_or_else(|| panic!("{model} is not in the gateway's speech list"))
.voices
}
#[derive(Debug)]
enum Spoken {
For(Duration),
NoSoundCard,
}
async fn tts(section: TtsCloudSettings, scope: TtsScope) -> Spoken {
let mut config = AppConfig::default();
config.tts.mode = TtsMode::OpenRouter;
config.tts.openrouter = TtsCloudSettings {
api_key_env: Some("MINDFORK_OPENROUTER_KEY".into()),
..section
};
let backend: Arc<dyn EngineBackend> = Arc::new(MockBackend::scripted(vec![
ChatChunk::Text(ANSWERED.into()),
ChatChunk::Finished(crate::shared::api::FinishReason::Stop),
]));
let (_dir, tx, mut rx, handle) = spawn_orch_cfg(Some(backend), config);
wait_for(&mut rx, |e| matches!(e, AppEvent::ChatActivated { .. })).await;
tx.send(AppCommand::SendMessage(ASKED.into())).unwrap();
wait_for(&mut rx, |e| matches!(e, AppEvent::Finished { .. })).await;
let no_sound_card = locale(Lang::default()).tf("ui.err.tts_no_audio", &[("err", "")]);
tx.send(AppCommand::Tts(scope)).unwrap();
let mut started = None;
let spoken = loop {
let event = tokio::time::timeout(Duration::from_secs(120), rx.recv())
.await
.expect("the speech neither ended nor failed in two minutes")
.expect("the orchestrator is running");
match event {
AppEvent::Error(said) if said.starts_with(no_sound_card.trim_end()) => {
eprintln!("skip: {said}");
break Spoken::NoSoundCard;
}
AppEvent::Error(said) => panic!("the feed was told of an error: {said}"),
AppEvent::TtsActive(true) => started = Some(Instant::now()),
AppEvent::TtsActive(false) => {
let started = started.expect("speech ended that never began");
break Spoken::For(started.elapsed());
}
_ => {}
}
};
tx.send(AppCommand::Quit).unwrap();
let _ = handle.await;
spoken
}
fn played_in_real_time(spoken: Spoken, sentences: u32, what: &str) {
let Spoken::For(lasted) = spoken else { return };
eprintln!(" {what}: spoken in {lasted:.2?}");
assert!(
lasted >= Duration::from_secs(2) * sentences,
"{what}: {lasted:.2?} is not the time a sentence takes to say"
);
assert!(
lasted < Duration::from_secs(40) * sentences,
"{what}: {lasted:.2?}"
);
}
#[tokio::test]
#[ignore = "requires MINDFORK_OPENROUTER_KEY and a sound card (speech is audible)"]
async fn a_model_that_takes_mp3_only_is_spoken_in_two_voices_live() {
let Some(key) = key() else { return };
let model = model(
"MINDFORK_OPENROUTER_TTS_MP3_MODEL",
"minimax/speech-2.8-turbo",
);
let listed = voices(&key, &model).await;
assert!(listed.len() >= 2, "{model} lists {listed:?}");
eprintln!("== {model}, voices {:?}", &listed[..2]);
let section = TtsCloudSettings {
model_name: Some(model.clone()),
voice: Some(listed[0].clone()),
user_voice: Some(listed[1].clone()),
..Default::default()
};
played_in_real_time(tts(section, TtsScope::All).await, 2, &model);
}
#[tokio::test]
#[ignore = "requires MINDFORK_OPENROUTER_KEY and a sound card (speech is audible)"]
async fn a_model_at_44_khz_without_a_voice_is_spoken_live() {
if key().is_none() {
return;
}
let model = model("MINDFORK_OPENROUTER_TTS_44K_MODEL", "fish-audio/s1");
eprintln!("== {model}, no voice");
let section = TtsCloudSettings {
model_name: Some(model.clone()),
..Default::default()
};
played_in_real_time(tts(section, TtsScope::Last).await, 1, &model);
}
#[tokio::test]
#[ignore = "requires MINDFORK_OPENROUTER_KEY and a sound card (speech is audible)"]
async fn a_model_that_takes_raw_samples_only_is_spoken_live() {
let Some(key) = key() else { return };
let model = model(
"MINDFORK_OPENROUTER_TTS_RAW_MODEL",
"google/gemini-3.8-flash-lite-tts",
);
let voice = voices(&key, &model).await.into_iter().next();
eprintln!("== {model}, voice {voice:?}");
let section = TtsCloudSettings {
model_name: Some(model.clone()),
voice,
..Default::default()
};
played_in_real_time(tts(section, TtsScope::Last).await, 1, &model);
}
#[tokio::test]
#[ignore = "requires MINDFORK_OPENROUTER_KEY and a sound card"]
async fn a_missing_voice_is_said_in_the_feed_in_the_gateways_words_live() {
if key().is_none() {
return;
}
let mut config = AppConfig::default();
config.tts.mode = TtsMode::OpenRouter;
config.tts.openrouter = TtsCloudSettings {
model_name: Some("x-ai/grok-voice-tts-1.0".into()),
api_key_env: Some("MINDFORK_OPENROUTER_KEY".into()),
..Default::default()
};
let backend: Arc<dyn EngineBackend> = Arc::new(MockBackend::scripted(vec![
ChatChunk::Text(ANSWERED.into()),
ChatChunk::Finished(crate::shared::api::FinishReason::Stop),
]));
let (_dir, tx, mut rx, handle) = spawn_orch_cfg(Some(backend), config);
wait_for(&mut rx, |e| matches!(e, AppEvent::ChatActivated { .. })).await;
tx.send(AppCommand::SendMessage(ASKED.into())).unwrap();
wait_for(&mut rx, |e| matches!(e, AppEvent::Finished { .. })).await;
tx.send(AppCommand::Tts(TtsScope::Last)).unwrap();
let said = wait_for(&mut rx, |e| matches!(e, AppEvent::Error(_))).await;
let Some(AppEvent::Error(said)) = said else {
panic!("no error was said")
};
eprintln!(" {said}");
if said.contains("explicit voice is required") {
assert!(!said.contains('{'), "the sentence, not the JSON: {said}");
let ended = wait_for(&mut rx, |e| matches!(e, AppEvent::TtsActive(false))).await;
assert!(ended.is_some(), "the speech never ended");
} else {
eprintln!("skip: nothing to play on, so nothing was asked");
}
tx.send(AppCommand::Quit).unwrap();
let _ = handle.await;
}