Generación de texto a voz con XTTSv2 sobre WSL2 con Coqui

En la era de la nube y los servicios de suscripción costosos, la filosofía del software libre y el desarrollo local nos invita a recuperar el control de nuestras herramientas. Bajo esta premisa , me propuse un reto técnico: montar un estudio de locución y síntesis de voz por inteligencia artificial completamente local, privado y automatizado, sin depender de APIs de pago ni de servidores externos.

Después de experimentar con diversas configuraciones, hoy comparto la guía definitiva que me funcionó de manera infalible en producción.

1. El Banco de Pruebas (Especificaciones de Hardware y Entorno)

Para replicar este entorno con éxito, es fundamental conocer la arquitectura sobre la cual testeamos y validamos este pipeline:

  • Sistema Host: Windows 11.
  • Capa de Virtualización: Subsistema de Windows para Linux (WSL2).
  • Entorno Invitado (Guest): Ubuntu 22.04 LTS (que provee nativamente Python 3.10).
  • Aceleración Gráfica (GPU): Tarjeta gráfica NVIDIA GeForce GTX 1060 con 6 GB de VRAM y controladores actualizados con soporte WDDM.

Ajuste preventivo de memoria en WSL2

Para evitar que la IA desborde los recursos de tu equipo durante la inferencia en la GPU, es altamente recomendable limitar la memoria de WSL. Crea un archivo llamado .wslconfig en tu ruta de usuario de Windows (%userprofile%) con estos valores:

[wsl2]
memory=6GB
swap=4GB

Aplica los cambios abriendo tu terminal en Windows y ejecutando: wsl --shutdown

2. Preparando el Entorno de Trabajo en Ubuntu (WSL2)

Inicia tu terminal de Ubuntu 22.04 y actualiza los paquetes base del sistema, asegurando las herramientas de compilación y manipulación de audio:

sudo apt update && sudo apt upgrade -y
sudo apt install -y python3-pip python3-venv git ffmpeg

Crea un directorio exclusivo para tu proyecto y aísla las dependencias en un entorno virtual (venv):

mkdir ~/podcast_ia
cd ~/podcast_ia
python3 -m venv podcast_env

¿Cómo iniciar sesión en cada nueva sesión de trabajo?

Cada vez que enciendas tu computadora o cierres la terminal y quieras volver a trabajar en tus pódcasts, recuerda seguir estos pasos para activar tu entorno virtual:

1. Entra a la carpeta del proyecto:

cd ~/podcast_ia

2. Activa el entorno (env):

source podcast_env/bin/activate

(Verás que la terminal antepone (podcast_env) en la línea de comandos).

3. Una vez terminado tu trabajo, puedes salir del entorno simplemente escribiendo: deactivate.

3. Instalación de Dependencias de IA (PyTorch y Coqui TTS)

Dentro de tu entorno virtual activo, instala PyTorch configurado específicamente para el soporte de CUDA (versión 11.8, óptima para la arquitectura de la GTX 1060), seguido por el motor de Coqui y utilidades de audio:

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
pip install TTS==0.22.0 transformers==4.33.0 pydub

Nota técnica previa: Para que el motor neuronal capture con absoluta fidelidad los matices de tu voz y evite distorsiones, artefactos o lecturas erróneas de puntuación, es indispensable alimentar el sistema con una muestra de audio perfectamente calibrada. Si deseas profundizar en el proceso de captura, el tiempo de duración ideal y las especificaciones exactas para exportar tu muestra limpia en Audacity, puedes consultar mi guía detallada en el post sobre la Generación de archivo .WAV de audio para entrenamiento de voz TTS.

4. El Script Básico: Síntesis Lineal con Archivo de Referencia .wav

Para nuestra primera versión funcional del script, mantendremos las cosas simples. Este programa básico toma un archivo de texto plano (guion.txt) y un archivo de audio con tu propia voz (referencia.wav), aplica el parche de seguridad indispensable para versiones recientes de PyTorch, procesa el texto eliminando los puntos finales problemáticos y une los fragmentos inyectando silencios limpios mediante Pydub.

