//! Read Aloud: the document as a queue of sentences that the system's speech program speaks one //! at a time, so reading can be paused, skipped forwards and backwards, and sped up. //! //! Citation-manager fields can be left out: Zotero, Mendeley and EndNote citations and //! bibliographies (range fields with an `ADDIN …` code) and Word's own `CITATION` and //! `BIBLIOGRAPHY` fields. The citation's text simply isn't in the queue, so every citation //! style is skipped the same way: "(Smith et al., 2020)", "[3]" or a superscript number. //! //! Each sentence is spoken in its own language: the run's language tag, or else the script of its //! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice //! where one is installed instead of being spelled out letter by letter. The system's own voice //! stays whenever it already speaks the language (English text on an English system keeps the //! voice the user chose), and without a voice for the language the default voice speaks. Speech //! Dispatcher is the exception: it can't be asked for its default language, so it is told the //! language of every sentence. //! //! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng` //! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence, //! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when //! the sentence ends. 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}; use serde_json::{Value, json}; use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK}; use wordcraft_doc::{Document, InlineObject, Pos}; /// 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 /// there, mid-word. const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE; /// Most sentences in one queue. const MAX_SENTENCES: usize = 20_000; /// Speed limits (1.0 = the normal speed). pub const MIN_RATE: f32 = 0.5; pub const MAX_RATE: f32 = 3.0; /// Words per minute at speed 1.0 (`say` and `espeak` both default to about this). #[cfg(not(target_arch = "wasm32"))] const BASE_WPM: f32 = 180.0; /// Words that end with a full stop without ending the sentence. const ABBREVIATIONS: &[&str] = &[ "al", "e.g", "i.e", "eg", "ie", "cf", "vs", "etc", "fig", "figs", "eq", "eqs", "no", "nos", "p", "pp", "vol", "vols", "ch", "chap", "sec", "tab", "ref", "refs", "dr", "mr", "mrs", "ms", "prof", "st", "approx", "ca", "resp", "ed", "eds", "suppl", "jr", "sr", "inc", "ltd", "co", "dept", "est", "min", "max", "viz", "ibid", ]; /// Is this field code a citation or a bibliography (Zotero, Mendeley, EndNote, Word)? pub fn is_citation_code(instr: &str) -> bool { let code = instr.trim_start(); let word = code.split_whitespace().next().unwrap_or("").to_ascii_uppercase(); if word == "CITATION" || word == "BIBLIOGRAPHY" { return true; } if word != "ADDIN" { return false; } let rest = code.get(5..).unwrap_or("").trim_start().to_ascii_uppercase(); ["ZOTERO_ITEM", "ZOTERO_BIBL", "CSL_CITATION", "CSL_BIBLIOGRAPHY", "MENDELEY", "EN.CITE", "EN.REFLIST"].iter().any(|k| rest.starts_with(k)) } /// One sentence to speak: its text and where it is in the document. #[derive(Clone, Debug, PartialEq)] pub struct Utterance { pub text: String, pub start: Pos, pub end: Pos, /// The language to speak it in (a BCP 47 tag), or `None` for the system's default voice. pub lang: Option, } impl Utterance { pub fn to_json(&self) -> Value { json!({"text": self.text, "start": crate::cmd::pos_json(&self.start), "end": crate::cmd::pos_json(&self.end)}) } } /// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order. /// With `skip_citations`, citation and bibliography fields are left out. At most /// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent. pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec { let story = from.story; // Spans to leave out: whole range fields, both markers included. let skipped = Spans::new(if skip_citations { doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect() } else { Vec::new() }); let in_skipped = |p: &Pos| skipped.contains(p); let mut out = Vec::new(); for path in doc.para_paths(story) { if path < from.path { continue; } if let Some(to) = to && path > to.path { break; } let Some(para) = doc.para(story, &path) else { continue }; // The spoken text, and for each char the paragraph offset it came from. let mut text = String::new(); let mut map: Vec<(usize, usize)> = Vec::new(); // (start, end) offsets in the paragraph let mut k = 0; for (off, c) in para.text.char_indices() { let end = off + c.len_utf8(); let obj = if c == OBJ { k += 1; para.objects.get(k - 1) } else { None }; if (path == from.path && off < from.off) || to.is_some_and(|t| path == t.path && off >= t.off) { continue; } let pos = Pos::new(story, path.clone(), off); if in_skipped(&pos) { continue; } match obj { Some(InlineObject::Field { instr, .. }) if skip_citations && is_citation_code(instr) => {} Some(o) => { for ch in o.plain_text().chars() { text.push(ch); map.push((off, end)); } } None if c == OBJ => {} None => { let ch = if matches!(c, LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | '\t') { ' ' } else { c }; text.push(ch); map.push((off, end)); } } } // Sliced per sentence; skipping from the start each time was quadratic in the paragraph. let text_chars: Vec = text.chars().collect(); for (a, b) in split_sentences(&text) { if out.len() >= MAX_SENTENCES { log::warn!( "Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)" ); return out; } let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::()); if !spoken.chars().any(char::is_alphanumeric) { continue; } let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue }; // The language of the sentence's first character, through styles and the defaults. let run = para.props_of_char(first.0); let explicit = doc.styles.resolve_char(para.props.style.as_deref(), run).lang; let lang = speech_language(&spoken, explicit.as_deref(), run.lang_bidi.as_deref()); out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1), lang }); } } out } /// Spans of the document (both ends included), sorted and merged so a position is looked up by /// binary search: checking every character against every citation was quadratic in a document /// with many of them. struct Spans(Vec<(Pos, Pos)>); impl Spans { fn new(mut spans: Vec<(Pos, Pos)>) -> Self { spans.retain(|(a, b)| a <= b); spans.sort(); let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len()); for (a, b) in spans { match merged.last_mut() { Some(last) if a <= last.1 => { if b > last.1 { last.1 = b; } } _ => merged.push((a, b)), } } Spans(merged) } /// Is `p` inside one of the spans? fn contains(&self, p: &Pos) -> bool { let after = self.0.partition_point(|(a, _)| a <= p); after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b) } } /// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian /// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th` /// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script /// doesn't say which language) and text without letters. Mixed text goes to its largest non-Latin /// script once that script has a real share of the letters (a fifth), even when Latin letters /// outnumber it ("Я открыл Microsoft Word and PowerPoint"): the default voice would spell those /// letters out, while a Russian voice reads the English words well enough. Ties go to the script /// listed first. pub fn detect_language(text: &str) -> Option<&'static str> { // Letters per script: Cyrillic, Greek, Arabic, Hebrew, Thai, Hangul, kana, Han, Latin and others. let mut n = [0usize; 10]; let mut ukrainian = false; for c in text.chars().filter(|c| c.is_alphabetic()) { let i = match c { '\u{0400}'..='\u{052F}' | '\u{1C80}'..='\u{1C8F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}' => { ukrainian |= matches!(c, 'і' | 'ї' | 'є' | 'ґ' | 'І' | 'Ї' | 'Є' | 'Ґ'); 0 } '\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}' => 1, '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => 2, '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => 3, '\u{0E00}'..='\u{0E7F}' => 4, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}' => 5, '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' => 6, '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{2FFFF}' => 7, _ => 8, }; if let Some(k) = n.get_mut(i) { *k += 1; } } // Japanese text mixes kana and Han letters; any kana makes it Japanese. if n[6] > 0 { n[6] += n[7]; n[7] = 0; } // The first script with the most letters (`max_by_key` would keep the last of a tie). let (best, count) = n.iter().take(8).copied().enumerate().fold((0, 0), |b, (i, k)| if k > b.1 { (i, k) } else { b }); let total: usize = n.iter().sum(); if count == 0 || count.saturating_mul(5) < total { return None; } Some(match best { 0 if ukrainian => "uk", 0 => "ru", 1 => "el", 2 => "ar", 3 => "he", 4 => "th", 5 => "ko", 6 => "ja", _ => "zh", }) } /// A language tag that is safe to hand to a speech program or put in a script: letters, digits /// and hyphens only, starting with a letter, at most 12 characters (a tag from a document is /// hostile input). fn plain_tag(tag: &str) -> Option<&str> { let t = tag.trim(); (!t.is_empty() && t.len() <= 12 && t.starts_with(|c: char| c.is_ascii_alphabetic()) && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) .then_some(t) } /// The script group a language is written in, for the languages [`detect_language`] can tell /// apart by script (the primary subtag in, the detected tag out). fn script_language(primary: &str) -> Option<&'static str> { Some(match primary { "ru" | "uk" | "be" | "bg" | "sr" | "mk" | "kk" | "ky" | "mn" | "tg" => "ru", "el" => "el", "ar" | "fa" | "ur" | "ps" | "ku" | "sd" | "ug" => "ar", "he" | "yi" => "he", "th" => "th", "ko" => "ko", "ja" => "ja", "zh" => "zh", _ => return None, }) } /// The language to speak `text` in: the document's `explicit` tag when it fits the text, else the /// language of its script, else `None` (the default voice). When the text is in a script /// [`detect_language`] recognises, a tag written in another script (the `en-US` Word writes on /// every run, on Cyrillic text) gives way to the script, or the voice would spell the letters out; /// right-to-left text tries the run's complex-script tag (`w:bidi`, e.g. `fa-IR`) first, and Han /// letters alone keep a Japanese or Korean tag. Latin text keeps any tag, as in Word, which reads /// text in its proofing language: English words in a run tagged `ru-RU` get the Russian voice. pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -> Option { let explicit = explicit.and_then(plain_tag); let Some(d) = detect_language(text) else { return explicit.map(str::to_string) }; let fits = |tag: &&str| { let primary = tag.split('-').next().unwrap_or("").to_ascii_lowercase(); let script = script_language(&primary); script == script_language(d) || (d == "zh" && matches!(script, Some("ja" | "ko"))) }; let bidi = bidi.and_then(plain_tag).filter(|_| matches!(d, "ar" | "he")); Some(bidi.into_iter().chain(explicit).find(fits).unwrap_or(d).to_string()) } /// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags /// without a two- or three-letter language, such as Word's `x-none`. #[cfg(any(unix, windows, test))] fn primary_tag(tag: &str) -> Option { let t = plain_tag(tag)?; t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase) } /// The language to ask the speech program for, or `None` to keep its default voice: only when /// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already. #[cfg(any(unix, test))] fn voice_language(lang: Option<&str>, default: &str) -> Option { lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default)) } /// The arguments of `espeak-ng`/`espeak`: the speed, the voice for `lang` unless it's English /// (espeak's default voice), and `--stdin`. #[cfg(any(all(unix, not(target_os = "macos")), test))] fn espeak_args(wpm: &str, lang: Option<&str>) -> Vec { let mut a = vec!["-s".to_string(), wpm.to_string()]; if let Some(p) = voice_language(lang, "en") { a.push("-v".into()); a.push(p); } a.push("--stdin".into()); a } /// The voices in `say -v '?'`'s `listing`: each line is `Name ll_RR # sample sentence`, and /// names may contain spaces. Gives (name, lowercased `ll_rr` code) pairs. #[cfg(any(target_os = "macos", test))] fn mac_voices(listing: &str) -> impl Iterator + '_ { listing.lines().filter_map(|line| { let line = line.split('#').next().unwrap_or(""); let words: Vec<&str> = line.split_whitespace().collect(); let at = words.iter().skip(1).position(|w| { let mut p = w.split('_'); p.next().is_some_and(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_lowercase())) && p.next().is_some_and(|r| !r.is_empty()) })? + 1; Some((words.get(..at)?.join(" "), words.get(at)?.to_ascii_lowercase())) }) } /// macOS's novelty voices (sound effects and singing), never chosen to read a document. #[cfg(any(target_os = "macos", test))] const MAC_NOVELTY_VOICES: &[&str] = &[ "Albert", "Bad News", "Bahh", "Bells", "Boing", "Bubbles", "Cellos", "Deranged", "Good News", "Hysterical", "Jester", "Organ", "Pipe Organ", "Superstar", "Trinoids", "Whisper", "Wobble", "Zarvox", ]; /// The voice `say` should use for `lang`, or `None` to keep the system voice: `None` when the /// system voice already speaks the language (`default`, a primary subtag) or no voice does, else /// the first voice in `listing` for the language, one of the same region first, never a novelty voice. #[cfg(any(target_os = "macos", test))] fn mac_voice(lang: &str, listing: &str, default: &str) -> Option { let primary = voice_language(Some(lang), default)?; let tag = plain_tag(lang)?.replace('-', "_").to_ascii_lowercase(); let mut first = None; for (name, code) in mac_voices(listing) { if code.split('_').next() != Some(primary.as_str()) || MAC_NOVELTY_VOICES.contains(&name.as_str()) { continue; } if code == tag { return Some(name); } first.get_or_insert(name); } first } /// The language (primary subtag) of the system voice: the `selected` voice's in `listing`, else /// the `locale`'s (`ru_RU`, `ru_RU.UTF-8`), else English. #[cfg(any(target_os = "macos", test))] fn mac_default_language(selected: Option<&str>, listing: &str, locale: Option<&str>) -> String { let voice = selected .and_then(|v| mac_voices(listing).find(|(name, _)| name == v.trim())) .and_then(|(_, code)| code.split('_').next().map(str::to_string)); let locale = || locale.and_then(|l| l.split(['_', '-', '.', '@']).next()).and_then(primary_tag); voice.or_else(locale).unwrap_or_else(|| "en".into()) } /// The language of the voice `say` uses by default (asked once). #[cfg(target_os = "macos")] fn mac_default_voice_language() -> &'static str { static LANG: std::sync::OnceLock = std::sync::OnceLock::new(); LANG.get_or_init(|| { let read = |args: &[&str]| { std::process::Command::new("defaults") .args(args) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) }; let voice = read(&["read", "com.apple.speech.voice.prefs", "SelectedVoiceName"]); let locale = read(&["read", "-g", "AppleLocale"]).or_else(|| std::env::var("LANG").ok()); mac_default_language(voice.as_deref(), mac_voice_listing(), locale.as_deref()) }) } #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] mod ssip; /// What Read Aloud says when the system has none of the speech programs it knows. #[cfg(not(target_arch = "wasm32"))] const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) { "Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages" } else { "Read Aloud couldn't find the system's speech program" }; /// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's /// normal speed: the slowest speed is -50 and the fastest 100. #[cfg(any(all(unix, not(target_os = "macos")), test))] fn spd_rate(rate: f32) -> i32 { let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 }; scaled.round().clamp(-100.0, 100.0) as i32 } /// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it /// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is /// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too. /// `None` when Speech Dispatcher isn't installed. #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option { let stream = match ssip::connect(changed) { ssip::Connection::Open(s) => s, ssip::Connection::Missing => return None, ssip::Connection::Interrupted => return Some(Spoken::Interrupted), ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))), }; let lang = dispatcher_language(lang); let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() }; Some(dispatcher_spoken(ssip::converse(&stream, &req, changed))) } /// How a sentence Speech Dispatcher handled ended, for the worker. #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] fn dispatcher_spoken(outcome: std::io::Result) -> Spoken { match outcome { Ok(ssip::Outcome::Done) => Spoken::Done, Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted, Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()), Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()), Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")), } } /// Why reading paused when another program cancelled or paused the sentence. #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] const HALTED_BY_OTHER: &str = "Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on"; /// Why reading paused when Speech Dispatcher never began the sentence. #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] const NOT_STARTED: &str = "Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again"; #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] thread_local! { /// Neither espeak command was found during this reading (the worker thread lives as long as /// one reading): the next sentences go straight to Speech Dispatcher. static NO_ESPEAK: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region, /// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`), /// when it names a language (Word's `x-none` doesn't). #[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))] fn dispatcher_language(lang: Option<&str>) -> Option { let tag = lang.and_then(plain_tag)?; let primary = primary_tag(tag)?; let region = tag .split('-') .skip(1) .take(2) .find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit()))); Some(match region { Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()), None => primary, }) } /// Should `speak` say the sentence again with the default voice? When it gave the program a voice /// and the program then exited with a failure: espeak-ng does that, silently, for a voice it /// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the /// program didn't exit on its own (stopped, or never started). #[cfg(any(not(target_arch = "wasm32"), test))] fn retry_without_voice(gave_voice: bool, exited_ok: Option) -> bool { gave_voice && exited_ok == Some(false) } /// The names `say` can voice, as `say -v '?'` lists them (asked once). #[cfg(target_os = "macos")] fn mac_voice_listing() -> &'static str { static LISTING: std::sync::OnceLock = std::sync::OnceLock::new(); LISTING.get_or_init(|| { std::process::Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default() }) } /// Sentence spans in `text` as char index ranges, without surrounding whitespace. fn split_sentences(text: &str) -> Vec<(usize, usize)> { let chars: Vec = text.chars().collect(); let mut out = Vec::new(); let mut start = 0; let mut i = 0; while i < chars.len() { let c = chars.get(i).copied().unwrap_or(' '); let mut end = None; if matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?') { // Include closing quotes and brackets. let mut j = i + 1; while chars.get(j).is_some_and(|c| matches!(c, '"' | '\'' | '”' | '’' | ')' | ']' | '»' | '.' | '!' | '?')) { j += 1; } let at_end = j >= chars.len(); let space_after = chars.get(j).is_some_and(|c| c.is_whitespace()); if (at_end || space_after) && (c != '.' || !is_abbreviation(&chars, start, i)) { end = Some(j); } i = j.max(i + 1); } else { i += 1; } // Very long sentences are split at a space so skipping stays fine-grained. if end.is_none() && i - start >= MAX_SENTENCE && chars.get(i).is_some_and(|c| c.is_whitespace()) { end = Some(i); } if let Some(e) = end { push_trimmed(&chars, start, e, &mut out); start = e; } } push_trimmed(&chars, start, chars.len(), &mut out); out } fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize, usize)>) { while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { a += 1; } while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) { b -= 1; } // No space to split at (a long URL, a run of letters or full stops): cut the span hard. while b - a > MAX_UTTERANCE { out.push((a, a + MAX_UTTERANCE)); a += MAX_UTTERANCE; while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { a += 1; } } if a < b { out.push((a, b)); } } /// Does the full stop at `dot` end an abbreviation ("et al.", "Fig.", "J. Smith") rather than a sentence? fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool { let mut w = dot; while w > start && chars.get(w - 1).is_some_and(|c| c.is_alphanumeric() || *c == '.') { w -= 1; } let word: String = chars.get(w..dot).unwrap_or(&[]).iter().collect::().to_lowercase(); if word.is_empty() { return false; } // An initial: "J. Smith", "A. B. Jones". let letters: Vec = word.chars().filter(|c| *c != '.').collect(); if letters.len() == 1 && letters.first().is_some_and(|c| c.is_alphabetic()) { return true; } ABBREVIATIONS.contains(&word.as_str()) } /// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".". /// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it /// and a space) removes the pair, so nested empty brackets "( [ ] )" go too. fn tidy(s: &str) -> String { let mut t = String::with_capacity(s.len()); for c in s.chars() { if c.is_whitespace() { if !t.is_empty() && !t.ends_with(' ') { t.push(' '); } continue; } let open = match c { ')' => Some('('), ']' => Some('['), _ => None, }; if let Some(open) = open { let body = t.strip_suffix(' ').unwrap_or(&t); if let Some(rest) = body.strip_suffix(open) { let keep = rest.len(); t.truncate(keep); continue; } } if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') { t.pop(); } t.push(c); } if t.ends_with(' ') { t.pop(); } t } /// Reading state. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum State { Stopped, Playing, Paused, } impl State { pub fn name(self) -> &'static str { match self { State::Stopped => "stopped", State::Playing => "playing", State::Paused => "paused", } } } /// How sentences are spoken. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Backend { /// The system's speech program. System, /// Silent; a sentence lasts until something interrupts it (tests, `WORDCRAFT_SPEECH=off`). Hold, /// Silent; every sentence finishes at once (tests). Instant, } struct Shared { queue: Vec, index: usize, state: State, rate: f32, /// Bumped on every change that should stop the sentence being spoken. generation: u64, error: Option, /// A worker thread is running (cleared under the lock as it exits). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] running: bool, /// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new /// Read Aloud), and what it learnt about the system's speech programs is per reading. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] readings: u64, } /// The Read Aloud player. pub struct ReadAloud { shared: Arc<(Mutex, Condvar)>, pub backend: Backend, /// Leave citations and bibliographies out of the next reading. pub skip_citations: bool, /// The player window is open (it stays open after the last sentence until closed). pub open: bool, /// The document being read ([`crate::Session::document_id`]): the sentences' positions are /// in it. pub document: Option, } impl Default for ReadAloud { fn default() -> Self { 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, readings: 0, }), Condvar::new(), )), backend: if off { Backend::Hold } else { Backend::System }, skip_citations: true, open: false, document: None, } } } fn lock(m: &Mutex) -> MutexGuard<'_, Shared> { m.lock().unwrap_or_else(PoisonError::into_inner) } impl ReadAloud { /// Change the shared state, stop the current sentence and wake the worker. fn change(&mut self, f: impl FnOnce(&mut Shared)) { { let (m, cv) = &*self.shared; let mut s = lock(m); f(&mut s); s.generation = s.generation.wrapping_add(1); cv.notify_all(); } self.ensure_worker(); } /// Start reading `queue` from its first sentence. pub fn start(&mut self, queue: Vec) { self.open = true; self.change(|s| { s.state = if queue.is_empty() { State::Stopped } else { State::Playing }; s.queue = queue; s.index = 0; s.error = None; s.readings = s.readings.wrapping_add(1); }); } /// Pause, or carry on (a finished reading starts again from the top). pub fn play_pause(&mut self) { self.change(|s| { s.state = match s.state { State::Playing => State::Paused, _ if s.queue.is_empty() => State::Stopped, _ => { if s.index >= s.queue.len() { s.index = 0; } s.error = None; State::Playing } }; }); } /// Move by `delta` sentences; a stopped player starts playing there. pub fn skip(&mut self, delta: i64) { self.change(|s| { let last = s.queue.len().saturating_sub(1) as i64; let at = (s.index.min(s.queue.len()) as i64).saturating_add(delta).clamp(0, last.max(0)); s.index = at as usize; if s.state == State::Stopped && !s.queue.is_empty() { s.state = State::Playing; } }); } /// Set the speed (1.0 = normal); the current sentence restarts at the new speed. pub fn set_rate(&mut self, rate: f32) { let r = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 }; let playing = self.state() == State::Playing; if playing { self.change(|s| s.rate = r); } else { lock(&self.shared.0).rate = r; } } /// Stop reading and close the player. pub fn stop(&mut self) { self.open = false; self.change(|s| s.state = State::Stopped); } pub fn state(&self) -> State { lock(&self.shared.0).state } pub fn rate(&self) -> f32 { lock(&self.shared.0).rate } /// The sentence being read (or paused on). pub fn current(&self) -> Option { let s = lock(&self.shared.0); if s.state == State::Stopped { None } else { s.queue.get(s.index).cloned() } } /// (index, number of sentences). pub fn progress(&self) -> (usize, usize) { let s = lock(&self.shared.0); (s.index, s.queue.len()) } /// The last speech error (no speech program…), if any. pub fn error(&self) -> Option { lock(&self.shared.0).error.clone() } pub fn status(&self) -> Value { let s = lock(&self.shared.0); let current = if s.state == State::Stopped { None } else { s.queue.get(s.index) }; json!({ "state": s.state.name(), "open": self.open, "index": s.index, "count": s.queue.len(), "rate": s.rate, "skipCitations": self.skip_citations, "current": current.map(Utterance::to_json), "error": s.error, }) } #[cfg(target_arch = "wasm32")] fn ensure_worker(&mut self) { let (m, _) = &*self.shared; let mut s = lock(m); if s.state == State::Playing && self.backend == Backend::System { s.state = State::Stopped; s.error = Some("Read Aloud isn't available in the browser".into()); } } #[cfg(not(target_arch = "wasm32"))] fn ensure_worker(&mut self) { let mut s = lock(&self.shared.0); if s.state != State::Playing || s.running { return; } s.running = true; let shared = Arc::clone(&self.shared); let backend = self.backend; if let Err(e) = std::thread::Builder::new().name("read-aloud".into()).spawn(move || worker(&shared, backend)) { s.running = false; s.state = State::Stopped; s.error = Some(format!("couldn't start Read Aloud: {e}")); } } } impl Drop for ReadAloud { fn drop(&mut self) { let (m, cv) = &*self.shared; let mut s = lock(m); s.state = State::Stopped; s.generation = s.generation.wrapping_add(1); cv.notify_all(); } } /// How a sentence ended. #[cfg(not(target_arch = "wasm32"))] enum Spoken { Done, Interrupted, /// Another program stopped the sentence: reading pauses on it, saying why. #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] Halted(String), Failed(String), } #[cfg(not(target_arch = "wasm32"))] fn worker(shared: &(Mutex, Condvar), backend: Backend) { let (m, cv) = shared; let mut reading = None; loop { let (text, lang, generation, rate, readings) = { let mut s = lock(m); while s.state == State::Paused { s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); } if s.state == State::Stopped { s.running = false; return; } match s.queue.get(s.index) { Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings), None => { s.state = State::Stopped; s.running = false; return; } } }; // A new reading asks the system afresh which speech programs it has. if reading != Some(readings) { reading = Some(readings); #[cfg(all(unix, not(target_os = "macos")))] NO_ESPEAK.set(false); } let changed = || lock(m).generation != generation; let outcome = match backend { Backend::System => speak(&text, lang.as_deref(), rate, &changed), Backend::Instant => Spoken::Done, Backend::Hold => { let mut s = lock(m); while s.generation == generation { s = cv.wait(s).unwrap_or_else(PoisonError::into_inner); } Spoken::Interrupted } }; let mut s = lock(m); if s.generation != generation { continue; } match outcome { Spoken::Done => { s.index += 1; if s.index >= s.queue.len() { s.state = State::Stopped; s.running = false; return; } } Spoken::Interrupted => {} Spoken::Halted(why) => { s.state = State::Paused; s.error = Some(why); } Spoken::Failed(e) => { s.state = State::Stopped; s.error = Some(e); s.running = false; return; } } } } /// Windows PowerShell by its full path, `\System32\WindowsPowerShell\v1.0\powershell.exe` /// (`C:\Windows` when `SystemRoot` is unset), rather than whichever `powershell` the program search /// finds first. #[cfg(any(target_os = "windows", test))] fn windows_powershell(system_root: Option) -> std::path::PathBuf { let root = system_root.filter(|r| !r.is_empty()).unwrap_or_else(|| "C:\\Windows".into()); std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe") } /// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window. #[cfg(any(target_os = "windows", test))] const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and /// its process creation flags. `speak` builds its `Command` from exactly this; std can't read /// creation flags back from a `Command`, so the tests check this value instead. #[cfg(any(target_os = "windows", test))] struct WindowsSpeech { program: std::path::PathBuf, args: [String; 4], creation_flags: u32, } #[cfg(any(target_os = "windows", test))] fn windows_speech(system_root: Option, 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; // 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 // the quote. let voice = lang.and_then(primary_tag).map_or_else(String::new, |p| { format!( "if ($s.Voice.Culture.TwoLetterISOLanguageName -ne '{p}') {{ $v = $s.GetInstalledVoices() | Where-Object {{ $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{p}' }} | Select-Object -First 1; if ($v) {{ $s.SelectVoice($v.VoiceInfo.Name) }} }}; " ) }); // The sentence arrives on stdin as UTF-8 bytes; `[Console]::In` would decode them with the // console's code page (Cyrillic became `?` and the voice read single letters), so read the raw // stream and decode it as UTF-8. The text itself is never part of the script. 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, } } /// Speak one sentence with the system's speech program, stopping early when `changed()`. When /// the program fails with the sentence's voice, the default voice says it instead. #[cfg(not(target_arch = "wasm32"))] fn speak(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken { let (spoken, gave_voice, exited_ok) = speak_with(text, lang, rate, changed); if retry_without_voice(gave_voice, exited_ok) { return speak_with(text, None, rate, changed).0; } spoken } /// [`speak`] in `lang`'s voice once: how it ended, whether the program was given a voice, and /// whether it exited successfully (`None` when it didn't exit on its own). #[cfg(not(target_arch = "wasm32"))] fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> (Spoken, bool, Option) { use std::io::Write; use std::process::{Command, Stdio}; let wpm = ((BASE_WPM * rate).round() as i64).to_string(); // The text goes in on stdin so it can never be taken for an option. Each try is a program // and whether it was given a voice. let mut tries: Vec<(Command, bool)> = Vec::new(); #[cfg(target_os = "macos")] { let mut c = Command::new("say"); c.args(["-r", &wpm]); let voice = lang.and_then(|l| mac_voice(l, mac_voice_listing(), mac_default_voice_language())); if let Some(voice) = &voice { c.args(["-v", voice.as_str()]); } tries.push((c, voice.is_some())); } #[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())); } #[cfg(all(unix, not(target_os = "macos")))] { let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] }; for prog in programs { let args = espeak_args(&wpm, lang); let voiced = args.iter().any(|a| a == "-v"); let mut c = Command::new(prog); c.args(args); tries.push((c, voiced)); } } let _ = (&wpm, lang); let mut child = None; let mut not_found = true; let mut last_err = String::from("no speech program on this system"); for (mut c, voiced) in tries { match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() { Ok(ch) => { child = Some((ch, voiced)); break; } Err(e) => { not_found &= e.kind() == std::io::ErrorKind::NotFound; last_err = e.to_string(); } } } let Some((mut child, voiced)) = child else { // Speech Dispatcher, which many desktops install without the espeak command. #[cfg(all(unix, not(target_os = "macos")))] if not_found { NO_ESPEAK.set(true); if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) { return (spoken, false, None); } } let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") }; return (Spoken::Failed(message), false, None); }; if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(text.as_bytes()); // Dropping stdin closes it, which tells the program the text is complete. } loop { match child.try_wait() { Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())), Ok(None) => {} Err(e) => return (Spoken::Failed(e.to_string()), voiced, None), } if changed() { let _ = child.kill(); let _ = child.wait(); return (Spoken::Interrupted, voiced, None); } std::thread::sleep(std::time::Duration::from_millis(30)); } } #[cfg(test)] mod tests;