Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Start here: Read `ARCHITECTURE.md` before implementing features that touch playb
- Read PDF: `reed -f doc.pdf` or `reed -f doc.pdf --pages 1,3-5`
- Read EPUB: `reed -f book.epub` or `reed -f book.epub --pages 1,3-5`
- Interactive mode: `reed` (launches automatically when no input provided)
- Drag and drop PDF/EPUB files to read them aloud (fastest method)
- `/load <path>` — Load and read a PDF or EPUB file (useful in SSH/tmux)
- List voices: `reed voices`
- Download voice: `reed download en_US-amy-medium`
- Typecheck: `mypy reed.py --ignore-missing-imports`
Expand Down Expand Up @@ -71,7 +73,9 @@ Start here: Read `ARCHITECTURE.md` before implementing features that touch playb
- Use Rich library for terminal UI enhancements (colors, panels, spinners, tables)
- Banner styled with rich markup in `BANNER_MARKUP` constant
- Visual feedback includes spinner during TTS generation
- Commands available in interactive mode: `/quit`, `/exit`, `/help`, `/clear`, `/replay`
- Commands available in interactive mode: `/quit`, `/exit`, `/help`, `/clear`, `/replay`, `/load`
- 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
- File loading: When reading multi-page PDFs or multi-chapter EPUBs, each page/chapter waits for the previous one to complete
- Drag-and-drop: Primary method for loading files; `/load` command is for remote terminals
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ A CLI that reads text aloud using [piper-tts](https://github.com/rhasspy/piper).
- **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, history, and non-blocking playback
- **Drag and drop** — drag PDF/EPUB files into interactive mode to read them aloud
- **File loading** — `/load` command in interactive mode for remote terminals
- **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`
Expand Down Expand Up @@ -160,10 +162,20 @@ When launched with no arguments, reed enters interactive mode. Type or paste tex

- Type text and press Enter to hear it
- Type `/quit` or `/exit` to stop
- Available commands in interactive mode: `/help`, `/clear`, `/replay`
- Available commands in interactive mode:
- `/help` — Show help
- `/clear` — Clear screen
- `/replay` — Replay last spoken text
- `/load <path>` — Load and read a PDF or EPUB file
- 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.
#### Drag and Drop

You can drag and drop PDF or EPUB files directly into the interactive mode prompt. The file will be read aloud automatically. Paths with spaces are supported.

**Tip:** Dragging and dropping a file is usually faster than typing `/load`. The command is mainly useful in remote terminals (SSH, tmux) or when you want to use tab completion.

**Note:** Interactive mode uses non-blocking playback for typed text — you can type the next line while audio is still playing. When reading multi-page PDFs or multi-chapter EPUBs via `/load` or drag-and-drop, each page/chapter waits for the previous one to complete before starting.

### Voice Management

Expand Down
77 changes: 76 additions & 1 deletion reed.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,17 @@ def ensure_model(
BANNER_MARKUP = """🔊 [bold]reed[/bold] - Interactive Mode
──────────────────────────────────────────────────────────────
[dim]Type or paste text and press Enter to hear it.[/dim]
[dim]Drag and drop PDF/EPUB files or type [bold]/load <path>[/bold] to read files.[/dim]
[dim]Type [bold]/quit[/bold] or [bold]/exit[/bold] to stop. Ctrl-D for EOF.[/dim]
[dim]Available commands: [bold]/help[/bold], [bold]/clear[/bold], [bold]/replay[/bold][/dim]"""
[dim]Available commands: [bold]/help[/bold], [bold]/clear[/bold], [bold]/replay[/bold], [bold]/load[/bold][/dim]"""

COMMANDS = {
"/quit": "Exit interactive mode",
"/exit": "Exit interactive mode (sync)",
"/help": "Show this help",
"/clear": "Clear screen",
"/replay": "Replay last text",
"/load <path>": "Load and read a PDF or EPUB file",
}


Expand Down Expand Up @@ -768,6 +770,7 @@ def interactive_loop(
help_cmd = "/help"
clear_cmd = "/clear"
replay_cmd = "/replay"
load_cmd = "/load"

print_banner(print_fn)

Expand All @@ -777,6 +780,52 @@ def interactive_loop(

last_text = ""

def _path_candidates(path_text: str) -> list[str]:
stripped = path_text.strip("\"'")
normalized = stripped.replace("\\ ", " ")
if normalized == stripped:
return [normalized]
return [normalized, stripped]

def _read_file_path(file_path_str: str) -> None:
"""Load and read a PDF or EPUB file."""
file_path: Optional[Path] = None
for candidate in _path_candidates(file_path_str):
candidate_path = Path(candidate)
if candidate_path.exists():
file_path = candidate_path
break

if file_path is None:
print_fn(f"[bold red]File not found:[/bold red] {file_path_str}\n")
return

suffix = file_path.suffix.lower()
if suffix not in (".pdf", ".epub"):
print_fn(
f"[bold red]Unsupported file type:[/bold red] {suffix} (use .pdf or .epub)\n"
)
return

try:
if suffix == ".pdf":
for page_num, total, page_text in _iter_pdf_pages(file_path, None):
print_fn(f"\n[bold cyan]📄 Page {page_num}/{total}[/bold cyan]")
speak_line(page_text)
# Wait for current speech to complete before next page
if controller is not None:
controller.wait()
elif suffix == ".epub":
for ch_num, total, ch_text in _iter_epub_chapters(file_path, None):
print_fn(f"\n[bold cyan]📖 Chapter {ch_num}/{total}[/bold cyan]")
speak_line(ch_text)
# Wait for current speech to complete before next chapter
if controller is not None:
controller.wait()
except ReedError as e:
print_error(str(e), print_fn)
print_fn("")

try:
while True:
try:
Expand Down Expand Up @@ -814,6 +863,32 @@ def interactive_loop(
else:
print_fn("[bold yellow]No text to replay.[/bold yellow]\n")
continue
elif cmd.startswith(load_cmd + " "):
# Handle /load <path>
parts = text.split(maxsplit=1)
if len(parts) < 2:
print_fn(
f"[bold yellow]Usage:[/bold yellow] {parts[0]} <file-path>\n"
)
continue
file_path_str = parts[1]
_read_file_path(file_path_str)
continue

# Check if input is a file path (drag-and-drop or pasted)
def _try_detect_file_path(input_text: str) -> Optional[str]:
"""Try to detect if input is a file path. Returns cleaned path or None."""
for candidate in _path_candidates(input_text):
if not candidate.lower().endswith((".pdf", ".epub")):
continue
if Path(candidate).exists():
return candidate
return None

detected_path = _try_detect_file_path(text)
if detected_path:
_read_file_path(detected_path)
continue

lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
if not lines:
Expand Down
Loading