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