use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use super::binary::{PiperOs, Platform};
use super::voice::VoiceFiles;
use super::PiperUnavailable;
pub(crate) const DEFAULT_SYNTH_TIMEOUT: Duration =
Duration::from_secs(30);
pub(crate) const WPM_AT_LENGTH_SCALE_ONE: f32 = 180.0;
pub(crate) fn wpm_to_length_scale(rate_wpm: Option<u16>) -> f32 {
match rate_wpm {
Some(wpm) if wpm > 0 => {
(WPM_AT_LENGTH_SCALE_ONE / wpm as f32).clamp(0.5, 2.0)
}
_ => 1.0,
}
}
pub(crate) fn synth_to_wav(
binary: &Path,
voice: &VoiceFiles,
text: &str,
rate_wpm: Option<u16>,
dest: &Path,
timeout: Duration,
) -> Result<u64, PiperUnavailable> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
PiperUnavailable::DownloadFailed(format!(
"mkdir synth dest {}: {e}",
parent.display(),
))
})?;
}
let length_scale = wpm_to_length_scale(rate_wpm);
let mut cmd = Command::new(binary);
cmd.arg("--model")
.arg(&voice.onnx)
.arg("--output_file")
.arg(dest)
.arg("--length_scale")
.arg(format!("{length_scale:.2}"));
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
PiperUnavailable::DownloadFailed(format!(
"spawn piper {}: {e}",
binary.display(),
))
})?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
if let Err(e) = stdin.write_all(text.as_bytes()) {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(dest);
return Err(PiperUnavailable::DownloadFailed(format!(
"write piper stdin: {e}",
)));
}
}
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if status.success() {
let bytes = std::fs::metadata(dest)
.map(|m| m.len())
.unwrap_or(0);
if bytes == 0 {
return Err(PiperUnavailable::DownloadFailed(
"piper exited 0 but produced no audio".into(),
));
}
return Ok(bytes);
}
let mut stderr = String::new();
if let Some(mut s) = child.stderr.take() {
use std::io::Read;
let _ = s.read_to_string(&mut stderr);
}
let _ = std::fs::remove_file(dest);
return Err(PiperUnavailable::DownloadFailed(format!(
"piper exited {} — {}",
status.code().unwrap_or(-1),
stderr.trim(),
)));
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(dest);
return Err(PiperUnavailable::DownloadFailed(
format!(
"piper timed out after {}s",
timeout.as_secs(),
),
));
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
let _ = std::fs::remove_file(dest);
return Err(PiperUnavailable::DownloadFailed(format!(
"piper wait: {e}",
)));
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlayCommand {
pub program: String,
pub args: Vec<String>,
}
pub(crate) fn select_play_command(
custom: Option<&str>,
os: PiperOs,
) -> PlayCommand {
if let Some(custom) = custom {
let mut parts = custom.split_whitespace();
let program = parts.next().unwrap_or("").to_string();
let args = parts.map(String::from).collect();
return PlayCommand { program, args };
}
match os {
PiperOs::Darwin => PlayCommand {
program: "afplay".into(),
args: vec!["{path}".into()],
},
PiperOs::Linux => PlayCommand {
program: "paplay".into(),
args: vec!["{path}".into()],
},
PiperOs::Windows => PlayCommand {
program: "powershell".into(),
args: vec![
"-NoProfile".into(),
"-Command".into(),
"(New-Object Media.SoundPlayer '{path}').PlaySync()".into(),
],
},
}
}
pub(crate) fn spawn_playback(
custom: Option<&str>,
wav_path: &Path,
platform: Platform,
) -> Result<Child, PiperUnavailable> {
let mut command = select_play_command(custom, platform.os);
if custom.is_none()
&& matches!(platform.os, PiperOs::Linux)
&& command.program == "paplay"
&& which(&command.program).is_none()
{
if which("aplay").is_some() {
command.program = "aplay".into();
}
}
let path_str = wav_path.to_string_lossy().to_string();
let resolved_args: Vec<String> = command
.args
.iter()
.map(|a| a.replace("{path}", &path_str))
.collect();
let mut cmd = Command::new(&command.program);
cmd.args(&resolved_args);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
cmd.spawn().map_err(|e| {
PiperUnavailable::DownloadFailed(format!(
"spawn playback `{} {:?}`: {e}",
command.program, resolved_args,
))
})
}
pub(crate) fn synth_wav_path(voices_dir: &Path, voice_key: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
voices_dir.join(format!(".synth-{voice_key}-{nonce}.wav"))
}
fn which(needle: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(needle);
if std::fs::metadata(&candidate)
.map(|m| m.is_file())
.unwrap_or(false)
{
return Some(candidate);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wpm_180_maps_to_unity() {
assert!((wpm_to_length_scale(Some(180)) - 1.0).abs() < 0.001);
}
#[test]
fn wpm_above_180_yields_faster_length_scale() {
let s = wpm_to_length_scale(Some(270));
assert!(s < 1.0);
assert!(s > 0.5);
}
#[test]
fn wpm_below_180_yields_slower_length_scale() {
let s = wpm_to_length_scale(Some(120));
assert!(s > 1.0);
assert!(s < 2.0);
}
#[test]
fn wpm_extreme_low_clamped_to_2() {
let s = wpm_to_length_scale(Some(50));
assert!((s - 2.0).abs() < 0.001);
}
#[test]
fn wpm_extreme_high_clamped_to_half() {
let s = wpm_to_length_scale(Some(1000));
assert!((s - 0.5).abs() < 0.001);
}
#[test]
fn wpm_none_defaults_to_unity() {
assert!((wpm_to_length_scale(None) - 1.0).abs() < 0.001);
}
#[test]
fn wpm_zero_defaults_to_unity() {
assert!((wpm_to_length_scale(Some(0)) - 1.0).abs() < 0.001);
}
#[test]
fn play_command_macos_default() {
let cmd = select_play_command(None, PiperOs::Darwin);
assert_eq!(cmd.program, "afplay");
assert_eq!(cmd.args, vec!["{path}".to_string()]);
}
#[test]
fn play_command_linux_default() {
let cmd = select_play_command(None, PiperOs::Linux);
assert_eq!(cmd.program, "paplay");
}
#[test]
fn play_command_windows_default() {
let cmd = select_play_command(None, PiperOs::Windows);
assert_eq!(cmd.program, "powershell");
let joined = cmd.args.join(" ");
assert!(joined.contains("Media.SoundPlayer"));
assert!(joined.contains("{path}"));
}
#[test]
fn play_command_custom_overrides_everything() {
let cmd = select_play_command(
Some("mpv --no-video {path}"),
PiperOs::Darwin,
);
assert_eq!(cmd.program, "mpv");
assert_eq!(
cmd.args,
vec!["--no-video".to_string(), "{path}".to_string()],
);
}
#[test]
fn play_command_custom_handles_no_path_placeholder() {
let cmd = select_play_command(Some("/bin/true"), PiperOs::Linux);
assert_eq!(cmd.program, "/bin/true");
assert!(cmd.args.is_empty());
}
#[test]
fn synth_wav_path_lives_inside_voices_dir() {
let voices_dir = Path::new("/voices");
let p = synth_wav_path(voices_dir, "en_US-lessac-medium");
assert!(p.starts_with(voices_dir));
assert!(p.to_string_lossy().contains("en_US-lessac-medium"));
assert!(p.to_string_lossy().ends_with(".wav"));
}
#[test]
fn synth_wav_paths_are_unique_per_call() {
let voices_dir = Path::new("/voices");
let a = synth_wav_path(voices_dir, "v");
std::thread::sleep(Duration::from_nanos(1));
let b = synth_wav_path(voices_dir, "v");
assert_ne!(a, b);
}
fn make_fake_piper(dir: &Path) -> PathBuf {
let path = dir.join("fake-piper");
let script = r#"#!/bin/sh
OUT=""
while [ "$#" -gt 0 ]; do
case "$1" in
--output_file) shift; OUT="$1" ;;
--model|--length_scale) shift ;;
esac
shift
done
cat > /dev/null
printf 'RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00data\x00\x00\x00\x00' > "$OUT"
"#;
std::fs::write(&path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
path
}
fn make_fake_failing_piper(dir: &Path) -> PathBuf {
let path = dir.join("failing-piper");
let script = r#"#!/bin/sh
cat > /dev/null
echo "fake piper failure" >&2
exit 7
"#;
std::fs::write(&path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
path
}
fn make_voice_files(dir: &Path) -> VoiceFiles {
let onnx = dir.join("v.onnx");
let onnx_json = dir.join("v.onnx.json");
std::fs::write(&onnx, b"fake-model").unwrap();
std::fs::write(&onnx_json, b"{}").unwrap();
VoiceFiles { onnx, onnx_json }
}
#[cfg(unix)]
#[test]
fn synth_writes_wav_with_fake_piper() {
let tmp = tempfile::tempdir().unwrap();
let binary = make_fake_piper(tmp.path());
let voice = make_voice_files(tmp.path());
let dest = tmp.path().join("out.wav");
let bytes = synth_to_wav(
&binary,
&voice,
"hello world",
Some(180),
&dest,
Duration::from_secs(5),
)
.unwrap();
assert!(bytes > 0);
let written = std::fs::read(&dest).unwrap();
assert!(written.starts_with(b"RIFF"), "expected RIFF header");
assert!(written.windows(4).any(|w| w == b"WAVE"));
}
#[cfg(unix)]
#[test]
fn synth_surfaces_subprocess_failure() {
let tmp = tempfile::tempdir().unwrap();
let binary = make_fake_failing_piper(tmp.path());
let voice = make_voice_files(tmp.path());
let dest = tmp.path().join("out.wav");
let err = synth_to_wav(
&binary,
&voice,
"hello",
None,
&dest,
Duration::from_secs(5),
)
.unwrap_err();
assert!(matches!(err, PiperUnavailable::DownloadFailed(_)));
let msg = err.to_user_message();
assert!(msg.contains("7") || msg.contains("piper"), "got: {msg}");
assert!(!dest.exists(), "expected dest cleaned on failure");
}
#[cfg(unix)]
#[test]
fn synth_errors_when_binary_missing() {
let tmp = tempfile::tempdir().unwrap();
let voice = make_voice_files(tmp.path());
let dest = tmp.path().join("out.wav");
let err = synth_to_wav(
Path::new("/nowhere/does-not-exist-piper"),
&voice,
"x",
None,
&dest,
Duration::from_secs(1),
)
.unwrap_err();
assert!(matches!(err, PiperUnavailable::DownloadFailed(_)));
}
#[cfg(unix)]
#[test]
fn spawn_playback_substitutes_path_placeholder() {
let tmp = tempfile::tempdir().unwrap();
let wav = tmp.path().join("test.wav");
std::fs::write(&wav, b"X").unwrap();
let out = tmp.path().join("copy.wav");
let custom =
format!("/bin/cp {{path}} {}", out.to_string_lossy());
let plat = Platform::from_consts("linux", "x86_64").unwrap();
let mut child =
spawn_playback(Some(&custom), &wav, plat).unwrap();
let status = child.wait().unwrap();
assert!(status.success());
assert!(out.exists());
assert_eq!(std::fs::read(&out).unwrap(), b"X");
}
}