diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..60d587f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Instructions to LLM Agents + +This is a Python project designed to be run in a separate virtual environment with Python version >=3.11 nad its main objective is to convert any epub file without DRM to an audiobook with text-to-speech tools available on Github, Hugging Face or publicy available. + +You may search for new tools to use, as long as they are kept as a separate package, preferrably as a git submodule under `./src/vendor/` and creating an interface inside `./src/epub_tts/.py` to call the package in its own virtual environment, using python's builtin `subprocess.call()` or `subprocess.run()`. + +If you add such a new tool, include a new item to the `backend` dictionary inside `./src/epub_tts/main.py`. + +## Attribution +- For every python file created, add the following 4 lines to its top and follow it with your attribution string, authorship and then with a short docstring describing the file's purpose. +```python +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +``` + +## Package source code +- This project is called `epub-tts` and it is a Python>=3.11 package available under the folder [src/epub_tts](./src/epub_tts) +- Parse metadata inside [pyproject.toml](./pyproject.toml), including coding style configuration for `ruff` +- The subfolders under [src/vendor](./src/vendor) are external packages required for this project + +## Dev environment tips +- Use `pip install '.[dev]'` to install package in editable mode with optional dependencies. + +## PR instructions +- Title format: `[epub-tts] ` +- Always run `ruff check --fix ./src/epub_tts` and `ruff format ./src/epub_tts` before committing. +- Write clear commit messages stating a general description of changes. +- Do not change any version or git tags, because they are handled automaticaly by `setuptools-scm` + +## Operating Model +- Ask quick clarifying questions if versions, entry points, or expected behaviors are ambiguous. +- Prefer small, incremental changes with brief plans for multi-step work. +- Do not commit secrets, especially .env files or any TOKENS, APITOKENS, APIKEYS or KEYS in general; +- avoid committing large build artifacts unless requested. + + +## Typical Tasks +- Implement features, fix bugs, run tests, and update docs as requested by the user. +- Keep the environment reproducible and synced with the declared requirements. diff --git a/pyproject.toml b/pyproject.toml index 8f9b0d0..509507f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ exclude = [ [tool.ruff.lint] # select = [...] # See the Default Rules page for the full listing. -ignore = [] +ignore = ["E501"] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] diff --git a/src/epub_tts/__main__.py b/src/epub_tts/__main__.py index 949068d..c582c27 100644 --- a/src/epub_tts/__main__.py +++ b/src/epub_tts/__main__.py @@ -7,6 +7,15 @@ import sys from argparse import ArgumentParser +from epub_tts import tts_backend +from epub_tts import tts_edge + +backend = { + # keys are the backend names, values are the corresponding TTS classes + "default": tts_backend.GenericTTSBackend, + "edge": tts_edge.TTSEdge, +} + def main(args=None): p = ArgumentParser(description="Convert EPUB to audio using TTS") @@ -20,10 +29,15 @@ def main(args=None): p.add_argument("-o", "--output", help="Output audio file", required=True) p.add_argument("-l", "--language", help="Language to use for TTS") p.add_argument("-v", "--voice", help="Voice to use for TTS") - + p.add_argument("-b", "--backend", + help="Backend to use for TTS", + default="default", + choices=backend.keys()) args = p.parse_args(args or sys.argv[1:]) print( - f"Converting {args.input} to {args.output} using voice {args.voice}, language {args.language}, ..." + f"Converting {args.input} to {args.output} using voice " + f"{args.voice}, language {args.language}, " + f"backend {args.backend}, ..." ) diff --git a/src/epub_tts/tts_backend.py b/src/epub_tts/tts_backend.py new file mode 100644 index 0000000..50c4c05 --- /dev/null +++ b/src/epub_tts/tts_backend.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Generic TTS Backend for handling text-to-speech conversion. + +This module provides a generic TTS backend that can be used to convert text to speech using various TTS engines. It defines a `GenericTTSBackend` class that can be initialized with a specific backend command, language, and voice. The class provides methods to normalize paths, determine input and output flags based on the type of input/output (file or directory), build the command for the TTS engine, and run the command. + +Classes: + GenericTTSBackend: + Parameters: + backend_cmd (str): Command or executable for the backend. + language (Optional[str]): Language code for TTS output. + voice (Optional[str]): Voice name for TTS output. + Methods: + _normalize_path(value: PathLike) -> Path: Normalize a string or Path to Path. + _input_flag(input_path: Path) -> str: Return backend flag for input file or directory. + _output_flag(output_path: Path) -> str: Return backend flag for output file or directory. + _build_command(input_source: PathLike, output_dest: PathLike) -> list[str]: Build command list for subprocess. + run(input_source: PathLike, output_dest: PathLike) -> subprocess.CompletedProcess: Execute backend command. + +""" + +import subprocess +from pathlib import Path +from typing import Optional, Union + +PathLike = Union[str, Path] + + +class GenericTTSBackend: + def __init__( + self, + backend_cmd: str, + language: Optional[str] = None, + voice: Optional[str] = None, + ): + self.backend_cmd = backend_cmd + self.language = language + self.voice = voice + + def _normalize_path(self, value: PathLike) -> Path: + if isinstance(value, Path): + return value + return Path(value) + + def _input_flag(self, input_path: Path) -> str: + return "--input-dir" if input_path.is_dir() else "--input-file" + + def _output_flag(self, output_path: Path) -> str: + return "--output-dir" if output_path.is_dir() else "--output-file" + + def _build_command( + self, input_source: PathLike, output_dest: PathLike + ) -> list[str]: + input_path = self._normalize_path(input_source) + output_path = self._normalize_path(output_dest) + + command = [ + self.backend_cmd, + self._input_flag(input_path), + str(input_path), + self._output_flag(output_path), + str(output_path), + ] + + if self.language: + command.extend(["--language", self.language]) + if self.voice: + command.extend(["--voice", self.voice]) + + return command + + def run( + self, input_source: PathLike, output_dest: PathLike + ) -> subprocess.CompletedProcess: + command = self._build_command(input_source, output_dest) + return subprocess.run(command, check=True)