collective

Browse / github.com/storytold/wordcraft / patch

speech: Read Aloud on Linux falls back to Speech Dispatcher

speech: Read Aloud on Linux falls back to Speech Dispatcher Supersedes #2934 (same commits, rebased on current `main`; the review history, including @FredApps's requested changes, is there). The voice picker (#2937) and the browser player (#2978) build on this. ## What changes On Linux, Read Aloud only tried `espeak-ng` and `espeak`. Many desktops ship Speech Dispatcher but not those commands, and Read Aloud stopped at the first sentence with an OS error. - **Speech Dispatcher fallback.** Without `espeak-ng`/`espeak`, Read Aloud talks to Speech Dispatcher over its socket (SSIP, `crates/engine/src/speech/ssip.rs`). It finds the server the way libspee

Status
open
Score
0.00
Adopters
0 other operators
Author
Skaywalker (imported from pull request #4576, open)
Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750
Size
+1224 −19 in 8 files
Review
pending
Shared
2026-10-28
Id
eae4d5f699688b45c9461df7

How it went

No other operator has reported using it yet.

Diff

raw · README.md ROADMAP.md crates/engine/src/speech.rs crates/engine/src/speech/ssip.rs crates/engine/src/speech/tests.rs crates/ui-egui/src/read_aloud.rs docs/hardware-parity.md packaging/linux/nfpm.yaml

diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -285,6 +285,14 @@ adapter. The log file records the choice (lines starting with RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+ (Ubuntu 24.04, Debian 13 and newer). +**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it+tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.+The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or+`speech-dispatcher`, so the package manager installs one of the two by default. With the+AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,+`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has+no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.+ **Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because winit (the windowing library) supports dragging files into the window only on X11. To use the native Wayland backend instead (for example for sharper text with fractional scaling, at the costdiff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -4,7 +4,7 @@  **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: Linux Read Aloud through Speech Dispatcher, #2934; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -200,6 +200,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).  | Date | What landed |+| 2026-10-21 | Read Aloud on Linux speaks through Speech Dispatcher when neither `espeak-ng` nor `espeak` is installed (#2934). It talks to the server over its own socket (SSIP), so the text is never taken for a command and stopping cancels only WordCraft's own speech, not a screen reader's; each sentence goes in its language, region included, and a sentence a screen reader's announcement cuts off is said again after a short pause; if another program keeps stopping it, reading pauses on it with a message. With no speech program at all, Read Aloud names the package to install, and the `.deb` and `.rpm` packages recommend one. | | 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | | 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only |@@ -643,6 +644,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps  | Date | Change | Summary | |---|---|---|+| 2026-10-21 | trivial | Progress log: Linux Read Aloud through Speech Dispatcher (#2934) | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window |diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs--- a/crates/engine/src/speech.rs+++ b/crates/engine/src/speech.rs@@ -10,12 +10,15 @@ //! 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.+//! 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`-//! / `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.+//! 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}; @@ -372,6 +375,90 @@ fn mac_default_voice_language() -> &'static str {     }) } +#[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<Spoken> {+    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<ssip::Outcome>) -> 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<bool> = 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<String> {+    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@@ -541,6 +628,10 @@ struct Shared {     /// 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.@@ -561,7 +652,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,+                    error: None,+                    running: false,+                    readings: 0,+                }),                 Condvar::new(),             )),             backend: if off { Backend::Hold } else { Backend::System },@@ -597,6 +697,7 @@ impl ReadAloud {             s.queue = queue;             s.index = 0;             s.error = None;+            s.readings = s.readings.wrapping_add(1);         });     } @@ -610,6 +711,7 @@ impl ReadAloud {                     if s.index >= s.queue.len() {                         s.index = 0;                     }+                    s.error = None;                     State::Playing                 }             };@@ -723,14 +825,18 @@ impl Drop for ReadAloud { 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<Shared>, Condvar), backend: Backend) {     let (m, cv) = shared;+    let mut reading = None;     loop {-        let (text, lang, generation, rate) = {+        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);@@ -740,7 +846,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.readings),                 None => {                     s.state = State::Stopped;                     s.running = false;@@ -748,6 +854,12 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {                 }             }         };+        // 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),@@ -774,6 +886,10 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {                 }             }             Spoken::Interrupted => {}+            Spoken::Halted(why) => {+                s.state = State::Paused;+                s.error = Some(why);+            }             Spoken::Failed(e) => {                 s.state = State::Stopped;                 s.error = Some(e);@@ -876,7 +992,8 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b     }     #[cfg(all(unix, not(target_os = "macos")))]     {-        for prog in ["espeak-ng", "espeak"] {+        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);@@ -886,6 +1003,7 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b     }     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() {@@ -893,11 +1011,23 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b                 child = Some((ch, voiced));                 break;             }-            Err(e) => last_err = e.to_string(),+            Err(e) => {+                not_found &= e.kind() == std::io::ErrorKind::NotFound;+                last_err = e.to_string();+            }         }     }     let Some((mut child, voiced)) = child else {-        return (Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")), false, None);+        // 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());diff --git a/crates/engine/src/speech/ssip.rs b/crates/engine/src/speech/ssip.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/speech/ssip.rs@@ -0,0 +1,927 @@+//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have+//! Speech Dispatcher but not the `espeak-ng` command.+//!+//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the+//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a+//! document must never send commands), and stopping cancels only WordCraft's own speech+//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included.+//!+//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and+//! pause events, sets the rate and language, sends the text, and waits for the sentence's end+//! event. A sentence another client's message cancels (a screen reader's announcement) is sent+//! again after a pause, twice at most; a sentence cancelled once more or paused by another+//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began+//! ([`Outcome::NotStarted`]).++use std::io::{ErrorKind, Read, Write};+use std::net::{TcpStream, ToSocketAddrs};+use std::os::unix::net::UnixStream;+use std::path::PathBuf;+use std::time::{Duration, Instant};++/// How often a wait for the end event looks at whether reading moved on.+const POLL: Duration = Duration::from_millis(30);+/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one).+const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) };+/// Longest reply line kept (a server sends short lines; this bounds a broken one).+const MAX_LINE: usize = 4096;+/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none.+const DEFAULT_PORT: u16 = 6560;+/// How long a freshly started server gets to open its socket (it loads its speech modules first).+const START_WAIT: Duration = Duration::from_secs(5);+/// Longest wait for a TCP connection to one address.+const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);+/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each+/// character: far more than the slowest reading takes, so only a stuck server reaches it.+const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) };+const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400);+/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it+/// began, which sends no pause event).+const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) };+/// The pauses before a sentence another client cancelled is sent again: the other client's+/// message gets time to end, and the server holds ours back while a higher-priority one speaks.+const RESEND_AFTER: [Duration; 2] =+    if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] };++/// What to say and how.+pub(super) struct Request<'a> {+    pub text: &'a str,+    /// Speech Dispatcher's rate, -100 to 100.+    pub rate: i32,+    /// A plain language tag (`hu`, `pt-BR`); anything else is left out.+    pub lang: Option<&'a str>,+}++/// How a sentence ended.+#[derive(Debug, PartialEq, Eq)]+pub(super) enum Outcome {+    /// Spoken to the end.+    Done,+    /// Reading moved on: the sentence was cancelled.+    Interrupted,+    /// Another client stopped it: cancelled it again after it was sent again, or paused it.+    /// Reading halts on it instead of fighting the other client.+    Halted,+    /// The sentence never began: queued too long, behind other clients' speech, paused before it+    /// began (which sends no event), or with a speech module that doesn't start. Reading halts on it.+    NotStarted,+}++/// Where Speech Dispatcher listens.+#[derive(Debug, PartialEq, Eq)]+pub(super) enum Address {+    Unix(PathBuf),+    /// Host and port.+    Inet(String, u16),+}++/// The server's address, found the way Speech Dispatcher's own client library finds it:+/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket+/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder+/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that+/// isn't a number, or no folder to look in.+pub(super) fn address(spec: Option<&str>, runtime_dir: Option<PathBuf>, cache_home: Option<PathBuf>, home: Option<PathBuf>) -> Option<Address> {+    let default_socket = || {+        let set = |p: Option<PathBuf>| p.filter(|p| !p.as_os_str().is_empty());+        set(runtime_dir)+            .or_else(|| set(cache_home))+            .or_else(|| set(home).map(|h| h.join(".cache")))+            .map(|d| d.join("speech-dispatcher").join("speechd.sock"))+    };+    let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) };+    let (method, rest) = spec.split_once(':').unwrap_or((spec, ""));+    match method {+        "unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })),+        "inet_socket" => {+            let mut p = rest.split(':');+            let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1");+            let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?;+            Some(Address::Inet(host.to_string(), port))+        }+        _ => None,+    }+}++/// Is `host` this machine (the only place a server may be started)?+fn is_local(host: &str) -> bool {+    host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())+}++/// A connection to Speech Dispatcher.+pub(super) enum Stream {+    Unix(UnixStream),+    Tcp(TcpStream),+}++impl Stream {+    fn try_clone(&self) -> std::io::Result<Stream> {+        Ok(match self {+            Stream::Unix(s) => Stream::Unix(s.try_clone()?),+            Stream::Tcp(s) => Stream::Tcp(s.try_clone()?),+        })+    }++    /// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after+    /// [`REPLY_TIMEOUT`].+    fn set_timeouts(&self) -> std::io::Result<()> {+        match self {+            Stream::Unix(s) => {+                s.set_read_timeout(Some(POLL))?;+                s.set_write_timeout(Some(REPLY_TIMEOUT))+            }+            Stream::Tcp(s) => {+                s.set_read_timeout(Some(POLL))?;+                s.set_write_timeout(Some(REPLY_TIMEOUT))+            }+        }+    }+}++impl Read for Stream {+    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {+        match self {+            Stream::Unix(s) => s.read(buf),+            Stream::Tcp(s) => s.read(buf),+        }+    }+}++impl Write for Stream {+    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {+        match self {+            Stream::Unix(s) => s.write(buf),+            Stream::Tcp(s) => s.write(buf),+        }+    }+    fn flush(&mut self) -> std::io::Result<()> {+        match self {+            Stream::Unix(s) => s.flush(),+            Stream::Tcp(s) => s.flush(),+        }+    }+}++/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`.+fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result<Stream> {+    match address {+        Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix),+        Address::Inet(host, port) => {+            let mut last = std::io::Error::from(ErrorKind::NotFound);+            for a in (host.as_str(), *port).to_socket_addrs()? {+                if changed() {+                    return Err(moved_on());+                }+                match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) {+                    Ok(s) => return Ok(Stream::Tcp(s)),+                    Err(e) => last = e,+                }+            }+            Err(last)+        }+    }+}++/// How [`connect`] went.+pub(super) enum Connection {+    Open(Stream),+    /// Speech Dispatcher isn't installed.+    Missing,+    /// It is installed but can't be reached: why.+    Unreachable(String),+    /// Reading moved on while the server was starting.+    Interrupted,+}++/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say`+/// does), unless the address is another machine's.+pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection {+    let env = |k: &str| std::env::var_os(k).map(PathBuf::from);+    let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty());+    let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else {+        return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing };+    };+    match open(&address, changed) {+        Ok(s) => return Connection::Open(s),+        Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted,+        Err(_) => {}+    }+    let mut start = std::process::Command::new("speech-dispatcher");+    start.arg("--spawn");+    // A server started for an address of its own must open it there.+    match (&address, spec.is_some()) {+        (_, false) => {}+        (Address::Unix(path), true) => {+            start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path);+        }+        (Address::Inet(host, port), true) if is_local(host) => {+            start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]);+        }+        (Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()),+    }+    // `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way+    // to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy.+    let until = Instant::now() + START_WAIT;+    match start_server(&mut start, until, changed) {+        Started::Exited(true) => {}+        Started::Exited(false) => {+            return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into());+        }+        Started::Missing => return Connection::Missing,+        Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")),+        Started::Interrupted => return Connection::Interrupted,+        Started::TimedOut => {+            return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open);+        }+    }+    // The server opens its socket a moment after it starts.+    loop {+        if changed() {+            return Connection::Interrupted;+        }+        if let Ok(s) = open(&address, changed) {+            return Connection::Open(s);+        }+        if Instant::now() >= until {+            return Connection::Unreachable("it started but doesn't answer".into());+        }+        std::thread::sleep(Duration::from_millis(50));+    }+}++/// How running the server's start command went.+#[derive(Debug)]+enum Started {+    /// It exited, successfully or not.+    Exited(bool),+    /// The command isn't installed.+    Missing,+    /// It couldn't be run or waited for.+    Failed(std::io::Error),+    /// Reading moved on before it exited; it was stopped.+    Interrupted,+    /// It was still running at the deadline; it was stopped.+    TimedOut,+}++/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while+/// reading stays put; a command still running then is stopped and reaped.+fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started {+    use std::process::Stdio;+    let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {+        Ok(child) => child,+        Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing,+        Err(e) => return Started::Failed(e),+    };+    let stopped = loop {+        match child.try_wait() {+            Ok(Some(status)) => return Started::Exited(status.success()),+            Ok(None) if changed() => break Started::Interrupted,+            Ok(None) if Instant::now() >= until => break Started::TimedOut,+            Ok(None) => std::thread::sleep(Duration::from_millis(20)),+            Err(e) => break Started::Failed(e),+        }+    };+    let _ = child.kill();+    let _ = child.wait();+    stopped+}++/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway),+/// a leading `.` doubled so it can't end the message, and the terminating `.` line.+pub(super) fn message_data(text: &str) -> String {+    let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect();+    let dot = if line.starts_with('.') { "." } else { "" };+    format!("{dot}{line}\r\n.\r\n")+}++/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split+/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer.+struct Lines<R> {+    reader: R,+    pending: Vec<u8>,+}++impl<R: Read> Lines<R> {+    fn new(reader: R) -> Self {+        Lines { reader, pending: Vec::new() }+    }++    /// The next complete line without its line ending, or `Ok(None)` when none is complete after+    /// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading+    /// moved on between calls, however slowly the bytes trickle in.+    fn next(&mut self) -> std::io::Result<Option<String>> {+        if let Some(line) = self.complete_line() {+            return Ok(Some(line));+        }+        if self.pending.len() > MAX_LINE {+            return Err(ErrorKind::InvalidData.into());+        }+        let mut buf = [0u8; 512];+        match self.reader.read(&mut buf) {+            Ok(0) => Err(ErrorKind::UnexpectedEof.into()),+            Ok(n) => {+                self.pending.extend_from_slice(buf.get(..n).unwrap_or_default());+                Ok(self.complete_line())+            }+            Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None),+            Err(e) => Err(e),+        }+    }++    /// The first complete line in `pending`, taken out of it.+    fn complete_line(&mut self) -> Option<String> {+        let end = self.pending.iter().position(|&b| b == b'\n')?;+        let line: Vec<u8> = self.pending.drain(..=end).collect();+        Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string())+    }+}++/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a+/// continuation line (`225-12`).+fn final_code(line: &str) -> Option<u16> {+    let code = line.get(..3)?.parse().ok()?;+    (line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code)+}++/// A `2xx` reply, or an error naming the code.+fn ok(code: u16) -> std::io::Result<()> {+    if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) }+}++/// The error a conversation stops with when reading moved on.+fn moved_on() -> std::io::Error {+    std::io::Error::new(ErrorKind::Interrupted, "reading moved on")+}++/// The begin (701), end (702), cancel (703) and pause (704) events.+const BEGIN: u16 = 701;+const END: u16 = 702;+const CANCEL: u16 = 703;+const PAUSE: u16 = 704;++/// One conversation with the server.+struct Conversation<'a, W, R> {+    w: W,+    lines: Lines<R>,+    /// The last end, cancel or pause event, until it's taken.+    event: Option<u16>,+    /// The message just queued began.+    begun: bool,+    /// Whether reading moved on (checked between lines and at every read timeout).+    changed: &'a dyn Fn() -> bool,+}++impl<W: Write, R: Read> Conversation<'_, W, R> {+    /// The reply to the command just sent: its final code. An end, cancel or pause event that arrives+    /// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending+    /// other lines, and as soon as reading moves on.+    fn reply(&mut self) -> std::io::Result<u16> {+        let start = Instant::now();+        loop {+            if let Some(line) = self.lines.next()? {+                match final_code(&line) {+                    Some(BEGIN) => self.begun = true,+                    Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),+                    Some(code) if (700..800).contains(&code) => {}+                    Some(code) => return Ok(code),+                    None => {}+                }+            }+            if start.elapsed() > REPLY_TIMEOUT {+                return Err(ErrorKind::TimedOut.into());+            }+            if (self.changed)() {+                return Err(moved_on());+            }+        }+    }++    /// Send one command line and read its reply code.+    fn command(&mut self, cmd: &str) -> std::io::Result<u16> {+        self.w.write_all(cmd.as_bytes())?;+        self.w.write_all(b"\r\n")?;+        self.reply()+    }++    /// What became of the message just queued: its end, cancel or pause event, or+    /// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is+    /// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading+    /// moves on.+    fn fate(&mut self, limit: Duration) -> std::io::Result<Fate> {+        let queued = Instant::now();+        let mut began = self.begun.then(Instant::now);+        loop {+            match self.event.take() {+                Some(END) => return Ok(Fate::End),+                Some(CANCEL) => return Ok(Fate::Cancelled),+                Some(_) => return Ok(Fate::Paused),+                None => {}+            }+            if (self.changed)() {+                return Err(moved_on());+            }+            match began {+                None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted),+                Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")),+                _ => {}+            }+            if let Some(line) = self.lines.next()? {+                match final_code(&line) {+                    Some(BEGIN) => {+                        began.get_or_insert_with(Instant::now);+                    }+                    Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),+                    _ => {}+                }+            }+        }+    }++    /// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on.+    fn wait(&mut self, pause: Duration) -> std::io::Result<()> {+        let start = Instant::now();+        while start.elapsed() < pause {+            if (self.changed)() {+                return Err(moved_on());+            }+            self.lines.next()?;+        }+        Ok(())+    }++    /// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our+    /// own speech, and it stops waiting first, so a cancel or pause event comes from another+    /// client. A cancel is most often another client's message, a screen reader's announcement:+    /// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has+    /// ended or the server holds ours back until it has. Cancelled again after that, paused, or+    /// never begun, the sentence halts reading.+    fn speak(&mut self, req: &Request<'_>) -> std::io::Result<Outcome> {+        ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?;+        ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?;+        ok(self.command("SET SELF NOTIFICATION END on")?)?;+        ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?;+        ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?;+        ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?;+        // A language the server doesn't know leaves its default: no error. The tag is checked+        // again here, at the edge of the protocol: it goes on a command line.+        if let Some(lang) = req.lang.and_then(super::plain_tag) {+            self.command(&format!("SET SELF LANGUAGE {lang}"))?;+        }+        let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX);+        let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars));+        let mut resends = RESEND_AFTER.iter();+        loop {+            self.event = None;+            self.begun = false;+            ok(self.command("SPEAK")?)?;+            self.w.write_all(message_data(req.text).as_bytes())?;+            ok(self.reply()?)?;+            match self.fate(limit)? {+                Fate::End => return Ok(Outcome::Done),+                Fate::Cancelled => match resends.next() {+                    Some(pause) => self.wait(*pause)?,+                    None => return Ok(Outcome::Halted),+                },+                Fate::Paused => return Ok(Outcome::Halted),+                Fate::NotStarted => return Ok(Outcome::NotStarted),+            }+        }+    }+}++/// What became of a queued message.+enum Fate {+    End,+    Cancelled,+    Paused,+    NotStarted,+}++/// The last words after a sentence that didn't finish.+const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n";++/// Speak `req` over `stream`, cancelling it when `changed()`.+pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result<Outcome> {+    stream.set_timeouts()?;+    let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed };+    let result = c.speak(req);+    // The last words wait for no answer: a server that stopped answering mustn't hold up Stop.+    // After anything but a finished sentence our message may still be queued, or the server may+    // be reading message data: an empty line and `.` end a message being sent (outside one they+    // are two refused commands), and `CANCEL self` drops ours.+    let done = matches!(result, Ok(Outcome::Done));+    let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE });+    match result {+        Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted),+        r => r,+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use std::io::{BufRead, BufReader};++    #[test]+    fn the_address_follows_the_environment() {+        let unix = |p: &str| Some(Address::Unix(PathBuf::from(p)));+        let run = || Some(PathBuf::from("/run/user/1"));+        assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock"));+        assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));+        // `unix_socket` alone is the default socket, as in Speech Dispatcher's client library.+        assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));+        assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));+        // Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first.+        assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));+        assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock"));+        // A TCP address, with the default host and port filled in.+        assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561)));+        assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT)));+        assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT)));+        assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None);+        assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None);+        assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None);+        assert_eq!(address(None, None, None, None), None);+        assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1"));+        assert!(!is_local("example.org") && !is_local("192.0.2.1"));+    }++    #[test]+    fn document_text_is_always_message_data() {+        // `spd-say -e` would run this line as an SSIP command; here it's spoken text.+        assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n");+        // A line of a single `.` would end the message early, and a line break would start a+        // new line that could be one.+        assert_eq!(message_data(".\r\nQUIT"), "..  QUIT\r\n.\r\n");+        assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n");+        assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225));+        assert_eq!(final_code("225-17"), None);+        assert_eq!(final_code("x"), None);+    }++    /// Hands out the given reads one by one, then reports a timeout.+    struct Reads(std::collections::VecDeque<Vec<u8>>);++    impl Read for Reads {+        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {+            match self.0.pop_front() {+                Some(chunk) if !chunk.is_empty() => {+                    let n = chunk.len().min(buf.len());+                    buf[..n].copy_from_slice(&chunk[..n]);+                    if n < chunk.len() {+                        self.0.push_front(chunk[n..].to_vec());+                    }+                    Ok(n)+                }+                _ => Err(ErrorKind::WouldBlock.into()),+            }+        }+    }++    #[test]+    fn a_character_split_by_a_read_timeout_stays_whole() {+        // `é` is C3 A9; the read times out (the empty read) between its two bytes.+        let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into()));+        assert_eq!(lines.next().unwrap(), None, "half a line");+        assert_eq!(lines.next().unwrap(), None, "the timeout");+        assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone"));+        assert_eq!(lines.next().unwrap().as_deref(), Some("702 END"));+        assert_eq!(lines.next().unwrap(), None);+    }++    #[test]+    fn a_line_without_an_end_is_an_error_not_a_growing_buffer() {+        // A peer that never stops sending and never ends the line.+        let mut lines = Lines::new(std::io::repeat(b'x'));+        let mut calls = 0;+        let err = loop {+            calls += 1;+            assert!(calls < 100, "never gave up");+            match lines.next() {+                Ok(None) => {}+                Ok(Some(l)) => panic!("{l}"),+                Err(e) => break e,+            }+        };+        assert_eq!(err.kind(), ErrorKind::InvalidData);+        assert!(lines.pending.len() <= MAX_LINE + 512);+    }++    #[test]+    fn a_trickle_of_bytes_hands_control_back_after_each_read() {+        // One byte per read and never a line end: every call returns, so the caller can look at+        // its deadline and at whether reading moved on.+        let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into()));+        assert_eq!(lines.next().unwrap(), None);+        assert_eq!(lines.next().unwrap(), None);+        assert_eq!(lines.next().unwrap(), None);+        assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK"));+    }++    /// How the stand-in server ends a message.+    #[derive(Clone, Copy)]+    enum Ending {+        /// The end event.+        End,+        /// Nothing until `CANCEL self`.+        Hold,+        /// Cancelled by another client every time.+        CancelledByOther,+        /// Cancelled by another client the first time only.+        CancelledOnce,+        /// Queued and never begun (paused before it began: no event at all).+        NeverBegins,+        /// Paused by another client (`PAUSE all`).+        PausedByOther,+    }++    /// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as+    /// `ending` says. Returns the lines it received.+    fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle<Vec<String>> {+        std::thread::spawn(move || {+            let mut got = Vec::new();+            let mut r = BufReader::new(s.try_clone().unwrap());+            let mut in_data = false;+            let mut messages = 0;+            loop {+                let mut line = String::new();+                if r.read_line(&mut line).unwrap_or(0) == 0 {+                    return got;+                }+                let line = line.trim_end_matches(['\r', '\n']).to_string();+                got.push(line.clone());+                let reply: &[u8] = if in_data {+                    if line != "." {+                        continue;+                    }+                    in_data = false;+                    messages += 1;+                    match ending {+                        Ending::CancelledOnce if messages == 1 => {+                            b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n"+                        }+                        Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n",+                        Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n",+                        Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n",+                        Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n",+                        Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n",+                        Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n",+                    }+                } else if line == "SPEAK" {+                    in_data = true;+                    b"230 OK RECEIVING DATA\r\n"+                } else if line == "CANCEL self" {+                    b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n"+                } else if line == "QUIT" {+                    let _ = s.write_all(b"231 HAPPY HACKING\r\n");+                    return got;+                } else {+                    b"200 OK\r\n"+                };+                // The client doesn't wait for the answers to its last words.+                let _ = s.write_all(reply);+            }+        })+    }++    fn speaks(got: &[String]) -> usize {+        got.iter().filter(|l| *l == "SPEAK").count()+    }++    #[test]+    fn a_sentence_is_spoken_to_its_end() {+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::End);+        let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") };+        assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);+        let got = h.join().unwrap();+        assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}");+        // The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`).+        assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}");+        let at = got.iter().position(|l| l == "SPEAK").unwrap();+        assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data");+        assert_eq!(speaks(&got), 1);+        assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled");+        assert_eq!(got.last().map(String::as_str), Some("QUIT"));+    }++    #[test]+    fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() {+        // One announcement: the sentence is said again once it has had time to end.+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::CancelledOnce);+        let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };+        let start = Instant::now();+        assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);+        assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again");+        let got = h.join().unwrap();+        assert_eq!(speaks(&got), 2, "{got:?}");+        assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}");+        assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}");+    }++    #[test]+    fn a_sentence_another_client_keeps_stopping_halts_reading() {+        // Cancelled every time (another client stopping everything, a failing module): two+        // tries more, then reading halts, never moving on silently. A pause halts at once.+        for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] {+            let (client, srv) = UnixStream::pair().unwrap();+            let h = server(srv, ending);+            let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };+            assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted);+            let got = h.join().unwrap();+            assert_eq!(speaks(&got), sent, "{got:?}");+            assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}");+            // Only our own message is dropped.+            assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}");+        }+    }++    #[test]+    fn a_withheld_end_times_out_and_the_next_sentence_plays() {+        // The server accepts the sentence, begins it, stays connected and never reports its end:+        // the wait gives up with an error, drops the message (`CANCEL self`), and closes.+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::Hold);+        let req = Request { text: "Withheld.", rate: 0, lang: None };+        let start = Instant::now();+        let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err();+        assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}");+        assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());+        let got = h.join().unwrap();+        assert_eq!(speaks(&got), 1);+        assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}");+        // Playback recovers: the next sentence, on a new connection as every sentence gets,+        // is spoken to its end.+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::End);+        assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done);+        assert_eq!(speaks(&h.join().unwrap()), 1);+    }++    #[test]+    fn a_sentence_that_never_begins_halts_reading_instead_of_failing() {+        // `PAUSE all` before our message began sends no event: the time queued has its own+        // bound, and reaching it halts reading (Play carries on) rather than stopping with an error.+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::NeverBegins);+        let req = Request { text: "Queued behind another client.", rate: 0, lang: None };+        let start = Instant::now();+        assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted);+        assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());+        assert!(h.join().unwrap().contains(&"CANCEL self".to_string()));+    }++    #[test]+    fn a_sentence_that_never_ends_times_out() {+        // A stuck speech module: no end event ever. The wait is bounded by the sentence's length.+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::Hold);+        let req = Request { text: "x", rate: 0, lang: None };+        let start = Instant::now();+        assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);+        assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());+        assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped");+    }++    #[test]+    fn moving_on_cancels_only_our_own_speech() {+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::Hold);+        let req = Request { text: "Long sentence.", rate: 0, lang: None };+        let start = Instant::now();+        let changed = || start.elapsed() > Duration::from_millis(300);+        assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted);+        let got = h.join().unwrap();+        assert_eq!(speaks(&got), 1, "{got:?}");+        assert!(got.contains(&"CANCEL self".to_string()), "{got:?}");+        assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")));+        assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command");+    }++    #[test]+    fn moving_on_stops_waiting_for_a_slow_server() {+        // A server that reads but never answers: Stop acts at once, not after the reply timeout.+        let (client, mut srv) = UnixStream::pair().unwrap();+        let h = std::thread::spawn(move || {+            let mut all = String::new();+            let _ = srv.read_to_string(&mut all);+            all+        });+        let req = Request { text: "x", rate: 0, lang: None };+        let start = Instant::now();+        assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted);+        assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed());+        // Stopped while the server may be about to read message data: the empty line and `.` end+        // any message first, so `CANCEL self` is never taken for text.+        let all = h.join().unwrap();+        assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}");+    }++    #[test]+    fn a_server_that_only_sends_events_times_out() {+        // Events and continuation lines are no reply: they can't keep a command waiting forever.+        let (client, mut srv) = UnixStream::pair().unwrap();+        let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {});+        let req = Request { text: "x", rate: 0, lang: None };+        let start = Instant::now();+        let client = Stream::Unix(client);+        assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);+        assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed());+        drop(client);+        h.join().unwrap();+    }++    #[test]+    fn a_hostile_language_tag_is_never_sent() {+        let (client, srv) = UnixStream::pair().unwrap();+        let h = server(srv, Ending::End);+        let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") };+        assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);+        let got = h.join().unwrap();+        assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}");+    }++    #[test]+    fn a_server_that_hangs_up_is_an_error_not_a_hang() {+        let (client, mut srv) = UnixStream::pair().unwrap();+        let h = std::thread::spawn(move || {+            let mut buf = [0u8; 64];+            let _ = srv.read(&mut buf);+        });+        let req = Request { text: "x", rate: 0, lang: None };+        assert!(converse(&Stream::Unix(client), &req, &|| false).is_err());+        h.join().unwrap();+    }++    #[test]+    fn a_start_command_that_exits_reports_how() {+        let far = Instant::now() + Duration::from_secs(30);+        let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false);+        assert!(matches!(run("exit 0"), Started::Exited(true)));+        assert!(matches!(run("exit 1"), Started::Exited(false)));+        let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false);+        assert!(matches!(missing, Started::Missing), "{missing:?}");+    }++    /// A start command that never exits, writing its process id to `pid` first (whole, by renaming).+    fn hanging_start(pid: &std::path::Path) -> std::process::Command {+        let mut c = std::process::Command::new("sh");+        c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid);+        c+    }++    /// A fresh path for a start command's process id.+    fn pid_file(name: &str) -> std::path::PathBuf {+        let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id()));+        let _ = std::fs::remove_file(&p);+        let _ = std::fs::remove_file(p.with_extension("pid.part"));+        p+    }++    /// The process id the start command wrote (whole, or still in its `.part` file when it was+    /// stopped before renaming it); `None` when it was stopped before writing any.+    fn written_pid(pid: &std::path::Path) -> Option<String> {+        let part = pid.with_extension("pid.part");+        let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok();+        let _ = std::fs::remove_file(pid);+        let _ = std::fs::remove_file(part);+        id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty())+    }++    /// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still+    /// finds)?+    fn gone(id: &str) -> bool {+        let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap();+        !probe.success()+    }++    #[test]+    fn a_start_command_that_hangs_is_stopped_at_the_deadline() {+        // A start stopped before the stand-in wrote its id is tried again with a longer deadline+        // (a loaded machine may not run it at once); one that never wrote it left nothing to reap.+        for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] {+            let pid = pid_file("deadline");+            let begun = Instant::now();+            let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false);+            assert!(matches!(got, Started::TimedOut), "{got:?}");+            assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed());+            if let Some(id) = written_pid(&pid) {+                assert!(gone(&id), "the start command was reaped");+                return;+            }+        }+    }++    #[test]+    fn stop_ends_the_wait_for_a_start_command_at_once() {+        let pid = pid_file("stop");+        let begun = Instant::now();+        // Stop once the command is surely running.+        let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists());+        assert!(matches!(got, Started::Interrupted), "{got:?}");+        assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed());+        let id = written_pid(&pid).expect("stopped only once the id was written");+        assert!(gone(&id), "the start command was reaped");+    }+}diff --git a/crates/engine/src/speech/tests.rs b/crates/engine/src/speech/tests.rs--- a/crates/engine/src/speech/tests.rs+++ b/crates/engine/src/speech/tests.rs@@ -186,6 +186,76 @@ fn player_finishes_on_its_own() {     assert_eq!(r.state(), State::Playing); } +#[test]+fn every_reading_counts_as_a_new_one() {+    // The worker forgets what it learnt about the speech programs when a reading starts, even+    // when the same worker thread carries on (a pause, then Read Aloud again).+    let mut r = ReadAloud::default();+    r.backend = Backend::Hold;+    let readings = |r: &ReadAloud| lock(&r.shared.0).readings;+    let doc = Document::from_text("One. Two.");+    r.start(sentences(&doc, &Pos::body(0, 0), None, true));+    let first = readings(&r);+    r.play_pause();+    r.start(sentences(&doc, &Pos::body(0, 0), None, true));+    assert_eq!(readings(&r), first + 1);+    r.play_pause();+    r.play_pause();+    assert_eq!(readings(&r), first + 1, "pause and play are the same reading");+    r.stop();+}++#[cfg(all(unix, not(target_os = "macos")))]+#[test]+fn a_sentence_that_never_began_says_so_not_another_program() {+    // No begin event within the queue bound: no other program need be involved, so the+    // message names the sentence not starting, not another program stopping it.+    let halted = |o| match dispatcher_spoken(Ok(o)) {+        Spoken::Halted(m) => m,+        _ => String::new(),+    };+    assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED);+    assert!(!NOT_STARTED.contains("another program"));+    assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER);+}++#[test]+fn play_after_a_failed_sentence_plays_it_again() {+    // A sentence whose end never came stops reading with an error (as the worker does on a+    // timeout); Play starts it again and the message goes.+    let mut r = ReadAloud::default();+    r.backend = Backend::Hold;+    r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));+    r.skip(1);+    {+        let mut s = lock(&r.shared.0);+        s.state = State::Stopped;+        s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into());+    }+    r.play_pause();+    assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None));+    r.stop();+}++#[test]+fn play_after_a_halt_carries_on_and_clears_the_message() {+    // Another program stopped the sentence: reading paused on it with a message, and Play+    // goes on from the same sentence.+    let mut r = ReadAloud::default();+    r.backend = Backend::Hold;+    r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));+    {+        let mut s = lock(&r.shared.0);+        s.state = State::Paused;+        s.error = Some("stopped by another program".into());+    }+    r.play_pause();+    assert_eq!(r.state(), State::Playing);+    assert_eq!(r.progress().0, 0);+    assert_eq!(r.error(), None);+    r.stop();+}+ #[test] fn empty_queue_does_not_play() {     let mut r = ReadAloud::default();@@ -271,6 +341,38 @@ fn espeak_gets_the_language() {     assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]);     assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]);     assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]);+    assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]);+}++#[test]+fn speech_dispatcher_rate_follows_the_speed() {+    assert_eq!(spd_rate(1.0), 0);+    assert_eq!(spd_rate(MIN_RATE), -50);+    assert_eq!(spd_rate(MAX_RATE), 100);+    assert_eq!(spd_rate(2.0), 50);+    assert_eq!(spd_rate(10.0), 100);+    assert_eq!(spd_rate(0.0), -50);+    assert_eq!(spd_rate(f32::NAN), 0);+    assert_eq!(spd_rate(1.5), 25);+}++#[test]+fn speech_dispatcher_keeps_the_region() {+    let tag = |t| dispatcher_language(Some(t));+    assert_eq!(tag("en-GB").as_deref(), Some("en-GB"));+    assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR"));+    assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW"));+    assert_eq!(tag("hu").as_deref(), Some("hu"));+    assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU"));+    // SSIP takes language and region: script and variant subtags are left out.+    assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS"));+    assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW"));+    assert_eq!(tag("es-419").as_deref(), Some("es-419"));+    assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH"));+    assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr"));+    assert_eq!(dispatcher_language(Some("x-none")), None);+    assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None);+    assert_eq!(dispatcher_language(None), None); }  #[test]diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs--- a/crates/ui-egui/src/read_aloud.rs+++ b/crates/ui-egui/src/read_aloud.rs@@ -23,10 +23,7 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {     if ra.state() == State::Playing {         ctx.request_repaint_after(std::time::Duration::from_millis(120));     }-    if let Some(e) = app.session.read_aloud.error()-        && app.read_aloud_error.as_deref() != Some(e.as_str())-    {-        app.read_aloud_error = Some(e.clone());+    if let Some(e) = error_notice(&mut app.read_aloud_error, app.session.read_aloud.error()) {         app.status(e);     }     // The caret follows the reading only in the document being read.@@ -41,6 +38,23 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {     } } +/// The notice for the player's error `now`, given the one last `shown`: each message is shown+/// once, and when the error clears (Play after a halt) the last one is forgotten, so the same+/// message coming back is shown again.+fn error_notice(shown: &mut Option<String>, now: Option<String>) -> Option<String> {+    match now {+        None => {+            *shown = None;+            None+        }+        Some(e) if shown.as_deref() == Some(e.as_str()) => None,+        Some(e) => {+            *shown = Some(e.clone());+            Some(e)+        }+    }+}+ /// The page rectangles (in page units) of the sentence being read, to highlight. pub fn highlight(app: &mut WordApp) -> Option<(wordcraft_engine::doc::Pos, wordcraft_engine::doc::Pos)> {     if !app.session.read_aloud.open || !reads_shown_document(app) {@@ -170,3 +184,19 @@ fn button(ui: &mut Ui, t: &Tokens, icon: &str, tip: &str, enabled: bool) -> bool     crate::icons::paint(ui.painter(), r, icon, c, if enabled { t.accent } else { Color32::TRANSPARENT });     enabled && resp.clicked() }++#[cfg(test)]+mod tests {+    use super::error_notice;++    #[test]+    fn the_same_error_after_play_is_shown_again() {+        let mut shown = None;+        let halt = || Some("stopped for another program".to_string());+        assert_eq!(error_notice(&mut shown, halt()), halt(), "shown once");+        assert_eq!(error_notice(&mut shown, halt()), None, "not on every frame");+        // Play clears the error; the same halt later is a new notice.+        assert_eq!(error_notice(&mut shown, None), None);+        assert_eq!(error_notice(&mut shown, halt()), halt());+    }+}diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md--- a/docs/hardware-parity.md+++ b/docs/hardware-parity.md@@ -1,6 +1,6 @@ # Hardware parity with Microsoft Word -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** trivial (Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-26 · **Change:** trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again; previously trivial: Linux Read Aloud review fixes: Speech Dispatcher address, language region, pausing when another program stops the sentence; previously trivial: Linux Read Aloud falls back to Speech Dispatcher when neither espeak-ng nor espeak is installed, and names the program to install; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows  > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -17,7 +17,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. one- and two-stroke arrows with open or closed heads (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) | | Multiple monitors, per-monitor DPI, remembered window position, a window that fits its screen | ✅ | ✅ (#38, #76, #1215) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 | | Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) |-| Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 | 🟡 | 75% | 1–2 |+| Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 espeak-ng, then espeak, then Speech Dispatcher (over its socket), each sentence in its language's voice; packages recommend a speech program; silent in the Flatpak (no audio access, no speech program) | 🟡 | 75% | 1–2 | | Touch screens and touchpads: pinch zoom, inertial scroll | ✅ | ✅ pinch (#179) | ✅ | ✅ inertial scroll (#252) | ✅ | 85% | 0–1 | | Scanners/cameras (Insert from device / Continuity Camera on Mac) | ✅ | ❌ | ❌ | ❌ | ❌ | 0% | 2–4 | @@ -25,6 +25,9 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | trivial | Linux Read Aloud: a sentence another program's message cancels (a screen reader's announcement) is said again after a short pause, twice at most; cancelled again, paused by another program, or never begun, it pauses reading with a message |+| 2026-10-21 | trivial | Linux Read Aloud review fixes: Speech Dispatcher is found at `SPEECHD_ADDRESS` (`unix_socket`, `inet_socket`) or the default socket, gets the sentence's language and region, and a sentence another program cancels or pauses (a screen reader's message, a stop for every program) pauses reading on it with a message instead of being skipped or said over the other program |+| 2026-10-18 | trivial | Linux Read Aloud: Speech Dispatcher (over its socket, with the sentence's language; stopping cancels only WordCraft's own speech) when neither espeak-ng nor espeak is installed, a message naming the program to install when none is, and a speech program recommended by the `.deb` (`espeak-ng` or `speech-dispatcher`) and `.rpm` (`espeak-ng`) packages | | 2026-10-15 | trivial | Pen / stylus row: Ink to Shape also makes two-stroke arrows and arrows with a closed triangle head | | 2026-10-17 | minor | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` | | 2026-10-14 | trivial | Pen / stylus row corrected: ink, eraser and Ink to Shape (with arrows) work; pressure and Ink to Math missing |diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml--- a/packaging/linux/nfpm.yaml+++ b/packaging/linux/nfpm.yaml@@ -24,7 +24,8 @@ description: |-  # The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the # GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here-# rather than ELF NEEDED entries.+# rather than ELF NEEDED entries. Read Aloud speaks through espeak-ng (or Speech Dispatcher), a+# program it starts, so that is a recommendation too. overrides:   deb:     depends:@@ -41,6 +42,7 @@ overrides:       - libxrandr2       - mesa-vulkan-drivers       - xdg-desktop-portal+      - espeak-ng | speech-dispatcher   rpm:     depends:       - libxkbcommon@@ -53,6 +55,7 @@ overrides:       - libXrandr       - mesa-vulkan-drivers       - xdg-desktop-portal+      - espeak-ng  contents:   - src: ${STAGE}/usr/bin/wordcraft