diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 56f5b5f..df35f87 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,3 +78,8 @@ gdk = "0.18" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] } + +[target.'cfg(target_os = "windows")'.dependencies] +# Phase 4 TTS: PowerShell -EncodedCommand expects UTF-16-LE base64. +# Windows-only because the other platforms' TTS paths pass text via argv. +base64 = "0.22" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8781354..8905222 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod profiles; pub mod tasks; pub mod transcription; pub mod transcripts; +pub mod tts; pub mod update; pub mod windows; diff --git a/src-tauri/src/commands/tts.rs b/src-tauri/src/commands/tts.rs new file mode 100644 index 0000000..2366a64 --- /dev/null +++ b/src-tauri/src/commands/tts.rs @@ -0,0 +1,421 @@ +//! 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()) +} + +// ---------- 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())?; + + 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")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 41f6742..80eb708 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,7 @@ pub fn run() { app.manage(commands::hotkey::HotkeyState::new()); app.manage(commands::audio::NativeCaptureState::new()); app.manage(commands::live::LiveTranscriptionState::default()); + app.manage(commands::tts::TtsState::new()); app.manage(AppState { whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))), @@ -292,6 +293,10 @@ pub fn run() { // HITL feedback (Phase 2 roadmap) commands::feedback::record_feedback, commands::feedback::list_feedback_examples_cmd, + // Read aloud (Phase 4 roadmap) + commands::tts::tts_speak, + commands::tts::tts_stop, + commands::tts::tts_list_voices, // Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12 commands::profiles::list_profiles_cmd, commands::profiles::get_profile_cmd, diff --git a/src/lib/components/MicroSteps.svelte b/src/lib/components/MicroSteps.svelte index bc57c17..226bac2 100644 --- a/src/lib/components/MicroSteps.svelte +++ b/src/lib/components/MicroSteps.svelte @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte'; import { profilesStore } from '$lib/stores/profiles.svelte.ts'; + import SpeakerButton from '$lib/components/SpeakerButton.svelte'; let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props(); @@ -270,6 +271,12 @@ >