Architecture
This guide explains how textweaver is put together: which crate does what, which way the crates depend on each other, which threads and processes run, and how a file on disk becomes a spoken word with its highlight. It is for contributors, and for anyone who wants to know why the highlight never drifts. Each part links to the decision record (ADR) that explains the choice, and to the crate that holds the code.
The interactive architecture page shows the same crate map: choose a crate to see its job, its ADRs, and what it depends on. The speech pipeline page steps through the data flow below one stage at a time.
The big picture
textweaver is one Cargo workspace with 39 crates and a maintenance crate, xtask. One program comes out of it, with a name for the terminal:
textweaver, built fromcrates/textweaver-xilem(itstextweaverbinary): the app by default, and the terminal program,crates/textweaver-cli's library, with--twor--clifirst, when started astw, or when the first argument is atwcommand (src/dispatch.rs). On its own or with a file the terminal program runs the terminal reader (the library incrates/textweaver-tui), and with a command it runs headless;tw, a link totextweaveron Linux and macOS, and on Windowstw.exe, built fromcrates/textweaver-launcher: a console program that runstextweaver.exe --twbeside it and waits, sincetextweaver.exeis a GUI-subsystem program that terminals do not wait for.textweaver-clialso builds a terminal-onlytwfor development and its tests, which no package ships.
The app is the GUI, on Xilem, Linebender's all-Rust toolkit (ADR-0027). The first GUI, a feasibility spike on wxDragon (ADR-0014), was removed once the Xilem GUI passed its second screen-reader test session.
Eloquence, SAPI5 voices, DECtalk, and on Windows eSpeak NG (Linux and macOS run eSpeak NG in process) run in engine host processes. Since beta 3 these are not separate programs (ADR-0007 addendum): textweaver starts a copy of itself with --engine-host <engine>, checked before anything else in main (textweaver_host::host_mode), and on Windows one 32-bit program, textweaver-host-x86.exe (the textweaver-host binary built for i686-pc-windows-msvc), runs every engine's 32-bit library. cargo xtask hosts builds it. Each engine crate still builds a separate development host (textweaver-eci-host and so on) for its own tests; the packages do not ship them. The eSpeak NG helper page explains how that host is built and why Windows prefers it.
Everything a user does goes through one application core, textweaver-app. The terminal reader, the GUI, and the JSON-RPC server (tw serve) are thin frontends over it. They turn keys or messages into commands, and draw or send what the core returns. Lists and prompts are the core's too: a frontend sends list and prompt keys, and the core moves the focus, filters, completes, and says "item, 3 of 12", the same everywhere.
The crates
The crates are grouped here by the part of the system they serve. For each crate: what it does, the ADRs that decided it, and the workspace crates it depends on (normal dependencies only; test-only dependencies are left out).
Foundation
textweaver-core: shared leaf types. Positions (CharPos,CharRange), units, marker kinds, theOffsetMap, edits and how positions move across them, rate, pitch, and volume, theUtterancehanded to speech, and small preference enums. Also the two helpers every crate shares:fs::write_atomic, the one atomic save (synced temp file, rename retried while Windows reports the file in use), andprocess, which finds programs onPATHby one rule, starts them without a console window, and decodes their output by one rule. ADRs: 0002, 0005. Depends on nothing in the workspace.textweaver-components: optional components (W8a-d): the models, fonts, and voices textweaver downloads only after the reader agrees. One pin type (size and SHA-256, or git's blob SHA-1), the one downloader (a.partfile in a staging folder, the check, then a rename; files that check out are kept, a stopped download resumes, a lock keeps two downloads of one component apart), install from a zip or a folder, a mirror's manifest of extra components, the fake fetcher for tests, and the neutral User-Agent. HTTP is itsdownloadfeature, so the lean reader links no HTTP client. Depends on no workspace crate; the OCR, fonts, and Piper crates and the app depend on it. See Optional components.
Documents
textweaver-text: the document model. ADocumentis canonical text in a rope plusMarkers. Units (grapheme, word, sentence, line, paragraph), navigation, go to, history, search, narration (narrate::plan), and the heading-anchor rule (slug) are pure functions of a document and a position. ADRs: 0002, 0005. Depends on core.textweaver-formats: loaders. Text, Markdown (with Obsidian's callouts, embeds, tags, and block ids), HTML, EPUB, DOCX, PDF, DAISY, PowerPoint, spreadsheets, archives, JSON and notebooks, SVG, MathML, LaTeX, email, and web pages, a registry that picks the loader by extension, a document cache, and exports to Markdown, HTML, and text. The loaders refuse binary files and limit nesting depth and counters, so a hostile file cannot crash a batch. Its defaultocrfeature adds OCR for scanned PDF pages and image files, throughtextweaver-ocrand its in-process ocrs engine; the smallerimagesfeature, whichocrturns on, gives the picture loader and the scanned-page path without that engine (Tesseract only), for the fuzz targets. An optionalpandocfeature adds the one Pandoc loader, sandboxed and with a timeout; onlytw convertregisters it. The optionalbibliographyfeature reads LaTeX bibliographies through cite; convert turns it on. It also holds the callout rules the renderer shares. ADRs: 0002, 0010, 0026, 0044. Depends on core and text, with its defaultocrfeature on ocr, and withbibliographyon cite.textweaver-ocr: OCR of scanned pages: the pure-Rustocrsengine in process by default, a Tesseract subprocess as the fallback, model downloads checked by SHA-256 (off by default;tw ocr downloadturns it on), and PDF page images decoded throughhayro. ADR: 0026. Depends on nothing in the workspace.textweaver-math: LaTeX and ASCIIMath parsed into one tree, written as MathML, spoken as English with an offset map, navigable part by part, and found in plain text without mistaking prices for math. ADR: 0018. Depends on core.textweaver-cite: the reference library, DOI and ISBN lookup, BibTeX, RIS, and CSL-JSON, CSL formatting, and Pandoc citation keys. ADR: 0019. Depends on core.
Speech
textweaver-speech: theSpeechBackendtrait, theSpeechServiceand its thread, the queue, pacing, the normalization pipeline, and the in-process backends (null,recording,espeak,omnivox,speechd), with the backend registry. Its optionalmathcatfeature routes math totextweaver-mathcatinstead oftextweaver-mathwhen the[reading] math_enginesetting asks for it. ADRs: 0003, 0004, 0005, 0029. Depends on core and math, and, with themathcatfeature, on mathcat.textweaver-mathcat: math speech through MathCAT (ClearSpeak and SimpleSpeak), on its own dedicated thread so a MathCAT call can never block speech, withtextweaver-mathas the fallback when MathCAT cannot speak an expression. Pinned to0.7.6-rc.3, with its rules embedded at build time; braille output waits on a MathCAT issue. ADR: 0029. Depends on core and math.textweaver-enginehost: everything the out-of-process engines share: the framed protocol, starting and watching a host process, the playback client with its audio clock, WAV writing, and the host side's request reader. ADR: 0012. Depends on core and speech.textweaver-eci: ETI-Eloquence through its ECI library, in a host process, with the community dictionaries. ADRs: 0007, 0012. Depends on core, speech, and enginehost.textweaver-sapi: Windows SAPI5 and OneCore voices, in 64-bit and 32-bit host processes. ADRs: 0009, 0012. Depends on core, speech, and enginehost.textweaver-dectalk: a user-installed DECtalk, in a host process. ADRs: 0021, 0012. Depends on core, speech, and enginehost.textweaver-host: host mode (--engine-host <engine>), whichtwand the app check first thing, and the combined 32-bit host,textweaver-host-x86.exe. Each engine's host is a library function (host::main) in its crate; this crate chooses one by name. ADR: 0007 (addendum). Depends on enginehost, eci, espeak, dectalk, and sapi.textweaver-espeak: an installed eSpeak NG in a host process built for the library's architecture, and the backend that plays its audio. It drives libespeak-ng through the speech crate's loader in retrieval mode, so both ways of running eSpeak NG share one set of declarations. ADR: 0012; see the eSpeak NG helper. Depends on core, speech, and enginehost.textweaver-apple: Apple's voices on macOS, as thensspeechandavspeechbackends. Empty on other systems. ADR: 0008. Depends on core and speech.textweaver-piper: Piper neural voices, run in process on RTen (a pure-Rust ONNX runtime), with word timing read from the model instead of estimated. Phonemes come from an installedlibespeak-ngwhen present, else a built-in pure-Rust eSpeak NG port; voice downloads from Hugging Face are checked by hash and stay behind thedownloadfeature. ADR: 0023. Depends on core, speech, and enginehost.textweaver-engines: the one backend registry every frontend shares: the speech crate's built-ins plus Eloquence, SAPI5, Apple, DECtalk, eSpeak NG's helper, and Piper, each configured from the settings, and the speech service configuration. It has the in-process engine features (espeak,omnivox,speechd,mathcat) and re-exports the engine crates. ADRs: 0001, 0012, 0023. Depends on core, speech, store, eci, sapi, dectalk, espeak, apple, and piper.textweaver-export: reads a document into WAV, MP3, or M4B with chapters, and writes SRT or WebVTT subtitles. ADR: 0011. Depends on core, speech, and text.
State and input
textweaver-store: settings and key overrides, per-document state (position, history, bookmarks, notes, highlights), recent files, the library and its full-text index, folder sidecars and their merge rules, settings import and export, and the star migration. It owns the one notes model (Note,Highlight,Relation,RelationType) and the saved form of the[reading_aids]settings (reading_aids). ADR: 0001. Depends on core.textweaver-sync: sync through a folder the user chooses (ADR-0049, "Sync beyond the place"): the hybrid logical clock, the merge types (newest-wins registers by id with deletion records, add-wins sets, per-computer counters), one record per document (each computer's place, bookmarks, notes, highlights, and statistics) whose merge lists what was replaced, and the folder format, in which each computer writes only its own files. It also keeps the install marker that gives a copied state folder a fresh id, and document identity: a random sync id per document, found by the file's SHA-256, then the SHA-256 of its text, then its library folder's id and path inside it (a DOI or ISBN is only suggested), kept insync-ids.jsonin the data folder and worked out on the app's background writer. No networking. Depends on core and store.textweaver-keymap: every action, key chords, layers, the default keys for the terminal and the GUI, overrides, conflict checks, and the generated help. ADR: 0006. Depends on core.textweaver-a11y: theAnnouncertrait, the announcement catalog, verbosity, the accessibility mode withroute(which decides whether each message, echo, caret move, and piece of read text goes to the voice, the status line, or both), and screen reader detection (detect). ADR: 0006. Depends on core.textweaver-editor: undo and redo over a rope, Markdown commands, find and replace, typing echo, autosave and recovery, and saving. Depends on core.
Application
textweaver-app: the application core.Appowns all mutable state. Frontends sendCommands toApp::dispatchand act on theEffects it returns;App::poll_speechapplies speech status. It also holds reading aids, themes, notes, the library list, the writer thread (writer,writes), finding marks again after outside edits (relocate), the structure of Markdown source while editing (structure), the authoring features (outline, spelling, citations, export, preview, templates), and the JSON-RPC server (rpc). It also holds what a GUI needs: the document window (window), the list and prompt state every frontend shares (list_model), the waker (wake), the settings schema and settings screen (settings_schema), opening in the background (opening), andCommand::ReplaceRangefor native text controls. ADRs: 0003, 0006, 0015, 0024, 0025. Depends on core, text, formats, speech, engines, store, keymap, a11y, editor, aids, theme, math, lexicon (define word, and the message catalog), and summary, and, with itspublishfeature (on by default and in releases) for export, preview, and citations in the reader, on render, convert, and cite. Withoutpublishthose commands say they are not in this build. With itsdictationfeature (on by default) it depends on dictation, for the Dictate command in edit mode (ADR-0042); without it the command stays hidden.
Frontends
textweaver-tui: the terminal reader, on ratatui and crossterm, as a library;tw(textweaver-cli) runs it. ADRs: 0006, 0020, 0022. Depends on app, engines, theme, and aids. Its default features (publish,grammar,audio-export,opus, and others) turn on the app's;cargo build -p textweaver-tui --no-default-featuresbuilds a lean reader without export, preview, citations, grammar checking, and Export audio.textweaver-cli: the terminal program, as a library thattextweaverruns (main_with,is_command), with a terminal-onlytwbinary for development:twalone ortw FILEruns the terminal reader, and each subcommand runs headless. One module per subcommand.tw,tw open, andtw serverun the terminal reader and the JSON-RPC server in process. ADRs: 0015, 0016, 0011. Depends on app (withpublish), engines, tui, convert, render, writers, export, cite, vault, and dictation.textweaver-xilem: the all-Rust GUI on Masonry (Xilem's widget layer), Vello, Parley, AccessKit, and winit, with its ownDocumentView; buildstextweaver, the one program, which also runs the terminal program. Masonry is vendored underthird_party/xilem, patched to AccessKit 0.25. ADR: 0027. Depends on app, cli, theme, and fonts. Not a default member of the workspace.textweaver-launcher:tw.exein the Windows package, a console program that runstextweaver.exe --twwith its own arguments and standard handles, ignores Ctrl+C while it waits, and ends with that program's exit status. Standard library only.
Output and study tools
textweaver-render: Markdown to accessible HTML, with two engines, four flavors, math as MathML, and MiniJinja templates. ADRs: 0016, 0018. Depends on text (for the heading slug rule), math, and formats without its default features (for the callout rules the reader shares, ADR-0044).textweaver-convert: converts files and folder trees on every core, skipping what is up to date, and watches folders. It formats citations with a References section. ADR: 0016. Depends on core, text, formats, render, writers, and cite.textweaver-writers: EPUB 3, DOCX, BRF braille, and tagged PDF, written from aDocument, with math typeset in each. ADR: 0017. Depends on core, text, fonts, and math.textweaver-fonts: the bundled fonts (Atkinson Hyperlegible Next and Mono, OpenDyslexic), a scan of installed fonts (once per process,system::installed), and the one place a font choice is resolved (choice: the family a reader picked, the reading fonts, platform fallbacks, and which family to use). The reading aids, the writers, and the GUIs all use it. ADRs: 0017, 0022. Depends on nothing in the workspace.textweaver-theme: the 24 built-in themes (star's 23 and Lamplight), user themes, contrast checks, and output for the terminal, the GUI, and CSS. ADR: 0020. Depends on nothing in the workspace.textweaver-aids: RSVP, bionic reading, text spacing, fonts, the reading ruler, difficult words, reading level, and syllables, as pure data in and out. It converts between the saved[reading_aids]settings in store and its own working types (settings). It also shortens a difficult word's first definition from any dictionary the caller passes (definitions), and checks RSVP against WCAG 2.3.1's flash limit (rsvp::flash). ADRs: 0022, 0037. Depends on core, text, store, and fonts.textweaver-summary: extractive summaries without a model: LexRank on TF-IDF cosine similarity, computed without building the similarity matrix, over the text crate's sentences, sampled evenly in long texts.tw summarizeand the Summarize command use it. ADR: 0037. Depends on core and text.textweaver-vault: Obsidian vault export and import of notes and highlights, on the store's ownNoteandHighlight(one notes model). Ported from star; no ADR. Depends on core and store.textweaver-dictation: theDictationtrait, the Whisper subprocess backend, spoken commands, Whisper in-process on RTen with the microphone, and live dictation: speech found as the audio arrives and words committed while the speaker talks (vad,stream). ADRs: 0013, 0023, 0042. Depends on core.textweaver-lexicon: language and study aids: offline "define word" (a glossary, the Open English WordNet, and CMUdict pronunciations, in anfstindex with zstd-compressed entries) and the Fluent-subset interface message catalog, with its pseudo-locales for translation testing. ADRs: 0025, 0030. Depends on nothing in the workspace.
Tools
xtask:cargo xtask benchandstartup(with a baseline gate),soak,dist,appimage,release,hosts,eci-host,sapi-host,keyboard(writes keyboard.md),deps(checks the dependency direction),notices(writesTHIRD-PARTY-NOTICES.md), andparity(writes the star parity report totarget/parity-report.md). Depends on formats, keymap, and text, and on app for the benchmarks.
Dependency direction
Dependencies point down, from the frontends to the foundation, and never back up. Cargo refuses cycles, so this is enforced by the build. The rules that keep the design clean (ADR-0001):
textweaver-coredepends on no other workspace crate. Everything else may depend on it.textweaver-speechnever sees aDocument. It receivesUtterances: text plus an offset map. It depends only on core and math, so it can be tested with no document, no frontend, and no audio.- Engine crates (eci, sapi, dectalk, espeak, apple) depend on speech, not the other way round.
textweaver-enginesregisters them with the speech registry; the app and the frontends use that crate, not each engine crate. textweaver-syncdepends only on core and store, and outside them only on serde, serde_json, sha2 (document identity), thiserror, and log: it reads and writes files in a folder, and never opens a network connection.- The editor works on a rope and core's
Edit. The app applies the same edit to theDocumentwithDocument::apply, so markers, bookmarks, and notes move with it. textweaver-appis the only crate that knows about everything. Frontends depend on the app, never on each other, except that the CLI runs the TUI in process fortw open.- Output crates (render, convert, writers) do not depend on speech or the app, so conversion works with no speech at all.
Two rules changed after ADR-0001 was written. Speech depends on math, because math is spoken inside the normalization pipeline. And the app can depend on render, convert, and cite, because the reader exports, previews, and inserts citations; that is the app's publish feature, on by default and in releases. Documents still stay out of speech. Store depends only on core again: the reading-aid settings types are store's own, and aids converts them.
Three more engine-shaped crates were added on the same pattern. textweaver-piper (ADR-0023) depends on speech and enginehost exactly like eci, sapi, and dectalk, and textweaver-engines registers it the same way, behind the speech crate's own feature flags rather than a host process. textweaver-mathcat (ADR-0029) depends on math, and speech depends on it only behind its mathcat feature, so the lean reader still links neither MathCAT nor a second math engine. textweaver-ocr and textweaver-lexicon depend on nothing in the workspace; formats reaches ocr behind its own ocr feature (on by default), and the app depends on lexicon directly for define word and the message catalog.
cargo xtask deps --check checks these rules and runs in CI. It refuses a forbidden edge: core depending on anything, speech reaching text or formats, store reaching more than core, or the reader reaching the conversion and citation stack without its default features. It resolves cargo features as Cargo does, and reports what the reader reaches only through publish as allowed.
The crates in levels, from the bottom up. Each crate depends only on crates in lower levels:
- core, fonts, theme, lexicon, and ocr, which depend on no workspace crate;
- text, math, cite, store, keymap, a11y, editor, and dictation;
- formats, aids, speech, render, writers, vault, and mathcat;
- enginehost, apple, export, and convert;
- eci, sapi, dectalk, espeak, and piper;
- engines;
- app;
- tui, xilem, and xtask;
- cli.
Threads and processes
textweaver keeps the user interface responsive by never waiting for speech on the interface thread (ADR-0003).
The input and interface thread
In the terminal reader, the main thread runs the event loop in crates/textweaver-tui/src/lib.rs. Each turn of the loop:
- applies any speech status that arrived (
App::poll_speech) and runs housekeeping (App::tick): it applies what the writer thread finished, queues the autosave snapshot and the periodic position save, and opens lists that were being prepared in the background (the library, the voices); - on macOS, pumps the main run loop for Apple's
avspeechbackend; - draws the screen, and parks the terminal's hardware cursor where attention is (the prompt, the list item, the Speech Cursor line, the spoken word, or the caret), so screen readers and magnifiers follow it;
- waits briefly for a key, then turns the key into a
Commandthrough the keymap and dispatches it.
Nothing on this thread blocks on audio or on the disk. A key press is handled in milliseconds even while a long document is being read or saved. Other slow work goes to short-lived background threads too: exporting from the reader, looking up a DOI or ISBN, parsing the Markdown source of a file of 256 KB or more when edit mode opens it, opening a file of 512 KB or more (with progress, and Escape to stop waiting), counting misspellings after a save, and starting the speech engine the first time.
A frontend does not have to poll for this work. App::set_waker takes a callback that the speech thread, the writer thread, and every background job call when they have something to apply; the frontend then calls App::poll_speech and App::tick on its own thread. A GUI posts an event to its event loop from the callback. App::tick_interval says how long it may sleep when nothing calls it (ADR-0024). The terminal reader still waits on the keyboard with a short timeout.
tw serve --stdio runs the same core on its calling thread and reads JSON-RPC messages on a second thread (ADR-0015). The app's waker wakes it as each word is heard; otherwise it looks for work every 250 milliseconds at most.
The speech thread
SpeechService::spawn starts one speech thread. It builds the engine backend on that thread, from a factory, because many engines must stay on the thread that created them (SAPI and WinRT COM apartments, espeak-ng's global state). The app talks to it through a command channel and reads SpeechStatus from a status channel.
The speech thread owns the queue, the normalization pipeline, the generation counter, and the playback clock. While speech is active it calls the backend's poll every few milliseconds, which is how stop and pause reach an engine in the middle of a sentence. Nothing on it waits for long: an engine host that is starting, the audio device that is opening, and speech-dispatcher's replies are all picked up in poll (Phase 2).
Each backend lists its voices once, when it starts, into a VoiceCache that any thread reads without waiting; SAPI fills its cache from a background thread. If the speech thread dies, the app goes silent at once and restarts speech in place, once by itself and then on the Restart Speech command, starting the new service on a helper thread (crates/textweaver-app/src/restart.rs).
Engine hosts
Eloquence, SAPI5 voices, and DECtalk each run in a separate host process (ADR-0012). A host synthesizes into memory and streams PCM audio and word positions back over a pipe. In textweaver's process:
- a reader thread decodes the host's replies;
- the playback client feeds the audio to the output device (through rodio, on its own audio thread) and keeps the audio clock;
- word events are emitted as each word is heard.
A host that crashes or stalls is killed and started again, and reading resumes from the last word heard, at most three times for one sentence. Hosts never outlive textweaver: a host exits when its input closes, on Windows every host is in a Job Object that ends with textweaver, and on Linux each asks for a signal when its parent dies. A new host starts without holding up the speech thread: requests queue until it reports ready, which poll notices. A host also lets a 32-bit engine work with 64-bit textweaver, and keeps a proprietary engine at arm's length from the reader.
The writer thread
Every file the app writes while it runs goes through one writer thread (crates/textweaver-app/src/writer.rs): saves, autosave snapshots and their deletion, reading positions, bookmarks, notes, and highlights, the library sidecars, the bookshelf and recent list, settings.toml, and the two-second check for a change on disk. The interface thread hands it a copy of the text (a rope clone, which costs nothing) and reads the result on its next tick: "Saved", an error, or a question about a file that changed on disk. Saves of the same document's state that queue up are collapsed into the newest. Quitting waits for the writer, at most ten seconds, saying so when it takes more than a moment.
Other workers
tw convertruns conversions on a rayon thread pool, one document per worker (ADR-0016).- Dictation runs a Whisper program as a subprocess and reads its output on a worker thread (ADR-0013).
- Audio export runs synthesis on the calling thread, utterance by utterance (ADR-0011).
- The Library command scans the library folders on a background thread and shows the count found as it goes.
From file to highlighted word
This is the path a document takes from the disk to your ears, with the highlight following along. The speech pipeline page walks through it one step at a time.
- Load.
textweaver-formatspicks a loader by the file's extension and builds aDocument: canonical text in a rope, plus markers for headings, paragraphs, lists, tables, links, code, math, strikethrough, horizontal rules, pages, and sections (ADR-0002). Positions areCharPos, counts of Unicode scalar values. They are what gets saved. - Units.
textweaver-textfinds words and sentences with Unicode text segmentation (UAX #29), with an abbreviation list so "Dr." does not end a sentence. Navigation moves by these units and by markers. - Plan. When you press Space, the app asks
narrate::planfor utterances covering about ten minutes of speech from the cursor (32,768 characters, ended at a sentence boundary). Each utterance is about one sentence. Structure the eye sees but the ear would miss, such as "heading level 2" or a table's row and column, is added as inserted speech (ADR-0005). When that window has been read, the next one is planned. - Hand over. The app calls
SpeechService::read, which returns a new reading generation (see below). - Normalize. Just before an utterance enters the two-sentence lookahead, the speech thread runs it through the normalization pipeline, on its own and never across sentences: math first, then Markdown residue, your pronunciations, the community lexicon, abbreviations, numbers, split capitals, and punctuation. Each transform returns its text and an offset map, and the maps are composed, so the final map still points into the document. Transforms an engine does itself, such as Eloquence's numbers and abbreviations, are skipped for that engine.
- Speak. The backend's
speakstarts the utterance and returns at once. The engine, in process or in a host, starts producing audio. - Word events. The engine reports each word as a byte range in the spoken text, and, for engines that know it, the word's time on the audio clock.
- Audio-clock engines (espeak-ng) have each word scheduled at its audio time plus the latency offset (
[speech] latency_offset_ms, 120 milliseconds by default). - Engines whose audio textweaver plays itself (Eloquence, SAPI, DECtalk, Apple's
avspeech) report each word as it is heard, and it is used at once. - Engines with no word events (Omnivox) are paced by a timer from the effective words per minute.
- Generation check. Events from an older generation are dropped before anything looks at them. See below.
- Map back. The byte range goes through the utterance's offset map to a range of document characters. An expanded token, such as "$5" read as "five dollars", highlights the whole token; inserted speech highlights nothing.
- Highlight. The service sends
SpeechStatus::Position. The app checks its reading generation, moves the highlight (shifted by[highlight] lead_words), and moves the cursor if[reading] cursor_follows_speechis on. The terminal reader draws it on the next turn of its loop and parks the hardware cursor on the word.
Pausing records the last word that was confirmed as heard. Resuming starts from there, so a word may be repeated, but none is ever skipped.
Offset maps
An OffsetMap (ADR-0005, crates/textweaver-core) connects the spoken text to the document. It is a sorted list of spans. Each span pairs a byte range of spoken text with a character range of source text, and has one of four kinds:
- Literal: the same text, character for character. A word inside maps to exactly its source characters.
- Expanded: spoken words that stand for a whole source token, such as "Doctor" for "Dr." or "squared" for
^2. Any word inside highlights the whole token. - Inserted: spoken words with no source, such as "heading level 2". They highlight nothing. If you pause inside one, reading resumes at its anchor.
- Elided: source that is not spoken, such as Markdown's
**or the dollar signs around math. It has no spoken text.
A small example, the Markdown The area is $x^2$ today., is spoken as "The area is x squared today.". This is the real map, from tw speak --backend null --json:
- "The area is " is literal: spoken bytes 0 to 12, source characters 0 to 12.
- The first
$is elided: source character 12. - "x" is literal: spoken byte 12, source character 13.
- The space after it is inserted: spoken byte 13, anchored at source character 14.
- "squared" is expanded from
^2: spoken bytes 14 to 21, source characters 14 to 16. - The second
$is elided: source character 16. - " today." is literal: spoken bytes 21 to 28, source characters 17 to 24.
When the engine reports the word "squared" (spoken bytes 14 to 21), the map finds the expanded span and highlights ^2, characters 14 to 16. The word "x" highlights character 13, so the two words together cover the whole x^2.
The map answers three questions: which source range a spoken word came from (for the highlight), where in the spoken text to start for a cursor position (for reading from the cursor), and where to resume after a pause. Every map is checked by OffsetMap::check_invariants, and property tests generate random maps and compositions.
The same idea is used elsewhere: audio export maps word times back through offset maps so subtitles show the document's own text, and the syllable display in textweaver-aids maps its dotted words back to the document.
Reading generations
Speech is asynchronous, so an event can arrive after the reading it belongs to has been replaced. star's worst highlight bugs came from exactly that: a late "done" from the previous sentence stopped the current highlight. textweaver guards against it at two levels.
- Utterance generations, inside the speech service. Every utterance carries an id: a generation and a chunk number. The service bumps the generation before every stop or restart. Its event sink drops any event whose generation is not current before the service logic sees it, so a late
WordorFinishedcan never move the highlight of a newer reading (ADR-0003). - Reading generations, between the service and the app. Every
readreturns aReadingGeneration, a number, and everyPosition,Paused,Stopped, andFinishedstatus carries it. The app remembers the generation of the reading it started and ignores statuses from any other. Checking one number replaced guessing from positions, which failed, for example, when a reading started at a skipped code block.
On engines that report words, changing the rate, pitch, or volume while reading restarts the reading from the last confirmed word, with a new generation, so the change is heard at once.
Announcements
Every state change is announced through the Announcer trait in textweaver-a11y (ADR-0006). The app filters by [speech] verbosity and sends each announcement to:
- the status line, which the terminal reader draws and screen readers read;
- the speech service, when the accessibility mode lets textweaver speak, but never over the reading itself unless it is an error;
- JSON-RPC clients, as
announcementnotifications.
Before anything is spoken or written to the status line, the app asks textweaver_a11y::route where it goes. The answer depends on the kind of output (a message, typing echo, a caret move, read text, or a Speech Cursor line) and on [accessibility] mode: self-voicing, hybrid, or screen reader. So a screen reader that reads the status line never hears the same thing twice. New code must route its announcements the same way. Using textweaver with a screen reader describes the modes for users.
The GUI sends announcements to the screen reader as UI Automation notifications on Windows, and as a live region on macOS and Linux.
The GUI's view of a document
A GUI does not lay out a whole document. It shows a DocWindow (crates/textweaver-app/src/window.rs): about 500,000 UTF-16 units around the focus, starting and ending on paragraph boundaries. The window slides forward while reading, saying what left its front and what joined its end, and recenters after a jump. Positions in the window are converted to and from the text control's own units (UTF-16 for Windows and macOS controls, UTF-8 bytes for Parley and AccessKit, chars for GTK) through the document's DisplayIndex, which is the rope itself and answers in O(log n). Session::revision changes whenever the text changes, so the GUI knows to reload. Edits made in a native text control come back as Command::ReplaceRange. The terminal reader keeps its own slicing: a screenful of wrapped rows from the viewport's top line (ADR-0024).
Persistence
textweaver-store keeps settings in settings.toml and key overrides in keymap.toml, written only when they change, atomically, keeping keys it does not know. The app's settings schema (crates/textweaver-app/src/settings_schema.rs) is generated from the store's own keys, with a label, help, and range or choices for each; it drives the settings screen, the GUI's settings dialog, and JSON-RPC. Each document's position, history, bookmarks, notes, and highlights are in a state file named after the document; positions are saved every 30 seconds while they move, on quit, and on switching documents, by the writer thread. The state file also keeps the text's length and hash and, with each position and bookmark, the 40 characters it was on: when the file changed outside textweaver, positions, bookmarks, notes, and highlights are found again from their text on the next open (crates/textweaver-app/src/relocate.rs). Library folders can hold a sidecar, .textweaver/progress.json, so positions follow a folder between computers. Settings and the library describe the files and folders.
See also
- Interactive architecture page: choose a crate to see its job, ADRs, dependencies, and dependents.
- Speech pipeline, step by step: the data flow above, one stage at a time, with the offset map example.
- CONTRIBUTING.md: building, testing, and sending changes.
- Roadmap: the planned changes to this architecture, including the GUI and the stability work.
- ADR-0001: Workspace and dependencies, ADR-0003: Speech threading, and ADR-0005: Narration and the OffsetMap: the three decisions this guide leans on most.
- Documentation index