filtros-limpieza-total.txt(файл создан)
| @@ -0,0 +1,21 @@ | |||
| 1 | + | # ============================================================================== | |
| 2 | + | # Pipeline completo de limpieza para audios muy ruidosos | |
| 3 | + | # REGLA DE ORO: limpieza ANTES de normalización | |
| 4 | + | # Episodio 842 — atareao con Linux | |
| 5 | + | # ============================================================================== | |
| 6 | + | # | |
| 7 | + | # 1. highpass=f=80 → Quita frecuencias muy graves (rumor, viento) | |
| 8 | + | # 2. lowpass=f=8000 → Quita frecuencias muy agudas (siseo, ruido electrónico) | |
| 9 | + | # 3. afftdn → Reduce ruido de fondo (FFT Denoising) | |
| 10 | + | # 4. loudnorm → Normaliza el volumen final (EBU R128, -23 LUFS) | |
| 11 | + | # | |
| 12 | + | # Uso: | |
| 13 | + | # ffmpeg -i grabacion_mala.mp3 \ | |
| 14 | + | # -af "$(cat filtros-limpieza-total.txt)" \ | |
| 15 | + | # salida_limpia_normalizada.wav | |
| 16 | + | # ============================================================================== | |
| 17 | + | ||
| 18 | + | highpass=f=80, | |
| 19 | + | lowpass=f=8000, | |
| 20 | + | afftdn, | |
| 21 | + | loudnorm | |
filtros-tts-profesional.txt(файл создан)
| @@ -0,0 +1,18 @@ | |||
| 1 | + | # ============================================================================== | |
| 2 | + | # Filtros ffmpeg para postprocesado profesional de TTS | |
| 3 | + | # Episodio 842 — atareao con Linux | |
| 4 | + | # ============================================================================== | |
| 5 | + | # | |
| 6 | + | # Aplica estos filtros a la salida de Piper, Kokoro o cualquier TTS | |
| 7 | + | # para que suene más natural y profesional. | |
| 8 | + | # | |
| 9 | + | # Uso: | |
| 10 | + | # ffmpeg -i tts_output.wav -af "$(cat filtros-tts-profesional.txt)" \ | |
| 11 | + | # salida_profesional.wav | |
| 12 | + | # ============================================================================== | |
| 13 | + | ||
| 14 | + | loudnorm, | |
| 15 | + | compand=attacks=0.1:decays=0.2:\ | |
| 16 | + | points=-80/-80|-45/-45|-30/-30|-25/-20|-20/-15|\ | |
| 17 | + | -15/-12|-10/-6|-6/-3|0/-0.5, | |
| 18 | + | highpass=f=80 | |
pipeline-completo.py(файл создан)
| @@ -0,0 +1,420 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """ | |
| 3 | + | Pipeline completo: micrófono → ffmpeg → Whisper → Ollama → TTS → ffmpeg → altavoz | |
| 4 | + | ================================================================================ | |
| 5 | + | ||
| 6 | + | Este script une todo lo visto en el arco de audio/vídeo de la Temporada 9: | |
| 7 | + | 842 — ffmpeg (preparación y postprocesado de audio) | |
| 8 | + | 843 — Whisper (transcripción local) | |
| 9 | + | 845 — TTS con Piper/Kokoro (voz sintética) | |
| 10 | + | 847 — visión artificial local (LLaVA, Florence-2, Moondream) | |
| 11 | + | ||
| 12 | + | Requisitos: | |
| 13 | + | pip install faster-whisper requests piper-tts | |
| 14 | + | ||
| 15 | + | Además necesitas tener instalado: | |
| 16 | + | - ffmpeg (sudo apt install ffmpeg) | |
| 17 | + | - Ollama corriendo en localhost:11434 | |
| 18 | + | - Piper TTS con voz en español (es_ES-sharvard-medium.onnx) | |
| 19 | + | ||
| 20 | + | atareao con Linux — Episodio 842 | |
| 21 | + | """ | |
| 22 | + | ||
| 23 | + | import subprocess | |
| 24 | + | import tempfile | |
| 25 | + | import time | |
| 26 | + | from pathlib import Path | |
| 27 | + | from typing import Optional | |
| 28 | + | ||
| 29 | + | import requests | |
| 30 | + | ||
| 31 | + | # ── Configuración ──────────────────────────────────────────────────────── | |
| 32 | + | OLLAMA_URL = "http://localhost:11434/api/generate" | |
| 33 | + | OLLAMA_MODEL = "llama3.2:latest" | |
| 34 | + | ||
| 35 | + | ||
| 36 | + | # ── Colores para terminal ──────────────────────────────────────────────── | |
| 37 | + | class Color: | |
| 38 | + | CYAN = "\033[0;36m" | |
| 39 | + | GREEN = "\033[0;32m" | |
| 40 | + | YELLOW = "\033[1;33m" | |
| 41 | + | RED = "\033[0;31m" | |
| 42 | + | NC = "\033[0m" | |
| 43 | + | ||
| 44 | + | ||
| 45 | + | def print_step(emoji: str, msg: str) -> None: | |
| 46 | + | """Imprime un paso del pipeline con formato.""" | |
| 47 | + | print(f"\n{emoji} {Color.CYAN}{msg}{Color.NC}") | |
| 48 | + | print("─" * 50) | |
| 49 | + | ||
| 50 | + | ||
| 51 | + | def print_ok(msg: str) -> None: | |
| 52 | + | print(f" {Color.GREEN}✅{Color.NC} {msg}") | |
| 53 | + | ||
| 54 | + | ||
| 55 | + | def print_warn(msg: str) -> None: | |
| 56 | + | print(f" {Color.YELLOW}⚠️{Color.NC} {msg}") | |
| 57 | + | ||
| 58 | + | ||
| 59 | + | # ── Paso 1: Grabar y preparar con ffmpeg ──────────────────────────────── | |
| 60 | + | def grabar_y_preparar(segundos: int = 5) -> Optional[str]: | |
| 61 | + | """ | |
| 62 | + | Graba audio del micrófono y lo prepara para Whisper con ffmpeg. | |
| 63 | + | ||
| 64 | + | 1. arecord captura el audio en bruto | |
| 65 | + | 2. ffmpeg lo convierte a: 16 kHz, mono, s16le + loudnorm | |
| 66 | + | ||
| 67 | + | Returns: | |
| 68 | + | Ruta al archivo WAV listo para Whisper, o None si falla. | |
| 69 | + | """ | |
| 70 | + | print_step("🎤", f"Grabando {segundos} segundos...") | |
| 71 | + | ||
| 72 | + | # Archivo temporal para la grabación en bruto | |
| 73 | + | with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: | |
| 74 | + | raw_path = f.name | |
| 75 | + | ||
| 76 | + | # Archivo para el audio preparado | |
| 77 | + | whisper_path = raw_path.replace(".wav", "_whisper.wav") | |
| 78 | + | ||
| 79 | + | # ── arecord: grabar desde el micrófono ────────────────────────── | |
| 80 | + | # PipeWire expone el micrófono como dispositivo PulseAudio. | |
| 81 | + | # arecord es la herramienta CLI de ALSA/PulseAudio para grabar. | |
| 82 | + | try: | |
| 83 | + | subprocess.run( | |
| 84 | + | ["arecord", "-f", "cd", "-t", "wav", "-d", str(segundos), raw_path], | |
| 85 | + | capture_output=True, | |
| 86 | + | check=True, | |
| 87 | + | ) | |
| 88 | + | print_ok(f"Grabación cruda: {raw_path}") | |
| 89 | + | except subprocess.CalledProcessError as e: | |
| 90 | + | print_warn(f"Error al grabar: {e.stderr.decode().strip()}") | |
| 91 | + | print_warn("Asegúrate de tener el micrófono conectado y PipeWire funcionando.") | |
| 92 | + | Path(raw_path).unlink(missing_ok=True) | |
| 93 | + | return None | |
| 94 | + | except FileNotFoundError: | |
| 95 | + | print_warn("arecord no encontrado. Instala alsa-utils o pulseaudio-utils.") | |
| 96 | + | return None | |
| 97 | + | ||
| 98 | + | # ── ffmpeg: preparar para Whisper ─────────────────────────────── | |
| 99 | + | # Esto es el comando estrella del Episodio 842: | |
| 100 | + | # -ar 16000 → 16 kHz (Whisper lo espera así) | |
| 101 | + | # -ac 1 → Mono | |
| 102 | + | # -c:a pcm_s16le → Formato nativo WAV | |
| 103 | + | # -af loudnorm → Normalización EBU R128 | |
| 104 | + | print_step("⚡", "Preparando audio con ffmpeg (16 kHz, mono, loudnorm)...") | |
| 105 | + | ||
| 106 | + | result = subprocess.run( | |
| 107 | + | [ | |
| 108 | + | "ffmpeg", | |
| 109 | + | "-y", | |
| 110 | + | "-i", | |
| 111 | + | raw_path, | |
| 112 | + | "-ar", | |
| 113 | + | "16000", | |
| 114 | + | "-ac", | |
| 115 | + | "1", | |
| 116 | + | "-c:a", | |
| 117 | + | "pcm_s16le", | |
| 118 | + | "-af", | |
| 119 | + | "loudnorm", | |
| 120 | + | whisper_path, | |
| 121 | + | ], | |
| 122 | + | capture_output=True, | |
| 123 | + | check=False, | |
| 124 | + | ) | |
| 125 | + | ||
| 126 | + | # Limpiar grabación cruda | |
| 127 | + | Path(raw_path).unlink(missing_ok=True) | |
| 128 | + | ||
| 129 | + | if result.returncode != 0 or not Path(whisper_path).exists(): | |
| 130 | + | print_warn(f"Error en ffmpeg: {result.stderr.decode()[:200]}") | |
| 131 | + | return None | |
| 132 | + | ||
| 133 | + | # Mostrar info del archivo resultante | |
| 134 | + | size = Path(whisper_path).stat().st_size | |
| 135 | + | print_ok(f"Audio preparado: {whisper_path} ({size // 1024} KB)") | |
| 136 | + | print_ok("Formato: 16 kHz / Mono / PCM s16le / Normalizado") | |
| 137 | + | return whisper_path | |
| 138 | + | ||
| 139 | + | ||
| 140 | + | # ── Paso 2: Transcribir con Whisper ───────────────────────────────────── | |
| 141 | + | def transcribir(audio_path: str) -> str: | |
| 142 | + | """ | |
| 143 | + | Transcribe el audio preparado con faster-whisper. | |
| 144 | + | ||
| 145 | + | faster-whisper es una reimplementación de Whisper usando CTranslate2, | |
| 146 | + | que es 4x más rápida que la original y usa menos memoria. | |
| 147 | + | ||
| 148 | + | Args: | |
| 149 | + | audio_path: Ruta al WAV preparado con ffmpeg. | |
| 150 | + | ||
| 151 | + | Returns: | |
| 152 | + | Texto transcrito, o cadena vacía si falla. | |
| 153 | + | """ | |
| 154 | + | print_step("📝", "Transcribiendo con Whisper...") | |
| 155 | + | ||
| 156 | + | try: | |
| 157 | + | from faster_whisper import WhisperModel | |
| 158 | + | except ImportError: | |
| 159 | + | print_warn("faster-whisper no instalado. Ejecuta:") | |
| 160 | + | print_warn(" pip install faster-whisper") | |
| 161 | + | return "" | |
| 162 | + | ||
| 163 | + | # Cargar modelo (descarga automática la primera vez) | |
| 164 | + | print_ok("Cargando modelo Whisper 'base'...") | |
| 165 | + | t0 = time.time() | |
| 166 | + | model = WhisperModel("base", device="cpu", compute_type="int8") | |
| 167 | + | print_ok(f"Modelo cargado en {time.time() - t0:.1f}s") | |
| 168 | + | ||
| 169 | + | # Transcribir | |
| 170 | + | # language="es" fuerza español (evita que detecte mezcla) | |
| 171 | + | # beam_size=1 es más rápido, beam_size>1 más preciso | |
| 172 | + | segments, info = model.transcribe( | |
| 173 | + | audio_path, | |
| 174 | + | language="es", | |
| 175 | + | beam_size=1, | |
| 176 | + | ) | |
| 177 | + | ||
| 178 | + | texto = " ".join(segment.text for segment in segments) | |
| 179 | + | print_ok( | |
| 180 | + | f"Idioma detectado: {info.language} (prob: {info.language_probability:.2f})" | |
| 181 | + | ) | |
| 182 | + | ||
| 183 | + | # Limpiar archivo temporal | |
| 184 | + | Path(audio_path).unlink(missing_ok=True) | |
| 185 | + | ||
| 186 | + | return texto.strip() | |
| 187 | + | ||
| 188 | + | ||
| 189 | + | # ── Paso 3: Consultar a Ollama ────────────────────────────────────────── | |
| 190 | + | def consultar_ollama(prompt: str) -> str: | |
| 191 | + | """ | |
| 192 | + | Envía el texto transcrito a Ollama y obtiene una respuesta. | |
| 193 | + | ||
| 194 | + | Ollama corre el modelo localmente (llama3.2, mistral, etc.). | |
| 195 | + | Nada se envía a la nube. | |
| 196 | + | ||
| 197 | + | Args: | |
| 198 | + | prompt: Texto transcrito del usuario. | |
| 199 | + | ||
| 200 | + | Returns: | |
| 201 | + | Respuesta del modelo, o mensaje de error. | |
| 202 | + | """ | |
| 203 | + | print_step("🧠", "Consultando Ollama...") | |
| 204 | + | ||
| 205 | + | payload = { | |
| 206 | + | "model": OLLAMA_MODEL, | |
| 207 | + | "prompt": prompt, | |
| 208 | + | "stream": False, | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | try: | |
| 212 | + | t0 = time.time() | |
| 213 | + | resp = requests.post(OLLAMA_URL, json=payload, timeout=60) | |
| 214 | + | resp.raise_for_status() | |
| 215 | + | respuesta = resp.json().get("response", "") | |
| 216 | + | print_ok(f"Respuesta generada en {time.time() - t0:.1f}s") | |
| 217 | + | return respuesta | |
| 218 | + | except requests.exceptions.ConnectionError: | |
| 219 | + | print_warn(f"No se pudo conectar a Ollama en {OLLAMA_URL}") | |
| 220 | + | print_warn("¿Está Ollama corriendo?") | |
| 221 | + | return "Lo siento, no pude conectar con el asistente." | |
| 222 | + | except requests.exceptions.Timeout: | |
| 223 | + | print_warn("Ollama tardó demasiado en responder.") | |
| 224 | + | return "Lo siento, la respuesta tardó demasiado." | |
| 225 | + | except Exception as e: | |
| 226 | + | print_warn(f"Error inesperado: {e}") | |
| 227 | + | return "" | |
| 228 | + | ||
| 229 | + | ||
| 230 | + | # ── Paso 4: Responder con TTS + ffmpeg ───────────────────────────────── | |
| 231 | + | def responder(texto: str) -> None: | |
| 232 | + | """ | |
| 233 | + | Genera voz sintética con Piper y la postprocesa con ffmpeg. | |
| 234 | + | ||
| 235 | + | El pipeline de audio es: | |
| 236 | + | 1. Piper genera WAV crudo | |
| 237 | + | 2. ffmpeg postprocesa con: loudnorm + compand + highpass | |
| 238 | + | 3. aplay reproduce el resultado | |
| 239 | + | ||
| 240 | + | Args: | |
| 241 | + | texto: Texto a convertir a voz. | |
| 242 | + | """ | |
| 243 | + | if not texto: | |
| 244 | + | return | |
| 245 | + | ||
| 246 | + | print_step("🔊", "Generando voz con TTS + ffmpeg...") | |
| 247 | + | ||
| 248 | + | try: | |
| 249 | + | import wave | |
| 250 | + | from piper import PiperVoice | |
| 251 | + | except ImportError: | |
| 252 | + | print_warn("piper-tts no instalado. Ejecuta:") | |
| 253 | + | print_warn(" pip install piper-tts") | |
| 254 | + | print_warn( | |
| 255 | + | " (o descarga el modelo .onnx desde https://github.com/rhasspy/piper)" | |
| 256 | + | ) | |
| 257 | + | # Fallback: mostrar el texto por pantalla | |
| 258 | + | print(f"\n{Color.GREEN}🤖 IA:{Color.NC} {texto}") | |
| 259 | + | return | |
| 260 | + | ||
| 261 | + | # ── Generar WAV con Piper ────────────────────────────────────── | |
| 262 | + | tts_path = "/tmp/tts_raw.wav" | |
| 263 | + | ||
| 264 | + | try: | |
| 265 | + | # Cargar modelo de voz en español | |
| 266 | + | # Descarga el modelo desde: | |
| 267 | + | # https://huggingface.co/rhasspy/piper-voices/tree/main/es/es_ES/sharvard/medium | |
| 268 | + | voice = PiperVoice.load("es_ES-sharvard-medium.onnx") | |
| 269 | + | ||
| 270 | + | with wave.open(tts_path, "wb") as wav_file: | |
| 271 | + | voice.synthesize_wav(texto, wav_file) | |
| 272 | + | ||
| 273 | + | print_ok(f"TTS generado: {tts_path}") | |
| 274 | + | except Exception as e: | |
| 275 | + | print_warn(f"Error al generar TTS con Piper: {e}") | |
| 276 | + | print_warn("Asegúrate de tener el modelo 'es_ES-sharvard-medium.onnx'") | |
| 277 | + | # Fallback | |
| 278 | + | print(f"\n{Color.GREEN}🤖 IA:{Color.NC} {texto[:200]}...") | |
| 279 | + | return | |
| 280 | + | ||
| 281 | + | # ── Postprocesar con ffmpeg ──────────────────────────────────── | |
| 282 | + | # Este es el SEGUNDO comando estrella del Episodio 842: | |
| 283 | + | # loudnorm → Normaliza el volumen | |
| 284 | + | # compand → Comprime el rango dinámico (equilibra suaves y fuertes) | |
| 285 | + | # highpass → Elimina frecuencias < 80 Hz (soplidos) | |
| 286 | + | final_path = "/tmp/tts_final.wav" | |
| 287 | + | ||
| 288 | + | filter_chain = ( | |
| 289 | + | "loudnorm," | |
| 290 | + | "compand=attacks=0.1:decays=0.2:" | |
| 291 | + | "points=-80/-80|-45/-45|-30/-30|-25/-20|-20/-15" | |
| 292 | + | "|-15/-12|-10/-6|-6/-3|0/-0.5," | |
| 293 | + | "highpass=f=80" | |
| 294 | + | ) | |
| 295 | + | ||
| 296 | + | result = subprocess.run( | |
| 297 | + | [ | |
| 298 | + | "ffmpeg", | |
| 299 | + | "-y", | |
| 300 | + | "-i", | |
| 301 | + | tts_path, | |
| 302 | + | "-af", | |
| 303 | + | filter_chain, | |
| 304 | + | final_path, | |
| 305 | + | ], | |
| 306 | + | capture_output=True, | |
| 307 | + | check=False, | |
| 308 | + | ) | |
| 309 | + | ||
| 310 | + | if result.returncode != 0 or not Path(final_path).exists(): | |
| 311 | + | print_warn(f"Error en ffmpeg post-procesado: {result.stderr.decode()[:200]}") | |
| 312 | + | # Reproducir el TTS crudo como fallback | |
| 313 | + | subprocess.run(["aplay", "-q", tts_path], capture_output=True) | |
| 314 | + | Path(tts_path).unlink(missing_ok=True) | |
| 315 | + | return | |
| 316 | + | ||
| 317 | + | size = Path(final_path).stat().st_size | |
| 318 | + | print_ok(f"Audio postprocesado con ffmpeg: {final_path} ({size // 1024} KB)") | |
| 319 | + | print_ok("Filtros aplicados: loudnorm + compand + highpass") | |
| 320 | + | ||
| 321 | + | # ── Reproducir con aplay ─────────────────────────────────────── | |
| 322 | + | print_step("▶️", "Reproduciendo respuesta...") | |
| 323 | + | subprocess.run(["aplay", "-q", final_path], capture_output=True) | |
| 324 | + | print_ok("Reproducción completada") | |
| 325 | + | ||
| 326 | + | # ── Limpiar archivos temporales ──────────────────────────────── | |
| 327 | + | Path(tts_path).unlink(missing_ok=True) | |
| 328 | + | Path(final_path).unlink(missing_ok=True) | |
| 329 | + | print_ok("Archivos temporales eliminados") | |
| 330 | + | ||
| 331 | + | ||
| 332 | + | # ── Bucle principal ───────────────────────────────────────────────────── | |
| 333 | + | def main() -> None: | |
| 334 | + | """ | |
| 335 | + | Bucle principal del asistente de voz. | |
| 336 | + | ||
| 337 | + | Pipeline completo: | |
| 338 | + | Micrófono → [arecord] → ffmpeg → [Whisper] → Ollama → [Piper] → ffmpeg → [aplay] | |
| 339 | + | """ | |
| 340 | + | print("\n" + "═" * 60) | |
| 341 | + | print(f"{Color.CYAN}🎤 Asistente de voz local{Color.NC}") | |
| 342 | + | print("Episodio 842 — atareao con Linux") | |
| 343 | + | print( | |
| 344 | + | f"{Color.GREEN}Mic → ffmpeg → Whisper → Ollama → TTS → ffmpeg → Altavoz{Color.NC}" | |
| 345 | + | ) | |
| 346 | + | print("═" * 60) | |
| 347 | + | print(f"\n{Color.YELLOW}Habla. Pulsa Ctrl+C para salir.{Color.NC}\n") | |
| 348 | + | ||
| 349 | + | # Verificar dependencias al inicio | |
| 350 | + | dependencies_ok = True | |
| 351 | + | ||
| 352 | + | # Verificar ffmpeg | |
| 353 | + | if not subprocess.run(["which", "ffmpeg"], capture_output=True).returncode == 0: | |
| 354 | + | print_warn("ffmpeg no está instalado. Ejecuta: sudo apt install ffmpeg") | |
| 355 | + | dependencies_ok = False | |
| 356 | + | ||
| 357 | + | # Verificar Ollama | |
| 358 | + | try: | |
| 359 | + | requests.get(OLLAMA_URL.replace("/api/generate", "/api/tags"), timeout=5) | |
| 360 | + | print_ok(f"Ollama conectado en {OLLAMA_URL}") | |
| 361 | + | except Exception: | |
| 362 | + | print_warn(f"Ollama no responde en {OLLAMA_URL}") | |
| 363 | + | print_warn("¿Está corriendo? Ejecuta: ollama serve && ollama run llama3.2") | |
| 364 | + | dependencies_ok = False | |
| 365 | + | ||
| 366 | + | # Verificar arecord | |
| 367 | + | if not subprocess.run(["which", "arecord"], capture_output=True).returncode == 0: | |
| 368 | + | print_warn("arecord no encontrado. Instala alsa-utils.") | |
| 369 | + | dependencies_ok = False | |
| 370 | + | ||
| 371 | + | if not dependencies_ok: | |
| 372 | + | print( | |
| 373 | + | f"\n{Color.RED}⚠️ Hay dependencias faltantes. Revisa los mensajes anteriores.{Color.NC}" | |
| 374 | + | ) | |
| 375 | + | print("El script intentará continuar, pero puede fallar en algunos pasos.\n") | |
| 376 | + | ||
| 377 | + | # ── Bucle principal ──────────────────────────────────────────── | |
| 378 | + | try: | |
| 379 | + | while True: | |
| 380 | + | print("\n" + "━" * 60) | |
| 381 | + | ||
| 382 | + | # 1. Grabar y preparar con ffmpeg | |
| 383 | + | audio = grabar_y_preparar(segundos=5) | |
| 384 | + | if audio is None: | |
| 385 | + | print_warn("No se pudo grabar. Reintentando...") | |
| 386 | + | continue | |
| 387 | + | ||
| 388 | + | # 2. Transcribir con Whisper | |
| 389 | + | texto = transcribir(audio) | |
| 390 | + | if not texto: | |
| 391 | + | print_warn("No se detectó voz. ¿Demasiado silencio?") | |
| 392 | + | continue | |
| 393 | + | ||
| 394 | + | print(f"\n{Color.GREEN}📝 Tú:{Color.NC} {texto}") | |
| 395 | + | ||
| 396 | + | # 3. Consultar a Ollama | |
| 397 | + | respuesta = consultar_ollama(texto) | |
| 398 | + | if not respuesta: | |
| 399 | + | print_warn("No se obtuvo respuesta de Ollama.") | |
| 400 | + | continue | |
| 401 | + | ||
| 402 | + | # Mostrar preview de la respuesta | |
| 403 | + | preview = respuesta[:150] + ("..." if len(respuesta) > 150 else "") | |
| 404 | + | print(f"{Color.YELLOW}🤖 IA: {preview}{Color.NC}") | |
| 405 | + | ||
| 406 | + | # 4. Responder con TTS + ffmpeg | |
| 407 | + | responder(respuesta) | |
| 408 | + | ||
| 409 | + | print("─" * 60) | |
| 410 | + | ||
| 411 | + | except KeyboardInterrupt: | |
| 412 | + | print(f"\n\n{Color.GREEN}👋 ¡Hasta luego!{Color.NC}") | |
| 413 | + | print("Recuerda: ffmpeg es la navaja suiza del audio para tu IA.") | |
| 414 | + | except Exception as e: | |
| 415 | + | print(f"\n{Color.RED}❌ Error inesperado:{Color.NC} {e}") | |
| 416 | + | raise | |
| 417 | + | ||
| 418 | + | ||
| 419 | + | if __name__ == "__main__": | |
| 420 | + | main() | |
whisperize.sh(файл создан)
| @@ -0,0 +1,104 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # ============================================================================== | |
| 3 | + | # whisperize.sh — Script autónomo para preparar audio para Whisper | |
| 4 | + | # | |
| 5 | + | # Convierte CUALQUIER archivo de audio al formato que Whisper espera: | |
| 6 | + | # - Frecuencia de muestreo: 16.000 Hz (16 kHz) | |
| 7 | + | # - Canales: Mono (1) | |
| 8 | + | # - Formato: PCM signed 16-bit little-endian (pcm_s16le) | |
| 9 | + | # - Normalizado con loudnorm (EBU R128, -23 LUFS) | |
| 10 | + | # | |
| 11 | + | # Uso: | |
| 12 | + | # ./whisperize.sh grabacion.mp3 → grabacion_whisper.wav | |
| 13 | + | # ./whisperize.sh grabacion.mp3 salida → salida.wav | |
| 14 | + | # | |
| 15 | + | # atareao con Linux — Episodio 842 | |
| 16 | + | # ============================================================================== | |
| 17 | + | set -euo pipefail | |
| 18 | + | ||
| 19 | + | # ── Colores (si terminal lo soporta) ────────────────────────────────────── | |
| 20 | + | if [ -t 1 ]; then | |
| 21 | + | VERDE='\033[0;32m' | |
| 22 | + | AMARILLO='\033[1;33m' | |
| 23 | + | CYAN='\033[0;36m' | |
| 24 | + | NC='\033[0m' # Sin color | |
| 25 | + | else | |
| 26 | + | VERDE=''; AMARILLO=''; CYAN=''; NC='' | |
| 27 | + | fi | |
| 28 | + | ||
| 29 | + | # ── Ayuda ───────────────────────────────────────────────────────────────── | |
| 30 | + | usage() { | |
| 31 | + | echo "Uso: $0 <entrada> [salida]" | |
| 32 | + | echo "" | |
| 33 | + | echo "Prepara un archivo de audio para su transcripción con Whisper." | |
| 34 | + | echo "" | |
| 35 | + | echo "Argumentos:" | |
| 36 | + | echo " entrada Ruta al archivo de audio original (cualquier formato)" | |
| 37 | + | echo " salida Ruta del archivo WAV resultante (opcional)" | |
| 38 | + | echo "" | |
| 39 | + | echo "Si no se especifica salida, se genera: <entrada_sin_ext>_whisper.wav" | |
| 40 | + | echo "" | |
| 41 | + | echo "Ejemplos:" | |
| 42 | + | echo " $0 grabacion.mp3" | |
| 43 | + | echo " $0 audio.ogg michelada_whisper.wav" | |
| 44 | + | exit 0 | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | # ── Parsear argumentos ──────────────────────────────────────────────────── | |
| 48 | + | if [ $# -eq 0 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then | |
| 49 | + | usage | |
| 50 | + | fi | |
| 51 | + | ||
| 52 | + | ENTRADA="$1" | |
| 53 | + | SALIDA="${2:-${ENTRADA%.*}_whisper.wav}" | |
| 54 | + | ||
| 55 | + | # ── Validar entrada ─────────────────────────────────────────────────────── | |
| 56 | + | if [ ! -f "$ENTRADA" ]; then | |
| 57 | + | echo -e "${AMARILLO}❌ Error: No se encuentra el archivo '${ENTRADA}'${NC}" >&2 | |
| 58 | + | exit 1 | |
| 59 | + | fi | |
| 60 | + | ||
| 61 | + | if ! command -v ffmpeg &>/dev/null; then | |
| 62 | + | echo -e "${AMARILLO}❌ Error: ffmpeg no está instalado.${NC}" >&2 | |
| 63 | + | echo " Instálalo con: sudo apt install ffmpeg" >&2 | |
| 64 | + | exit 1 | |
| 65 | + | fi | |
| 66 | + | ||
| 67 | + | # ── Mostrar info del archivo original ───────────────────────────────────── | |
| 68 | + | echo -e "${CYAN}🎤 whisperize — Preparando audio para Whisper${NC}" | |
| 69 | + | echo "" | |
| 70 | + | ORIG_SIZE=$(stat -c%s "$ENTRADA" 2>/dev/null || echo "?") | |
| 71 | + | echo -e " ${VERDE}Entrada:${NC} $ENTRADA ($([ -f "$ENTRADA" ] && echo "$(du -h "$ENTRADA" | cut -f1)" || echo "?"))" | |
| 72 | + | echo -e " ${VERDE}Salida:${NC} $SALIDA" | |
| 73 | + | echo "" | |
| 74 | + | ||
| 75 | + | # Mostrar info del audio original (si ffprobe está disponible) | |
| 76 | + | if command -v ffprobe &>/dev/null; then | |
| 77 | + | echo " Información del original:" | |
| 78 | + | ffprobe -v quiet -show_entries \ | |
| 79 | + | stream=codec_name,sample_rate,channels:format=format_name,duration \ | |
| 80 | + | -of default=noprint_wrappers=1 "$ENTRADA" 2>/dev/null \ | |
| 81 | + | | sed 's/^/ /' | |
| 82 | + | echo "" | |
| 83 | + | fi | |
| 84 | + | ||
| 85 | + | # ── Convertir ───────────────────────────────────────────────────────────── | |
| 86 | + | echo -e " ${VERDE}Procesando...${NC}" | |
| 87 | + | ||
| 88 | + | ffmpeg -y \ | |
| 89 | + | -i "$ENTRADA" \ | |
| 90 | + | -ar 16000 \ | |
| 91 | + | -ac 1 \ | |
| 92 | + | -c:a pcm_s16le \ | |
| 93 | + | -af loudnorm \ | |
| 94 | + | "$SALIDA" 2>&1 \ | |
| 95 | + | | grep -E "(size=|bitrate=|Output)" \ | |
| 96 | + | | sed 's/^/ /' | |
| 97 | + | ||
| 98 | + | echo "" | |
| 99 | + | echo -e " ${VERDE}✅ Listo:${NC} $SALIDA ($(du -h "$SALIDA" | cut -f1))" | |
| 100 | + | echo "" | |
| 101 | + | echo -e " ${CYAN}📋 Resumen:${NC}" | |
| 102 | + | echo " Frecuencia: 16 kHz Canales: Mono Formato: PCM s16le" | |
| 103 | + | echo " Volumen: Normalizado (loudnorm, -23 LUFS)" | |
| 104 | + | echo " Listo para: whisper transcribe" | |