Files
epub-tts/src/epub_tts/tts_generic.py
T

168 lines
6.7 KiB
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)
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
"""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.
gen_input_flag(input_path: Path) -> str: Return backend flag for input file or directory.
gen_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 os
import re
import subprocess
from pathlib import Path
from typing import Optional, Callable
from .vendor_utils import build_run_command, ensure_venv
from . import log, PathLike
class GenericTTSBackend:
"""Generic TTS backend for handling text-to-speech conversion.
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.
"""
REPO: Optional[str] = None
CMD: Optional[str] = None
FLAGS: Optional[dict] = {}
DEFAULT_FLAGS: Optional[dict] = {}
INTERMEDIATE_TXT: Optional[bool] = False
INTERMEDIATE_CALL: Optional[Callable] = None
DEFAULT_SPEAKER: Optional[str] = None
DEFAULT_SAMPLE: Optional[str] = None
SUPPORTED_AUDIO_FORMATS = {
# subclasses may override this
".m4b": "m4b",
".wav": "wav",
".flac": "flac",
".mp3": "mp3",
".ogg": "ogg",
}
INPUT_FLAG = {True: "--input-dir", False: "--input-file"}
OUTPUT_FLAG = {True: "--output-dir", False: "--output-file"}
ENV = None # subclasses may override this to set environment variables for the backend
def __init__(self, **kwargs):
log(kwargs)
for k,v in kwargs.items():
setattr(self, k, v)
if hasattr(self, 'CWD') and self.CWD is not None:
self.CWD = Path(self.CWD).resolve()
else:
self.CWD = self._normalize_path(self.output).resolve() if hasattr(self, 'output') else Path.cwd()
if not self.CWD.is_dir():
self.CWD = self.CWD.parent.resolve()
self.ENV = os.environ.copy()
#log(f"SELF:{self.__dict__}")
def _normalize_path(self, value: PathLike) -> Path:
if isinstance(value, Path):
return value.resolve()
else:
return Path(value).resolve()
def gen_input_flag(self, input_path: Path) -> list[str]:
p = self._normalize_path(input_path)
return [self.INPUT_FLAG[p.is_dir()], str(p)]
def gen_output_flag(self, output_path: Path) -> list[str]:
p = self._normalize_path(output_path)
return [self.OUTPUT_FLAG[p.is_dir()], str(p)]
def _build_command(self, input_source: PathLike, output_dest: PathLike) -> list[str]:
if not self.CMD and not self.REPO:
raise ValueError("Either CMD or REPO must be specified for the backend.")
if self.CMD and not self.REPO:
command = [self.CMD]
elif self.REPO and not self.CMD:
command = [str(ensure_venv(self.REPO))]
else:
command = [str(ensure_venv(self.REPO))]
command.extend(["-m",self.CMD] if isinstance(self.CMD, str)
else self.CMD)
command.extend(self.gen_input_flag(input_source))
if self.gen_output_flag:
command.extend(self.gen_output_flag(output_dest))
for flag_name, var_name in self.FLAGS.items():
if getattr(self, var_name, self.DEFAULT_FLAGS[var_name]) is not None:
command.extend([flag_name, str(getattr(self, var_name))
])
return command
def run(self,
input_source: PathLike,
output_dest: PathLike,
**kwargs
) -> subprocess.CompletedProcess:
command = self._build_command(input_source, output_dest)
log(command)
# PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
if not input_source.endswith(".txt"):
completed = subprocess.run(
self._build_command(input_source, output_dest),
cwd=str(self.CWD),
env=self.ENV,
check=True)
if self.INTERMEDIATE_TXT:
txt_file = self._normalize_path(input_source).with_suffix(".txt")
if txt_file.exists():
if (self.INTERMEDIATE_CALL is not None and
kwargs.get('replace_map', None) is not None):
self.INTERMEDIATE_CALL(
txt_file,
txt_file.with_stem(txt_file.stem + "_replaced"), kwargs.get('replace_map', {}))
txt_file = txt_file.with_stem(txt_file.stem + "_replaced")
completed = subprocess.run(
self._build_command(txt_file, output_dest),
cwd=str(self.CWD),
env=self.ENV,
check=True
)
return completed
def _replace_map(self,
input_path: PathLike,
output_path: PathLike,
replace_map: dict = None,
):
"""Run a simple regex substitution for the intermediate text extraction step."""
if replace_map is not None:
pattern = re.compile("|".join(re.escape(key) for key in replace_map.keys()))
with open(input_path, 'r') as src, open(output_path, 'w') as dest:
for line in src:
dest.write(
pattern.sub(lambda match: replace_map[match.group(0)], line))