#!/usr/bin/env python3 """ Pipeline completo: micrófono → ffmpeg → Whisper → Ollama → TTS → ffmpeg → altavoz ================================================================================ Este script une todo lo visto en el arco de audio/vídeo de la Temporada 9: 842 — ffmpeg (preparación y postprocesado de audio) 843 — Whisper (transcripción local) 845 — TTS con Piper/Kokoro (voz sintética) 847 — visión artificial local (LLaVA, Florence-2, Moondream) Requisitos: pip install faster-whisper requests piper-tts Además necesitas tener instalado: - ffmpeg (sudo apt install ffmpeg) - Ollama corriendo en localhost:11434 - Piper TTS con voz en español (es_ES-sharvard-medium.onnx) atareao con Linux — Episodio 842 """ import subprocess import tempfile import time from pathlib import Path from typing import Optional import requests # ── Configuración ──────────────────────────────────────────────────────── OLLAMA_URL = "http://localhost:11434/api/generate" OLLAMA_MODEL = "llama3.2:latest" # ── Colores para terminal ──────────────────────────────────────────────── class Color: CYAN = "\033[0;36m" GREEN = "\033[0;32m" YELLOW = "\033[1;33m" RED = "\033[0;31m" NC = "\033[0m" def print_step(emoji: str, msg: str) -> None: """Imprime un paso del pipeline con formato.""" print(f"\n{emoji} {Color.CYAN}{msg}{Color.NC}") print("─" * 50) def print_ok(msg: str) -> None: print(f" {Color.GREEN}✅{Color.NC} {msg}") def print_warn(msg: str) -> None: print(f" {Color.YELLOW}⚠️{Color.NC} {msg}") # ── Paso 1: Grabar y preparar con ffmpeg ──────────────────────────────── def grabar_y_preparar(segundos: int = 5) -> Optional[str]: """ Graba audio del micrófono y lo prepara para Whisper con ffmpeg. 1. arecord captura el audio en bruto 2. ffmpeg lo convierte a: 16 kHz, mono, s16le + loudnorm Returns: Ruta al archivo WAV listo para Whisper, o None si falla. """ print_step("🎤", f"Grabando {segundos} segundos...") # Archivo temporal para la grabación en bruto with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: raw_path = f.name # Archivo para el audio preparado whisper_path = raw_path.replace(".wav", "_whisper.wav") # ── arecord: grabar desde el micrófono ────────────────────────── # PipeWire expone el micrófono como dispositivo PulseAudio. # arecord es la herramienta CLI de ALSA/PulseAudio para grabar. try: subprocess.run( ["arecord", "-f", "cd", "-t", "wav", "-d", str(segundos), raw_path], capture_output=True, check=True, ) print_ok(f"Grabación cruda: {raw_path}") except subprocess.CalledProcessError as e: print_warn(f"Error al grabar: {e.stderr.decode().strip()}") print_warn("Asegúrate de tener el micrófono conectado y PipeWire funcionando.") Path(raw_path).unlink(missing_ok=True) return None except FileNotFoundError: print_warn("arecord no encontrado. Instala alsa-utils o pulseaudio-utils.") return None # ── ffmpeg: preparar para Whisper ─────────────────────────────── # Esto es el comando estrella del Episodio 842: # -ar 16000 → 16 kHz (Whisper lo espera así) # -ac 1 → Mono # -c:a pcm_s16le → Formato nativo WAV # -af loudnorm → Normalización EBU R128 print_step("⚡", "Preparando audio con ffmpeg (16 kHz, mono, loudnorm)...") result = subprocess.run( [ "ffmpeg", "-y", "-i", raw_path, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", "-af", "loudnorm", whisper_path, ], capture_output=True, check=False, ) # Limpiar grabación cruda Path(raw_path).unlink(missing_ok=True) if result.returncode != 0 or not Path(whisper_path).exists(): print_warn(f"Error en ffmpeg: {result.stderr.decode()[:200]}") return None # Mostrar info del archivo resultante size = Path(whisper_path).stat().st_size print_ok(f"Audio preparado: {whisper_path} ({size // 1024} KB)") print_ok("Formato: 16 kHz / Mono / PCM s16le / Normalizado") return whisper_path # ── Paso 2: Transcribir con Whisper ───────────────────────────────────── def transcribir(audio_path: str) -> str: """ Transcribe el audio preparado con faster-whisper. faster-whisper es una reimplementación de Whisper usando CTranslate2, que es 4x más rápida que la original y usa menos memoria. Args: audio_path: Ruta al WAV preparado con ffmpeg. Returns: Texto transcrito, o cadena vacía si falla. """ print_step("📝", "Transcribiendo con Whisper...") try: from faster_whisper import WhisperModel except ImportError: print_warn("faster-whisper no instalado. Ejecuta:") print_warn(" pip install faster-whisper") return "" # Cargar modelo (descarga automática la primera vez) print_ok("Cargando modelo Whisper 'base'...") t0 = time.time() model = WhisperModel("base", device="cpu", compute_type="int8") print_ok(f"Modelo cargado en {time.time() - t0:.1f}s") # Transcribir # language="es" fuerza español (evita que detecte mezcla) # beam_size=1 es más rápido, beam_size>1 más preciso segments, info = model.transcribe( audio_path, language="es", beam_size=1, ) texto = " ".join(segment.text for segment in segments) print_ok( f"Idioma detectado: {info.language} (prob: {info.language_probability:.2f})" ) # Limpiar archivo temporal Path(audio_path).unlink(missing_ok=True) return texto.strip() # ── Paso 3: Consultar a Ollama ────────────────────────────────────────── def consultar_ollama(prompt: str) -> str: """ Envía el texto transcrito a Ollama y obtiene una respuesta. Ollama corre el modelo localmente (llama3.2, mistral, etc.). Nada se envía a la nube. Args: prompt: Texto transcrito del usuario. Returns: Respuesta del modelo, o mensaje de error. """ print_step("🧠", "Consultando Ollama...") payload = { "model": OLLAMA_MODEL, "prompt": prompt, "stream": False, } try: t0 = time.time() resp = requests.post(OLLAMA_URL, json=payload, timeout=60) resp.raise_for_status() respuesta = resp.json().get("response", "") print_ok(f"Respuesta generada en {time.time() - t0:.1f}s") return respuesta except requests.exceptions.ConnectionError: print_warn(f"No se pudo conectar a Ollama en {OLLAMA_URL}") print_warn("¿Está Ollama corriendo?") return "Lo siento, no pude conectar con el asistente." except requests.exceptions.Timeout: print_warn("Ollama tardó demasiado en responder.") return "Lo siento, la respuesta tardó demasiado." except Exception as e: print_warn(f"Error inesperado: {e}") return "" # ── Paso 4: Responder con TTS + ffmpeg ───────────────────────────────── def responder(texto: str) -> None: """ Genera voz sintética con Piper y la postprocesa con ffmpeg. El pipeline de audio es: 1. Piper genera WAV crudo 2. ffmpeg postprocesa con: loudnorm + compand + highpass 3. aplay reproduce el resultado Args: texto: Texto a convertir a voz. """ if not texto: return print_step("🔊", "Generando voz con TTS + ffmpeg...") try: import wave from piper import PiperVoice except ImportError: print_warn("piper-tts no instalado. Ejecuta:") print_warn(" pip install piper-tts") print_warn( " (o descarga el modelo .onnx desde https://github.com/rhasspy/piper)" ) # Fallback: mostrar el texto por pantalla print(f"\n{Color.GREEN}🤖 IA:{Color.NC} {texto}") return # ── Generar WAV con Piper ────────────────────────────────────── tts_path = "/tmp/tts_raw.wav" try: # Cargar modelo de voz en español # Descarga el modelo desde: # https://huggingface.co/rhasspy/piper-voices/tree/main/es/es_ES/sharvard/medium voice = PiperVoice.load("es_ES-sharvard-medium.onnx") with wave.open(tts_path, "wb") as wav_file: voice.synthesize_wav(texto, wav_file) print_ok(f"TTS generado: {tts_path}") except Exception as e: print_warn(f"Error al generar TTS con Piper: {e}") print_warn("Asegúrate de tener el modelo 'es_ES-sharvard-medium.onnx'") # Fallback print(f"\n{Color.GREEN}🤖 IA:{Color.NC} {texto[:200]}...") return # ── Postprocesar con ffmpeg ──────────────────────────────────── # Este es el SEGUNDO comando estrella del Episodio 842: # loudnorm → Normaliza el volumen # compand → Comprime el rango dinámico (equilibra suaves y fuertes) # highpass → Elimina frecuencias < 80 Hz (soplidos) final_path = "/tmp/tts_final.wav" filter_chain = ( "loudnorm," "compand=attacks=0.1:decays=0.2:" "points=-80/-80|-45/-45|-30/-30|-25/-20|-20/-15" "|-15/-12|-10/-6|-6/-3|0/-0.5," "highpass=f=80" ) result = subprocess.run( [ "ffmpeg", "-y", "-i", tts_path, "-af", filter_chain, final_path, ], capture_output=True, check=False, ) if result.returncode != 0 or not Path(final_path).exists(): print_warn(f"Error en ffmpeg post-procesado: {result.stderr.decode()[:200]}") # Reproducir el TTS crudo como fallback subprocess.run(["aplay", "-q", tts_path], capture_output=True) Path(tts_path).unlink(missing_ok=True) return size = Path(final_path).stat().st_size print_ok(f"Audio postprocesado con ffmpeg: {final_path} ({size // 1024} KB)") print_ok("Filtros aplicados: loudnorm + compand + highpass") # ── Reproducir con aplay ─────────────────────────────────────── print_step("▶️", "Reproduciendo respuesta...") subprocess.run(["aplay", "-q", final_path], capture_output=True) print_ok("Reproducción completada") # ── Limpiar archivos temporales ──────────────────────────────── Path(tts_path).unlink(missing_ok=True) Path(final_path).unlink(missing_ok=True) print_ok("Archivos temporales eliminados") # ── Bucle principal ───────────────────────────────────────────────────── def main() -> None: """ Bucle principal del asistente de voz. Pipeline completo: Micrófono → [arecord] → ffmpeg → [Whisper] → Ollama → [Piper] → ffmpeg → [aplay] """ print("\n" + "═" * 60) print(f"{Color.CYAN}🎤 Asistente de voz local{Color.NC}") print("Episodio 842 — atareao con Linux") print( f"{Color.GREEN}Mic → ffmpeg → Whisper → Ollama → TTS → ffmpeg → Altavoz{Color.NC}" ) print("═" * 60) print(f"\n{Color.YELLOW}Habla. Pulsa Ctrl+C para salir.{Color.NC}\n") # Verificar dependencias al inicio dependencies_ok = True # Verificar ffmpeg if not subprocess.run(["which", "ffmpeg"], capture_output=True).returncode == 0: print_warn("ffmpeg no está instalado. Ejecuta: sudo apt install ffmpeg") dependencies_ok = False # Verificar Ollama try: requests.get(OLLAMA_URL.replace("/api/generate", "/api/tags"), timeout=5) print_ok(f"Ollama conectado en {OLLAMA_URL}") except Exception: print_warn(f"Ollama no responde en {OLLAMA_URL}") print_warn("¿Está corriendo? Ejecuta: ollama serve && ollama run llama3.2") dependencies_ok = False # Verificar arecord if not subprocess.run(["which", "arecord"], capture_output=True).returncode == 0: print_warn("arecord no encontrado. Instala alsa-utils.") dependencies_ok = False if not dependencies_ok: print( f"\n{Color.RED}⚠️ Hay dependencias faltantes. Revisa los mensajes anteriores.{Color.NC}" ) print("El script intentará continuar, pero puede fallar en algunos pasos.\n") # ── Bucle principal ──────────────────────────────────────────── try: while True: print("\n" + "━" * 60) # 1. Grabar y preparar con ffmpeg audio = grabar_y_preparar(segundos=5) if audio is None: print_warn("No se pudo grabar. Reintentando...") continue # 2. Transcribir con Whisper texto = transcribir(audio) if not texto: print_warn("No se detectó voz. ¿Demasiado silencio?") continue print(f"\n{Color.GREEN}📝 Tú:{Color.NC} {texto}") # 3. Consultar a Ollama respuesta = consultar_ollama(texto) if not respuesta: print_warn("No se obtuvo respuesta de Ollama.") continue # Mostrar preview de la respuesta preview = respuesta[:150] + ("..." if len(respuesta) > 150 else "") print(f"{Color.YELLOW}🤖 IA: {preview}{Color.NC}") # 4. Responder con TTS + ffmpeg responder(respuesta) print("─" * 60) except KeyboardInterrupt: print(f"\n\n{Color.GREEN}👋 ¡Hasta luego!{Color.NC}") print("Recuerda: ffmpeg es la navaja suiza del audio para tu IA.") except Exception as e: print(f"\n{Color.RED}❌ Error inesperado:{Color.NC} {e}") raise if __name__ == "__main__": main()