https://github.com/Halconx2030
feat: resilient retry engine, multiformat doc loader (PDF/EPUB), subtitles, CLI and UI enhancements
42d1e23
Raw History Blame Contribute Delete
6.67 kB
"""Interfaz de línea de comandos (CLI) para síntesis de voz desatendida y por lotes."""
import argparse
import asyncio
import os
import sys
from pathlib import Path
from typing import Optional
# Asegurar que la raíz del proyecto esté en el path de importación
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from src.core.document_loader import DocumentLoader, DocumentLoadError
from src.core.models import TTSConfig
from src.core.text_preprocessor import TextPreprocessor
from src.core.tts_engine import TTSEngine
def create_parser() -> argparse.ArgumentParser:
"""Configura los argumentos admitidos por la herramienta de consola."""
parser = argparse.ArgumentParser(
prog="free-tts",
description="🎙️ Free-TTS: Síntesis de voz ilimitada con Edge TTS",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
input_group = parser.add_mutually_exclusive_group()
input_group.add_argument(
"-t", "--text",
type=str,
help="Cadena de texto directa a sintetizar.",
)
input_group.add_argument(
"-i", "--input",
type=Path,
help="Ruta a un archivo de texto o documento (.txt, .md, .pdf, .epub).",
)
input_group.add_argument(
"--list-voices",
action="store_true",
help="Muestra el catálogo de voces disponibles.",
)
parser.add_argument(
"-o", "--output",
type=Path,
default=None,
help="Ruta del archivo de audio de destino (.mp3).",
)
parser.add_argument(
"-v", "--voice",
type=str,
default="es-ES-AlvaroNeural",
help="Nombre de la voz a utilizar (por defecto: es-ES-AlvaroNeural).",
)
parser.add_argument(
"--locale",
type=str,
default=None,
help="Filtro regional para listar voces (ej. 'es-ES', 'en-US').",
)
parser.add_argument(
"--rate",
type=int,
default=0,
help="Ajuste de velocidad en porcentaje de -50 a 50 (por defecto: 0).",
)
parser.add_argument(
"--pitch",
type=int,
default=0,
help="Ajuste de tono en Hz de -20 a 20 (por defecto: 0).",
)
parser.add_argument(
"--volume",
type=int,
default=0,
help="Ajuste de volumen en porcentaje de -50 a 50 (por defecto: 0).",
)
parser.add_argument(
"--subtitles",
action="store_true",
help="Genera un archivo de subtítulos sincronizados (.srt) junto al audio.",
)
parser.add_argument(
"--no-clean-markdown",
action="store_true",
help="Desactiva la limpieza automática de sintaxis Markdown y URLs.",
)
return parser
async def handle_list_voices(engine: TTSEngine, locale: Optional[str] = None) -> int:
"""Imprime el catálogo de voces formateado en consola."""
print("Obteniendo catálogo de voces...")
voices = await engine.get_voices_by_locale(locale) if locale else await engine.get_voices()
if not voices:
print(f"No se encontraron voces para la región '{locale}'.")
return 0
print(f"\nTotal de voces encontradas: {len(voices)}")
print(f"{'Voz (ShortName)':<32} | {'Género':<8} | {'Región':<10} | {'Nombre descriptivo'}")
print("-" * 80)
for v in voices:
print(f"{v.short_name:<32} | {v.gender:<8} | {v.locale:<10} | {v.friendly_name}")
return 0
async def run_cli(args: argparse.Namespace) -> int:
"""Punto de ejecución asíncrono para coordinar la síntesis desde la terminal."""
engine = TTSEngine()
if args.list_voices:
return await handle_list_voices(engine, args.locale)
# Obtener el texto a procesar
raw_text: str = ""
if args.text:
raw_text = args.text
elif args.input:
try:
raw_text = DocumentLoader.load_file(args.input)
print(f"Documento cargado exitosamente: '{args.input}'")
except DocumentLoadError as err:
print(f"Error al leer documento: {err}", file=sys.stderr)
return 1
else:
print("Error: Debes especificar un texto con '--text' o un archivo con '--input'.", file=sys.stderr)
return 1
clean_md = not args.no_clean_markdown
words, chars = TextPreprocessor.count_words_and_chars(raw_text)
est_duration = TextPreprocessor.estimate_duration_seconds(words, args.rate)
formatted_duration = TextPreprocessor.format_duration(est_duration)
print(f"\n--- Estadísticas del Texto ---")
print(f"Palabras: {words:,} | Caracteres: {chars:,} | Duración estimada: ~{formatted_duration}")
print(f"Voz seleccionada: {args.voice} | Velocidad: {args.rate:+d}% | Tono: {args.pitch:+d}Hz")
if args.subtitles:
print("Generación de subtítulos (.srt): Activada")
print("------------------------------\n")
config = TTSConfig(
voice=args.voice,
rate=args.rate,
pitch=args.pitch,
volume=args.volume,
clean_markdown=clean_md,
generate_subtitles=args.subtitles,
)
def print_progress(current: int, total: int):
percent = int((current / total) * 100)
print(f"\rProcesando fragmento {current}/{total} [{percent}%]...", end="", flush=True)
result = await engine.synthesize(
text=raw_text,
config=config,
progress_callback=print_progress,
)
print() # Salto de línea tras la barra de progreso
if not result.is_success:
print(f"Error en la síntesis: {result.error_message}", file=sys.stderr)
return 1
print(f"\nAudio generado exitosamente:")
print(f"Audio: {result.audio_path}")
if result.subtitle_path:
print(f"Subtítulos (.srt): {result.subtitle_path}")
# Si se especificó una ruta de salida personalizada, mover/copiar el archivo
if args.output and result.audio_path:
dest_audio = Path(args.output).resolve()
dest_audio.parent.mkdir(parents=True, exist_ok=True)
# Renombrar archivo de audio
Path(result.audio_path).replace(dest_audio)
print(f"Guardado en destino: {dest_audio}")
if result.subtitle_path:
dest_srt = dest_audio.with_suffix(".srt")
Path(result.subtitle_path).replace(dest_srt)
print(f"Subtítulos guardados en: {dest_srt}")
return 0
def main():
"""Punto de entrada síncrono para la CLI."""
parser = create_parser()
args = parser.parse_args()
exit_code = asyncio.run(run_cli(args))
sys.exit(exit_code)
if __name__ == "__main__":
main()