# Propuesta C — Microservicio de audio en NestJS

> Evaluación de reescribir `herandro-audio-service` en NestJS / Node.js. Documento hermano de
> [`MIGRACION_AUDIO_MICROSERVICIO.md`](./MIGRACION_AUDIO_MICROSERVICIO.md), que define el
> contrato y las fases.
>
> Objetivos declarados: eficiencia, control de carga, control de errores, menor consumo de
> recursos, más velocidad de respuesta.

---

## 1. La misma advertencia

**La inferencia no se acelera cambiando de lenguaje.** `onnxruntime-node` invoca la misma
librería C++ que `onnxruntime` de Python. El `session.run()` tarda lo mismo.

Diferencia específica de Node: el modelo de ejecución es **un solo hilo con event loop**. Una
inferencia de 50 s en el hilo principal bloquea el servidor entero — health checks incluidos.
Todo el diseño gira alrededor de evitar eso.

---

## 2. Por qué esta opción merece consideración pese a lo anterior

Es la única de las tres que **el equipo ya sabe mantener**. El repo tiene un dashboard
Angular 20 con arquitectura por capas `domain/application/infrastructure/presentation`, y el
propio módulo de seguridad de la API está escrito imitando NestJS —
`resource_guard("studies") → _scopes("canRead")` es literalmente el patrón
`@Resource + @Scopes` de `nest-keycloak-connect`, y así lo documenta
`app/core/security.py:1-8,72`.

Eso no es un detalle menor: el riesgo que más suele hundir estas migraciones no es técnico,
es "quién depura esto en producción un domingo". En NestJS, la respuesta es "el mismo equipo
que ya escribe TypeScript".

---

## 3. Alcance

Fase 1 (síntesis). Y, a diferencia de Rust y Go, aquí sí tiene sentido plantear también la
**fase 2**, porque la orquestación es I/O-bound y ahí Node rinde bien. La limitación no es la
orquestación, son las fases LLM que dependen de LangChain — aunque LangChain.js existe y es
razonablemente maduro, migrar el pipeline de agentes es un proyecto aparte y queda fuera.

---

## 4. Stack equivalente

