This document describes the internal architecture of reed, a CLI text-to-speech application built on piper-tts.
┌─────────────────────────────────────────────────────────────────┐
│ reed CLI │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ main() │ │ get_text() │ │ speak_text() │ │
│ │ - arg parse│ │ - file I/O │ │ - piper TTS │ │
│ │ - routing │ │ - clipboard │ │ - audio playback │ │
│ └──────┬──────┘ └──────────────┘ └───────────┬────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ PlaybackController (interactive mode) │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │
│ │ │ Background │ │ Pause/ │ │ Process │ │ │
│ │ │ Thread │ │ Resume │ │ Management │ │ │
│ │ └─────────────┘ └──────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ External Tools │
│ piper-tts │ afplay/paplay │
└───────────────────────────────┘
Location: reed.py:main()
Responsibilities:
- Parse CLI arguments with
argparse - Resolve voice model path (name →
~/.local/share/reed/) - Route to appropriate mode:
- Interactive mode — no input provided, TTY detected
- File mode — PDF/EPUB with optional page/chapter selection
- One-shot mode — text argument, clipboard, or stdin
- Handle special commands:
reed voices,reed download <voice>
Flow:
argv → argparse → resolve model → ensure_model() → route
├─ interactive_loop()
├─ _iter_pdf_pages() / _iter_epub_chapters()
└─ get_text() → speak_text()
Location: reed.py:get_text(), _iter_pdf_pages(), _iter_epub_chapters()
Responsibilities:
- Extract text from various sources
- Normalize and chunk text for TTS
Input Sources:
| Source | Implementation |
|---|---|
| Text argument | args.text joined with spaces |
| File | Path.read_text() |
| Clipboard | _default_clipboard_cmd() → subprocess |
| stdin | stdin.read() if not TTY |
pypdf.PdfReader → _iter_pdf_pages() |
|
| EPUB | zipfile + xml.etree → _iter_epub_chapters() |
PDF Processing:
PDF file → PdfReader → extract_text() per page → yield (page_num, total, text)
EPUB Processing:
EPUB file → zipfile → META-INF/container.xml → OPF spine → XHTML chapters
→ _strip_html() → yield (chapter_num, total, text)
Location: reed.py:build_piper_cmd(), speak_text()
Responsibilities:
- Construct piper-tts command line
- Generate WAV audio from text
- Route audio to player or file
Piper Command:
[
sys.executable, "-m", "piper",
"--model", "<path>.onnx",
"--length-scale", "<speed>",
"--volume", "<volume>",
"--sentence-silence", "<silence>",
"--output-file", "<output.wav>" # optional
]Two Playback Modes:
| Mode | Implementation | Use Case |
|---|---|---|
| Blocking | subprocess.run() |
File output, non-interactive |
| Non-blocking | PlaybackController.play() |
Interactive mode |
Location: reed.py:PlaybackController, PlaybackState
Purpose: Enable pause/resume/stop controls without blocking the interactive prompt.
Architecture:
┌─────────────────────────────────────────────────────────┐
│ PlaybackController │
│ ┌────────────────────────────────────────────────────┐ │
│ │ State Machine: IDLE → PLAYING → PAUSED → PLAYING │ │
│ │ PLAYING → STOPPED → IDLE │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────┐ ┌────────────────────────────┐ │
│ │ play(text) │───▶│ Background Thread │ │
│ │ pause() │ │ 1. Run piper (Popen) │ │
│ │ resume() │ │ 2. Run player (Popen) │ │
│ │ stop() │ │ 3. Wait & cleanup │ │
│ └────────────────┘ └────────────────────────────┘ │
│ │
│ ┌────────────────┐ ┌────────────────────────────┐ │
│ │ Unix (POSIX) │ │ Windows (NT) │ │
│ │ SIGSTOP/SIGCONT│ │ Pause/resume not supported │ │
│ └────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Key Methods:
| Method | Description | Platform |
|---|---|---|
play(text, config) |
Start playback in background thread | All |
pause() |
Send SIGSTOP to player process |
Unix only |
resume() |
Send SIGCONT to player process |
Unix only |
stop() |
Terminate piper + player processes | All |
wait() |
Block until playback completes | All |
is_playing() |
Check current state | All |
get_current_text() |
Get last spoken text (for replay) | All |
Thread Safety:
- All state mutations protected by
threading.Lock _stop_event(threading.Event) for clean shutdown- Daemon threads prevent hanging on exit
Process Management:
# Generation
piper_proc = Popen(piper_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
piper_proc.communicate(input=text.encode())
# Playback
player_proc = Popen([*play_cmd, tmp_path])
player_proc.wait() # Blocks thread, not main loopLocation: reed.py:interactive_loop()
Responsibilities:
- Display prompt with
prompt_toolkit - Handle commands:
/quit,/help,/clear,/replay - Route text to
speak_text()with controller
Flow:
┌─────────────────────────────────────────────────────────┐
│ interactive_loop() │
│ │
│ while True: │
│ text = prompt_fn() # prompt_toolkit │
│ if text in QUIT_WORDS: return 0 │
│ if text == "/help": print_help(); continue │
│ if text == "/clear": clear_fn(); continue │
│ if text == "/replay": │
│ controller.get_current_text() → speak_line() │
│ else: │
│ speak_line(text) # non-blocking via controller │
└─────────────────────────────────────────────────────────┘
Integration with Controller:
# In main()
controller = PlaybackController(print_fn=print_fn)
interactive_loop(
speak_line=lambda line: speak_text(
line, config, controller=controller # Non-blocking
),
controller=controller, # For replay
)Location: reed.py:_default_play_cmd()
Strategy: Detect available player based on OS and installed tools.
| OS | Priority Order |
|---|---|
| macOS | afplay (bundled) |
| Linux | paplay → aplay → ffplay |
| Windows | PowerShell SoundPlayer → ffplay |
Clipboard Detection:
| OS | Priority Order |
|---|---|
| macOS | pbpaste (bundled) |
| Linux | wl-paste → xclip → xsel |
| Windows | PowerShell Get-Clipboard |
User input → prompt_toolkit → interactive_loop
│
▼
speak_text(controller=c)
│
▼
controller.play(text, config)
│
┌───────────────┴───────────────┐
│ Background Thread │
│ 1. piper → temp WAV │
│ 2. player → audio output │
│ 3. cleanup temp file │
└───────────────────────────────┘
│
▼
Prompt returns immediately
PDF/EPUB → _iter_*_pages() → for each chunk:
│
▼
speak_text(config, run=subprocess.run)
│
▼
piper (run) → player (run)
│
▼
Next chunk (sequential)
speak_text(config.output=Path("out.wav"))
│
▼
piper --output-file out.wav
│
▼
Return (no playback)
class PlaybackState(Enum):
IDLE = auto() # No active playback
PLAYING = auto() # Currently playing
PAUSED = auto() # Paused (Unix only)
STOPPED = auto() # Stopped mid-playback ┌──────────────────────────────────────────┐
│ │
▼ │
┌──────┐ play() ┌─────────┐ │
│ IDLE │──────────────▶│ PLAYING │◀──────────┤
└──────┘ └────┬────┘ │
▲ │ │
│ stop() │ pause() │
│ ┌────────────┘ │
│ ▼ │
┌────────┐ stop() ┌─────────┐ resume() │
│ STOPPED│◀───────────│ PAUSED │────────────┘
└────────┘ └─────────┘
- Pause/Resume:
SIGSTOP/SIGCONTsignals - Audio:
afplay(macOS),paplay/aplay/ffplay(Linux) - Clipboard:
pbpaste(macOS),wl-paste/xclip/xsel(Linux)
- Pause/Resume: Not supported (returns
False) - Audio: PowerShell
SoundPlayerorffplay - Clipboard: PowerShell
Get-Clipboard
Custom exception for user-facing errors:
class ReedError(Exception):
"""Raised for reed-specific errors (model not found, no player, etc.)"""| Error | Handling |
|---|---|
| Model not found | Auto-download from Hugging Face |
| No audio player | ReedError → printed panel → exit 1 |
| Piper failure | ReedError with stderr output |
| Process termination failure | Catch ProcessLookupError, TimeoutExpired |
- Unit Tests — Individual functions (
build_piper_cmd,_strip_html) - Integration Tests —
main()with mocked subprocess - Controller Tests —
PlaybackControllerstate transitions - Platform Tests — OS-specific behavior via
monkeypatch
# Mock subprocess.run
def fake_run(cmd, **kwargs):
return types.SimpleNamespace(returncode=0, stderr="")
# Mock PlaybackController.play
def fake_play(self, text, config):
played_texts.append(text)
# Mock platform detection
monkeypatch.setattr("reed.platform.system", lambda: "Darwin")| Package | Purpose | Optional |
|---|---|---|
piper-tts |
Text-to-speech generation | No |
prompt_toolkit |
Interactive prompt | No |
rich |
Terminal formatting | No |
pypdf |
PDF text extraction | Yes (PDF support) |
| Category | macOS | Linux | Windows |
|---|---|---|---|
| Audio | afplay |
paplay, aplay, ffplay |
PowerShell, ffplay |
| Clipboard | pbpaste |
wl-paste, xclip, xsel |
PowerShell |
Add interactive commands /pause, /play, /stop wired to PlaybackController.
Persist reading position in ~/.local/share/reed/bookmarks.json:
{
"/abs/path/to/book.pdf": {
"page": 12,
"char_offset": 340,
"timestamp": "2026-02-18T10:00:00"
}
}Replace temp file with pipe-based streaming:
piper --output-raw | ffplay -f s16le -ar 22050 -ac 1 -
Requires refactoring PlaybackController to use stdin=PIPE for player.
reed/
├── reed.py # Main module (all logic)
├── test_reed.py # Tests (TDD style)
├── pyproject.toml # Package metadata, dependencies
├── ARCHITECTURE.md # This document
├── README.md # User documentation
└── ROADMAP.md # Feature roadmap
Note: reed.py is intentionally monolithic (~1000 lines) to minimize dependencies and simplify distribution. Functions are organized by layer:
- Imports & constants
- Exceptions & enums
- Core classes (
PlaybackController) - Helper functions (
_data_dir,_model_url) - Input pipeline (
get_text,_iter_*) - TTS pipeline (
build_piper_cmd,speak_text) - UI functions (
print_*,interactive_loop) - Entry point (
main)