Spaces:
Running on Zero
Running on Zero
https://github.com/Halconx2030
feat: resilient retry engine, multiformat doc loader (PDF/EPUB), subtitles, CLI and UI enhancements
42d1e23 Download src/cli.py from Halconx/Free_TTS_Texto_a_Voz_Ilimitado: direct link, hf CLI and curl.
- Browser
- Download file 6.67 kB
-
https://huggingface.co/spaces/Halconx/Free_TTS_Texto_a_Voz_Ilimitado/resolve/main/src/cli.py
- Command line
-
hf download hf://spaces/Halconx/Free_TTS_Texto_a_Voz_Ilimitado/src/cli.py
-
curl -L -o cli.py https://huggingface.co/spaces/Halconx/Free_TTS_Texto_a_Voz_Ilimitado/resolve/main/src/cli.py
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() | |