#!/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 re import subprocess from pathlib import Path from typing import Optional, Callable from .vendor_utils import build_run_command from . import 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] = {} 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"} def __init__(self,**kwargs): for k,v in kwargs.items(): setattr(self, k, v) 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 self.REPO is None or self.CMD is None: command = [self.CMD] else: command = build_run_command(self.REPO, CMD=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, flag_name, None) is not None: command.extend([ f"--{flag_name.replace('_', '-')}", 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) print(command) print(f"Running command: {' '.join(command)}") completed = subprocess.run( self._build_command(input_source, output_dest), cwd=str(self._normalize_path(input_source).parent), 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._normalize_path(input_source).parent), 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))