| Componente actual | Equivalente NestJS | Madurez |
|---|---|---|
| FastAPI | `@nestjs/platform-fastify` (más rápido que Express) | Alta |
| `StreamingResponse` con heartbeat | `StreamableFile` o `res.write()` directo sobre un `Readable` | Alta |
| pydantic | `class-validator` + `class-transformer` con `ValidationPipe` | Alta — es lo más parecido a pydantic fuera de Python |
| pydantic-settings | `@nestjs/config` con esquema Joi/Zod | Alta |
| Depends() | DI nativa de Nest (decoradores, providers, scopes) | Alta — más rica que la de FastAPI |
| onnxruntime | [`onnxruntime-node`](https://www.npmjs.com/package/onnxruntime-node) — binding oficial de Microsoft | **Alta — es oficial, a diferencia de Rust y Go** |
| kokoro | [`kokoro-js`](https://www.npmjs.com/package/kokoro-js) — `device: "cpu"` usa `onnxruntime-node` vía `@huggingface/transformers` | Alta |
| tokenizers | `@huggingface/transformers` (Transformers.js) | Alta |
| numpy | `onnxruntime-common.Tensor` + `TypedArray`. Sin broadcasting | Media |
| soundfile / WAV | `node-wav`, `wavefile` | Media-alta |
| librosa | `ffmpeg` por `fluent-ffmpeg` (ya está en la imagen) | Media |
| `ProcessPoolExecutor` | `worker_threads` vía `piscina`, o `child_process` para aislamiento anticrash real | Alta |
| `_synthesize_lock` | **BullMQ** — cola en Redis con concurrencia, reintentos y prioridades | Alta |
| Celery | BullMQ + `@nestjs/bullmq` | Alta |
| Prometheus | `@willsoto/nestjs-prometheus` | Media-alta |
| logging | `nestjs-pino` | Alta |
| hf_hub_download | `@huggingface/hub` | Media-alta |

Fase 2: TypeORM o Prisma, `ioredis`, `@aws-sdk/client-s3`. Todos de primera línea.

---

## 5. Portabilidad provider por provider

| Provider | Portabilidad a NestJS | Coste |
|---|---|---|
| **piper** | **Trivial.** `child_process.spawn`, mismo binario | Horas |
| **cosyvoice** | **Trivial.** Shell-out | Horas |
| **kokoro** | **La más fácil de las tres propuestas.** `kokoro-js` ya lo implementa, con `from_pretrained`, `generate(text, {voice})`, `list_voices()` y una API de streaming por chunks. Fonemización incluida — se evita el problema de espeak-ng que sufren Rust y Go | Días |
| **chatterbox** | **Media-baja.** Mismo port de 527 líneas, con `Tensor` de onnxruntime-common en vez de numpy. Manipular KV-cache de 30 capas con `Float32Array` y reshape manual es tedioso y propenso a error | 5-7 semanas |
| **supertonic** | **Baja.** Sin port | Alta, o no se porta |
| **f5_tts** | **Inviable.** PyTorch | No se porta |

Mismo pivote que en las otras dos, con un matiz favorable: **si el catálogo activo es solo
`kokoro` + `piper`, NestJS es la ruta más corta de las tres**, porque `kokoro-js` elimina el
trabajo de fonemización que en Rust y Go hay que resolver con FFI a espeak-ng.

---

## 6. Control de carga

Aquí NestJS tiene una respuesta que las otras dos no dan de serie: **BullMQ**.

```ts
@Processor('tts', { concurrency: 2 })          // concurrencia por worker
export class TtsProcessor extends WorkerHost {
  async process(job: Job<SynthesizeDto>) { … }
}

await this.ttsQueue.add('synthesize', dto, {
  attempts: 2,
  backoff: { type: 'exponential', delay: 2000 },
  removeOnComplete: 100,
  timeout: 900_000,
});
```

Lo que da gratis y hoy no existe:

- **Cola persistida en Redis**, no en la memoria del proceso. Un reinicio no pierde el
  trabajo encolado.
- **Reintentos con backoff** declarativos.
- **Prioridades** — una síntesis interactiva de `/tts/v1/generate` puede adelantar a los
  segmentos de un podcast en batch. Hoy compiten por el mismo lock global sin distinción.
- **Rate limiting por cola** y **workers escalables por separado** del proceso HTTP.
- **Observabilidad de cola**: profundidad, edad del trabajo más viejo, tasa de fallo. Métricas
  que hoy no existen en ninguna forma.

Contrapartida seria: BullMQ **cambia el modelo de ejecución** de síncrono-con-heartbeat a
encolado. Para `/tts/v1/generate` —que hoy responde con el audio en la misma petición— hay
que mantener la fachada esperando el resultado del job, lo que reintroduce la espera larga.
Es implementable (`job.waitUntilFinished()`), pero conviene ser consciente de que se añade un
salto por Redis en el camino caliente.

**Aislamiento del event loop — no negociable.** La inferencia debe correr en `worker_threads`
(vía `piscina`) o en procesos hijos. Si se ejecuta en el hilo principal, un segmento de
Chatterbox de 50 s deja el servicio sin responder a `/health` y el orquestador lo dará por
caído. Esto no es una optimización, es un requisito de corrección.

---

## 7. Control de errores

| Hoy | En NestJS |
|---|---|
| `SIGSEGV` de ORT mata el subproceso aislado | Con `worker_threads`, un fallo nativo **mata el proceso entero** — los workers comparten proceso. Solo `child_process` da el aislamiento actual |
| Sin timeout | `timeout` de BullMQ + `AbortController` |
| `except Exception` que degrada en silencio | `try/catch` con `ExceptionFilter` global; los DTOs validados por `ValidationPipe` rechazan payloads malformados antes de llegar al servicio |
| Sin reintentos | BullMQ los da declarativos |
| Sin trazabilidad de fallos | `nestjs-pino` con `X-Request-Id` propagado, que ya existe en la API (`request_id_middleware`) |

`ValidationPipe` + `class-validator` reproduce muy fielmente el comportamiento de pydantic,
incluida la validación cruzada que hoy hace `GenerateTtsRequestDTO` ("debe venir `model_name`
o `model_id`"). Es la migración de DTOs más directa de las tres propuestas.

---

## 8. Consumo de recursos

| Concepto | Python hoy | NestJS |
|---|---|---|
| RSS del servidor en vacío | ~250-400 MB | ~60-100 MB (V8 + Nest) |
| RSS por modelo | Igual (lo reserva ORT) | Igual |
| Memoria de ORT | Fuera del heap de Python | **Fuera del heap de V8.** `--max-old-space-size` no la limita, y el GC de V8 no la ve |
| Imagen Docker | ~6-8 GB | ~400-700 MB (Node + ORT nativo + node_modules) |
| GC | Refcount + `gc.collect()` forzado cada 10 requests | V8 generacional; pausas cortas pero presentes |

Mejora clara frente a Python, peor que Rust y Go. Y con el mismo problema que Go: la memoria
que importa —la de los modelos— está fuera del alcance del runtime.

---

## 9. Velocidad de respuesta

| Fase | Comentario |
|---|---|
| Parseo + validación | `class-validator` es más lento que pydantic v2 (que es Rust por debajo). Diferencia de microsegundos, irrelevante aquí |
| Espera por el lock | **La ganancia grande**, igual que en las otras dos propuestas |
| `session.run()` | Idéntico |
| Base64 + JSON | `Buffer.toString('base64')` es nativo y rápido; V8 maneja bien strings grandes |
| Salto por BullMQ | **Coste añadido** de unos ms por Redis, a cambio de la cola persistida |

Nota específica de Node: serializar audio grande a base64 y devolverlo en JSON genera strings
de decenas de MB en el heap de V8. Con varias respuestas concurrentes eso presiona el GC. En
Python el problema existe igual, pero conviene tenerlo medido antes de asumir que Node
mejora el uso de memoria bajo carga real.

---

## 10. Límites y riesgos

1. **El event loop es un límite estructural**, no un detalle de configuración. Todo el
   diseño debe mantener la inferencia fuera del hilo principal, siempre, sin excepciones.
2. **Sin aislamiento anticrash con `worker_threads`.** Para replicar la garantía actual de
   `process_isolation.py` hay que usar `child_process`, con lo que se recupera la misma
   complejidad de IPC y reaping que hoy.
3. **Ecosistema numérico débil**, igual que Go. Portar Chatterbox con `Float32Array` es
   trabajo manual y frágil.
4. **Sin equivalente a f5-tts ni supertonic.** Mismo techo que las otras dos.
5. **Dos runtimes nativos en la imagen** (Node + ORT) con sus propias asunciones de threads.
   Hay que fijar `UV_THREADPOOL_SIZE` y los threads intra-op de ORT para que no compitan.
6. **Riesgo de dependencias.** `node_modules` con ORT y Transformers.js arrastra binarios
   por plataforma; los builds multi-arch se complican.

---

## 11. Esfuerzo

| Trabajo | Estimación |
|---|---|
| Andamiaje Nest: módulos, DI, DTOs, config, métricas, contrato interno | 4-5 días |
| `piper` + `cosyvoice` | 2 días |
| `kokoro` con `kokoro-js` | **3-4 días** |
| `chatterbox` (port a Tensor/Float32Array) | **5-7 semanas** |
| Cola BullMQ + workers aislados | 1 semana |
| Mezcla de fondo (vía ffmpeg) + normalizador | 1 semana |
| Validación de audio y carga | 1-2 semanas |
| **Total con Chatterbox** | **~3-3,5 meses** |
| **Total solo kokoro + piper** | **~3 semanas — el más rápido de los tres** |

---

## 12. Veredicto

**NestJS es la opción de menor riesgo organizativo y la más rápida de entregar, siempre que
el catálogo activo sea `kokoro` + `piper`.** Es también la técnicamente más floja para
inferencia pesada.

Dónde gana claramente:

- `kokoro-js` elimina semanas de trabajo de fonemización.
- `onnxruntime-node` es el **único binding oficial de Microsoft** de los tres candidatos.
- BullMQ resuelve cola, reintentos, prioridades y observabilidad con configuración, no con
  código.
- `class-validator` traduce los DTOs de pydantic casi línea a línea, lo que reduce el riesgo
  de romper el contrato — que es la regla número uno de esta migración.
- El equipo ya vive en TypeScript.

Dónde pierde:

- El event loop obliga a una arquitectura de workers desde el día uno.
- Sin aislamiento anticrash sin volver a procesos hijos.
- Con Chatterbox activo, el port es igual de caro que en Go y más frágil que en Rust.

Recomendación: **si se decide reescribir y el catálogo lo permite, NestJS es la elección
pragmática; si el catálogo incluye Chatterbox y aun así se quiere reescribir, Rust es la
elección correcta.** Y en ambos casos, la extracción en Python de la fase 1 del documento
principal debería ir primero: crea la frontera de red y el contrato interno que hacen que
cualquiera de estos tres cambios sea después un ejercicio acotado y reversible.

---

## Fuentes

- [`kokoro-js` en npm](https://www.npmjs.com/package/kokoro-js)
- [Kokoro-82M-v1.0-ONNX](https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX)
- [`onnxruntime-node`](https://www.npmjs.com/package/onnxruntime-node)