Crea un archivo llamado estudio_basico.py y pega el siguiente código:

"""
=============================================================================
ESTUDIO BÁSICO DE PÓDCAST IA - CLONACIÓN ZERO-SHOT CON ARCHIVO WAV
Descripción: Script lineal y directo para generar locuciones a partir de 
             un guion en texto plano y una muestra de voz personalizada.
=============================================================================
"""

import os
import torch
import re
from TTS.api import TTS
from pydub import AudioSegment

# --- 1. PARCHE DE SEGURIDAD PARA PYTORCH 2.6+ ---
# Permite cargar de forma segura los pesos locales del modelo de Coqui.
original_load = torch.load
def parche_load(*args, **kwargs):
    kwargs['weights_only'] = False
    return original_load(*args, **kwargs)
torch.load = parche_load

os.environ["COQUI_TOS_AGREED"] = "1"

def cargar_guion_simple(ruta_archivo):
    """
    Lee el guion línea por línea, eliminando puntos finales para evitar 
    que la IA verbalice la palabra 'punto'.
    """
    if not os.path.exists(ruta_archivo):
        return None

    with open(ruta_archivo, 'r', encoding='utf-8') as f:
        lineas = f.readlines()

    parrafos_limpios = []
    for linea in lineas:
        texto = linea.strip()
        if not texto or texto.startswith(('#', '[')):
            continue
            
        # Purgado de caracteres raros y eliminación del punto final
        texto = texto.replace('\xa0', ' ').replace('\t', ' ')
        texto = texto.rstrip('.')
        texto = re.sub(r'\s+', ' ', texto).strip()
        
        if texto:
            parrafos_limpios.append(texto)
                
    return parrafos_limpios

def main():
    print("\n=== ESTUDIO BÁSICO DE PÓDCAST (LOCAL) ===")
    
    # Detección de hardware (GPU NVIDIA)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    print(f"Hardware en uso: {device.upper()}")
    
    # 1. Carga del guion
    archivo_entrada = input("Ingresa el nombre de tu archivo de guion (ej. guion.txt): ").strip()
    parrafos = cargar_guion_simple(archivo_entrada)
    
    if parrafos is None:
        print(f"❌ Error: No se encontró el archivo '{archivo_entrada}'.")
        return

    print(f"✅ Guion cargado: {len(parrafos)} bloques listos.")
    
    # 2. Verificación del archivo de voz de referencia
    audio_referencia = "referencia.wav"
    if not os.path.exists(audio_referencia):
        print(f"❌ Error crítico: No se encuentra el archivo '{audio_referencia}' en este directorio.")
        print("Coloca tu muestra de voz en formato .wav (Mono, 22050Hz) antes de continuar.")
        return

    # 3. Carga del motor XTTSv2 en la GPU
    print("\nCargando motor XTTSv2 en la GPU...")
    tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)

    # --- PARÁMETROS DE CALIBRACIÓN ACÚSTICA ---
    CONFIG_VELOCIDAD = 0.85
    CONFIG_TEMPERATURA = 0.65
    CONFIG_PENALIZACION = 2.0

    print("\n🚀 Iniciando síntesis de audio...")
    audio_maestro = AudioSegment.empty()
    
    for i, p in enumerate(parrafos):
        print(f"[{i+1}/{len(parrafos)}] Procesando fragmento...")
        temp_file = f"temp_chunk_{i}.wav"
        
        # Inferencia utilizando tu archivo de voz personalizado
        tts.tts_to_file(
            text=p, 
            language="es", 
            speaker_wav=audio_referencia, 
            file_path=temp_file, 
            split_sentences=False,
            speed=CONFIG_VELOCIDAD,
            temperature=CONFIG_TEMPERATURA,
            repetition_penalty=CONFIG_PENALIZACION
        )
        
        # Ensamblaje con pausa natural de medio segundo entre frases
        chunk_audio = AudioSegment.from_wav(temp_file)
        silencio = AudioSegment.silent(duration=500) 
        audio_maestro += chunk_audio + silencio
        os.remove(temp_file)
        
    archivo_final = "episodio_maestro.wav"
    audio_maestro.export(archivo_final, format="wav")
    print(f"\n🎉 ¡Proceso finalizado! Audio guardado con éxito como: '{archivo_final}'")

