diff --git a/AGENTS.md b/AGENTS.md index eabe75b..55b9ab0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` — 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` @@ -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 diff --git a/README.md b/README.md index 2827c84..cb85d14 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 ` — 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 diff --git a/reed.py b/reed.py index 70833e1..df6207f 100755 --- a/reed.py +++ b/reed.py @@ -300,8 +300,9 @@ 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 [/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", @@ -309,6 +310,7 @@ def ensure_model( "/help": "Show this help", "/clear": "Clear screen", "/replay": "Replay last text", + "/load ": "Load and read a PDF or EPUB file", } @@ -768,6 +770,7 @@ def interactive_loop( help_cmd = "/help" clear_cmd = "/clear" replay_cmd = "/replay" + load_cmd = "/load" print_banner(print_fn) @@ -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: @@ -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 + parts = text.split(maxsplit=1) + if len(parts) < 2: + print_fn( + f"[bold yellow]Usage:[/bold yellow] {parts[0]} \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: diff --git a/test_reed.py b/test_reed.py index 2ffcfd1..5ed5fb0 100644 --- a/test_reed.py +++ b/test_reed.py @@ -221,6 +221,273 @@ def test_banner_printed(self): assert result == 0 assert any("reed" in str(item) for item in printed) + def test_load_command_with_valid_pdf(self, tmp_path, monkeypatch): + from reed import interactive_loop + + # Create a mock PDF file + pdf_path = tmp_path / "test.pdf" + pdf_path.write_text("%PDF-1.4") # Minimal PDF header + + spoken: list[str] = [] + printed: list[object] = [] + + def mock_iter_pdf_pages(path, selection): + yield (1, 1, "Test PDF content") + + monkeypatch.setattr(_reed, "_iter_pdf_pages", mock_iter_pdf_pages) + + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: printed.append(args[0] if args else None), + prompt_fn=_make_prompt_fn(["/load " + str(pdf_path), "/quit"]), + ) + assert result == 0 + assert "Test PDF content" in spoken + + def test_pdf_command_alias(self, tmp_path, monkeypatch): + from reed import interactive_loop + + pdf_path = tmp_path / "test.pdf" + pdf_path.write_text("%PDF-1.4") + + spoken: list[str] = [] + + def mock_iter_pdf_pages(path, selection): + yield (1, 1, "Test PDF content") + + monkeypatch.setattr(_reed, "_iter_pdf_pages", mock_iter_pdf_pages) + + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn(["/load " + str(pdf_path), "/quit"]), + ) + assert result == 0 + assert "Test PDF content" in spoken + + def test_load_command_file_not_found(self, tmp_path): + from reed import interactive_loop + + non_existent = tmp_path / "missing.pdf" + printed: list[object] = [] + + result = interactive_loop( + speak_line=lambda t: None, + print_fn=lambda *args, **kwargs: printed.append(args[0] if args else None), + prompt_fn=_make_prompt_fn(["/load " + str(non_existent), "/quit"]), + ) + assert result == 0 + assert any("File not found" in str(item) for item in printed) + + def test_load_command_unsupported_type(self, tmp_path): + from reed import interactive_loop + + txt_path = tmp_path / "test.txt" + txt_path.write_text("Some text") + printed: list[object] = [] + + result = interactive_loop( + speak_line=lambda t: None, + print_fn=lambda *args, **kwargs: printed.append(args[0] if args else None), + prompt_fn=_make_prompt_fn(["/load " + str(txt_path), "/quit"]), + ) + assert result == 0 + assert any("Unsupported file type" in str(item) for item in printed) + + def test_load_command_windows_path_preserves_backslashes(self, monkeypatch): + from reed import interactive_loop + + windows_pdf = r"C:\Users\runneradmin\book.pdf" + spoken: list[str] = [] + + class FakePath: + def __init__(self, raw: str): + self.raw = raw + + def exists(self) -> bool: + return self.raw == windows_pdf + + @property + def suffix(self) -> str: + return ".pdf" if self.raw.lower().endswith(".pdf") else "" + + def mock_iter_pdf_pages(path, selection): + assert path.raw == windows_pdf + yield (1, 1, "Windows path PDF content") + + monkeypatch.setattr(_reed, "Path", FakePath) + monkeypatch.setattr(_reed, "_iter_pdf_pages", mock_iter_pdf_pages) + + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn([f"/load {windows_pdf}", "/quit"]), + ) + assert result == 0 + assert "Windows path PDF content" in spoken + + def test_drag_drop_windows_path_with_escaped_spaces(self, monkeypatch): + from reed import interactive_loop + + windows_pdf = r"C:\Users\runneradmin\My Book.pdf" + escaped_input = r"C:\Users\runneradmin\My\ Book.pdf" + spoken: list[str] = [] + + class FakePath: + def __init__(self, raw: str): + self.raw = raw + + def exists(self) -> bool: + return self.raw == windows_pdf + + @property + def suffix(self) -> str: + return ".pdf" if self.raw.lower().endswith(".pdf") else "" + + def mock_iter_pdf_pages(path, selection): + assert path.raw == windows_pdf + yield (1, 1, "Windows escaped-space PDF content") + + monkeypatch.setattr(_reed, "Path", FakePath) + monkeypatch.setattr(_reed, "_iter_pdf_pages", mock_iter_pdf_pages) + + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn([escaped_input, "/quit"]), + ) + assert result == 0 + assert "Windows escaped-space PDF content" in spoken + + def test_drag_drop_pdf_file_path(self, tmp_path, monkeypatch): + from reed import interactive_loop + + pdf_path = tmp_path / "book.pdf" + pdf_path.write_text("%PDF-1.4") + + spoken: list[str] = [] + + def mock_iter_pdf_pages(path, selection): + yield (1, 1, "Dragged PDF content") + + monkeypatch.setattr(_reed, "_iter_pdf_pages", mock_iter_pdf_pages) + + # Simulate drag-drop by passing the file path as input + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn([str(pdf_path), "/quit"]), + ) + assert result == 0 + assert "Dragged PDF content" in spoken + + def test_drag_drop_pdf_with_quotes(self, tmp_path, monkeypatch): + from reed import interactive_loop + + pdf_path = tmp_path / "book.pdf" + pdf_path.write_text("%PDF-1.4") + + spoken: list[str] = [] + + def mock_iter_pdf_pages(path, selection): + yield (1, 1, "Quoted PDF content") + + monkeypatch.setattr(_reed, "_iter_pdf_pages", mock_iter_pdf_pages) + + # Simulate drag-drop with quotes (common in terminals) + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn(['"' + str(pdf_path) + '"', "/quit"]), + ) + assert result == 0 + assert "Quoted PDF content" in spoken + + def test_drag_drop_epub_file(self, tmp_path, monkeypatch): + from reed import interactive_loop + + epub_path = tmp_path / "book.epub" + epub_path.write_text("EPUB content placeholder") + + spoken: list[str] = [] + + def mock_iter_epub_chapters(path, selection): + yield (1, 1, "EPUB chapter content") + + monkeypatch.setattr(_reed, "_iter_epub_chapters", mock_iter_epub_chapters) + + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn([str(epub_path), "/quit"]), + ) + assert result == 0 + assert "EPUB chapter content" in spoken + + def test_non_existent_file_path_not_treated_as_file(self, tmp_path): + from reed import interactive_loop + + # A path that ends with .pdf but doesn't exist should be treated as text + fake_path = tmp_path / "missing.pdf" + spoken: list[str] = [] + + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn([str(fake_path), "/quit"]), + ) + assert result == 0 + # Should try to speak it as text since file doesn't exist + assert len(spoken) == 1 + + def test_drag_drop_pdf_with_spaces_in_path(self, tmp_path, monkeypatch): + from reed import interactive_loop + + # Create a PDF with spaces in the filename + pdf_path = tmp_path / "My Document.pdf" + pdf_path.write_text("%PDF-1.4") + + spoken: list[str] = [] + + def mock_iter_pdf_pages(path, selection): + yield (1, 1, "PDF with spaces in path") + + monkeypatch.setattr(_reed, "_iter_pdf_pages", mock_iter_pdf_pages) + + # Simulate pasted path with backslash escapes for spaces + escaped_path = str(pdf_path).replace(" ", "\\ ") + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn([escaped_path, "/quit"]), + ) + assert result == 0 + assert "PDF with spaces in path" in spoken + + def test_drag_drop_epub_with_spaces_in_path(self, tmp_path, monkeypatch): + from reed import interactive_loop + + # Create an EPUB with spaces in the filename + epub_path = tmp_path / "My Book.epub" + epub_path.write_text("EPUB content") + + spoken: list[str] = [] + + def mock_iter_epub_chapters(path, selection): + yield (1, 1, "EPUB with spaces in path") + + monkeypatch.setattr(_reed, "_iter_epub_chapters", mock_iter_epub_chapters) + + # Simulate pasted path with backslash escapes for spaces + escaped_path = str(epub_path).replace(" ", "\\ ") + result = interactive_loop( + speak_line=lambda t: spoken.append(t), + print_fn=lambda *args, **kwargs: None, + prompt_fn=_make_prompt_fn([escaped_path, "/quit"]), + ) + assert result == 0 + assert "EPUB with spaces in path" in spoken + # ─── build_piper_cmd tests ────────────────────────────────────────────