#!/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))