if __name__ == "__main__":
    main()

5. Evolucionando al Siguiente Nivel: El Script Avanzado (estudio_voces.py)

Una vez que dominas la síntesis básica con tu archivo de referencia, es probable que quieras un flujo de trabajo más versátil: poder elegir entre distintas voces oficiales integradas en el modelo, calibrar los parámetros de velocidad y temperatura al vuelo, y nombrar tus episodios personalizados sin el riesgo de perder producciones anteriores por sobrescritura accidental.

Para lograrlo, creamos estudio_voces.py. Este script interactivo incluye un gestor de nombres seguro y un menú de selección múltiple.

Crea este archivo en tu directorio de trabajo y pega el código completo:

"""
=============================================================================
ESTUDIO AVANZADO DE PÓDCAST IA - SELECTOR DE VOCES Y GESTIÓN INTELIGENTE
Descripción: Script interactivo para la generación automatizada de locuciones 
             con opciones de voces oficiales, purga de puntuación y protección 
             de archivos.
=============================================================================
"""

import os
import torch
import re
from TTS.api import TTS
from pydub import AudioSegment

# --- 1. PARCHE DE SEGURIDAD PARA PYTORCH 2.6+ ---
original_load = torch.load
def parche_load(*args, **kwargs):
    kwargs['weights_only'] = False
    return original_load(*args, **kwargs)
torch.load = parche_load

os.environ["COQUI_TOS_AGREED"] = "1"

# --- 2. INGESTA Y LIMPIEZA QUIRÚRGICA DEL GUION ---
def cargar_guion_por_parrafos(ruta_archivo):
    """
    Lee el guion línea por línea, limpiando caracteres especiales y 
    eliminando puntos finales para evitar la lectura fonética de 'punto'.
    """
    if not os.path.exists(ruta_archivo):
        return None

    with open(ruta_archivo, 'r', encoding='utf-8') as f:
        lineas = f.readlines()

    parrafos_limpios = []
    for linea in lineas:
        texto = linea.strip()
        if not texto or texto.startswith(('#', '[')):
            continue
            
        texto = texto.replace('\xa0', ' ').replace('\t', ' ')
        texto = re.sub(r'\[.*?\]', '', texto)
        texto = texto.rstrip('.') # Truco antipunto final
        texto = re.sub(r'\.{2,}', ',', texto)
        texto = re.sub(r'\s+', ' ', texto).strip()
        
        if texto:
            parrafos_limpios.append(texto)
                
    return parrafos_limpios

# --- 3. GESTOR INTELIGENTE DE NOMBRES DE ARCHIVO ---
def obtener_nombre_archivo_seguro(nombre_base):
    """
    Previene la sobrescritura de archivos agregando un sufijo numérico 
    autoincremental si el nombre de salida ya existe.
    """
    if not nombre_base.endswith(".wav"):
        nombre_base += ".wav"
        
    if not os.path.exists(nombre_base):
        return nombre_base
        
    base, ext = os.path.splitext(nombre_base)
    contador = 1
    while True:
        nuevo_nombre = f"{base}_{contador}{ext}"
        if not os.path.exists(nuevo_nombre):
            return nuevo_nombre
        contador += 1

