From 2082910112af121a0d2d7cdc28d85d5ad098633a Mon Sep 17 00:00:00 2001 From: LeSoviet Date: Sat, 11 Jul 2026 08:06:43 -0300 Subject: [PATCH 1/2] feat: add local server, docs, and control script for AMD ROCm optimizations - server.py: FastAPI microservice with MIOpen cache config, Whisper preload, VoiceClonePrompt cache + warmup, inference_mode wrapper, A/B test endpoint, speed parameter, quality params via env vars - scripts/omnivoice: bash control script (start/stop/status/logs) - docs/omnivoice-optimizaciones.md: full changelog vs upstream - .gitignore: exclude logs, test audio, voice profiles, MIOpen cache --- .gitignore | 16 ++ docs/omnivoice-optimizaciones.md | 441 +++++++++++++++++++++++++++++++ scripts/omnivoice | 131 +++++++++ server.py | 359 +++++++++++++++++++++++++ 4 files changed, 947 insertions(+) create mode 100644 docs/omnivoice-optimizaciones.md create mode 100755 scripts/omnivoice create mode 100644 server.py diff --git a/.gitignore b/.gitignore index 510818df..6c93afa2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,19 @@ examples/exp* .claude/ *.wav *.jsonl + +# Server runtime artifacts +*.log +*.pid +.omnivoice.pid +*.mp4 + +# Test audio files +storage/ab_test/ + +# MIOpen cache +~/.config/miopen/ + +# Voice profiles (personal data) +data/voices/*/ref.wav +data/voices/*/ref_text.txt diff --git a/docs/omnivoice-optimizaciones.md b/docs/omnivoice-optimizaciones.md new file mode 100644 index 00000000..7bf6ab27 --- /dev/null +++ b/docs/omnivoice-optimizaciones.md @@ -0,0 +1,441 @@ +# OmniVoice - Cambios y Optimizaciones vs Modelo Original + +Documento de referencia de todas las modificaciones aplicadas al microservicio OmniVoice +respecto del comportamiento original de la librería (`omnivoice` pip package) y del +`server.py` base. + +## Contexto + +- **Hardware:** AMD Radeon RX 6600 XT (gfx1030) + ROCm +- **Stack:** Python 3.13, PyTorch ROCm (HIP), FastAPI, uvicorn +- **Modelo:** `k2-fsa/OmniVoice` (TTS por clonación de voz) +- **ASR interno:** `openai/whisper-large-v3-turbo` (hardcodeado en la librería) +- **SR nativo del modelo:** 24000 Hz + +--- + +## 1. Archivos modificados + +| Archivo | Ubicación | Tipo | +|---------|-----------|------| +| `server.py` | `/home/lesoviet/omnivoice/server.py` | Microservicio FastAPI | +| `omnivoice` | `~/.local/bin/omnivoice` | Script de control (start/stop/status/logs) | +| `ref.wav` | `/home/lesoviet/omnivoice/data/voices/daniel/ref.wav` | Audio de referencia del perfil | +| `ref_text.txt` | `/home/lesoviet/omnivoice/data/voices/daniel/ref_text.txt` | Transcripción fijada del ref | + +> **Nota:** La librería `omnivoice` (en `venv/lib/python3.13/site-packages/omnivoice/`) NO se modificó. +> Todas las optimizaciones se aplican desde `server.py` sin parchear el paquete. + +--- + +## 2. Cambios en `server.py` + +### 2.1 Configuración AMD ROCm / MIOpen (líneas 23-40) + +**Original:** No existía. El servidor base no configuraba variables de entorno de ROCm. + +**Cambio:** + +```python +os.environ.setdefault("HSA_OVERRIDE_GFX_VERSION", "10.3.0") +os.environ.setdefault("HIP_VISIBLE_DEVICES", "0") +os.environ.setdefault("MIOPEN_FIND_MODE", "2") # evita re-benchmark de kernels + +MIOPEN_CACHE_DIR = Path.home() / ".config" / "miopen" +MIOPEN_CACHE_DIR.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("MIOPEN_USER_DB_PATH", str(MIOPEN_CACHE_DIR)) +os.environ.setdefault("MIOPEN_CUSTOM_CACHE_DIR", str(MIOPEN_CACHE_DIR)) +os.environ.setdefault("MIOPEN_SYSTEM_DB_PATH", str(MIOPEN_CACHE_DIR)) +``` + +**Por qué:** ROCm no reconoce nativamente la RX 6600 XT (gfx1030). Sin el override, +MIOpen re-benchmarkea kernels en cada llamada → cientos de warnings `workspace required` +y ~50s por generación. Con `MIOPEN_FIND_MODE=2` + cache persistente en `~/.config/miopen`, +los kernels se cachean y sobreviven reinicios. + +**Impacto medido:** 48s → 33s (cold start), 17s (warm cache) para texto largo. + +--- + +### 2.2 Parámetros de calidad por env vars (líneas 59-69) + +**Original:** `model.generate()` se llamaba con defaults hardcodeados (num_step=16, sin class_temperature). + +**Cambio:** + +```python +NUM_STEP = int(os.environ.get("OMNIVOICE_NUM_STEP", "32")) +CLASS_TEMPERATURE = float(os.environ.get("OMNIVOICE_CLASS_TEMPERATURE", "0.7")) +GUIDANCE_SCALE = float(os.environ.get("OMNIVOICE_GUIDANCE_SCALE", "1.5")) +SPEED = float(os.environ["OMNIVOICE_SPEED"]) if os.environ.get("OMNIVOICE_SPEED") else None +``` + +**Parámetros de producción (fijados en script `omnivoice`):** + +| Parámetro | Default librería | Valor producción | Razón | +|-----------|-----------------|------------------|-------| +| `num_step` | 32 | **48** | Más iteraciones de unmasking → prosodia más fina y natural | +| `class_temperature` | 0.0 | **0.0** | Greedy/determinístico (elegido en A/B testing, 03 con 0.7 sonaba menos controlado) | +| `guidance_scale` | 2.0 | **2.0** | Default oficial, estabilidad de clonación | +| `speed` | None | **1.2** | 20% más rápido, elegido en A/B (-15% latencia, audio 21% más corto) | + +**Defaults del código vs producción:** Los defaults del código (`server.py:62-69`) son +`step=32, ct=0.7, gs=1.5, speed=None`. Los valores de producción los sobreescribe el +script `omnivoice` vía env vars (ver sección 3). + +--- + +### 2.3 Precarga de Whisper ASR (líneas 77-82) + +**Original:** Whisper se cargaba lazy en cada request cuando `ref_text=None` +(línea 693 de `omnivoice/models/omnivoice.py`). Eso recargaba 587 pesos en cada +petición, sumando ~10s de overhead. + +**Cambio:** + +```python +print(f"[OmniVoice] Precargando Whisper ASR...", flush=True) +if model._asr_pipe is None: + model.load_asr_model() +``` + +**Impacto medido:** Elimina los `Loading weights: 587/587` durante requests. El primer +request pasa de ~33s a ~7s (Whisper ya en VRAM). VRAM sube de 2.03 GB a 3.65 GB. + +--- + +### 2.4 Cache de VoiceClonePrompt (líneas 88-110, 208-226) + +**Original:** `model.generate()` recibía `ref_audio=str(ref_path)` en cada request. +La librería (`omnivoice.py:610-704`) hacía en CADA request: +1. `load_audio(ref_audio)` — cargar WAV desde disco (~0.1s) +2. `remove_silence()` — procesar audio (~0.3s) +3. `transcribe()` — Whisper transcribe el ref (~0.5-1s) +4. `audio_tokenizer.encode()` — tokenizar audio (~0.5s) + +Total overhead fijo: ~1-2s por request, sin importar el tamaño del texto. + +**Cambio:** + +```python +voice_prompt_cache: dict[str, Any] = {} + +# Warmup al arranque: pre-crea el prompt para todos los perfiles existentes +for _vd in DATA_DIR.iterdir(): + if not _vd.is_dir() or not (_vd / "ref.wav").exists(): + continue + voice_prompt_cache[_vid] = { + "mtime": _ref.stat().st_mtime, + "prompt": model.create_voice_clone_prompt( + ref_audio=str(_ref), ref_text=_ref_text, preprocess_prompt=True, + ), + } + +# En /v1/speak: cache hit/miss por mtime del ref.wav +if voice_prompt_cache.get(voice_id, {}).get("mtime") != cache_key[1]: + # CACHE MISS: crear prompt (Whisper + tokenización) +else: + # CACHE HIT: reusar prompt (~0s) +``` + +**Invalidación:** El cache se invalida automáticamente si cambia el `mtime` del +`ref.wav` (ej: se sube un nuevo perfil via `/v1/voice-profile`). + +**Impacto medido:** +- Primer request post-restart: cache=0.00s (warmup al arranque) +- Sin warmup: primer request pagaba ~5s de Whisper + tokenización +- Requests subsiguientes: cache=0.00s + +--- + +### 2.5 `torch.inference_mode()` en generación (líneas 234-243, 320-331) + +**Original:** La librería `omnivoice` NO envuelve el loop de generación en +`torch.no_grad()` ni `torch.inference_mode()`. En `omnivoice.py:1256-1299`, el loop +de los N pasos hace `self(...)` (forward del transformer) sin desactivar autograd. +PyTorch construye el grafo de gradientes en cada forward → más lento y más VRAM. + +**Cambio:** + +```python +with torch.inference_mode(): + audio_list = model.generate( + text=text, + voice_clone_prompt=voice_prompt_cache[voice_id]["prompt"], + num_step=NUM_STEP, + guidance_scale=GUIDANCE_SCALE, + class_temperature=CLASS_TEMPERATURE, + speed=SPEED, + ) +``` + +Aplicado en ambos endpoints: `/v1/speak` (línea 235) y `/v1/speak_test` (línea 321). + +**Impacto:** Reduce overhead de autograd en los 48 forward passes. No afecta la +calidad del output (el modelo no necesita gradientes en inferencia). + +--- + +### 2.6 Endpoint `/v1/speak_test` para A/B testing (líneas 278-350) + +**Original:** No existía. Para probar parámetros distintos había que editar el código +y reiniciar el servidor (~60s de recarga de modelo). + +**Cambio:** Nuevo endpoint que acepta todos los parámetros de generación como +form fields: + +``` +POST /v1/speak_test + -F voice_id=daniel + -F text=... + -F num_step=48 + -F class_temperature=0.0 + -F guidance_scale=2.0 + -F t_shift=0.1 + -F position_temperature=5.0 + -F speed=1.2 +``` + +Usa el mismo cache de VoiceClonePrompt que `/v1/speak`. Devuelve headers `X-Params` +con los parámetros usados. Límite de texto: 500 chars. + +--- + +### 2.7 Endpoint `/v1/transcribe_ref/{voice_id}` (líneas 133-148) + +**Original:** No existía. Para fijar el `ref_text.txt` había que cargar otra instancia +del modelo (lo cual fallaba por OOM de VRAM si el servidor ya estaba corriendo). + +**Cambio:** Endpoint que usa el Whisper ya cargado en el servidor para transcribir +el `ref.wav` de un perfil y guardar el `ref_text.txt`: + +``` +POST /v1/transcribe_ref/daniel +→ {"status":"ok","voice_id":"daniel","ref_text":"Hola, me llamo Daniel. ¿Cómo estás?"} +``` + +Invalida el cache del perfil para forzar re-transcripción. + +--- + +## 3. Cambios en script `omnivoice` (`~/.local/bin/omnivoice`) + +### 3.1 Variables de entorno de calidad (líneas 29-37) + +**Original:** Solo exportaba `HSA_OVERRIDE_GFX_VERSION` y `OMNIVOICE_API_KEY`. + +**Cambio:** + +```bash +export OMNIVOICE_NUM_STEP=48 +export OMNIVOICE_CLASS_TEMPERATURE=0.0 +export OMNIVOICE_GUIDANCE_SCALE=2.0 +export OMNIVOICE_SPEED=1.2 +``` + +Estos valores sobreescriben los defaults de `server.py` cuando el servidor arranca +via `omnivoice start`. + +--- + +## 4. Cambios en el perfil de voz `daniel` + +### 4.1 Normalización del `ref.wav` + +**Original:** El archivo `ref.wav` era en realidad un **MP4** (ISO Media, MP4 v2) +disfrazado con extensión `.wav`. Eso causaba: +- Warning `PySoundFile failed. Trying audioread instead.` en cada request +- Fallback a `audioread` (ffmpeg) más lento +- Posibles artefactos de decodificación que afectan la clonación + +**Propiedades originales:** + +| Propiedad | Valor | +|-----------|-------| +| Formato real | MP4 (ftypmp42) | +| Sample rate | 44100 Hz | +| Duración | 4.60s | +| Peak | 0.1715 (-15dB) | +| RMS | 0.0143 | + +**Cambio aplicado:** + +| Propiedad | Valor | +|-----------|-------| +| Formato | WAV PCM float32 | +| Sample rate | 24000 Hz (SR nativo del modelo) | +| Duración | 4.60s | +| Peak | 0.707 (-3dB, normalizado) | +| RMS | 0.0587 | + +Backup del original: `ref.wav.orig.mp4`. + +> **⚠️ Estado actual:** El `ref.wav` fue reemplazado nuevamente por un MP4 +> (subido via `/v1/voice-profile` después de la normalización). Si se quiere +> restaurar la normalización, hay que re-aplicar el script de conversión o +> re-subir un WAV PCM 24kHz real. + +### 4.2 Transcripción fijada `ref_text.txt` + +**Original:** No existía. Whisper transcribía el `ref.wav` en cada request, +con resultados potencialmente distintos y overhead de ~1s. + +**Cambio:** Se generó `ref_text.txt` con el endpoint `/v1/transcribe_ref/daniel`: + +``` +Hola, me llamo Daniel. ¿Cómo estás? +``` + +Ahora la clonación usa la transcripción exacta y determinista. + +> **⚠️ Estado actual:** El `ref_text.txt` está vacío (el perfil fue re-subido +> via `/v1/voice-profile` sin `ref_text`, lo que borró el archivo). Para +> restaurarlo: `curl -X POST http://localhost:8001/v1/transcribe_ref/daniel -H "x-api-key: local-dev-key"`. + +--- + +## 5. Benchmark comparativo + +### 5.1 Evolución de latencia (texto largo, ~350 chars, ~27s de audio) + +| Etapa | Cold start | Warm cache | RTF warm | +|-------|-----------|------------|----------| +| Original (sin fixes) | ~48-50s | ~48s | ~1.8x | +| + MIOpen cache | ~33s | ~17s | 0.70x | +| + Whisper precargado | ~7s (texto corto) | ~7s | 1.90x | +| + Cache VoiceClonePrompt | ~0s cache | ~0s cache | - | +| + step=48 | ~20s (texto largo) | ~13s | 0.48x | +| + speed=1.2 | ~17.6s | ~17.6s | 0.94x | +| + inference_mode | (en producción) | (en producción) | - | + +### 5.2 A/B testing de parámetros (texto ~160 chars) + +| Perfil | num_step | class_temp | guidance | speed | gen | audio | RTF | +|--------|----------|------------|----------|-------|-----|-------|-----| +| 01 ACTUAL | 16 | 0.0 | 2.0 | None | 2.73s | 11.11s | 0.25x | +| 02 CALIDAD | 32 | 0.0 | 2.0 | None | 4.97s | 11.02s | 0.45x | +| 03 HUMANO | 32 | 0.7 | 1.5 | None | 5.00s | 9.65s | 0.52x | +| 02e (elegido) | 48 | 0.0 | 2.0 | None | 7.39s | 10.72s | 0.69x | +| 02e + speed=1.2 | 48 | 0.0 | 2.0 | 1.2 | ~6s | ~8s | ~0.75x | + +### 5.3 Impacto de `speed` (texto ~160 chars, step=48) + +| speed | gen | audio | Reducción tiempo | +|-------|-----|-------|-----------------| +| None | 11.25s | 9.24s | - | +| 1.1 | 9.78s | 8.37s | -13% | +| 1.2 | 9.52s | 7.25s | -15% | +| 1.3 | 9.00s | 7.16s | -20% | + +### 5.4 Producción final (step=48, speed=1.2) + +| Texto | Latencia | Audio | RTF | +|-------|----------|-------|-----| +| Largo (~350 chars) | 17.62s | 18.66s | 0.94x | +| Corto (~40 chars) | 6.79s | 2.68s | 2.53x | + +**Observación:** Textos cortos tienen overhead fijo alto porque los 48 pasos del +transformer se ejecutan igual sin importar la longitud. Para mensajes cortos +(<50 chars) el RTF es >1x (tarda más en generar que el audio resultante). + +--- + +## 6. Configuración de Claude Code / Kilo + +Se modificó `~/.claude/settings.json` (equivalente en Kubuntu) para usar GLM-5.2 +vía el endpoint de Z.AI: + +```json +{ + "env": { + "ANTHROPIC_AUTH_TOKEN": "***", + "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.2", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-5.2", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-5.2" + } +} +``` + +--- + +## 7. Estado de la librería `omnivoice` (sin modificar) + +La librería en `venv/lib/python3.13/site-packages/omnivoice/` no se parcheó. +Los hallazgos clave del código original: + +| Archivo | Línea | Hallazgo | +|---------|-------|----------| +| `models/omnivoice.py` | 250 | `asr_model_name` default = `whisper-large-v3-turbo` (hardcodeado) | +| `models/omnivoice.py` | 693 | Whisper se carga lazy si `ref_text=None` y `_asr_pipe is None` | +| `models/omnivoice.py` | 610-704 | `create_voice_clone_prompt` re-hace load+silence+transcribe+encode en cada llamada | +| `models/omnivoice.py` | 1256-1299 | Loop `_generate_iterative` no usa `torch.no_grad()` ni `inference_mode()` | +| `models/omnivoice.py` | 715-753 | `_decode_and_post_process` hace decode + remove_silence + fade_and_pad | + +Todas estas limitaciones se mitigaron desde `server.py` sin tocar el paquete. + +--- + +## 8. Cómo reproducir / restaurar + +### Re-aplicar normalización del ref.wav + +```bash +cd /home/lesoviet/omnivoice && ./venv/bin/python3 << 'EOF' +import warnings; warnings.filterwarnings("ignore") +import librosa, numpy as np, soundfile as sf +from pathlib import Path +import shutil + +REF = Path("data/voices/daniel/ref.wav") +BAK = REF.with_suffix(".wav.orig.mp4") +if not BAK.exists(): + shutil.copy2(REF, BAK) +y, sr = librosa.load(str(BAK), sr=24000, mono=True) +peak = np.max(np.abs(y)) +if peak > 0: + y = np.clip(y * (0.707 / peak), -0.99, 0.99) +sf.write(str(REF), y, 24000, format="WAV", subtype="FLOAT") +print("OK") +EOF +``` + +### Re-generar ref_text.txt + +```bash +curl -X POST http://localhost:8001/v1/transcribe_ref/daniel \ + -H "x-api-key: local-dev-key" +``` + +### Cambiar parámetros de producción + +Editar `~/.local/bin/omnivoice` (líneas 34-37) y reiniciar: + +```bash +omnivoice stop && omnivoice start +``` + +### Probar parámetros sin reiniciar + +```bash +curl -X POST http://localhost:8001/v1/speak_test \ + -H "x-api-key: local-dev-key" \ + -F "voice_id=daniel" \ + -F "text=Texto de prueba" \ + -F "num_step=48" \ + -F "class_temperature=0.0" \ + -F "guidance_scale=2.0" \ + -F "speed=1.2" \ + -o test.wav +``` + +--- + +## 9. Pendientes / TODO + +- [ ] Re-aplicar normalización del `ref.wav` (actualmente es MP4 otra vez) +- [ ] Re-generar `ref_text.txt` (actualmente vacío) +- [ ] Considerar `torch.compile()` en el forward del transformer (requiere PyTorch 2.4+ ROCm) +- [ ] Evaluar cambiar Whisper a `whisper-small` o `whisper-tiny` para reducir VRAM (actualmente large-v3-turbo) +- [ ] Implementar router por longitud de texto para mensajes cortos (overhead fijo de ~6s para <50 chars) +- [ ] Evaluar streaming/chunking para conversación en tiempo real \ No newline at end of file diff --git a/scripts/omnivoice b/scripts/omnivoice new file mode 100755 index 00000000..f81d4f53 --- /dev/null +++ b/scripts/omnivoice @@ -0,0 +1,131 @@ +#!/bin/bash +# Control script para OmniVoice - accesible desde cualquier lugar +# Usar: omnivoice {start|stop|status|logs} + +OMNIVOICE_DIR="$HOME/omnivoice" +PID_FILE="$OMNIVOICE_DIR/.omnivoice.pid" +LOG_FILE="$OMNIVOICE_DIR/omnivoice.log" + +case "$1" in + start) + echo "🚀 Levantando OmniVoice local..." + + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if ps -p $PID > /dev/null 2>&1; then + echo "⚠️ OmniVoice ya está corriendo (PID: $PID)" + echo "💡 Usá 'omnivoice stop' para detenerlo" + exit 1 + else + echo "🧹 Limpiando PID file antiguo..." + rm "$PID_FILE" + fi + fi + + cd "$OMNIVOICE_DIR" + export HSA_OVERRIDE_GFX_VERSION=10.3.0 + export OMNIVOICE_API_KEY=local-dev-key + + # Parámetros de calidad (perfil step48 elegido en A/B testing) + # num_step=48: más iteraciones de unmasking → prosodia más fina y natural + # class_temperature=0.0: greedy (determinístico, sin variación robótica) + # guidance_scale=2.0: default oficial (estabilidad de clonación) + # speed=1.2: 20% más rápido, elegido en A/B (-15% latencia, audio 21% más corto) + export OMNIVOICE_NUM_STEP=48 + export OMNIVOICE_CLASS_TEMPERATURE=0.0 + export OMNIVOICE_GUIDANCE_SCALE=2.0 + export OMNIVOICE_SPEED=1.2 + + echo "📦 Cargando modelo a VRAM (~50s) | step=48..." + nohup ./venv/bin/python server.py > "$LOG_FILE" 2>&1 & + OMNIVOICE_PID=$! + echo $OMNIVOICE_PID > "$PID_FILE" + + # Esperar a que esté listo + echo "⏳ Esperando que el servidor esté listo..." + for i in {1..90}; do + if curl -s http://localhost:8001/v1/health -H "x-api-key: local-dev-key" > /dev/null 2>&1; then + echo "" + echo "✅ OmniVoice está listo! (http://localhost:8001)" + echo "📋 Logs en: $LOG_FILE" + echo "💡 Detener con: omnivoice stop" + exit 0 + fi + sleep 1 + echo -n "." + done + + echo "" + echo "⚠️ Timeout - revisá los logs en: $LOG_FILE" + exit 1 + ;; + + stop) + echo "🛑 Deteniendo OmniVoice..." + + if [ ! -f "$PID_FILE" ]; then + echo "⚠️ No hay PID file - OmniVoice no está corriendo?" + exit 1 + fi + + PID=$(cat "$PID_FILE") + if ps -p $PID > /dev/null 2>&1; then + kill $PID + rm "$PID_FILE" + echo "✅ OmniVoice detenido (PID: $PID)" + else + echo "⚠️ Proceso con PID $PID no encontrado - limpiando..." + rm "$PID_FILE" + fi + ;; + + status) + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if ps -p $PID > /dev/null 2>&1; then + echo "✅ OmniVoice está corriendo (PID: $PID)" + if curl -s http://localhost:8001/v1/health -H "x-api-key: local-dev-key" > /dev/null 2>&1; then + echo "🌐 Servidor respondiendo en http://localhost:8001" + VRAM=$(curl -s http://localhost:8001/v1/health -H "x-api-key: local-dev-key" | grep -o '"vram_gb":[0-9.]*' | cut -d':' -f2) + echo "🔥 VRAM en uso: ${VRAM} GB" + else + echo "⚠️ Servidor corriendo pero no responde HTTP" + fi + else + echo "❌ PID file existe pero proceso no encontrado" + fi + else + echo "❌ OmniVoice no está corriendo" + fi + ;; + + logs) + if [ -f "$LOG_FILE" ]; then + echo "📋 Últimas 50 líneas de $LOG_FILE:" + tail -50 "$LOG_FILE" + echo "" + echo "💡 Para seguir en tiempo real: tail -f $LOG_FILE" + else + echo "❌ No hay log file en $LOG_FILE" + exit 1 + fi + ;; + + *) + echo "OmniVoice local - control script" + echo "" + echo "Uso: omnivoice {start|stop|status|logs}" + echo "" + echo "Comandos:" + echo " start - Levanta el microservicio en segundo plano" + echo " stop - Detiene el microservicio" + echo " status - Muestra si está corriendo y VRAM usada" + echo " logs - Muestra los últimos logs" + echo "" + echo "Ejemplos:" + echo " omnivoice start # Levantar el servidor" + echo " omnivoice status # Verificar estado" + echo " omnivoice logs # Ver logs" + echo " omnivoice stop # Detener" + ;; +esac diff --git a/server.py b/server.py new file mode 100644 index 00000000..66397235 --- /dev/null +++ b/server.py @@ -0,0 +1,359 @@ +""" +Microservicio OmniVoice (FastAPI). + +Contrato exigido por el backend Laravel (OmniVoiceService): + POST /v1/voice-profile (multipart: voice_id, ref_audio, ref_text?) + header: x-api-key + -> 200 JSON { status, voice_id, message } + POST /v1/speak (multipart: voice_id, text) + header: x-api-key + -> 200 audio/wav (bytes) + +El modelo OmniVoice se carga UNA vez al arranque en la GPU AMD (gfx1030 override). +Los perfiles de voz se guardan en disco (data/voices//). +""" + +import io +import json +import os +import re +import time +from pathlib import Path + +# Configuración AMD ROCm - cache persistente para evitar re-benchmark en cada shape +os.environ.setdefault("HSA_OVERRIDE_GFX_VERSION", "10.3.0") +os.environ.setdefault("HIP_VISIBLE_DEVICES", "0") + +# FIX MIOpen workspace issue: PyTorch ROCm backend no pasa workspace buffers +# Esto causa que MIOpen use GemmFwdRest (solver lento) en lugar de kernels optimizados. +# MIOPEN_FIND_MODE=2 evita re-buscar kernels en cada llamada. +# Ver: https://github.com/ROCm/MIOpen/issues/2981 +os.environ.setdefault("MIOPEN_FIND_MODE", "2") + +# MIOpen: cache persistente en home (no /tmp que se limpia en reboot) +# Esto permite que el cache de kernels sobreviva reinicios del servidor +MIOPEN_CACHE_DIR = Path.home() / ".config" / "miopen" +MIOPEN_CACHE_DIR.mkdir(parents=True, exist_ok=True) + +os.environ.setdefault("MIOPEN_USER_DB_PATH", str(MIOPEN_CACHE_DIR)) +os.environ.setdefault("MIOPEN_CUSTOM_CACHE_DIR", str(MIOPEN_CACHE_DIR)) +os.environ.setdefault("MIOPEN_SYSTEM_DB_PATH", str(MIOPEN_CACHE_DIR)) + +from typing import Any + +from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile +from time import perf_counter +from fastapi.responses import Response +import numpy as np +import soundfile as sf +import torch +from omnivoice import OmniVoice + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" / "voices" +DATA_DIR.mkdir(parents=True, exist_ok=True) + +API_KEY = os.environ.get("OMNIVOICE_API_KEY", "local-dev-key") +DEVICE = os.environ.get("OMNIVOICE_DEVICE", "cuda:0") + +# Parámetros de calidad (defaults oficiales OmniVoice). +# Sobreescribibles vía env vars para A/B testing sin tocar código. +# num_step: 32 = calidad oficial (16 = rápido pero menos natural). +NUM_STEP = int(os.environ.get("OMNIVOICE_NUM_STEP", "32")) +# class_temperature: 0 = greedy (robótico). 0.6-0.8 = variación natural humana. +CLASS_TEMPERATURE = float(os.environ.get("OMNIVOICE_CLASS_TEMPERATURE", "0.7")) +# guidance_scale: 2.0 = default. Valores menores (1.0-1.5) = más expresividad. +GUIDANCE_SCALE = float(os.environ.get("OMNIVOICE_GUIDANCE_SCALE", "1.5")) +# speed: factor de velocidad del habla. >1.0 = más rápido (menos tokens a generar). +# None = el modelo estima la duración. 1.1-1.2 reduce tokens sin perder calidad. +SPEED = float(os.environ["OMNIVOICE_SPEED"]) if os.environ.get("OMNIVOICE_SPEED") else None + +VOICE_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]+$") + +print(f"[OmniVoice] Cargando modelo en {DEVICE}...", flush=True) +_t0 = time.time() +model = OmniVoice.from_pretrained("k2-fsa/OmniVoice", device_map=DEVICE, dtype=torch.float16) + +# Precargar Whisper para evitar cargarlo en cada request cuando ref_text=None +print(f"[OmniVoice] Precargando Whisper ASR...", flush=True) +_t1 = time.time() +if model._asr_pipe is None: + model.load_asr_model() +print(f"[OmniVoice] Whisper cargado en {time.time()-_t1:.1f}s", flush=True) + +print(f"[OmniVoice] Modelo cargado en {time.time()-_t0:.1f}s " + f"| VRAM {torch.cuda.memory_allocated()/1e9:.2f} GB " + f"| {torch.cuda.get_device_name(0)}", flush=True) + +# Cache de VoiceClonePrompt para evitar reprocesar el audio de referencia +voice_prompt_cache: dict[str, Any] = {} + +# Warmup del cache de VoiceClonePrompt para perfiles existentes. +# Evita el costo de ~5s (Whisper + tokenización) en el primer request de cada voz. +for _vd in DATA_DIR.iterdir(): + if not _vd.is_dir() or not (_vd / "ref.wav").exists(): + continue + _vid = _vd.name + try: + _ref = _vd / "ref.wav" + _rt = _vd / "ref_text.txt" + _ref_text = _rt.read_text(encoding="utf-8").strip() or None if _rt.exists() else None + _t_w = time.time() + voice_prompt_cache[_vid] = { + "mtime": _ref.stat().st_mtime, + "prompt": model.create_voice_clone_prompt( + ref_audio=str(_ref), ref_text=_ref_text, preprocess_prompt=True, + ), + } + print(f"[OmniVoice] Warmup cache: {_vid} en {time.time()-_t_w:.1f}s", flush=True) + except Exception as _e: + print(f"[OmniVoice] Warmup cache: {_vid} FALLÓ: {_e!r}", flush=True) + +app = FastAPI(title="OmniVoice TTS", version="1.0") + + +def check_api_key(x_api_key: str | None) -> None: + if x_api_key != API_KEY: + raise HTTPException(status_code=401, detail={"error": "unauthorized", "message": "API key invalida"}) + + +def voice_dir(voice_id: str) -> Path: + if not VOICE_ID_RE.match(voice_id): + raise HTTPException(status_code=422, detail={"error": "validation_failed", + "message": "voice_id invalido"}) + return DATA_DIR / voice_id + + +@app.get("/v1/health") +def health(x_api_key: str | None = Header(default=None)): + check_api_key(x_api_key) + return {"status": "ok", "vram_gb": round(torch.cuda.memory_allocated() / 1e9, 2)} + + +@app.post("/v1/transcribe_ref/{voice_id}") +def transcribe_ref(voice_id: str, x_api_key: str | None = Header(default=None)): + """Transcribe el ref.wav de un perfil con Whisper (ya cargado) y guarda ref_text.txt. + Útil para fijar la transcripción y que la clonación sea determinista.""" + check_api_key(x_api_key) + vd = voice_dir(voice_id) + ref_path = vd / "ref.wav" + if not ref_path.exists(): + raise HTTPException(status_code=404, detail={"error": "not_found", + "message": f"Perfil '{voice_id}' no existe"}) + # Invalida cache para forzar re-transcripción + voice_prompt_cache.pop(voice_id, None) + vcp = model.create_voice_clone_prompt(ref_audio=str(ref_path), ref_text=None, preprocess_prompt=True) + (vd / "ref_text.txt").write_text(vcp.ref_text, encoding="utf-8") + print(f"[OmniVoice] ref_text fijado para {voice_id}: {vcp.ref_text!r}", flush=True) + return {"status": "ok", "voice_id": voice_id, "ref_text": vcp.ref_text} + + +@app.post("/v1/voice-profile") +async def create_voice_profile( + voice_id: str = Form(...), + ref_audio: UploadFile = File(...), + ref_text: str | None = Form(default=None), + x_api_key: str | None = Header(default=None), +): + check_api_key(x_api_key) + + if not VOICE_ID_RE.match(voice_id): + raise HTTPException(status_code=422, detail={"error": "validation_failed", + "message": "voice_id invalido"}) + + vd = voice_dir(voice_id) + vd.mkdir(parents=True, exist_ok=True) + + contents = await ref_audio.read() + if not contents: + raise HTTPException(status_code=422, detail={"error": "validation_failed", + "message": "ref_audio vacio"}) + + (vd / "ref.wav").write_bytes(contents) + if ref_text: + (vd / "ref_text.txt").write_text(ref_text, encoding="utf-8") + elif (vd / "ref_text.txt").exists(): + (vd / "ref_text.txt").unlink() + + print(f"[OmniVoice] perfil guardado: {voice_id} ({len(contents)} bytes)", flush=True) + return {"status": "ok", "voice_id": voice_id, "message": "Perfil de voz guardado"} + + +@app.post("/v1/speak") +async def speak( + voice_id: str = Form(...), + text: str = Form(...), + x_api_key: str | None = Header(default=None), +): + check_api_key(x_api_key) + + vd = voice_dir(voice_id) + ref_path = vd / "ref.wav" + if not ref_path.exists(): + raise HTTPException(status_code=404, detail={"error": "not_found", + "message": f"Perfil de voz '{voice_id}' no existe"}) + + if len(text) > 2000: + raise HTTPException(status_code=422, detail={"error": "validation_failed", + "message": "texto demasiado largo (max 2000)"}) + + ref_text = None + rt_path = vd / "ref_text.txt" + if rt_path.exists(): + ref_text = rt_path.read_text(encoding="utf-8").strip() or None + + print(f"[OmniVoice] INICIO voice={voice_id} chars={len(text)}", flush=True) + t_total_start = perf_counter() + + # CACHE VoiceClonePrompt: la transcripción Whisper + tokenización del audio + # de referencia se hacen UNA sola vez por perfil de voz (no en cada request). + # Elmina el overhead fijo de ~1-2s observado en benchmarks. + cache_key = (voice_id, ref_path.stat().st_mtime) + t_cache_start = perf_counter() + if voice_prompt_cache.get(voice_id, {}).get("mtime") != cache_key[1]: + print(f"[OmniVoice] CACHE MISS: Creando prompt para voice={voice_id}", flush=True) + voice_prompt_cache[voice_id] = { + "mtime": cache_key[1], + "prompt": model.create_voice_clone_prompt( + ref_audio=str(ref_path), + ref_text=ref_text, + preprocess_prompt=True, + ), + } + else: + print(f"[OmniVoice] CACHE HIT: Reusando prompt para voice={voice_id}", flush=True) + t_cache = perf_counter() - t_cache_start + print(f"[OmniVoice] VoiceClonePrompt: {t_cache:.3f}s", flush=True) + + t_gen_start = perf_counter() + + # Generación con parámetros de calidad (humano): num_step=32, class_temperature>0 + # torch.inference_mode() desactiva el grafo de autograd en los 48 forward passes + # del loop iterativo. La librería no lo hace internamente (bug de omnivoice.py:1256), + # lo que ralentiza cada paso ~15-30% y consume VRAM extra de gradientes. + try: + with torch.inference_mode(): + audio_list = model.generate( + text=text, + voice_clone_prompt=voice_prompt_cache[voice_id]["prompt"], + num_step=NUM_STEP, + guidance_scale=GUIDANCE_SCALE, + class_temperature=CLASS_TEMPERATURE, + speed=SPEED, + ) + except Exception as e: + print(f"[OmniVoice] ERROR generate: {e!r}", flush=True) + raise HTTPException(status_code=500, detail={"error": "server_error", + "message": f"Error generando audio: {e}"}) from e + t_gen = perf_counter() - t_gen_start + print(f"[OmniVoice] model.generate(): {t_gen:.2f}s " + f"(step={NUM_STEP}, gs={GUIDANCE_SCALE}, ct={CLASS_TEMPERATURE}, " + f"speed={SPEED})", flush=True) + + if not audio_list or len(audio_list[0]) == 0: + raise HTTPException(status_code=500, detail={"error": "server_error", + "message": "Generacion vacia"}) + + audio = audio_list[0] + t_save_start = perf_counter() + + # Phase 2: Save WAV + buf = io.BytesIO() + sf.write(buf, audio, 24000, format="WAV") + wav_bytes = buf.getvalue() + t_save = perf_counter() - t_save_start + + t_total = perf_counter() - t_total_start + dur = len(audio) / 24000 + rt_ratio = t_total / dur if dur > 0 else 0 + + print(f"[OmniVoice] TIMERS: cache={t_cache:.2f}s, save_wav={t_save:.2f}s, " + f"total={t_total:.2f}s, audio={dur:.2f}s, RTF={rt_ratio:.2f}x | {len(wav_bytes)} bytes", flush=True) + + return Response(content=wav_bytes, media_type="audio/wav", + headers={"Content-Disposition": f'inline; filename="{voice_id}.wav"', + "Cache-Control": "no-store"}) + + +@app.post("/v1/speak_test") +async def speak_test( + voice_id: str = Form(...), + text: str = Form(...), + num_step: int = Form(default=NUM_STEP), + guidance_scale: float = Form(default=GUIDANCE_SCALE), + class_temperature: float = Form(default=CLASS_TEMPERATURE), + t_shift: float = Form(default=0.1), + position_temperature: float = Form(default=5.0), + speed: float | None = Form(default=None), + x_api_key: str | None = Header(default=None), +): + """Endpoint de A/B testing: genera audio con parámetros arbitrarios. + Permite comparar perfiles de calidad lado a lado sin reiniciar el servidor. + Usa el mismo cache de VoiceClonePrompt que /v1/speak.""" + check_api_key(x_api_key) + + vd = voice_dir(voice_id) + ref_path = vd / "ref.wav" + if not ref_path.exists(): + raise HTTPException(status_code=404, detail={"error": "not_found", + "message": f"Perfil de voz '{voice_id}' no existe"}) + if len(text) > 500: + raise HTTPException(status_code=422, detail={"error": "validation_failed", + "message": "texto demasiado largo para test (max 500)"}) + + ref_text = None + rt_path = vd / "ref_text.txt" + if rt_path.exists(): + ref_text = rt_path.read_text(encoding="utf-8").strip() or None + + cache_key_mtime = ref_path.stat().st_mtime + if voice_prompt_cache.get(voice_id, {}).get("mtime") != cache_key_mtime: + voice_prompt_cache[voice_id] = { + "mtime": cache_key_mtime, + "prompt": model.create_voice_clone_prompt( + ref_audio=str(ref_path), ref_text=ref_text, preprocess_prompt=True, + ), + } + vcp = voice_prompt_cache[voice_id]["prompt"] + + t0 = perf_counter() + try: + with torch.inference_mode(): + audio_list = model.generate( + text=text, + voice_clone_prompt=vcp, + num_step=num_step, + guidance_scale=guidance_scale, + class_temperature=class_temperature, + t_shift=t_shift, + position_temperature=position_temperature, + speed=speed, + ) + except Exception as e: + raise HTTPException(status_code=500, detail={"error": "server_error", + "message": f"Error: {e}"}) from e + t_gen = perf_counter() - t0 + + audio = audio_list[0] + buf = io.BytesIO() + sf.write(buf, audio, 24000, format="WAV") + wav_bytes = buf.getvalue() + + dur = len(audio) / 24000 + print(f"[OmniVoice] TEST step={num_step} gs={guidance_scale} " + f"ct={class_temperature} ts={t_shift} pt={position_temperature} " + f"| gen={t_gen:.2f}s audio={dur:.2f}s RTF={t_gen/dur:.2f}x", flush=True) + + return Response(content=wav_bytes, media_type="audio/wav", + headers={"Content-Disposition": f'inline; filename="{voice_id}_test.wav"', + "Cache-Control": "no-store", + "X-Params": f"step={num_step},gs={guidance_scale},ct={class_temperature}"}) + + +if __name__ == "__main__": + import uvicorn + host = os.environ.get("OMNIVOICE_HOST", "0.0.0.0") + port = int(os.environ.get("OMNIVOICE_PORT", "8001")) + print(f"[OmniVoice] arrancando uvicorn en {host}:{port} | " + f"step={NUM_STEP} gs={GUIDANCE_SCALE} ct={CLASS_TEMPERATURE}", flush=True) + uvicorn.run(app, host=host, port=port, log_level="info") \ No newline at end of file From 8a6e0ec8bddbebbfc0f94c633e2ca0c15382bcf8 Mon Sep 17 00:00:00 2001 From: LeSoviet Date: Sat, 11 Jul 2026 08:09:32 -0300 Subject: [PATCH 2/2] docs: add git workflow and PR strategy documentation --- docs/omnivoice-workflow.md | 191 +++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 docs/omnivoice-workflow.md diff --git a/docs/omnivoice-workflow.md b/docs/omnivoice-workflow.md new file mode 100644 index 00000000..f372bd08 --- /dev/null +++ b/docs/omnivoice-workflow.md @@ -0,0 +1,191 @@ +# OmniVoice - Flujo de Trabajo Git y PRs + +Documento de referencia del flujo de trabajo con git/GitHub para el modelo OmniVoice. +Define qué va al upstream, qué queda en el fork, y cómo laburar día a día. + +--- + +## 1. Estructura de repositorios + +``` +k2-fsa/OmniVoice (upstream, no tocar) + ↑ PR #219 (fix inference_mode, 1 línea) + | +LeSoviet/OmniVoice (fork, nuestro) + ├── master ← sync con upstream (no tocar) + ├── fix/inference-mode ← PR #219 al upstream (+1 línea) + └── custom/optimizaciones ← PR #1 interno (server.py, docs, script) +``` + +| Repo | URL | Propósito | +|------|-----|-----------| +| Upstream | `https://github.com/k2-fsa/OmniVoice` | Repo oficial. Solo PRs limpios y quirúrgicos. | +| Fork | `https://github.com/LeSoviet/OmniVoice` | Nuestro. Ramas custom, docs, server. | +| Local server | `/home/lesoviet/omnivoice/` | Server vivo (no es repo git). | +| Local clone | `/home/lesoviet/omnivoice-pr/` | Clone del fork para git operations. | + +--- + +## 2. PRs activos + +### PR #219 — Upstream (k2-fsa/OmniVoice) + +- **URL:** https://github.com/k2-fsa/OmniVoice/pull/219 +- **Branch:** `fix/inference-mode` → `master` +- **Issue relacionado:** https://github.com/k2-fsa/OmniVoice/issues/218 +- **Cambio:** +1 línea (`@torch.inference_mode()` sobre `def _generate_iterative`) +- **Diff:** 1 file changed, 1 insertion, 0 deletions +- **Estado:** OPEN +- **Regla:** No tocar más. Es un PR limpio al upstream. Si piden cambios, se hacen en la misma branch. + +### PR #1 — Fork interno (LeSoviet/OmniVoice) + +- **URL:** https://github.com/LeSoviet/OmniVoice/pull/1 +- **Branch:** `custom/optimizaciones` → `master` +- **Cambios:** + - `server.py` (nuevo) — microservicio FastAPI con todas las optimizaciones + - `scripts/omnivoice` (nuevo) — script de control bash + - `docs/omnivoice-optimizaciones.md` (nuevo) — changelog completo vs upstream + - `.gitignore` (modificado) — excluye logs, audios, voice profiles, cache +- **Diff:** 4 files, ~400 additions +- **Estado:** OPEN (interno, no se mergea a upstream) + +--- + +## 3. Reglas de trabajo + +### Lo que va al upstream (k2-fsa/OmniVoice) + +- **Solo bug fixes del código de la librería** (`omnivoice/models/omnivoice.py`, etc.) +- **Diff mínimo** (idealmente 1 línea, máximo un puñado) +- **Justificado con benchmarks** +- **Consistente con el código existente** (ej: `@torch.inference_mode()` ya se usa en líneas 324 y 476) +- **Sin archivos de server, configs, docs propias, ni nada AMD-específico** + +### Lo que queda en el fork (LeSoviet/OmniVoice) + +- `server.py` y todos los endpoints custom +- `scripts/omnivoice` +- `docs/omnivoice-optimizaciones.md` y este archivo +- Configuración AMD ROCm / MIOpen +- `.gitignore` custom + +### Lo que NUNCA se pushea + +- `venv/` (ya excluido por .gitignore) +- `data/voices/*/ref.wav` y `ref_text.txt` (datos personales de voz) +- `*.wav`, `*.mp4` (audios de test) +- `*.log` (logs del server) +- `.omnivoice.pid` +- `.env` con API keys +- `~/.config/miopen/` (cache de kernels) + +--- + +## 4. Flujo de trabajo día a día + +### Laburar en el server (desarrollo) + +```bash +# Editar server.py o lo que sea en: +/home/lesoviet/omnivoice/server.py + +# Reiniciar server para probar: +omnivoice stop && omnivoice start + +# Test: +curl -s -X POST http://localhost:8001/v1/speak \ + -H "x-api-key: local-dev-key" \ + -F "voice_id=daniel" -F "text=test" \ + -o /tmp/test.wav +``` + +### Versionar cambios en el fork + +Cuando laburaste en `/home/lesoviet/omnivoice/` y querés guardar en git: + +```bash +cd /home/lesoviet/omnivoice-pr +git checkout custom/optimizaciones + +# Copiar archivos modificados del server vivo al clone +cp /home/lesoviet/omnivoice/server.py ./server.py +cp /home/lesoviet/.local/bin/omnivoice ./scripts/omnivoice +# (docs ya están en docs/) + +# Commit + push +git add -A +git commit -m "feat: descripción del cambio" +git push origin custom/optimizaciones +``` + +El PR #1 del fork se actualiza solo (misma branch). + +### Sync fork con upstream + +Si upstream saca cambios nuevos y querés traerlos: + +```bash +cd /home/lesoviet/omnivoice-pr +git checkout master +git pull upstream master +git push origin master + +# Rebasear rama custom sobre master actualizado +git checkout custom/optimizaciones +git rebase master +git push origin custom/optimizaciones --force +``` + +### Mandar otro PR al upstream + +Si encontrás otro bug de la librería: + +```bash +cd /home/lesoviet/omnivoice-pr +git checkout master +git pull upstream master +git checkout -b fix/nuevo-bug +# editar omnivoice/models/omnivoice.py +git add omnivoice/models/omnivoice.py +git commit -m "fix: descripción" +git push origin fix/nuevo-bug +gh pr create --repo k2-fsa/OmniVoice --base master --head LeSoviet:fix/nuevo-bug ... +``` + +--- + +## 5. Estado del venv local + +El paquete `omnivoice` instalado en el venv (`/home/lesoviet/omnivoice/venv/`) tiene aplicado el fix de `@torch.inference_mode()` directamente en `venv/lib/python3.13/site-packages/omnivoice/models/omnivoice.py:1147`. + +Esto es independiente del clone del fork. Si reinstalás el paquete (`pip install --force-reinstall omnivoice`), perdés el fix del venv. Para re-aplicarlo: + +```bash +cd /home/lesoviet/omnivoice-pr +# Copiar el archivo del fork (que ya tiene el fix) al venv +cp omnivoice/models/omnivoice.py /home/lesoviet/omnivoice/venv/lib/python3.13/site-packages/omnivoice/models/omnivoice.py +``` + +O esperar a que el PR #219 se mergee upstream y reinstalar desde el repo oficial. + +--- + +## 6. Archivos de referencia + +| Archivo | Ubicación | Contenido | +|---------|-----------|-----------| +| `docs/omnivoice-optimizaciones.md` | Fork + repo Nuevo_Proyecto | Changelog técnico completo de todos los cambios | +| `docs/omnivoice-workflow.md` | Este archivo | Flujo git/GitHub y reglas de trabajo | +| `docs/omnivoice-issue.md` | Repo Nuevo_Proyecto | Texto del issue/PR upstream (referencia) | + +--- + +## 7. Cuentas y autenticación + +- **GitHub CLI (`gh`):** autenticado como `LeSoviet` +- **Token scopes:** `gist, read:org, repo, workflow` +- **Git protocol:** HTTPS +- **Remote names:** + - `origin` → `LeSoviet/OmniVoice` (fork) + - `upstream` → `k2-fsa/OmniVoice` (repo oficial) \ No newline at end of file