use std::io::{IsTerminal, Write};
#[must_use]
pub fn decorate() -> bool {
if std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
return false;
}
std::io::stdout().is_terminal()
}
fn paint(code: &str, text: &str) -> String {
if decorate() {
format!("\u{1b}[{code}m{text}\u{1b}[0m")
} else {
text.to_owned()
}
}
pub fn ok(out: &mut dyn Write, message: &str) -> std::io::Result<()> {
writeln!(out, "{} {message}", paint("32;1", "✓"))
}
pub fn info(out: &mut dyn Write, message: &str) -> std::io::Result<()> {
writeln!(out, "{} {message}", paint("34;1", "→"))
}
pub fn warn(out: &mut dyn Write, message: &str) -> std::io::Result<()> {
writeln!(out, "{} {message}", paint("33;1", "!"))
}
#[must_use]
pub fn detail(text: &str) -> String {
paint("2", text)
}
#[must_use]
pub fn emphasis(text: &str) -> String {
paint("1", text)
}
pub fn confirm(out: &mut dyn Write, question: &str) -> std::io::Result<Option<bool>> {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Ok(None);
}
write!(
out,
"{} {question} {} ",
paint("33;1", "?"),
detail("[y/N]")
)?;
out.flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let answer = answer.trim();
Ok(Some(
answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"),
))
}
#[must_use]
pub fn is_interactive() -> bool {
std::io::stdout().is_terminal()
}
#[derive(Default)]
pub struct SayPresenter {
destination: Option<String>,
load_started_ms: u64,
synthesis_started_ms: u64,
load_ms: u64,
synthesis_ms: u64,
frames: u64,
}
impl SayPresenter {
#[must_use]
pub fn writing_to(destination: Option<String>) -> Self {
Self {
destination,
..Self::default()
}
}
pub fn event(&mut self, event: &serde_json::Value, out: &mut dyn Write) -> std::io::Result<()> {
let kind = event.get("event").and_then(serde_json::Value::as_str);
let elapsed = event
.get("elapsed_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
match kind {
Some("run_start") => {
let voice = event
.get("voice")
.and_then(serde_json::Value::as_str)
.unwrap_or("default");
writeln!(out, "{} {}", detail("voice"), emphasis(voice))?;
}
Some("stage") => {
let name = event
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let state = event
.get("state")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
match (name, state) {
("load", "begin") => self.load_started_ms = elapsed,
("load", "end") => {
self.load_ms = elapsed.saturating_sub(self.load_started_ms);
ok(
out,
&format!("model loaded {}", detail(&secs(self.load_ms))),
)?;
}
("synthesis", "begin") => self.synthesis_started_ms = elapsed,
("synthesis", "end") => {
self.synthesis_ms = elapsed.saturating_sub(self.synthesis_started_ms);
}
_ => {}
}
}
Some("audio_chunk") => {}
Some("run_complete") => {
let audio_ms = event
.get("duration_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
self.frames = event
.get("frames")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
if self.frames > 0 {
let where_to = self
.destination
.as_deref()
.map_or_else(String::new, |path| format!(" → {}", emphasis(path)));
ok(
out,
&format!(
"synthesized {} frames {}{where_to}",
self.frames,
detail(&secs(self.synthesis_ms))
),
)?;
}
let mut tail = format!("{} of audio in {} total", secs(audio_ms), secs(elapsed));
if let Some(ttfa) = event.get("ttfa_ms").and_then(serde_json::Value::as_u64) {
tail.push_str(&format!(" · first audio {ttfa} ms"));
}
if audio_ms > 0 && self.synthesis_ms > 0 {
#[allow(clippy::cast_precision_loss)]
let ratio = audio_ms as f64 / self.synthesis_ms as f64;
tail.push_str(&format!(" · synthesis {ratio:.2}× real time"));
}
writeln!(out, " {}", detail(&tail))?;
}
_ => {}
}
Ok(())
}
}
fn secs(ms: u64) -> String {
#[allow(clippy::cast_precision_loss)]
let seconds = ms as f64 / 1000.0;
if seconds < 10.0 {
format!("{seconds:.2} s")
} else {
format!("{seconds:.1} s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_lines_carry_no_escapes_when_the_sink_is_not_a_terminal() {
let mut buffer: Vec<u8> = Vec::new();
ok(&mut buffer, "enrolled").expect("write");
info(&mut buffer, "loading").expect("write");
warn(&mut buffer, "noisy reference").expect("write");
let text = String::from_utf8(buffer).expect("utf8");
assert!(
!text.contains('\u{1b}'),
"decoration leaked into a captured sink: {text:?}"
);
assert!(text.contains("enrolled") && text.contains("loading"));
}
#[test]
fn confirm_declines_to_block_without_a_terminal() {
let mut buffer: Vec<u8> = Vec::new();
let answer = confirm(&mut buffer, "Overwrite?").expect("confirm");
assert_eq!(
answer, None,
"a non-interactive run must fall through to the caller's own policy"
);
assert!(
buffer.is_empty(),
"nothing should be printed with no reader"
);
}
}