# --- 4. FLUJO PRINCIPAL AVANZADO ---
def main():
    print("\n==========================================================")
    print("=== ESTUDIO DE PÓDCAST IA - SELECTOR DE VOZ & AVANZADO ===")
    print("==========================================================\n")
    
    device = "cuda" if torch.cuda.is_available() else "cpu"
    print(f"Hardware detectado: {device.upper()}")
    if device == "cuda":
        print(f"Tarjeta gráfica en uso: {torch.cuda.get_device_name(0)}")
    
    # Paso 1: Carga del texto
    archivo_entrada = input("\n[1/5] Ingresa el nombre de tu archivo de guion (ej. guion.txt): ").strip()
    parrafos = cargar_guion_por_parrafos(archivo_entrada)
    
    if parrafos is None:
        print(f"❌ Error: No se encontró el archivo '{archivo_entrada}'.")
        return

    print(f"✅ Guion cargado: {len(parrafos)} bloques purgados y listos.")
    
    # Paso 2: Elección de fuente de voz (Oficiales del sistema vs. Archivo WAV)
    print("\n[2/5] Selecciona la fuente de voz para la locución:")
    print("[1] Voces oficiales del sistema (Luis Moray, Marcos Rudaski, Alma María)")
    print("[2] Usar mi propio archivo de audio de referencia (.wav personalizado)")
    tipo_voz = input("Elige una opción (1 o 2) [Por defecto 1]: ").strip()
    if tipo_voz == "":
        tipo_voz = "1"

    speaker_id = None
    speaker_wav_path = None

    if tipo_voz == '1':
        print("\n--- Voces Oficiales del Sistema ---")
        print("[A] Luis Moray (Masculino - Predeterminado)")
        print("[B] Marcos Rudaski (Masculino alternativo)")
        print("[C] Alma María (Femenino)")
        eleccion = input("Elige tu locutor (A, B o C) [Por defecto A]: ").strip().upper()
        
        if eleccion == 'B':
            speaker_id = "Marcos Rudaski"
        elif eleccion == 'C':
            speaker_id = "Alma María"
        else:
            speaker_id = "Luis Moray"
        print(f"🎙️ Locutor seleccionado: {speaker_id}")
    else:
        nombre_wav = input("Ingresa el nombre de tu archivo de referencia [Por defecto 'referencia.wav']: ").strip()
        if nombre_wav == "":
            nombre_wav = "referencia.wav"
        if not os.path.exists(nombre_wav):
            print(f"❌ Error: No se encuentra el archivo '{nombre_wav}'.")
            return
        speaker_wav_path = nombre_wav
        print(f"🎙️ Archivo de referencia seleccionado: {speaker_wav_path}")

    # Paso 3: Calibración acústica interactiva
    print("\n[3/5] Calibración acústica (Presiona Enter para usar los valores por defecto):")
    vel_input = input(" -> Velocidad [Por defecto 0.85]: ").strip()
    CONFIG_VELOCIDAD = float(vel_input) if vel_input else 0.85

    temp_input = input(" -> Temperatura [Por defecto 0.65]: ").strip()
    CONFIG_TEMPERATURA = float(temp_input) if temp_input else 0.65

    pen_input = input(" -> Penalización de repetición [Por defecto 2.0]: ").strip()
    CONFIG_PENALIZACION = float(pen_input) if pen_input else 2.0

    # Paso 4: Modo de exportación
    print("\n[4/5] ¿Cómo deseas exportar el audio final?")
    print("[1] Un solo archivo maestro continuo (con pausas automáticas)")
    print("[2] Múltiples archivos independientes (un .wav por cada párrafo)")
    opcion_exp = input("Elige una opción (1 o 2) [Por defecto 1]: ").strip()
    if opcion_exp == "":
        opcion_exp = "1"

    # Paso 5: Nombramiento inteligente del archivo
    nombre_personalizado = input("\n[5/5] ¿Cómo quieres nombrar tu archivo de salida? [Por defecto 'episodio_maestro.wav']: ").strip()
    if not nombre_personalizado:
        nombre_personalizado = "episodio_maestro.wav"

    archivo_final = obtener_nombre_archivo_seguro(nombre_personalizado)
    if archivo_final != nombre_personalizado:
        print(f"ℹ️ El archivo '{nombre_personalizado}' ya existía. Se guardará de forma segura como: '{archivo_final}'")

    # Carga del motor en la GPU
    print("\nCargando motor XTTSv2 en la GPU (esto tomará unos segundos)...")
    tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device)

    print(f"\n🚀 Iniciando síntesis por lotes...\n")

    if opcion_exp == '1':
        audio_maestro = AudioSegment.empty()
        for i, p in enumerate(parrafos):
            print(f"[{i+1}/{len(parrafos)}] Procesando párrafo: {p[:50]}...")
            temp_file = f"temp_chunk_{i}.wav"
            
            if speaker_id:
                tts.tts_to_file(
                    text=p, language="es", speaker=speaker_id, file_path=temp_file, 
                    split_sentences=False, speed=CONFIG_VELOCIDAD, temperature=CONFIG_TEMPERATURA, repetition_penalty=CONFIG_PENALIZACION
                )
            else:
                tts.tts_to_file(
                    text=p, language="es", speaker_wav=speaker_wav_path, file_path=temp_file, 
                    split_sentences=False, speed=CONFIG_VELOCIDAD, temperature=CONFIG_TEMPERATURA, repetition_penalty=CONFIG_PENALIZACION
                )
            
            chunk_audio = AudioSegment.from_wav(temp_file)
            silencio = AudioSegment.silent(duration=500) # Pausa natural de respiración
            audio_maestro += chunk_audio + silencio
            os.remove(temp_file)
            
        audio_maestro.export(archivo_final, format="wav")
        print(f"\n🎉 ¡Proceso finalizado! Tu pódcast maestro está guardado como: '{archivo_final}'")
        
    elif opcion_exp == '2':
        carpeta_salida = os.path.splitext(archivo_final)[0] + "_fragmentos"
        os.makedirs(carpeta_salida, exist_ok=True)
        
        for i, p in enumerate(parrafos):
            print(f"[{i+1}/{len(parrafos)}] Procesando párrafo: {p[:50]}...")
            out_file = f"{carpeta_salida}/bloque_{i+1:03d}.wav"
            
            if speaker_id:
                tts.tts_to_file(
                    text=p, language="es", speaker=speaker_id, file_path=out_file, 
                    split_sentences=False, speed=CONFIG_VELOCIDAD, temperature=CONFIG_TEMPERATURA, repetition_penalty=CONFIG_PENALIZACION
                )
            else:
                tts.tts_to_file(
                    text=p, language="es", speaker_wav=speaker_wav_path, file_path=out_file, 
                    split_sentences=False, speed=CONFIG_VELOCIDAD, temperature=CONFIG_TEMPERATURA, repetition_penalty=CONFIG_PENALIZACION
                )
            
        print(f"\n🎉 ¡Proceso finalizado! Fragmentos independientes guardados en: '{carpeta_salida}/'")

if __name__ == "__main__":
    main()

Conclusión

Con esta infraestructura montada en tu equipo, cuentas con un estudio de locución automatizado de nivel profesional. Al correr localmente sobre tu propia GPU, garantizas la privacidad absoluta de tus contenidos, controlas los tiempos de renderizado y eliminas por completo los costos recurrentes de servicios en la nube.

Aquí demostramos que, combinando código abierto, herramientas de Linux en Windows (me habría gustado haber trabajado nativamente sobre GNU/Linux pero, no podía, ya que hice esto en una laptop prestada) y un procesamiento inteligente de textos, podemos crear soluciones potentes y elegantes. ¡Es momento de exportar tus guiones y comenzar a producir tus propios podcasts!

Comentarios

Una respuesta a “Generación de texto a voz con XTTSv2 sobre WSL2 con Coqui”

  1. […] nuestra entrada anterior (Generación de texto a voz con XTTSv2 sobre WSL2 con Coqui), exploramos cómo montar un estudio local de locución y síntesis mediante inteligencia […]

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *