"""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()