Spaces:
Running on Zero
Running on Zero
File size: 6,666 Bytes
42d1e23 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | """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()
|