//! Phase 4 of the feature-complete roadmap: platform-native Read Page Aloud. //! //! Layer-1 scope: shell out to the OS's built-in TTS binary, return //! immediately (non-blocking), track the spawned child so `tts_stop` //! can cancel in-flight speech. No SSML, no pause/resume, no cloud //! voices. User text is never interpolated into a shell string — //! every platform passes the text via argv (or, on Windows, inside a //! PowerShell here-string delivered through `-EncodedCommand`). use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use serde::Serialize; /// Active synth child process, if any. On Linux `spd-say` returns /// immediately so the slot is usually empty; macOS `say` and Windows /// PowerShell speak synchronously, so we store the handle to kill /// on `tts_stop`. #[derive(Default)] pub struct TtsState { child: Mutex>, } impl TtsState { pub fn new() -> Self { Self::default() } } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TtsVoice { pub id: String, pub name: String, pub language: Option, } /// Clamp user-supplied rate into the app's supported range. /// `0.5` = half speed, `1.0` = normal, `2.0` = double speed. pub fn clamp_rate(rate: f32) -> f32 { if !rate.is_finite() { return 1.0; } rate.clamp(0.5, 2.0) } // ---------- Linux (spd-say, espeak-ng fallback) ---------- #[cfg(target_os = "linux")] pub fn spd_rate(rate: f32) -> i32 { // spd-say -r takes -100..=100. 1.0 -> 0 (default rate). We map the // 1.0..=2.0 half linearly to 0..=100, and the 0.5..=1.0 half // linearly to -50..=0. Asymmetric but simple; users who want // slower playback than -50 can change the system synth rate in // their accessibility settings. let r = clamp_rate(rate); ((r - 1.0) * 100.0).round().clamp(-100.0, 100.0) as i32 } #[cfg(target_os = "linux")] pub fn espeak_rate(rate: f32) -> u32 { // espeak-ng -s: words per minute (default 175, min 80, max 450). let r = clamp_rate(rate); ((r * 175.0).round() as i32).clamp(80, 450) as u32 } #[cfg(target_os = "linux")] fn spawn_linux(text: &str, rate: f32, voice: Option<&str>) -> Result, String> { let mut cmd = Command::new("spd-say"); cmd.arg("-r").arg(spd_rate(rate).to_string()); if let Some(v) = voice { cmd.arg("-t").arg(v); } cmd.arg("--").arg(text); cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); match cmd.spawn() { // spd-say is non-blocking — the child exits before speech // finishes, so there's nothing useful to track. Ok(_) => Ok(None), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { let mut fb = Command::new("espeak-ng"); fb.arg("-s").arg(espeak_rate(rate).to_string()); if let Some(v) = voice { fb.arg("-v").arg(v); } fb.arg("--").arg(text); fb.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); fb.spawn() .map(Some) .map_err(|e| format!("neither spd-say nor espeak-ng is available: {e}")) } Err(e) => Err(format!("failed to spawn spd-say: {e}")), } } #[cfg(target_os = "linux")] fn stop_linux() { // Cancels all active spd-say messages for this user. Silent // failure is fine — if spd-say isn't installed there's nothing // to cancel, and any tracked espeak-ng child is killed separately. let _ = Command::new("spd-say") .arg("-S") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn(); } // ---------- macOS (say) ---------- #[cfg(target_os = "macos")] pub fn say_rate(rate: f32) -> u32 { // `say -r`: words per minute. 180 wpm is roughly the default; clamp // to a sane range so the slider can't produce an unreadable rate. let r = clamp_rate(rate); ((r * 180.0).round() as i32).clamp(90, 500) as u32 } #[cfg(target_os = "macos")] fn spawn_macos(text: &str, rate: f32, voice: Option<&str>) -> Result, String> { let mut cmd = Command::new("say"); cmd.arg("-r").arg(say_rate(rate).to_string()); if let Some(v) = voice { cmd.arg("-v").arg(v); } cmd.arg("--").arg(text); cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); cmd.spawn() .map(Some) .map_err(|e| format!("failed to spawn `say`: {e}")) } // ---------- Windows (PowerShell System.Speech) ---------- #[cfg(target_os = "windows")] pub fn win_rate(rate: f32) -> i32 { // SpeechSynthesizer.Rate is an integer in -10..=10. let r = clamp_rate(rate); ((r - 1.0) * 10.0).round().clamp(-10.0, 10.0) as i32 } /// A PowerShell single-quoted here-string terminates on `'@` at the /// start of a line. Neutralise any such sequence inside user text so /// the here-string always closes where we intend. #[cfg(target_os = "windows")] pub fn escape_ps_herestring(text: &str) -> String { text.replace("'@", "' @") } #[cfg(target_os = "windows")] fn spawn_windows(text: &str, rate: f32, voice: Option<&str>) -> Result, String> { use base64::{engine::general_purpose::STANDARD, Engine}; let rate_int = win_rate(rate); let safe_text = escape_ps_herestring(text); let select = match voice { // Regular single-quoted string (not here-string): doubled // single quotes are the escape for a literal `'`. Some(v) => format!("$s.SelectVoice('{}')", v.replace('\'', "''")), None => String::new(), }; let script = format!( "Add-Type -AssemblyName System.Speech;\n\ $s = New-Object System.Speech.Synthesis.SpeechSynthesizer;\n\ $s.Rate = {rate_int};\n\ {select};\n\ $s.Speak(@'\n\ {safe_text}\n\ '@)" ); let utf16: Vec = script .encode_utf16() .flat_map(|u| u.to_le_bytes()) .collect(); let encoded = STANDARD.encode(&utf16); let mut cmd = Command::new("powershell.exe"); cmd.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]); cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); cmd.spawn() .map(Some) .map_err(|e| format!("failed to spawn powershell: {e}")) } // ---------- Voice listing ---------- #[cfg(target_os = "linux")] fn list_voices_impl() -> Result, String> { // spd-say's voice set depends on the active synth module and the // list format isn't stable across distributions. For Phase 4 the // picker renders "System default" only, which matches what // `tts_speak` without a voice argument does anyway. Ok(Vec::new()) } #[cfg(target_os = "macos")] fn list_voices_impl() -> Result, String> { let out = Command::new("say") .arg("-v") .arg("?") .output() .map_err(|e| format!("failed to query voices: {e}"))?; if !out.status.success() { return Err(format!("`say -v ?` exited with status {}", out.status)); } let stdout = String::from_utf8_lossy(&out.stdout); Ok(parse_macos_voices(&stdout)) } #[cfg(target_os = "macos")] pub fn parse_macos_voices(raw: &str) -> Vec { raw.lines() .filter_map(|line| { let (prefix, _sample) = line.split_once('#')?; let mut parts = prefix.split_whitespace(); let name = parts.next()?.to_string(); let locale = parts.next().map(str::to_string); Some(TtsVoice { id: name.clone(), name, language: locale, }) }) .collect() } #[cfg(target_os = "windows")] fn list_voices_impl() -> Result, String> { use base64::{engine::general_purpose::STANDARD, Engine}; let script = "Add-Type -AssemblyName System.Speech;\n\ (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |\n\ ForEach-Object { $_.VoiceInfo } |\n\ ForEach-Object { @{ Name = $_.Name; Culture = $_.Culture.Name } } |\n\ ConvertTo-Json -Compress"; let utf16: Vec = script .encode_utf16() .flat_map(|u| u.to_le_bytes()) .collect(); let encoded = STANDARD.encode(&utf16); let out = Command::new("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]) .output() .map_err(|e| format!("failed to query voices: {e}"))?; if !out.status.success() { return Err(format!( "PowerShell voice-list exited with status {}", out.status )); } let stdout = String::from_utf8_lossy(&out.stdout); let trimmed = stdout.trim(); if trimmed.is_empty() { return Ok(Vec::new()); } let parsed: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| format!("voice-list JSON parse failed: {e}"))?; // ConvertTo-Json emits a bare object for a single item and an // array otherwise; normalise to always-array. let items: Vec = match parsed { serde_json::Value::Array(a) => a, v => vec![v], }; Ok(items .into_iter() .filter_map(|v| { let name = v.get("Name")?.as_str()?.to_string(); let culture = v .get("Culture") .and_then(|c| c.as_str()) .map(str::to_string); Some(TtsVoice { id: name.clone(), name, language: culture, }) }) .collect()) } // Non-desktop fallback. Android's native TextToSpeech API would need a // JNI bridge — out of scope for v0.1-android. The frontend already hides // the Read Page Aloud button behind a runtime detection; this stub keeps // the command surface consistent so a stray invoke surfaces a clear // error rather than panicking on an unbound `list_voices_impl`. #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] fn list_voices_impl() -> Result, String> { Ok(Vec::new()) } // ---------- Tauri commands ---------- #[tauri::command] pub fn tts_speak( state: tauri::State<'_, TtsState>, text: String, rate: f32, voice: Option, ) -> Result<(), String> { let trimmed = text.trim(); if trimmed.is_empty() { return Ok(()); } // Cut any in-flight speech so a second tap starts cleanly rather // than queueing on top. kill_child(&state); #[cfg(target_os = "linux")] let spawned = spawn_linux(trimmed, rate, voice.as_deref())?; #[cfg(target_os = "macos")] let spawned = spawn_macos(trimmed, rate, voice.as_deref())?; #[cfg(target_os = "windows")] let spawned = spawn_windows(trimmed, rate, voice.as_deref())?; // No bundled TTS shim for non-desktop targets. Android has its own // TextToSpeech APIs that would have to be reached over JNI; out of // scope for v0.1-android. Mirrors the paste.rs not-implemented // fallback so the command compiles cleanly across all targets and // surfaces a clear error if the frontend tries to invoke it. #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = (state, rate, voice); return Err("TTS not implemented on this platform".into()); } #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] { if let Some(c) = spawned { if let Ok(mut guard) = state.child.lock() { *guard = Some(c); } } Ok(()) } } #[tauri::command] pub fn tts_stop(state: tauri::State<'_, TtsState>) -> Result<(), String> { kill_child(&state); #[cfg(target_os = "linux")] stop_linux(); Ok(()) } fn kill_child(state: &TtsState) { if let Ok(mut guard) = state.child.lock() { if let Some(mut c) = guard.take() { let _ = c.kill(); let _ = c.wait(); } } } #[tauri::command] pub fn tts_list_voices() -> Result, String> { list_voices_impl() } // ---------- Tests ---------- #[cfg(test)] mod tests { use super::*; #[test] fn clamp_rate_handles_nan() { assert_eq!(clamp_rate(f32::NAN), 1.0); } #[test] fn clamp_rate_bounds() { assert_eq!(clamp_rate(0.1), 0.5); assert_eq!(clamp_rate(0.5), 0.5); assert_eq!(clamp_rate(1.0), 1.0); assert_eq!(clamp_rate(2.0), 2.0); assert_eq!(clamp_rate(3.0), 2.0); } #[cfg(target_os = "linux")] #[test] fn spd_rate_hits_anchors() { assert_eq!(spd_rate(1.0), 0); assert_eq!(spd_rate(2.0), 100); assert_eq!(spd_rate(1.5), 50); // 0.5 is only -50 with the simple linear slope — the asymmetry // is documented in the `spd_rate` doc comment. assert_eq!(spd_rate(0.5), -50); } #[cfg(target_os = "linux")] #[test] fn espeak_rate_stays_in_range() { assert_eq!(espeak_rate(1.0), 175); assert_eq!(espeak_rate(2.0), 350); // 0.5 * 175 = 87.5 → rounds to 88 → still ≥ 80 floor. assert_eq!(espeak_rate(0.5), 88); // NaN → clamp_rate returns 1.0 → rate maps to 175. assert_eq!(espeak_rate(f32::NAN), 175); } #[cfg(target_os = "macos")] #[test] fn say_rate_maps() { assert_eq!(say_rate(1.0), 180); assert_eq!(say_rate(2.0), 360); assert_eq!(say_rate(0.5), 90); } #[cfg(target_os = "windows")] #[test] fn win_rate_bounds() { assert_eq!(win_rate(0.5), -5); assert_eq!(win_rate(1.0), 0); assert_eq!(win_rate(2.0), 10); } #[cfg(target_os = "windows")] #[test] fn ps_herestring_terminator_is_broken() { let input = "hello\n'@ evil"; let out = escape_ps_herestring(input); assert!(!out.contains("'@")); assert!(out.contains("' @")); } #[cfg(target_os = "macos")] #[test] fn parses_macos_voices() { let raw = "Alex en_US # Most people recognize me\n\ Fred en_US # I sure like being inside\n"; let voices = parse_macos_voices(raw); assert_eq!(voices.len(), 2); assert_eq!(voices[0].name, "Alex"); assert_eq!(voices[0].language.as_deref(), Some("en_US")); } }