# Propuesta A — Microservicio de audio en Rust

> Evaluación de reescribir `herandro-audio-service` en Rust en lugar de portar el código
> Python actual. Documento hermano de [`MIGRACION_AUDIO_MICROSERVICIO.md`](./MIGRACION_AUDIO_MICROSERVICIO.md),
> que define el contrato y las fases. Aquí solo se evalúa el lenguaje.
>
> Objetivos declarados: eficiencia, control de carga, control de errores, menor consumo de
> recursos, más velocidad de respuesta.

---

## 1. Lo primero, porque cambia toda la evaluación

**La inferencia no se acelera cambiando de lenguaje.** Los seis providers ejecutan
ONNX Runtime o PyTorch, que son C++ compilado. Un `session.run()` desde Rust y uno desde
Python invocan el mismo kernel, con los mismos threads y las mismas instrucciones SIMD. El
tiempo de síntesis de un segmento de Chatterbox —hoy ~52 s sin cap de threads, según el
comentario en `chatterbox_worker.py:159-161`— **es idéntico en Rust**.

Lo que sí cambia con Rust:

| Dimensión | Ganancia real |
|---|---|
| RSS base del proceso | Alta — se eliminan el intérprete Python, torch y numpy del proceso servidor (cientos de MB antes de cargar un solo modelo) |
| Paralelismo | Alta — sin GIL, sin `_synthesize_lock` global, sin `ProcessPoolExecutor` por provider |
| Control de carga | Alta — `tower` da límite de concurrencia, cola con backpressure y load-shedding declarativos |
| Predictibilidad de latencia | Media-alta — sin GC, sin pausas de recolección; la varianza p99 baja |
| Tiempo de inferencia puro | **Nula** |
| Coste de mantenimiento | Negativa — hay que portar código de modelo, no solo fontanería |

Corolario: Rust se justifica por el **proceso alrededor** de la inferencia, no por la
inferencia. Si el cuello de botella medido es el `session.run()`, este documento no resuelve
nada y la respuesta correcta es la §10 de la propuesta final.

---

## 2. Alcance

Solo la **fase 1** de la migración: el servicio de síntesis sin estado. El orquestador de
podcast (fase 2) es I/O-bound y depende de LangChain/LangGraph, que no tiene equivalente
Rust razonable — reescribirlo sería rehacer el pipeline de agentes entero.

```
Rust:    POST /internal/v1/synthesize   →  factory → provider → ORT → bytes
Python:  todo lo demás (orquestador, DTOs, BD, Redis, Celery, Keycloak)
```

---

## 3. Stack equivalente

| Componente actual | Equivalente Rust | Madurez |
|---|---|---|
| FastAPI + uvicorn | `axum` + `tokio` + `hyper` | Alta |
| `StreamingResponse` con heartbeat | `axum::body::Body::from_stream` sobre un `Stream` | Alta |
| pydantic | `serde` + `validator` | Alta |
| pydantic-settings | `figment` o `config` | Alta |
| onnxruntime (python) | [`ort`](https://github.com/pykeio/ort) — wrapper de ONNX Runtime | Alta — es el binding de facto |
| tokenizers (HF) | `tokenizers` — **el crate nativo es la implementación original**; Python solo lo envuelve | Alta |
| numpy | `ndarray` | Alta |
| soundfile / WAV | `hound` (WAV puro), `symphonia` (decodificación) | Alta |
| librosa (resample) | `rubato` | Media-alta |
| `ProcessPoolExecutor` aislado | No hace falta: sin GIL se usan tareas `tokio` + `spawn_blocking`. Si se quiere el aislamiento anticrash, procesos hijos con `tokio::process` | — |
| `_synthesize_lock` global | `tokio::sync::Semaphore` con N permisos configurables | Alta |
| prometheus-fastapi-instrumentator | `metrics` + `metrics-exporter-prometheus` | Alta |
| logging | `tracing` + `tracing-subscriber` | Alta |
| espeak-ng (fonemización) | Crate `espeakng` (FFI) o shell-out al binario, igual que hoy | Media |
| requests / hf_hub_download | `reqwest` + `hf-hub` | Media-alta |

Para la fase 2, si algún día se hiciera: `sqlx` o `sea-orm`, `deadpool-redis`,
`aws-sdk-s3`. Celery no tiene cliente Rust mantenido — habría que exponer los jobs como HTTP,
que es justamente lo que el dynamic job runner ya hace.

---

## 4. Portabilidad provider por provider — la tabla que decide

Esta es la parte cara y la que suele subestimarse.

| Provider | Qué es hoy | Portabilidad a Rust | Coste |
|---|---|---|---|
| **piper** | Binario C++ invocado por `subprocess.run` (`providers/piper.py:29-46`) | **Trivial.** `std::process::Command`, mismo binario, mismos flags | Horas |
| **kokoro** | `kokoro_onnx.Kokoro` sobre ONNX + voces `.bin` | **Alta.** Existe [Kokoros](https://github.com/lucasjinreal/Kokoros), implementación Rust sobre `ort`, con servidor HTTP y streaming. El modelo recibe IDs de fonemas + vector de estilo + `speed`, salida a 24 kHz | Días |
| **chatterbox** | **Bucle de generación escrito a mano, 527 líneas de ONNX Runtime puro** (`chatterbox_worker.py`) — 8 ficheros ONNX, KV-cache de 30 capas, 16 cabezas, dim 64 | **Media, y es el trabajo real.** No hay port existente, pero como ya es ORT puro sin torch, se traduce 1:1 a `ort` + `ndarray`. Hay que replicar exactamente: chunking a 150 chars, recuperación de chunks silenciosos (re-chunk a 70, reintento con `exaggeration+0.15`, descarte), y el cache de referencias de voz por SHA-256 | 3-5 semanas, y es donde se introducen las regresiones de calidad |
| **supertonic** | `from supertonic import TTS` — paquete Python que gestiona descarga, estilos y segmentación multiidioma (31 idiomas) | **Baja.** No hay port. Reimplementar significa reescribir la lógica del paquete, incluido `_multilang.segment_by_language` | Alta, o no se porta |
| **f5_tts** | Paquete Python sobre **PyTorch** | **Muy baja.** `candle` no cubre la arquitectura; portarlo es un proyecto en sí mismo | No se porta |
| **cosyvoice** | Shell-out a un comando externo (`COSYVOICE_TTS_COMMAND`) | **Trivial** — sigue siendo un shell-out | Horas |

**El pivote de la decisión:** cuántos de estos providers tienen filas activas en
`tts_models` en producción. Ese es el primer punto del checklist de la fase 0 del documento
principal, y aquí se vuelve decisivo:

- Si solo hay `kokoro` + `piper` activos → **Rust es viable y el port es de semanas.**
- Si `chatterbox` está activo → el port es posible pero es el 80 % del esfuerzo, y hay que
  validar el audio bit a bit contra la referencia Python.
- Si `f5_tts`, `supertonic` o `cosyvoice` están activos → **Rust no puede sustituir al
  servicio Python**, solo complementarlo. Quedan dos opciones, ambas peores que no
  reescribir: mantener un sidecar Python para esos providers (dos servicios, dos stacks,
  dos despliegues), o dar de baja esos modelos del catálogo, que es una decisión de
  producto, no de arquitectura.

---

## 5. Control de carga

Es la ganancia más tangible y la más fácil de subestimar por lo mal que está hoy.

**Estado actual:** un `_synthesize_lock` global serializa toda la síntesis del proceso
(`shared/tts_model/service.py:56`). No hay cola, no hay límite de peticiones en vuelo, no hay
timeout (`process_isolation.py:30-46`). Bajo carga, las peticiones se apilan en el
`ThreadPoolExecutor` hasta que el cliente o el proxy se rinden.

**Con `tower`**, declarativo y compuesto en el router:

```rust
ServiceBuilder::new()
    .layer(TimeoutLayer::new(Duration::from_secs(900)))       // 504 en lugar de cuelgue
    .layer(LoadShedLayer::new())                               // 503 inmediato si está lleno
    .layer(ConcurrencyLimitLayer::new(max_concurrent))         // N síntesis en paralelo
    .layer(BufferLayer::new(queue_depth))                      // cola acotada, no infinita
    .layer(TraceLayer::new_for_http())
```

Lo relevante:

- **`load_shed` devuelve 503 al instante** en lugar de aceptar trabajo que no puede atender.
  Un cliente que recibe 503 reintenta; un cliente que espera 20 minutos y recibe un
  `ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC` no sabe qué pasó — que es exactamente el
  fallo documentado en `api/CLAUDE.md`.
- **La cola es acotada.** Hoy es la memoria del proceso.
- **`max_concurrent` puede ser > 1** porque no hay GIL. Con 4 CPU y Kokoro (~88 MB de pesos)
  caben varias síntesis simultáneas de verdad. Con Chatterbox no: cada sesión ORT reserva su
  propio arena y `CHATTERBOX_ONNX_NUM_THREADS` ya consume `cpu_count()-1`. El límite hay que
  fijarlo **por provider**, no global.

Residencia de modelos: hoy es un `dict` de clase con TTL y un reaper thread
(`service.py:54-137`). En Rust, un `moka::future::Cache` con `time_to_idle` y
`max_capacity` por peso da lo mismo, sin hilo propio y con desalojo por presión de memoria.

---

## 6. Control de errores

| Hoy | En Rust |
|---|---|
| Un `SIGSEGV` de ORT mata el subproceso; el pool se resetea y se lanza `RuntimeError` (`process_isolation.py:37-41`) | `ort` devuelve `Result`; los errores de ORT llegan como valores, no como señales. **Un panic de Rust no es un SIGSEGV**: `catch_unwind` o el aislamiento de tarea de tokio lo contienen |
| Sin timeout: cuelgue indefinido | `TimeoutLayer` + `tokio::time::timeout` |
| Errores silenciosos: chunk silencioso se reintenta y si falla se **descarta sin avisar** (`chatterbox_worker.py:497-503`) | Se porta igual —es contrato— pero se emite una métrica `audio_silent_chunk_dropped_total`. Hoy solo hay un log |
| `except Exception` que traga y devuelve el audio sin música (`tts/service.py:154-157`) | `Result` explícito; el compilador obliga a decidir. El comportamiento se conserva, pero queda escrito |

La ventaja no es que "haya menos errores", es que **el compilador no deja ignorar un caso de
error**. En el código actual hay tres `except Exception` que devuelven un valor degradado sin
que el llamador pueda distinguirlo del camino feliz.

---

## 7. Consumo de recursos

Estimación, no medición. **Medir antes de decidir.**

| Concepto | Python hoy | Rust |
|---|---|---|
| RSS del proceso servidor, en vacío | ~250-400 MB (intérprete + torch + numpy + FastAPI) | ~15-30 MB |
| RSS por modelo cargado | Igual — lo reserva ORT, no el lenguaje | Igual |
| Procesos por provider | 1 subproceso por provider vivo (`spawn`, con su propio intérprete) | 0 — hilos dentro del mismo proceso |
| Fragmentación de heap | Alta; se mitiga con jemalloc + `MALLOC_CONF` en el Dockerfile | Baja; el allocator de Rust no sufre el patrón de glibc con numpy |
| Imagen Docker | ~6-8 GB con torch CPU | ~150-300 MB (binario estático + ORT `.so`) |

El ahorro de imagen y de RSS base es real y grande. El de RAM total, no tanto: los pesos
dominan y son idénticos.

---

## 8. Velocidad de respuesta

| Fase de un request | Hoy | Rust | Comentario |
|---|---|---|---|
| Parseo HTTP + validación | ~1-3 ms | ~0,1 ms | Irrelevante frente a la síntesis |
| Espera por el lock global | **Segundos a minutos bajo carga** | 0 con concurrencia real | **La ganancia grande está aquí** |
| Carga del modelo (frío) | Segundos | Igual | Lo hace ORT |
| `session.run()` | X | X | **Idéntico** |
| Serialización base64 + JSON | ~10-50 ms para audio grande | ~2-10 ms | Marginal |

Traducción honesta: en una petición aislada y con el modelo caliente, la diferencia es de
milisegundos sobre decenas de segundos. **Bajo concurrencia, la diferencia es de órdenes de
magnitud** — pero porque hoy hay un lock global, no porque Python sea lento.

---

## 9. Límites y riesgos

1. **Riesgo de regresión de audio.** Portar el bucle de Chatterbox implica replicar
   chunking, padding, KV-cache, muestreo y recuperación de silencios. Una diferencia de
   redondeo cambia el audio. Mitigación obligatoria: el test de hash SHA-256 de §13 del
   documento principal, provider por provider, antes de conmutar.
2. **Fonemización.** Kokoro necesita fonemas, no texto. Hoy lo resuelve `phonemizer-fork` +
   `espeak-ng`. En Rust hay que hacer FFI a espeak-ng o shell-out. Es una dependencia nativa
   con licencia GPL — revisar implicaciones si el binario se distribuye.
3. **Idiomas.** El mapa `_LANG_MAP` de Kokoro (10 entradas) y los 31 idiomas de Supertonic
   son datos, se portan fácil. La **segmentación por idioma** de Supertonic es lógica, y no.
4. **Equipo.** El repo es Python + Angular + Kotlin. Añadir Rust añade un lenguaje que hay
   que saber depurar en producción a las 3 de la mañana. Es el riesgo menos técnico y el que
   más proyectos hunde.
5. **`ort` es un wrapper no oficial.** Bien mantenido, pero sigue a ONNX Runtime con retraso.
   Hoy el repo fija `onnxruntime==1.24.4`; hay que verificar que la versión de `ort` la
   soporte.
6. **El resto del sistema sigue en Python.** No se elimina torch de la API principal:
   `faster-whisper`, `pyannote-audio` y `sentence-transformers` lo necesitan. El ahorro es
   del servicio de audio, no del despliegue completo.

---

## 10. Esfuerzo

| Trabajo | Estimación |
|---|---|
| Andamiaje: axum, config, métricas, logging, contrato `/internal/v1/synthesize` | 1 semana |
| Provider `piper` + `cosyvoice` (shell-out) | 2-3 días |
| Provider `kokoro` (partiendo de Kokoros) | 1-1,5 semanas |
| Provider `chatterbox` (port del bucle ORT) | **3-5 semanas** |
| Mezcla de música de fondo + normalizador de texto | 1 semana |
| Capa de carga/errores con `tower` | 3-4 días |
| Validación de audio bit a bit y pruebas de carga | 1-2 semanas |
| **Total con Chatterbox** | **~2,5-3 meses** |
| **Total solo kokoro + piper** | **~3-4 semanas** |

---

## 11. Veredicto

**Rust es la mejor opción técnica de las tres, y aun así solo tiene sentido bajo una
condición: que el catálogo activo de `tts_models` sea únicamente `kokoro` y `piper`.**

- Con ese catálogo: 3-4 semanas, un binario de 200 MB en vez de una imagen de 7 GB,
  concurrencia real, control de carga declarativo. Vale la pena.
- Con Chatterbox activo: 2,5-3 meses y riesgo alto de regresión de calidad de audio, a
  cambio de una ganancia que se solapa mucho con simplemente arreglar el lock y añadir
  backpressure en Python. **No lo recomiendo como primer paso.**
- Con F5-TTS, Supertonic o CosyVoice activos: **no es viable como sustituto.** Obliga a
  mantener dos servicios.

Recomendación de secuencia: hacer primero la extracción en Python (fase 1 del documento
principal) con el lock y los timeouts arreglados, **medir**, y solo entonces decidir si Rust
aporta algo que no se consiguió ya. La extracción en Python es prerrequisito de cualquiera de
las tres propuestas, porque es la que crea la frontera de red y el contrato interno; una vez
existe esa frontera, cambiar la implementación detrás es un ejercicio acotado y reversible.

---

## Fuentes

- [Kokoros — Kokoro TTS en Rust](https://github.com/lucasjinreal/Kokoros)
- [`ort` — wrapper de ONNX Runtime para Rust](https://github.com/pykeio/ort)
- [Kokoro-82M-v1.0-ONNX](https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX)
