diff --git a/AGENTS.md b/AGENTS.md index 3b6621a..eabe75b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,10 +16,16 @@ This is `reed`, a convenient CLI for text-to-speech using piper-tts. - Windows: `powershell`/`ffplay` for audio, `powershell Get-Clipboard` for clipboard - Rich (terminal UI library for styled output) - Voice models stored in `~/.local/share/reed/` (Linux/macOS) or `%LOCALAPPDATA%\reed\` (Windows) +- `prompt_toolkit` (interactive prompt with history and autocomplete) ## Structure - `reed.py` — single-file CLI module, installed as console script via pyproject.toml +- `test_reed.py` — comprehensive test suite (TDD approach) +- `ARCHITECTURE.md` — detailed system design, component diagrams, and data flow +- `ROADMAP.md` — feature roadmap with priorities and dependencies + +Start here: Read `ARCHITECTURE.md` before implementing features that touch playback, interactive mode, or file processing. ## Commands @@ -41,6 +47,13 @@ This is `reed`, a convenient CLI for text-to-speech using piper-tts. - Tests use dependency injection (fake `run`, `stdin`, `print_fn`) to avoid real subprocess calls - The `reed` module is imported directly (`import reed as _reed`) - Run full test suite before and after every change: `uv run pytest -v` +- New components: Add tests for `PlaybackController` state transitions, thread safety, and signal handling + +## Workflow + +- Plan first: Before implementing any feature or fix, outline your approach and identify affected components +- Always run tests after code changes: Verify changes don't break existing functionality +- Use a subagent for running tests to avoid polluting the main conversation context with verbose output ## Conventions @@ -49,6 +62,9 @@ This is `reed`, a convenient CLI for text-to-speech using piper-tts. - Use `subprocess` to invoke piper and platform audio player - Default model auto-downloaded to `_data_dir()` on first run - `ReedConfig` dataclass for core configuration +- Thread safety: Use `threading.Lock` for shared state in `PlaybackController` +- Non-blocking playback: Interactive mode uses `PlaybackController`; file/output modes use blocking `subprocess.run()` +- Platform checks: Use `os.name == "posix"` for Unix-specific behavior (SIGSTOP/SIGCONT) ## UI Development @@ -58,3 +74,4 @@ This is `reed`, a convenient CLI for text-to-speech using piper-tts. - Commands available in interactive mode: `/quit`, `/exit`, `/help`, `/clear`, `/replay` - Tab autocomplete available for commands - Include `print_fn` parameter for testability with dependency injection + - Playback state: Future `/pause`, `/play`, `/stop` commands will wire to `PlaybackController` methods diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4ba4139 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,440 @@ +# Reed Architecture + +This document describes the internal architecture of reed, a CLI text-to-speech application built on piper-tts. + +## Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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 │ + └───────────────────────────────┘ +``` + +## Core Components + +### 1. Entry Point (`main()`) + +**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 ` + +**Flow:** +``` +argv → argparse → resolve model → ensure_model() → route + ├─ interactive_loop() + ├─ _iter_pdf_pages() / _iter_epub_chapters() + └─ get_text() → speak_text() +``` + +--- + +### 2. Input Pipeline (`get_text()`, `_iter_*()`) + +**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 | +| PDF | `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) +``` + +--- + +### 3. TTS Generation (`build_piper_cmd()`, `speak_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:** +```python +[ + sys.executable, "-m", "piper", + "--model", ".onnx", + "--length-scale", "", + "--volume", "", + "--sentence-silence", "", + "--output-file", "" # optional +] +``` + +**Two Playback Modes:** + +| Mode | Implementation | Use Case | +|------|----------------|----------| +| **Blocking** | `subprocess.run()` | File output, non-interactive | +| **Non-blocking** | `PlaybackController.play()` | Interactive mode | + +--- + +### 4. Playback Controller (Non-blocking) + +**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_requested` flag for clean shutdown +- Daemon threads prevent hanging on exit + +**Process Management:** +```python +# 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 loop +``` + +--- + +### 5. Interactive Loop + +**Location:** `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:** +```python +# 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 +) +``` + +--- + +### 6. Audio Player Detection + +**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` | + +--- + +## Data Flow + +### Interactive Mode (Non-blocking) +``` +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 +``` + +### File Mode (Blocking) +``` +PDF/EPUB → _iter_*_pages() → for each chunk: + │ + ▼ + speak_text(config, run=subprocess.run) + │ + ▼ + piper (run) → player (run) + │ + ▼ + Next chunk (sequential) +``` + +### Output Mode (Blocking) +``` +speak_text(config.output=Path("out.wav")) + │ + ▼ +piper --output-file out.wav + │ + ▼ +Return (no playback) +``` + +--- + +## State Management + +### PlaybackState Enum +```python +class PlaybackState(Enum): + IDLE = auto() # No active playback + PLAYING = auto() # Currently playing + PAUSED = auto() # Paused (Unix only) + STOPPED = auto() # Stopped mid-playback +``` + +### Controller State Transitions +``` + ┌──────────────────────────────────────────┐ + │ │ + ▼ │ + ┌──────┐ play() ┌─────────┐ │ + │ IDLE │──────────────▶│ PLAYING │◀──────────┤ + └──────┘ └────┬────┘ │ + ▲ │ │ + │ stop() │ pause() │ + │ ┌────────────┘ │ + │ ▼ │ + ┌────────┐ stop() ┌─────────┐ resume() │ + │ STOPPED│◀───────────│ PAUSED │────────────┘ + └────────┘ └─────────┘ +``` + +--- + +## Platform Abstraction + +### Unix (Linux/macOS) +- **Pause/Resume:** `SIGSTOP` / `SIGCONT` signals +- **Audio:** `afplay` (macOS), `paplay`/`aplay`/`ffplay` (Linux) +- **Clipboard:** `pbpaste` (macOS), `wl-paste`/`xclip`/`xsel` (Linux) + +### Windows +- **Pause/Resume:** Not supported (returns `False`) +- **Audio:** PowerShell `SoundPlayer` or `ffplay` +- **Clipboard:** PowerShell `Get-Clipboard` + +--- + +## Error Handling + +### ReedError +Custom exception for user-facing errors: +```python +class ReedError(Exception): + """Raised for reed-specific errors (model not found, no player, etc.)""" +``` + +### Error Categories +| 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` | + +--- + +## Testing Strategy + +### Test Categories +1. **Unit Tests** — Individual functions (`build_piper_cmd`, `_strip_html`) +2. **Integration Tests** — `main()` with mocked subprocess +3. **Controller Tests** — `PlaybackController` state transitions +4. **Platform Tests** — OS-specific behavior via `monkeypatch` + +### Mocking Patterns +```python +# 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") +``` + +--- + +## Dependencies + +### Runtime +| 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) | + +### System Tools (one per category) +| Category | macOS | Linux | Windows | +|----------|-------|-------|---------| +| **Audio** | `afplay` | `paplay`, `aplay`, `ffplay` | PowerShell, `ffplay` | +| **Clipboard** | `pbpaste` | `wl-paste`, `xclip`, `xsel` | PowerShell | + +--- + +## Future Extensions + +### Phase 1.3 — Playback Controls +Add interactive commands `/pause`, `/play`, `/stop` wired to `PlaybackController`. + +### Phase 3.1 — Bookmarks +Persist reading position in `~/.local/share/reed/bookmarks.json`: +```json +{ + "/abs/path/to/book.pdf": { + "page": 12, + "char_offset": 340, + "timestamp": "2026-02-18T10:00:00" + } +} +``` + +### Phase 4.1 — Streaming Audio +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. + +--- + +## File Structure + +``` +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: +1. Imports & constants +2. Exceptions & enums +3. Core classes (`PlaybackController`) +4. Helper functions (`_data_dir`, `_model_url`) +5. Input pipeline (`get_text`, `_iter_*`) +6. TTS pipeline (`build_piper_cmd`, `speak_text`) +7. UI functions (`print_*`, `interactive_loop`) +8. Entry point (`main`) diff --git a/README.md b/README.md index 2b90da0..2827c84 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A CLI that reads text aloud using [piper-tts](https://github.com/rhasspy/piper). - **PDF support** — read full PDFs or selected pages with `--pages` - **EPUB support** — read EPUB e-books, select chapters with `--pages` - **Pipe-friendly** — reads from stdin, works anywhere in a shell pipeline -- **Interactive mode** — conversational TTS with `/replay`, `/help`, `/clear`, tab completion, and history +- **Interactive mode** — conversational TTS with `/replay`, `/help`, `/clear`, tab completion, history, and non-blocking playback - **Adjustable speech** — control speed (`-s`), volume (`-v`), and sentence silence (`--silence`) - **Voice management** — download, list, and switch voices (`reed download`, `reed voices`, `-m`) - **Swappable voices** — use any piper-tts `.onnx` model with `-m` @@ -163,6 +163,8 @@ When launched with no arguments, reed enters interactive mode. Type or paste tex - Available commands in interactive mode: `/help`, `/clear`, `/replay` - Press `Ctrl-D` for EOF to exit +**Note:** Interactive mode uses non-blocking playback — you can type the next line while audio is still playing. + ### Voice Management Voices are stored in `~/.local/share/reed/` (Linux/macOS, respects `XDG_DATA_HOME`) or `%LOCALAPPDATA%\reed\` (Windows). diff --git a/ROADMAP.md b/ROADMAP.md index 38aebfc..5fddb42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,23 +10,22 @@ - Fuzzy-match support so `/h` → `/help` - Extend to file paths for a future `/open` command -### 1.2 Non-blocking Playback Controller +### 1.2 Non-blocking Playback Controller ✅ **Effort:** Medium · **Priority:** High **Dependencies:** None +**Status:** Done -Currently `speak_text()` blocks on `subprocess.run()` for playback. All interactive playback features depend on making this non-blocking. - -- Replace `subprocess.run([*play_cmd, tmp.name])` with a background `subprocess.Popen` process -- Create a `PlaybackController` class that tracks the playback `Popen` instance - - **Pause:** send `SIGSTOP` (Unix) / suspend thread (Windows) - - **Resume:** send `SIGCONT` (Unix) / resume thread (Windows) - - **Stop:** `proc.terminate()` -- Run playback in a background thread so the interactive prompt remains responsive -- Alternative: use `python-sounddevice` or `simpleaudio` for native Python playback with frame-level control (finer granularity but adds a dependency) +- ✅ Create `PlaybackController` class with background thread playback +- ✅ Pause/resume via `SIGSTOP`/`SIGCONT` (Unix) +- ✅ Stop via `proc.terminate()` +- ✅ Thread-safe state management with `threading.Lock` +- ✅ Integrated into interactive mode +- ✅ Zero new dependencies (stdlib `threading` + `signal`) ### 1.3 Playback Controls — Pause, Play, Stop **Effort:** Small · **Priority:** High -**Dependencies:** 1.2 (non-blocking playback) +**Dependencies:** 1.2 (non-blocking playback) ✅ +**Status:** Ready to implement - Add interactive commands: `/pause`, `/play` (resume), `/stop` - Wire commands to `PlaybackController.pause()`, `.resume()`, `.stop()` @@ -55,7 +54,8 @@ Currently `speak_text()` blocks on `subprocess.run()` for playback. All interact ### 3.1 Save & Resume Reading Position **Effort:** Medium · **Priority:** Medium -**Dependencies:** Phase 1.2 (stop controls), Phase 2.1 (epub support desirable) +**Dependencies:** Phase 1.2 (stop controls) ✅, Phase 2.1 (epub support desirable) ✅ +**Status:** Ready to implement - Create a JSON bookmarks file at `_data_dir() / "bookmarks.json"` - Schema: @@ -79,7 +79,8 @@ Currently `speak_text()` blocks on `subprocess.run()` for playback. All interact ### 4.1 Streaming TTS Playback **Effort:** Large · **Priority:** Medium -**Dependencies:** Phase 1.2 (playback controller) +**Dependencies:** Phase 1.2 (playback controller) ✅ +**Status:** Ready to implement Currently reed generates the full WAV file before playing. Streaming removes that wait. @@ -116,14 +117,14 @@ No Lithuanian piper voice model exists yet — this requires training one from s ## Summary Timeline -| Phase | Feature | Effort | Dependencies | -|-------|---------|--------|--------------| -| 1.1 | Command autocomplete | Small | — | -| 1.2 | Non-blocking playback controller | Medium | — | -| 1.3 | Pause / play / stop commands | Small | 1.2 | -| 2.1 | ✅ EPUB reading | Medium | — | -| 3.1 | Save & resume position | Medium | 1.2, 2.1 | -| 4.1 | Streaming audio | Large | 1.2 | -| 5.1 | Lithuanian voice model training | Large | — | - -Phases 1.1, 1.2, 2.1, and 5.1 can all be started in parallel. Phases 1.3, 3.1, and 4.1 build on the non-blocking playback controller (1.2). +| Phase | Feature | Effort | Dependencies | Status | +|-------|---------|--------|--------------|--------| +| 1.1 | Command autocomplete | Small | — | Partial | +| 1.2 | Non-blocking playback controller | Medium | — | ✅ Done | +| 1.3 | Pause / play / stop commands | Small | 1.2 | Ready | +| 2.1 | EPUB reading | Medium | — | ✅ Done | +| 3.1 | Save & resume position | Medium | 1.2, 2.1 | Ready | +| 4.1 | Streaming audio | Large | 1.2 | Ready | +| 5.1 | Lithuanian voice model training | Large | — | — | + +Phase 1.2 is complete ✅. Phases 1.3, 3.1, and 4.1 now have their dependencies satisfied and are ready to implement. diff --git a/reed.py b/reed.py index 7ec9ba1..70833e1 100755 --- a/reed.py +++ b/reed.py @@ -5,13 +5,17 @@ import os import platform import shutil +import signal import subprocess +from subprocess import CompletedProcess import sys import tempfile +import threading import time import urllib.request import xml.etree.ElementTree as ET import zipfile +from enum import Enum, auto from html.parser import HTMLParser from dataclasses import dataclass from pathlib import Path @@ -33,11 +37,206 @@ console = Console() +DEFAULT_SILENCE = 0.6 + class ReedError(Exception): pass +class PlaybackState(Enum): + """Enum representing the current playback state.""" + + IDLE = auto() + PLAYING = auto() + PAUSED = auto() + STOPPED = auto() + + +class PlaybackController: + """Non-blocking playback controller for managing TTS audio playback. + + Runs piper TTS and audio player in a background thread, allowing + pause/resume/stop controls without blocking the interactive prompt. + """ + + def __init__(self, print_fn: Callable[..., None] = console.print) -> None: + self._current_proc: Optional[subprocess.Popen] = None + self._piper_proc: Optional[subprocess.Popen] = None + self._playback_thread: Optional[threading.Thread] = None + self._state = PlaybackState.IDLE + self._current_text = "" + self._config: Optional[ReedConfig] = None + self._lock = threading.Lock() + self._print_fn = print_fn + self._stop_requested = False + + def play(self, text: str, config: ReedConfig) -> None: + """Start playback of text in a background thread. + + If already playing, stops current playback before starting new one. + """ + with self._lock: + if self._state == PlaybackState.PLAYING: + self._stop_locked() + self._current_text = text + self._config = config + self._state = PlaybackState.PLAYING + self._stop_requested = False + self._playback_thread = threading.Thread( + target=self._playback_worker, args=(text, config), daemon=True + ) + self._playback_thread.start() + + def _playback_worker(self, text: str, config: ReedConfig) -> None: + """Background worker that generates and plays audio. + + Runs piper to generate WAV, then plays it with the system audio player. + Uses Popen for both to enable pause/resume/stop controls. + """ + play_cmd = _default_play_cmd() + tmp_path = None + + try: + # Generate WAV with piper + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp_path = tmp.name + + piper_cmd = build_piper_cmd( + config.model, + config.speed, + config.volume, + config.silence, + Path(tmp_path), + ) + self._piper_proc = subprocess.Popen( + piper_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + piper_stdout, piper_stderr = self._piper_proc.communicate( + input=text.encode("utf-8") + ) + + if self._stop_requested or self._piper_proc.returncode != 0: + self._print_fn("\n[bold red]✗ Piper error[/bold red]") + return + + # Play WAV with audio player + self._current_proc = subprocess.Popen([*play_cmd, tmp_path]) + + # Wait for playback to complete or be interrupted + self._current_proc.wait() + + if self._stop_requested: + self._state = PlaybackState.STOPPED + self._print_fn("[bold red]⏹ Stopped[/bold red]") + else: + self._print_fn("[bold green]✓ Done[/bold green]") + + except Exception as e: + self._print_fn(f"[bold red]Playback error: {e}[/bold red]") + finally: + # Cleanup temp file + if tmp_path and os.path.exists(tmp_path): + try: + os.unlink(tmp_path) + except OSError: + pass + # Reset state if not stopped + with self._lock: + if self._state not in (PlaybackState.STOPPED, PlaybackState.PAUSED): + self._state = PlaybackState.IDLE + self._current_proc = None + self._piper_proc = None + + def pause(self) -> bool: + """Pause playback. Returns True if successful. + + On Unix: sends SIGSTOP to player process. + On Windows: not supported, returns False. + """ + with self._lock: + if self._state != PlaybackState.PLAYING or self._current_proc is None: + return False + if os.name == "posix": + sigstop = getattr(signal, "SIGSTOP", None) + if sigstop is None: + return False + self._current_proc.send_signal(sigstop) + self._state = PlaybackState.PAUSED + self._print_fn("\n[bold yellow]⏸ Paused[/bold yellow]") + return True + return False + + def resume(self) -> bool: + """Resume paused playback. Returns True if successful. + + On Unix: sends SIGCONT to player process. + On Windows: not supported, returns False. + """ + with self._lock: + if self._state != PlaybackState.PAUSED or self._current_proc is None: + return False + if os.name == "posix": + sigcont = getattr(signal, "SIGCONT", None) + if sigcont is None: + return False + self._current_proc.send_signal(sigcont) + self._state = PlaybackState.PLAYING + self._print_fn("\n[bold green]▶ Playing...[/bold green]") + return True + return False + + def stop(self) -> bool: + """Stop playback. Returns True if was playing/paused.""" + with self._lock: + return self._stop_locked() + + def _stop_locked(self) -> bool: + """Internal stop implementation - must be called with lock held.""" + if self._state == PlaybackState.IDLE: + return False + + self._stop_requested = True + + if self._current_proc: + try: + self._current_proc.terminate() + self._current_proc.wait(timeout=2) + except subprocess.TimeoutExpired, ProcessLookupError: + try: + self._current_proc.kill() + except ProcessLookupError: + pass + + if self._piper_proc and self._piper_proc.poll() is None: + try: + self._piper_proc.terminate() + except ProcessLookupError: + pass + + self._state = PlaybackState.IDLE + self._current_proc = None + self._piper_proc = None + return True + + def is_playing(self) -> bool: + """Check if currently playing.""" + with self._lock: + return self._state == PlaybackState.PLAYING + + def wait(self) -> None: + """Block until playback completes (for non-interactive mode).""" + if self._playback_thread: + self._playback_thread.join() + + def get_current_text(self) -> str: + """Get the currently playing text (for replay).""" + return self._current_text + + def _data_dir() -> Path: system = platform.system() if system == "Windows": @@ -66,7 +265,9 @@ def _model_url(name: str) -> tuple[str, str]: return (f"{base}.onnx", f"{base}.onnx.json") -def _download_file(url: str, dest: Path, print_fn: Callable = console.print) -> None: +def _download_file( + url: str, dest: Path, print_fn: Callable[..., None] = console.print +) -> None: print_fn(f"[bold cyan]⬇ Downloading[/bold cyan] {escape(dest.name)}…") urllib.request.urlretrieve(url, dest) print_fn(f"[bold green]✓ Saved[/bold green] {escape(str(dest))}") @@ -77,11 +278,13 @@ class ReedConfig: model: Path = DEFAULT_MODEL speed: float = 1.0 volume: float = 1.0 - silence: float = 0.6 + silence: float = DEFAULT_SILENCE output: Optional[Path] = None -def ensure_model(config: ReedConfig, print_fn: Callable = console.print) -> None: +def ensure_model( + config: ReedConfig, print_fn: Callable[..., None] = console.print +) -> None: if config.model.exists(): return if config.model.parent != _data_dir(): @@ -155,7 +358,7 @@ def _default_clipboard_cmd() -> list[str]: def get_text( args: argparse.Namespace, stdin: TextIO, - run: Callable = subprocess.run, + run: Callable[..., CompletedProcess] = subprocess.run, ) -> str: if args.clipboard: clipboard_cmd = _default_clipboard_cmd() @@ -428,15 +631,17 @@ def build_piper_cmd( return cmd -def print_generation_progress(print_fn: Callable = console.print) -> None: +def print_generation_progress(print_fn: Callable[..., None] = console.print) -> None: print_fn("[bold cyan]⠋ Generating speech...[/bold cyan]") -def print_playback_progress(print_fn: Callable = console.print) -> None: +def print_playback_progress(print_fn: Callable[..., None] = console.print) -> None: print_fn("[bold green]▶ Playing...[/bold green]") -def print_saved_message(output: Path, print_fn: Callable = console.print) -> None: +def print_saved_message( + output: Path, print_fn: Callable[..., None] = console.print +) -> None: panel = Panel.fit( f"[bold green]✓ Successfully saved[/bold green]\n\n" f"[dim]File:[/dim] [cyan]{escape(str(output))}[/cyan]", @@ -446,7 +651,7 @@ def print_saved_message(output: Path, print_fn: Callable = console.print) -> Non print_fn(panel) -def print_error(message: str, print_fn: Callable = console.print) -> None: +def print_error(message: str, print_fn: Callable[..., None] = console.print) -> None: panel = Panel.fit( f"[bold red]{escape(message)}[/bold red]", title="[bold]Error[/bold]", @@ -455,11 +660,11 @@ def print_error(message: str, print_fn: Callable = console.print) -> None: print_fn(panel) -def print_banner(print_fn: Callable = console.print) -> None: +def print_banner(print_fn: Callable[..., None] = console.print) -> None: print_fn(Text.from_markup(BANNER_MARKUP)) -def print_help(print_fn: Callable = console.print) -> None: +def print_help(print_fn: Callable[..., None] = console.print) -> None: text = Text.from_markup("\n[bold]Available Commands:[/bold]\n") for cmd, desc in COMMANDS.items(): cmd_text = Text(cmd) @@ -472,11 +677,24 @@ def print_help(print_fn: Callable = console.print) -> None: def speak_text( text: str, config: ReedConfig, - run: Callable = subprocess.run, - print_fn: Callable = console.print, + run: Callable[..., CompletedProcess] = subprocess.run, + print_fn: Callable[..., None] = console.print, play_cmd: Optional[list[str]] = None, + controller: Optional[PlaybackController] = None, ) -> None: + """Speak text aloud. + + Args: + text: Text to speak. + config: Reed configuration. + run: subprocess runner (for testing). + print_fn: Function for printing messages. + play_cmd: Audio player command (optional, auto-detected if None). + controller: PlaybackController for non-blocking playback (optional). + If provided, playback is non-blocking. If None, blocks. + """ if config.output: + # File output mode - always blocking print_generation_progress(print_fn) start = time.time() piper_cmd = build_piper_cmd( @@ -488,7 +706,12 @@ def speak_text( raise ReedError(f"piper error: {proc.stderr}") print_fn(f"\n[bold green]✓ Done in {elapsed:.1f}s[/bold green]") print_saved_message(config.output, print_fn) + elif controller is not None: + # Non-blocking mode with controller + print_generation_progress(print_fn) + controller.play(text, config) else: + # Legacy blocking mode print_generation_progress(print_fn) start = time.time() with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tmp: @@ -536,9 +759,10 @@ def interactive_loop( speak_line: Callable[[str], None], prompt: str = "> ", quit_words: tuple[str, ...] = QUIT_WORDS, - print_fn: Callable = console.print, + print_fn: Callable[..., None] = console.print, prompt_fn: Optional[Callable[[], str]] = None, - clear_fn: Callable = console.clear, + clear_fn: Callable[..., None] = console.clear, + controller: Optional[PlaybackController] = None, ) -> int: quit_set = {w.lower() for w in quit_words} help_cmd = "/help" @@ -576,7 +800,15 @@ def interactive_loop( print_banner(print_fn) continue elif cmd == replay_cmd: - if last_text: + if controller is not None: + # Replay using controller's stored text + replay_text = controller.get_current_text() + if replay_text: + speak_line(replay_text) + print_fn("") + else: + print_fn("[bold yellow]No text to replay.[/bold yellow]\n") + elif last_text: speak_line(last_text) print_fn("") else: @@ -605,10 +837,10 @@ def _should_enter_interactive( def main( argv: Optional[list[str]] = None, - run: Callable = subprocess.run, - interactive_loop_fn: Optional[Callable] = None, + run: Callable[..., CompletedProcess] = subprocess.run, + interactive_loop_fn: Optional[Callable[..., int]] = None, stdin: Optional[TextIO] = None, - print_fn: Callable = console.print, + print_fn: Callable[..., None] = console.print, ) -> int: if stdin is None: stdin = sys.stdin @@ -650,7 +882,7 @@ def main( parser.add_argument( "--silence", type=float, - default=0.6, + default=DEFAULT_SILENCE, help="Seconds of silence between sentences", ) args = parser.parse_args(argv) @@ -735,12 +967,20 @@ def main( play_cmd = None if _should_enter_interactive(args, stdin): + # Create controller for non-blocking interactive playback + controller = PlaybackController(print_fn=print_fn) loop_fn = interactive_loop_fn or interactive_loop code = loop_fn( speak_line=lambda line: speak_text( - line, config, run=run, print_fn=print_fn, play_cmd=play_cmd + line, + config, + run=run, + print_fn=print_fn, + play_cmd=play_cmd, + controller=controller, ), print_fn=print_fn, + controller=controller, ) return code diff --git a/test_reed.py b/test_reed.py index f340079..2ffcfd1 100644 --- a/test_reed.py +++ b/test_reed.py @@ -1337,3 +1337,369 @@ def isatty(self): stdin=FakeTty(), ) assert code == 0 + + +# ─── PlaybackController tests ──────────────────────────────────────── + + +class TestPlaybackState: + def test_enum_values(self): + from reed import PlaybackState + + assert PlaybackState.IDLE.value == 1 + assert PlaybackState.PLAYING.value == 2 + assert PlaybackState.PAUSED.value == 3 + assert PlaybackState.STOPPED.value == 4 + + +class TestPlaybackController: + def test_init_sets_idle_state(self): + from reed import PlaybackController + + controller = PlaybackController(print_fn=lambda *a, **k: None) + assert controller.is_playing() is False + + def test_play_starts_background_thread(self, monkeypatch): + from reed import PlaybackController, ReedConfig + + started = [] + + def fake_thread(*args, **kwargs): + started.append(True) + # Don't actually start thread in test + return types.SimpleNamespace(start=lambda: None) + + monkeypatch.setattr(_reed.threading, "Thread", fake_thread) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + config = ReedConfig(model=Path("test.onnx")) + controller.play("hello", config) + + assert len(started) == 1 + assert controller.get_current_text() == "hello" + + def test_stop_when_idle_returns_false(self): + from reed import PlaybackController + + controller = PlaybackController(print_fn=lambda *a, **k: None) + result = controller.stop() + assert result is False + + def test_pause_when_not_playing_returns_false(self): + from reed import PlaybackController + + controller = PlaybackController(print_fn=lambda *a, **k: None) + result = controller.pause() + assert result is False + + def test_resume_when_not_paused_returns_false(self): + from reed import PlaybackController + + controller = PlaybackController(print_fn=lambda *a, **k: None) + result = controller.resume() + assert result is False + + def test_pause_on_posix_sends_sigstop(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + sigstop = getattr(_reed.signal, "SIGSTOP", 9999) + monkeypatch.setattr(_reed.signal, "SIGSTOP", sigstop, raising=False) + + signals_sent = [] + fake_proc = types.SimpleNamespace( + send_signal=lambda sig: signals_sent.append(sig), + poll=lambda: None, + ) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PLAYING + controller._current_proc = fake_proc + controller._config = ReedConfig(model=Path("test.onnx")) + + monkeypatch.setattr("reed.os.name", "posix") + + result = controller.pause() + + assert result is True + assert len(signals_sent) == 1 + assert signals_sent[0] == sigstop + assert controller._state == PlaybackState.PAUSED + + def test_pause_on_windows_returns_false(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + fake_proc = types.SimpleNamespace() + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PLAYING + controller._current_proc = fake_proc + controller._config = ReedConfig(model=Path("test.onnx")) + + monkeypatch.setattr("reed.os.name", "nt") + + result = controller.pause() + + assert result is False + assert controller._state == PlaybackState.PLAYING + + def test_pause_on_posix_without_sigstop_returns_false(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + fake_proc = types.SimpleNamespace( + send_signal=lambda sig: None, poll=lambda: None + ) + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PLAYING + controller._current_proc = fake_proc + controller._config = ReedConfig(model=Path("test.onnx")) + + monkeypatch.setattr("reed.os.name", "posix") + monkeypatch.delattr(_reed.signal, "SIGSTOP", raising=False) + + result = controller.pause() + + assert result is False + assert controller._state == PlaybackState.PLAYING + + def test_resume_on_posix_sends_sigcont(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + sigcont = getattr(_reed.signal, "SIGCONT", 9998) + monkeypatch.setattr(_reed.signal, "SIGCONT", sigcont, raising=False) + + signals_sent = [] + fake_proc = types.SimpleNamespace( + send_signal=lambda sig: signals_sent.append(sig), + poll=lambda: None, + ) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PAUSED + controller._current_proc = fake_proc + controller._config = ReedConfig(model=Path("test.onnx")) + + monkeypatch.setattr("reed.os.name", "posix") + + result = controller.resume() + + assert result is True + assert len(signals_sent) == 1 + assert signals_sent[0] == sigcont + assert controller._state == PlaybackState.PLAYING + + def test_resume_on_windows_returns_false(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + fake_proc = types.SimpleNamespace() + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PAUSED + controller._current_proc = fake_proc + controller._config = ReedConfig(model=Path("test.onnx")) + + monkeypatch.setattr("reed.os.name", "nt") + + result = controller.resume() + + assert result is False + assert controller._state == PlaybackState.PAUSED + + def test_resume_on_posix_without_sigcont_returns_false(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + fake_proc = types.SimpleNamespace( + send_signal=lambda sig: None, poll=lambda: None + ) + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PAUSED + controller._current_proc = fake_proc + controller._config = ReedConfig(model=Path("test.onnx")) + + monkeypatch.setattr("reed.os.name", "posix") + monkeypatch.delattr(_reed.signal, "SIGCONT", raising=False) + + result = controller.resume() + + assert result is False + assert controller._state == PlaybackState.PAUSED + + def test_stop_terminates_processes(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + terminated = {"player": False, "piper": False} + + def fake_terminate_player(): + terminated["player"] = True + + def fake_terminate_piper(): + terminated["piper"] = True + + fake_player = types.SimpleNamespace( + terminate=fake_terminate_player, + wait=lambda timeout: None, + ) + fake_piper = types.SimpleNamespace( + terminate=fake_terminate_piper, + poll=lambda: None, + ) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PLAYING + controller._current_proc = fake_player + controller._piper_proc = fake_piper + controller._config = ReedConfig(model=Path("test.onnx")) + + result = controller.stop() + + assert result is True + assert terminated["player"] is True + assert terminated["piper"] is True + assert controller._state == PlaybackState.IDLE + assert controller._current_proc is None + assert controller._piper_proc is None + + def test_stop_handles_process_not_found(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + def fake_terminate(): + raise ProcessLookupError("No such process") + + def fake_kill(): + pass + + fake_player = types.SimpleNamespace( + terminate=fake_terminate, + wait=lambda timeout: (_ for _ in ()).throw(ProcessLookupError()), + kill=fake_kill, + ) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PLAYING + controller._current_proc = fake_player + controller._config = ReedConfig(model=Path("test.onnx")) + + result = controller.stop() + + assert result is True + assert controller._state == PlaybackState.IDLE + + def test_stop_handles_timeout_then_kills(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig, subprocess + + wait_called = [0] + + def fake_wait(timeout): + wait_called[0] += 1 + if wait_called[0] == 1: + raise subprocess.TimeoutExpired(cmd="test", timeout=timeout) + + def fake_kill(): + pass + + fake_player = types.SimpleNamespace( + terminate=lambda: None, + wait=fake_wait, + kill=fake_kill, + ) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PLAYING + controller._current_proc = fake_player + controller._config = ReedConfig(model=Path("test.onnx")) + + result = controller.stop() + + assert result is True + assert controller._state == PlaybackState.IDLE + + def test_get_current_text_returns_last_text(self): + from reed import PlaybackController + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._current_text = "test text" + assert controller.get_current_text() == "test text" + + def test_wait_joins_thread(self, monkeypatch): + from reed import PlaybackController + + joined = [] + fake_thread = types.SimpleNamespace(join=lambda: joined.append(True)) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._playback_thread = fake_thread + + controller.wait() + + assert joined == [True] + + def test_wait_with_no_thread_does_nothing(self): + from reed import PlaybackController + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._playback_thread = None + controller.wait() # Should not raise + + def test_play_stops_existing_before_starting(self, monkeypatch): + from reed import PlaybackController, PlaybackState, ReedConfig + + stopped = [] + config = ReedConfig(model=Path("test.onnx")) + + controller = PlaybackController(print_fn=lambda *a, **k: None) + controller._state = PlaybackState.PLAYING + + def fake_stop_locked(): + stopped.append(True) + + controller._stop_locked = fake_stop_locked + + controller.play("new text", config) + + assert stopped == [True] + assert controller.get_current_text() == "new text" + + +# ─── speak_text with controller tests ──────────────────────────────── + + +class TestSpeakTextWithController: + def test_with_controller_uses_non_blocking_playback(self, monkeypatch): + from reed import PlaybackController, ReedConfig, speak_text + + played_texts = [] + + def fake_play(self, text, config): + played_texts.append(text) + + monkeypatch.setattr(PlaybackController, "play", fake_play) + + config = ReedConfig(model=Path("test.onnx")) + controller = PlaybackController(print_fn=lambda *a, **k: None) + + speak_text( + "hello", config, print_fn=lambda *a, **k: None, controller=controller + ) + + assert played_texts == ["hello"] + + def test_with_controller_output_mode_still_blocking(self, monkeypatch): + from reed import PlaybackController, ReedConfig, speak_text + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return types.SimpleNamespace(returncode=0, stderr="") + + config = ReedConfig(model=Path("test.onnx"), output=Path("/tmp/out.wav")) + controller = PlaybackController(print_fn=lambda *a, **k: None) + + speak_text( + "hello", + config, + run=fake_run, + print_fn=lambda *a, **k: None, + controller=controller, + ) + + # Should only call piper (not player), controller not used for output mode + assert len(calls) == 1 + assert "--output-file" in calls[0]