168 lines
6.7 KiB
Python
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)) |