Browse / github.com/storytold/wordcraft / patch
[#3522] Windows Read Aloud: keep one speech host per reading
Read Aloud on Windows: stop starting PowerShell for every sentence ## TLDR Abstract Windows Read Aloud starts a new PowerShell process for every sentence, so every sentence waits about 1.5 seconds before it can speak. A persistent host started once per reading cuts that wait to about 16 milliseconds after the first sentence. The change keeps the same voices and settings, adds no dependencies, and falls back to today's path if the host fails. A one-sentence document will not feel faster; a page or a chapter will. ## Description I'm a writer and a reader. Read Aloud, an accessibility feature, is the reason I would use WordCraft, and the default Windows voic
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- d-0-t (imported from pull request #4494, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +723 −20 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
13d44020af7da62f8562d34b
How it went
No other operator has reported using it yet.
Diff
raw · contributors/people.toml crates/engine/src/speech.rs crates/engine/src/speech/tests.rs crates/engine/src/speech/windows_host.rs
@@ -181,3 +181,7 @@ sync_github_name = true [people.pakiat] display_name = "OtorO"++[people.d-0-t]+real_name = "Dora Sukolova"+sync_github_name = true@@ -13,9 +13,13 @@ //! voice the user chose), and without a voice for the language the default voice speaks. //! //! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak`-//! / `spd-say` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current-//! sentence, and moves on when it finishes. Commands only change the shared state and wake the-//! worker, which stops the running sentence when the state changed under it.+//! / `spd-say` (Linux, BSD) or the Windows speech synthesizer for the current sentence, and moves+//! on when it finishes. On Windows one PowerShell host stays alive for the whole reading and+//! loads System.Speech once; if it cannot start, or fails at any point, the reading falls back to+//! a fresh PowerShell process per sentence. A failure after a sentence starts repeats that+//! sentence on the fallback path, so no text is dropped, though the part already spoken may be+//! heard twice. The next reading tries the host again. Commands only change the shared state and+//! wake the worker, which stops the running sentence when the state changed under it. use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; @@ -23,6 +27,9 @@ use serde_json::{Value, json}; use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK}; use wordcraft_doc::{Document, InlineObject, Pos}; +#[cfg(any(target_os = "windows", test))]+mod windows_host;+ /// Longest sentence handed to the speech program (longer ones are split at a space). const MAX_SENTENCE: usize = 600; /// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut@@ -537,6 +544,10 @@ struct Shared { rate: f32, /// Bumped on every change that should stop the sentence being spoken. generation: u64,+ /// Bumped by `start`: a new reading gets a fresh speech host, so a failed host is retried+ /// and a changed default voice is captured again.+ #[cfg_attr(target_arch = "wasm32", allow(dead_code))]+ reading: u64, error: Option<String>, /// A worker thread is running (cleared under the lock as it exits). #[cfg_attr(target_arch = "wasm32", allow(dead_code))]@@ -561,7 +572,16 @@ impl Default for ReadAloud { let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off"); ReadAloud { shared: Arc::new((- Mutex::new(Shared { queue: Vec::new(), index: 0, state: State::Stopped, rate: 1.0, generation: 0, error: None, running: false }),+ Mutex::new(Shared {+ queue: Vec::new(),+ index: 0,+ state: State::Stopped,+ rate: 1.0,+ generation: 0,+ reading: 0,+ error: None,+ running: false,+ }), Condvar::new(), )), backend: if off { Backend::Hold } else { Backend::System },@@ -596,6 +616,7 @@ impl ReadAloud { s.state = if queue.is_empty() { State::Stopped } else { State::Playing }; s.queue = queue; s.index = 0;+ s.reading = s.reading.wrapping_add(1); s.error = None; }); }@@ -726,11 +747,68 @@ enum Spoken { Failed(String), } +/// The system voice for one reading. On Windows it keeps one PowerShell host alive from the+/// first system sentence; if the host cannot start, or fails, it falls back to a process per+/// sentence (repeating the sentence if it had started).+#[cfg(not(target_arch = "wasm32"))]+struct SystemSpeaker {+ #[cfg(target_os = "windows")]+ host: Option<windows_host::Host>,+ #[cfg(target_os = "windows")]+ started: bool,+}++#[cfg(not(target_arch = "wasm32"))]+impl SystemSpeaker {+ /// An empty speaker; the Windows host starts on the first system sentence.+ fn new() -> Self {+ Self {+ #[cfg(target_os = "windows")]+ host: None,+ #[cfg(target_os = "windows")]+ started: false,+ }+ }++ /// Speak one sentence with the persistent Windows host, or with a fresh process per sentence.+ fn speak(&mut self, text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {+ #[cfg(target_os = "windows")]+ {+ if !self.started {+ self.started = true;+ self.host = windows_host::Host::start(changed).ok();+ if changed() {+ if self.host.is_none() {+ self.started = false;+ }+ return Spoken::Interrupted;+ }+ }+ if let Some(host) = self.host.as_mut() {+ match host.speak(text, lang, rate, changed) {+ Ok(spoken) => return spoken,+ Err(windows_host::HostError::Voice) => {+ if let Ok(spoken) = host.speak(text, None, rate, changed) {+ return spoken;+ }+ }+ Err(windows_host::HostError::Unavailable) => {}+ }+ self.host = None;+ }+ }+ speak(text, lang, rate, changed)+ }+}+ #[cfg(not(target_arch = "wasm32"))]+/// The Read Aloud worker loop; it owns the system speaker for one reading. fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) { let (m, cv) = shared;+ let mut speaker = SystemSpeaker::new();+ let mut reading = lock(m).reading; loop {- let (text, lang, generation, rate) = {+ let (text, lang, generation, rate, now) = { let mut s = lock(m); while s.state == State::Paused { s = cv.wait(s).unwrap_or_else(PoisonError::into_inner);@@ -740,7 +818,7 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) { return; } match s.queue.get(s.index) {- Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate),+ Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.reading), None => { s.state = State::Stopped; s.running = false;@@ -748,9 +826,15 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) { } } };+ if now != reading {+ // A new reading: drop the old host (quit or kill) and start afresh, so a failed host+ // is retried and the default voice is captured again.+ reading = now;+ speaker = SystemSpeaker::new();+ } let changed = || lock(m).generation != generation; let outcome = match backend {- Backend::System => speak(&text, lang.as_deref(), rate, &changed),+ Backend::System => speaker.speak(&text, lang.as_deref(), rate, &changed), Backend::Instant => Spoken::Done, Backend::Hold => { let mut s = lock(m);@@ -807,10 +891,40 @@ struct WindowsSpeech { creation_flags: u32, } +/// The PowerShell program and common arguments for `script`. Both the per-sentence fallback and+/// the persistent host build their `Command` from this, so their path and flags cannot drift.+#[cfg(any(target_os = "windows", test))]+fn windows_command(system_root: Option<std::ffi::OsString>, script: &str) -> WindowsSpeech {+ WindowsSpeech {+ program: windows_powershell(system_root),+ args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script.into()],+ // PowerShell is a console program: without this, every sentence flashes a console window+ // over the document (#1122).+ creation_flags: CREATE_NO_WINDOW,+ }+}++#[cfg(target_os = "windows")]+impl WindowsSpeech {+ /// Build the PowerShell `Command` from the stored program, arguments and flags.+ fn command(&self) -> std::process::Command {+ let mut command = std::process::Command::new(&self.program);+ command.args(&self.args);+ std::os::windows::process::CommandExt::creation_flags(&mut command, self.creation_flags);+ command+ }+}++/// SAPI rate: -10 (a third of normal speed) … 10 (three times).+#[cfg(any(target_os = "windows", test))]+fn sapi_rate(rate: f32) -> i64 {+ ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64+}+ #[cfg(any(target_os = "windows", test))]+/// Build the per-sentence PowerShell script for `rate` and `lang`, and return its command. fn windows_speech(system_root: Option<std::ffi::OsString>, rate: f32, lang: Option<&str>) -> WindowsSpeech {- // SAPI rate: -10 (a third of normal speed) … 10 (three times).- let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64;+ let r = sapi_rate(rate); // The voice of the sentence's language, when one is installed (the default voice would spell // out Cyrillic, Greek… letters one by one), but only when the user's default voice doesn't // speak that language already, in any region. The tag is `primary_tag`-clean, so it can't end@@ -826,13 +940,7 @@ fn windows_speech(system_root: Option<std::ffi::OsString>, rate: f32, lang: Opti let script = format!( "Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; {voice}$m = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($m); $s.Speak([System.Text.Encoding]::UTF8.GetString($m.ToArray()))" );- WindowsSpeech {- program: windows_powershell(system_root),- args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script],- // PowerShell is a console program: without this, every sentence flashes a console window- // over the document (#1122).- creation_flags: CREATE_NO_WINDOW,- }+ windows_command(system_root, &script) } /// Speak one sentence with the system's speech program, stopping early when `changed()`. When@@ -869,10 +977,7 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b #[cfg(target_os = "windows")] { let w = windows_speech(std::env::var_os("SystemRoot"), rate, lang);- let mut c = Command::new(&w.program);- c.args(&w.args);- std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags);- tries.push((c, lang.and_then(primary_tag).is_some()));+ tries.push((w.command(), lang.and_then(primary_tag).is_some())); } #[cfg(all(unix, not(target_os = "macos")))] {@@ -29,6 +29,16 @@ fn powershell_starts_without_a_console_window() { assert!(windows_speech(None, 3.0, None).args[3].contains("$s.Rate = 10;")); } +#[test]+/// The shared launch helper keeps the PowerShell path, arguments and flags in one place.+fn powershell_command_uses_the_shared_program_and_flags() {+ let w = windows_command(Some("D:\\Win".into()), "script");+ assert_eq!(w.program, windows_powershell(Some("D:\\Win".into())));+ assert_eq!(w.creation_flags, 0x0800_0000);+ assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);+ assert_eq!(w.args[3], "script");+}+ #[test] fn splits_sentences_but_not_abbreviations() { let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one.";@@ -196,6 +206,23 @@ fn empty_queue_does_not_play() { r.play_pause(); assert_eq!(r.state(), State::Stopped); }++#[test]+/// Only `start` begins a new reading; the worker rebuilds its speaker when this changes.+fn a_new_reading_bumps_the_speaker_generation() {+ let mut r = ReadAloud::default();+ let reading = |r: &ReadAloud| lock(&r.shared.0).reading;+ assert_eq!(reading(&r), 0);+ r.start(Vec::new());+ assert_eq!(reading(&r), 1);+ // Pausing and skipping are not new readings.+ r.play_pause();+ r.skip(1);+ assert_eq!(reading(&r), 1);+ r.start(Vec::new());+ assert_eq!(reading(&r), 2);+}+ #[test] fn languages_are_detected_from_the_script() { for (text, tag) in [new file mode 100644@@ -0,0 +1,567 @@+//! The persistent Windows speech host: one PowerShell process per reading, so System.Speech and+//! the synthesizer load once instead of once per sentence. The current `speak` path remains the+//! fallback when the host cannot start or fails at any point; a failure after a sentence starts+//! repeats it on that path, so no text is dropped (the part already spoken may be heard twice).+//! The Windows default voice is captured when the host starts and restored before every sentence,+//! so changing it mid-reading takes effect on the next reading.++#[cfg(target_os = "windows")]+use std::io::{BufRead, BufReader, Write};+#[cfg(target_os = "windows")]+use std::process::{Child, ChildStdin, Command, Stdio};+#[cfg(target_os = "windows")]+use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};+#[cfg(target_os = "windows")]+use std::time::{Duration, Instant};++use serde_json::json;++#[cfg(target_os = "windows")]+use super::Spoken;++/// How long the host has to create its synthesizer and say it is ready.+#[cfg(target_os = "windows")]+const STARTUP_TIMEOUT: Duration = Duration::from_secs(5);+/// A sentence can be long and the speed can be slow; this only catches a host that stopped+/// answering.+#[cfg(target_os = "windows")]+const SPEAK_TIMEOUT: Duration = Duration::from_secs(300);+/// How long the host has to acknowledge a stop before it is considered unresponsive.+#[cfg(target_os = "windows")]+const STOP_TIMEOUT: Duration = Duration::from_secs(2);++/// The PowerShell program. It reads one JSON command per line and writes one reply per line.+const HOST_SCRIPT: &str = r#"$ErrorActionPreference = 'Stop'+Add-Type -AssemblyName System.Speech+$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer+# Captured once per reading: a default-voice change mid-reading takes effect next time.+$defaultVoice = $synth.Voice.Name+$defaultLanguage = $synth.Voice.Culture.TwoLetterISOLanguageName+$utf8 = New-Object System.Text.UTF8Encoding($false)+$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)+$out = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $utf8)+$out.AutoFlush = $true+$out.WriteLine('ready')+$task = $reader.ReadLineAsync()+$speaking = $null+$prompt = $null+$stopId = $null+while ($true) {+ if ($task.IsCompleted) {+ try { $line = $task.Result } catch { break }+ if ($null -eq $line) { break }+ $m = $null+ # 0 is never a sentence id; it marks a line the host could not parse.+ $id = '0'+ try {+ $m = $line | ConvertFrom-Json+ $id = [string]$m.id+ if ($id -eq '') { $id = '0' }+ switch ([string]$m.cmd) {+ 'speak' {+ $synth.SpeakAsyncCancelAll()+ $speaking = $null+ $prompt = $null+ $synth.Rate = [int]$m.rate+ $voice = [string]$m.voice+ $lang = [string]$m.lang+ $voiceError = $false+ try { $synth.SelectVoice($defaultVoice) } catch { $voiceError = $true }+ if (-not $voiceError) {+ if ($voice -ne '') {+ try { $synth.SelectVoice($voice) } catch { $voiceError = $true }+ } elseif ($lang -ne '' -and $defaultLanguage -ne $lang) {+ try {+ $v = $synth.GetInstalledVoices() | Where-Object { $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq $lang } | Select-Object -First 1+ if ($v) { $synth.SelectVoice($v.VoiceInfo.Name) }+ } catch { $voiceError = $true }+ }+ }+ if ($voiceError) {+ $out.WriteLine('error ' + $id + ' voice')+ } else {+ $prompt = $synth.SpeakAsync([string]$m.text)+ $out.WriteLine('started ' + $id)+ $speaking = $id+ }+ }+ 'stop' {+ $synth.SpeakAsyncCancelAll()+ $speaking = $null+ $prompt = $null+ if ($synth.State -eq [System.Speech.Synthesis.SynthesizerState]::Ready) {+ $out.WriteLine('stopped ' + $id)+ } else {+ $stopId = $id+ }+ }+ 'quit' {+ if ($null -ne $speaking) { $synth.SpeakAsyncCancelAll() }+ $synth.Dispose()+ exit 0+ }+ }+ } catch {+ $out.WriteLine('error ' + $id + ' other')+ }+ $task = $reader.ReadLineAsync()+ }+ if ($null -ne $stopId -and $synth.State -eq [System.Speech.Synthesis.SynthesizerState]::Ready) {+ $out.WriteLine('stopped ' + $stopId)+ $stopId = $null+ }+ if ($null -ne $speaking -and $null -ne $prompt -and $prompt.IsCompleted) {+ $out.WriteLine('done ' + $speaking)+ $speaking = $null+ $prompt = $null+ }+ if ($null -eq $speaking -and $null -eq $stopId) {+ # Nothing is speaking: wait for the next command instead of polling.+ try { [void]$task.Wait() } catch { break }+ } else {+ Start-Sleep -Milliseconds 15+ }+}+$synth.Dispose()+"#;++/// One line of the host protocol.+#[derive(Debug, PartialEq)]+enum Reply {+ Started(u64),+ Done(u64),+ Stopped(u64),+ Error(u64, String),+}++/// Parse one protocol reply: `started <id>`, `done <id>`, `stopped <id>` or `error <id> <code>`.+fn parse_reply(line: &str) -> Option<Reply> {+ let (kind, rest) = line.trim().split_once(' ')?;+ let (id, code) = match rest.split_once(' ') {+ Some((id, code)) => (id, Some(code)),+ None => (rest, None),+ };+ let id = id.parse().ok()?;+ match kind {+ "started" if code.is_none() => Some(Reply::Started(id)),+ "done" if code.is_none() => Some(Reply::Done(id)),+ "stopped" if code.is_none() => Some(Reply::Stopped(id)),+ "error" => Some(Reply::Error(id, code?.to_string())),+ _ => None,+ }+}++/// Encode one `speak` command as a single JSON line.+fn speak_message(id: u64, text: &str, lang: Option<&str>, rate: f32) -> String {+ let lang = lang.and_then(super::primary_tag).unwrap_or_default();+ json!({+ "cmd": "speak",+ "id": id,+ "rate": super::sapi_rate(rate),+ "lang": lang,+ "voice": null,+ "text": text,+ })+ .to_string()+}++#[cfg(target_os = "windows")]+pub(super) enum HostError {+ /// The requested voice could not be selected; the caller retries with the default voice.+ Voice,+ /// The host is gone or did not answer; the caller falls back. If the sentence had started,+ /// the caller repeats it from the beginning on the fallback path.+ Unavailable,+}++#[cfg(target_os = "windows")]+pub(super) struct Host {+ child: Child,+ stdin: ChildStdin,+ lines: Receiver<String>,+ reader: Option<std::thread::JoinHandle<()>>,+ next_id: u64,+ broken: bool,+}++#[cfg(target_os = "windows")]+impl Host {+ /// Start the real PowerShell host with the embedded script.+ pub(super) fn start(changed: &dyn Fn() -> bool) -> Result<Self, String> {+ Self::start_with(super::windows_command(std::env::var_os("SystemRoot"), HOST_SCRIPT).command(), changed)+ }++ /// Start a host from a prepared command; the tests use this to run a fake host.+ fn start_with(mut command: Command, changed: &dyn Fn() -> bool) -> Result<Self, String> {+ command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());+ let mut child = command.spawn().map_err(|e| e.to_string())?;+ let Some(stdin) = child.stdin.take() else {+ let _ = child.kill();+ let _ = child.wait();+ return Err("Read Aloud host has no input".into());+ };+ let Some(stdout) = child.stdout.take() else {+ let _ = child.kill();+ let _ = child.wait();+ return Err("Read Aloud host has no output".into());+ };+ let (tx, lines) = channel();+ let reader = std::thread::Builder::new()+ .name("read-aloud-host".into())+ .spawn(move || {+ for line in BufReader::new(stdout).lines() {+ let Ok(line) = line else { break };+ if tx.send(line).is_err() {+ break;+ }+ }+ })+ .map_err(|e| {+ let _ = child.kill();+ let _ = child.wait();+ e.to_string()+ })?;+ // Sentence ids start at 1: id 0 is the host's "could not parse the line" sentinel.+ let host = Self { child, stdin, lines, reader: Some(reader), next_id: 1, broken: false };+ let deadline = Instant::now() + STARTUP_TIMEOUT;+ loop {+ if changed() {+ return Err("Read Aloud host start cancelled".into());+ }+ match host.lines.recv_timeout(Duration::from_millis(30)) {+ Ok(line) if line.trim() == "ready" => return Ok(host),+ Ok(line) => return Err(format!("unexpected Read Aloud host greeting: {line}")),+ Err(RecvTimeoutError::Timeout) if Instant::now() >= deadline => {+ return Err("Read Aloud host did not start".into());+ }+ Err(RecvTimeoutError::Timeout) => {}+ Err(RecvTimeoutError::Disconnected) => return Err("Read Aloud host stopped".into()),+ }+ }+ }++ /// Speak one sentence and wait for `started`/`done`/`stopped`/`error`; a host failure is `Unavailable`.+ pub(super) fn speak(&mut self, text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Result<Spoken, HostError> {+ if self.broken {+ return Err(HostError::Unavailable);+ }+ if changed() {+ return Ok(Spoken::Interrupted);+ }+ let id = self.next_id;+ self.next_id = self.next_id.wrapping_add(1);+ let line = speak_message(id, text, lang, rate);+ if writeln!(self.stdin, "{line}").and_then(|_| self.stdin.flush()).is_err() {+ self.broken = true;+ return Err(HostError::Unavailable);+ }+ // Any failure falls back to the per-sentence path. If the sentence had started, that path+ // repeats it from the beginning, so the part already spoken may be heard twice.+ let mut started = false;+ let deadline = Instant::now() + SPEAK_TIMEOUT;+ loop {+ if changed() {+ self.stop(id);+ return Ok(Spoken::Interrupted);+ }+ match self.lines.recv_timeout(Duration::from_millis(30)) {+ Ok(line) => match parse_reply(&line) {+ Some(Reply::Started(got)) if got == id => started = true,+ Some(Reply::Done(got)) if got == id => return Ok(Spoken::Done),+ Some(Reply::Stopped(got)) if got == id => return Ok(Spoken::Interrupted),+ Some(Reply::Error(0, _)) => {+ // Id 0 is the host's parser sentinel, never a sentence id.+ return Err(HostError::Unavailable);+ }+ Some(Reply::Error(got, code)) if got == id => {+ if !started && code == "voice" {+ return Err(HostError::Voice);+ }+ return Err(HostError::Unavailable);+ }+ _ => {}+ },+ Err(RecvTimeoutError::Timeout) => {+ if Instant::now() >= deadline {+ self.broken = true;+ return Err(HostError::Unavailable);+ }+ }+ Err(RecvTimeoutError::Disconnected) => {+ self.broken = true;+ return Err(HostError::Unavailable);+ }+ }+ }+ }++ /// Cancel the current prompt and wait briefly for the host to acknowledge the stop. A host+ /// that does not answer is killed: it may still be speaking while the player is paused, and+ /// the next sentence falls back.+ fn stop(&mut self, id: u64) {+ if writeln!(self.stdin, "{{\"cmd\":\"stop\",\"id\":{id}}}").and_then(|_| self.stdin.flush()).is_err() {+ self.kill();+ return;+ }+ let stopped = format!("stopped {id}");+ let done = format!("done {id}");+ let deadline = Instant::now() + STOP_TIMEOUT;+ while Instant::now() < deadline {+ match self.lines.recv_timeout(Duration::from_millis(30)) {+ Ok(line) => {+ let line = line.trim();+ if line == stopped.as_str() || line == done.as_str() {+ return;+ }+ }+ Err(RecvTimeoutError::Timeout) => {}+ Err(RecvTimeoutError::Disconnected) => {+ self.broken = true;+ return;+ }+ }+ }+ self.kill();+ }++ /// Mark the host unusable and kill its child, so an unacknowledged stop cannot leave audio+ /// playing; [`Drop`] then reaps the child and its reader.+ fn kill(&mut self) {+ self.broken = true;+ let _ = self.child.kill();+ }+}++#[cfg(target_os = "windows")]+impl Drop for Host {+ /// Send `quit`, then kill the child if it does not exit; detach the reader as a last resort.+ fn drop(&mut self) {+ let _ = writeln!(self.stdin, "{{\"cmd\":\"quit\"}}");+ let _ = self.stdin.flush();+ let mut exited = wait_for_exit(&mut self.child, Duration::from_millis(500));+ if !exited {+ let _ = self.child.kill();+ exited = wait_for_exit(&mut self.child, Duration::from_secs(1));+ }+ if exited {+ if let Some(reader) = self.reader.take() {+ let _ = reader.join();+ }+ } else {+ // The child outlived `kill`; joining its reader could block forever on its stdout.+ let _ = self.reader.take();+ }+ }+}++/// Wait up to `timeout` for `child` to exit; `false` if it is still running or reaping failed.+#[cfg(target_os = "windows")]+fn wait_for_exit(child: &mut Child, timeout: Duration) -> bool {+ let deadline = Instant::now() + timeout;+ loop {+ match child.try_wait() {+ Ok(Some(_)) => return true,+ Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),+ _ => return false,+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ /// A stand-in for the real host: no System.Speech, so these tests run without audio and can+ /// drive the failure paths the real host cannot be made to hit on demand.+ #[cfg(target_os = "windows")]+ const FAKE_HOST_SCRIPT: &str = r#"$utf8 = New-Object System.Text.UTF8Encoding($false)+$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)+$out = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $utf8)+$out.AutoFlush = $true+$out.WriteLine('ready')+while ($null -ne ($line = $reader.ReadLine())) {+ $m = $line | ConvertFrom-Json+ $id = [string]$m.id+ switch ([string]$m.cmd) {+ 'speak' {+ $text = [string]$m.text+ if ($text -eq 'voice') {+ $out.WriteLine('error ' + $id + ' voice')+ } elseif ($text -eq 'parse') {+ $out.WriteLine('error 0 other')+ } elseif ($text -eq 'crash') {+ $out.WriteLine('started ' + $id)+ exit 1+ } elseif ($text -eq 'late') {+ $out.WriteLine('done ' + $id)+ } elseif ($text -eq 'wait') {+ $out.WriteLine('started ' + $id)+ Start-Sleep -Milliseconds 200+ $out.WriteLine('done ' + $id)+ } else {+ $out.WriteLine('started ' + $id)+ $out.WriteLine('done ' + $id)+ }+ }+ 'stop' { $out.WriteLine('stopped ' + $id) }+ 'quit' { exit 0 }+ }+}+"#;++ /// A stand-in that accepts commands but never acknowledges a stop; the host must kill it.+ #[cfg(target_os = "windows")]+ const FAKE_HOST_IGNORES_STOP: &str = r#"$utf8 = New-Object System.Text.UTF8Encoding($false)+$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), $utf8)+$out = New-Object System.IO.StreamWriter([Console]::OpenStandardOutput(), $utf8)+$out.AutoFlush = $true+$out.WriteLine('ready')+while ($null -ne ($line = $reader.ReadLine())) {+ $m = $line | ConvertFrom-Json+ if ([string]$m.cmd -eq 'quit') { exit 0 }+}+"#;++ #[cfg(target_os = "windows")]+ /// Start the fake host used by the protocol tests.+ fn fake_host() -> Host {+ Host::start_with(super::super::windows_command(None, FAKE_HOST_SCRIPT).command(), &|| false).unwrap()+ }++ #[cfg(target_os = "windows")]+ #[test]+ /// A host that exits before `ready` fails startup.+ fn fake_host_start_fails_if_it_exits_before_ready() {+ let err = Host::start_with(super::super::windows_command(None, "exit 1").command(), &|| false);+ assert!(err.is_err());+ }++ #[cfg(target_os = "windows")]+ #[test]+ /// The fake host's `done` is accepted even before `started`.+ fn fake_host_reports_done_including_before_started() {+ let mut host = fake_host();+ assert!(matches!(host.speak("ok", None, 1.0, &|| false), Ok(Spoken::Done)));+ assert!(matches!(host.speak("late", None, 1.0, &|| false), Ok(Spoken::Done)));+ assert!(matches!(host.speak("wait", None, 1.0, &|| false), Ok(Spoken::Done)));+ }++ #[cfg(target_os = "windows")]+ #[test]+ /// Voice and parser errors come back as distinct host errors.+ fn fake_host_reports_voice_and_protocol_errors() {+ let mut host = fake_host();+ assert!(matches!(host.speak("voice", None, 1.0, &|| false), Err(HostError::Voice)));+ assert!(matches!(host.speak("parse", None, 1.0, &|| false), Err(HostError::Unavailable)));+ }++ #[cfg(target_os = "windows")]+ #[test]+ /// A host that dies after `started` is unavailable, not done, so the caller can fall back.+ fn fake_host_falls_back_when_it_dies_after_started() {+ let mut host = fake_host();+ assert!(matches!(host.speak("crash", None, 1.0, &|| false), Err(HostError::Unavailable)));+ assert!(host.broken);+ }++ #[cfg(target_os = "windows")]+ #[test]+ /// An idle stop does not break the host; dropping it quits cleanly.+ fn fake_host_stops_when_idle_and_quits() {+ let mut host = fake_host();+ host.stop(99);+ assert!(!host.broken);+ drop(host);+ }++ #[cfg(target_os = "windows")]+ #[test]+ /// A host that never acknowledges a stop is killed, so it cannot keep talking while paused.+ fn fake_host_kills_a_child_that_ignores_stop() {+ let mut host = Host::start_with(super::super::windows_command(None, FAKE_HOST_IGNORES_STOP).command(), &|| false).unwrap();+ host.stop(1);+ assert!(host.broken);+ assert!(wait_for_exit(&mut host.child, Duration::from_secs(2)), "the child must be killed");+ }++ #[test]+ /// The embedded script loads System.Speech once and uses the async prompt APIs.+ fn host_script_keeps_one_synthesizer_alive() {+ for needle in [+ "Add-Type -AssemblyName System.Speech",+ "SpeechSynthesizer",+ "SpeakAsync",+ "SpeakAsyncCancelAll",+ "IsCompleted",+ "ReadLineAsync",+ "ConvertFrom-Json",+ "AutoFlush",+ ] {+ assert!(HOST_SCRIPT.contains(needle), "{needle}");+ }+ }++ #[test]+ /// Cyrillic text and quotes stay on one JSON line.+ fn speak_message_is_one_json_line() {+ let line = speak_message(7, "Привет, \"мир\".", Some("ru-RU"), 1.0);+ assert!(!line.contains('\n'));+ let v: serde_json::Value = serde_json::from_str(&line).unwrap();+ assert_eq!(v["cmd"], "speak");+ assert_eq!(v["id"], 7);+ assert_eq!(v["rate"], 0);+ assert_eq!(v["lang"], "ru");+ assert_eq!(v["text"], "Привет, \"мир\".");+ assert!(v["voice"].is_null());+ }++ #[test]+ /// Newlines, tabs, backslashes and control characters are escaped.+ fn speak_message_escapes_control_characters() {+ let text = "line\nbreak\ttab\\slash\"quote\u{1}";+ let line = speak_message(1, text, None, 1.0);+ assert!(!line.contains('\n') && !line.contains('\t'), "{line}");+ let v: serde_json::Value = serde_json::from_str(&line).unwrap();+ assert_eq!(v["text"], text);+ assert_eq!(v["lang"], "");+ }++ #[cfg(target_os = "windows")]+ #[test]+ #[ignore = "starts a real PowerShell speech host"]+ /// A real PowerShell host speaks an empty sentence and answers `done`.+ fn host_round_trips_a_speak_command() {+ let mut host = Host::start(&|| false).unwrap();+ assert!(matches!(host.speak("", None, 1.0, &|| false), Ok(Spoken::Done)));+ }++ #[cfg(target_os = "windows")]+ #[test]+ #[ignore = "starts a real PowerShell speech host"]+ /// One real host speaks two sentences, then answers an idle stop.+ fn host_reuses_one_process_for_two_sentences() {+ let mut host = Host::start(&|| false).unwrap();+ assert_eq!(host.next_id, 1, "sentence ids start at 1; 0 is the parser sentinel");+ assert!(matches!(host.speak("", None, 1.0, &|| false), Ok(Spoken::Done)));+ assert!(matches!(host.speak("", None, 1.0, &|| false), Ok(Spoken::Done)));+ assert_eq!(host.next_id, 3);+ host.stop(99);+ assert!(!host.broken, "an idle stop must not break the host");+ }++ #[test]+ /// Protocol replies parse, including padded and malformed lines.+ fn replies_are_parsed() {+ assert_eq!(parse_reply("started 3"), Some(Reply::Started(3)));+ assert_eq!(parse_reply("done 3"), Some(Reply::Done(3)));+ assert_eq!(parse_reply("stopped 3"), Some(Reply::Stopped(3)));+ assert_eq!(parse_reply("error 3 voice"), Some(Reply::Error(3, "voice".into())));+ assert_eq!(parse_reply("error 0 other"), Some(Reply::Error(0, "other".into())));+ assert_eq!(parse_reply("done 3 "), Some(Reply::Done(3)));+ assert_eq!(parse_reply(" error 3 voice "), Some(Reply::Error(3, "voice".into())));+ assert_eq!(parse_reply("error 3"), None);+ assert_eq!(parse_reply("error"), None);+ assert_eq!(parse_reply("what 3"), None);+ assert_eq!(parse_reply("started"), None);+ }+}