use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
fn model_dir() -> Option<PathBuf> {
let root = std::env::var("FTTS_MODEL_DIR").map_or_else(
|_| {
#[allow(deprecated)]
std::env::home_dir().map(|home| home.join(".cache/franken_tts/model"))
},
|dir| Some(PathBuf::from(dir)),
)?;
for required in [
"vocab.json",
"merges.txt",
"tokenizer_config.json",
"speech_tokenizer/model.safetensors",
] {
if !root.join(required).is_file() {
return None;
}
}
if !root.join("qwen3-tts-12hz-0.6b-base.fttsq").is_file()
&& !root.join("model.safetensors").is_file()
{
return None;
}
Some(root)
}
struct SayRun {
synthesis_ms: u64,
wav: Vec<u8>,
}
fn run_say(resident_dir: &std::path::Path, out: &std::path::Path, extra: &[&str]) -> SayRun {
let mut command = Command::new(env!("CARGO_BIN_EXE_ftts"));
command
.arg("say")
.arg("Warm start check.")
.arg("-o")
.arg(out)
.args(extra)
.env("FTTS_RESIDENT_DIR", resident_dir)
.env("FTTS_RESIDENT_IDLE_SECS", "30")
.env("FTTS_RESIDENT_CLIENT_TIMEOUT_SECS", "1800")
.env("FTTS_RESIDENT_SPAWN_WAIT_SECS", "180")
.env(
"FTTS_RESIDENT_LOG",
resident_dir.parent().unwrap().join("daemon.log"),
);
let output = command.output().expect("ftts say runs");
assert!(
output.status.success(),
"say failed: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let mut begin = None;
let mut end = None;
for line in stdout.lines() {
let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if event.get("event").and_then(|v| v.as_str()) == Some("stage")
&& event.get("name").and_then(|v| v.as_str()) == Some("synthesis")
{
let elapsed = event.get("elapsed_ms").and_then(serde_json::Value::as_u64);
match event.get("state").and_then(|v| v.as_str()) {
Some("begin") => begin = elapsed,
Some("end") => end = elapsed,
_ => {}
}
}
}
let (begin, end) = (
begin.expect("synthesis begin stage"),
end.expect("synthesis end stage"),
);
SayRun {
synthesis_ms: end.saturating_sub(begin),
wav: std::fs::read(out).expect("wav written"),
}
}
#[test]
fn resident_daemon_reuse_parity_and_idle_exit() {
let Some(_model) = model_dir() else {
eprintln!(
"SKIP-AS-SUCCESS: no complete model directory; resident e2e needs the real model"
);
return;
};
let scratch = std::env::temp_dir().join(format!("ftts-resident-e2e-{}", std::process::id()));
std::fs::create_dir_all(&scratch).expect("scratch dir");
let resident_dir = scratch.join("state");
let first = run_say(&resident_dir, &scratch.join("a.wav"), &[]);
let state_file = std::fs::read_dir(&resident_dir)
.expect("state dir exists after a resident run")
.filter_map(Result::ok)
.find(|entry| entry.file_name().to_string_lossy().starts_with("resident-"))
.unwrap_or_else(|| {
let daemon_log = std::fs::read_to_string(scratch.join("daemon.log"))
.unwrap_or_else(|_| "<no daemon log written>".to_owned());
panic!(
"resident state file missing after run 1 (synthesis {} ms; daemon log follows)
{daemon_log}",
first.synthesis_ms,
);
});
let pid_after_first = std::fs::read_to_string(state_file.path())
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|v| v.get("pid").and_then(serde_json::Value::as_u64))
.expect("state file carries the daemon pid");
let second = run_say(&resident_dir, &scratch.join("b.wav"), &[]);
let pid_after_second = std::fs::read_to_string(state_file.path())
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|v| v.get("pid").and_then(serde_json::Value::as_u64))
.expect("state file still present after reuse");
assert_eq!(pid_after_first, pid_after_second, "daemon was reused");
assert!(
second.synthesis_ms + 1500 <= first.synthesis_ms,
"second run should skip hydration: first={}ms second={}ms",
first.synthesis_ms,
second.synthesis_ms,
);
let inline = run_say(&resident_dir, &scratch.join("c.wav"), &["--no-resident"]);
assert_eq!(
second.wav, inline.wav,
"resident and in-process synthesis must produce identical WAV bytes",
);
let deadline = Instant::now() + Duration::from_secs(90);
loop {
if !state_file.path().exists() {
break;
}
assert!(
Instant::now() < deadline,
"daemon did not exit within the idle period",
);
std::thread::sleep(Duration::from_millis(250));
}
}