# Migración del módulo de generación de audio a microservicio

> Documento de trabajo. Objetivo: extraer la generación de audio (TTS, podcast, música de
> fondo) de `herandro-services-api` a un servicio propio, desplegable en hardware distinto,
> **sin romper un solo contrato HTTP existente**.
>
> Regla que gobierna todo el documento: **el request y el response de las rutas públicas
> actuales no cambian**. Ni path, ni método, ni campos, ni tipos, ni códigos de estado, ni
> el comportamiento de streaming/heartbeat. Cualquier cambio de contrato es un fallo de
> migración, no una mejora.
>
> Corolario y alcance real: **el cliente no nota nada; la API interna sí cambia.** Lo que se
> reescribe es el interior — llamadas Python in-process pasan a ser HTTP, las tablas se
> separan de schema, la sesión de BD deja de ser compartida. Por eso la separación de
> tablas es parte del trabajo y no un extra opcional.

> Documentos hermanos, con evaluación de reescribir el servicio en otro lenguaje:
> [`PROPUESTA_AUDIO_RUST.md`](./PROPUESTA_AUDIO_RUST.md),
> [`PROPUESTA_AUDIO_GO.md`](./PROPUESTA_AUDIO_GO.md),
> [`PROPUESTA_AUDIO_NESTJS.md`](./PROPUESTA_AUDIO_NESTJS.md).

---

## 1. Por qué separar

| Motivo | Evidencia en el repo |
|---|---|
| El audio es el consumidor dominante de RAM/CPU | `torch`, `torchaudio`, `onnxruntime`, `f5-tts`, `supertonic`, `librosa`, `soundfile` viven en `api/pyproject.toml:23-101`; el contenedor `fastapi` está capado a 5 CPU / 10 GB (`docker-compose.yml:122-126`) y comparte esos recursos con los agentes LLM |
| Ya existe aislamiento por proceso por necesidad, no por diseño | `app/modules/ai/shared/tts_model/providers/process_isolation.py:11-17` — los providers ONNX/torch mueren con SIGSEGV y tumbarían la API si corrieran in-process |
| La síntesis serializa todo el runtime | `service.py:56` — `_synthesize_lock` es un mutex global: dos podcasts concurrentes se bloquean entre sí dentro del mismo proceso |
| Los pesos son un lastre de despliegue | Volumen `tts_models_cache` (`docker-compose.yml`), variables `KOKORO_ONNX_CACHE_DIR`, `CHATTERBOX_ONNX_CACHE_DIR`, `SUPERTONIC_CACHE_DIR`, `HF_HOME` |
| Dependencias de sistema exclusivas del audio | `Dockerfile:19-20` (`espeak-ng`, `ffmpeg`) + binario `piper` descargado en el build (`Dockerfile:24-27`) |

Consecuencia directa: hoy un deploy de un cambio de una línea en el dashboard de créditos
reconstruye una imagen que arrastra el stack de inferencia de audio completo.

---

## 2. Inventario del módulo

### 2.1 Código

| Ruta | Líneas | Rol |
|---|---|---|
| `api/app/modules/ai/tts/` | ~215 | Capa HTTP de consumo (listar modelos, generar) |
| `api/app/modules/ai/podcast/` | ~2 700 | Orquestador multi-fase (plan → research → guion → síntesis → render) |
| `api/app/modules/ai/bg_music/` | ~300 | CRUD de presets y pistas de usuario |
| `api/app/modules/ai/shared/tts_model/` | — | Runtime: factory, providers, workers aislados, mixer, normalizador, streaming |
| `api/app/modules/admin/tts_model/` | — | CRUD admin del catálogo `tts_models` (**no se mueve en fase 1**, ver §6) |
| `api/app/shared/celery_worker/jobs/podcast.py` | 110 | Jobs `generate`, `render`, `generate_and_deliver`, `resynthesize` |
| `api/app/shared/celery_worker/router.py:82-140` | — | Endpoints del dynamic job runner para esos jobs |

### 2.2 Providers TTS

`shared/tts_model/factory.py:13-28` — despacho puro por string desde la columna
`tts_models.provider`. **No hay variable de entorno que seleccione provider**: el catálogo
en BD manda.

| Provider | Aislado en subproceso | Descarga de pesos | Cache dir |
|---|---|---|---|
| `kokoro` | Sí | `urllib.request.urlretrieve` desde GitHub releases | `KOKORO_ONNX_CACHE_DIR` (def. `~/.cache/kokoro-onnx`) |
| `chatterbox` | Sí | `hf_hub_download` de `onnx-community/chatterbox-multilingual-ONNX` | `CHATTERBOX_ONNX_CACHE_DIR` |
| `supertonic` | Sí | delegada al paquete `supertonic` (`auto_download`) | interna del paquete |
| `f5_tts` | Sí | delegada al paquete `f5_tts` | cache HF estándar |
| `piper` | No (`subprocess.run` del binario) | `hf_hub_download` o `params.model_path` | cache HF estándar |
| `cosyvoice` | No (shell-out) | externa, comando en `COSYVOICE_TTS_COMMAND` | n/a |

Detalles que **deben viajar tal cual** al nuevo servicio o el audio cambia:

- `chatterbox`: variante LM `q4` (354 MB vs 2,08 GB fp32), `CHATTERBOX_ONNX_NUM_THREADS`
  por defecto `cpu_count()-1` — sin ese cap ORT satura el contenedor (~52 s/segmento) y
  mata la validación de Keycloak por inanición de CPU (`chatterbox_worker.py:159-166`).
  Recuperación de chunks silenciosos: re-chunk a 70 chars, reintento con
  `exaggeration + 0.15`, descarte si sigue silencioso (`:409-503`).
- Límites de chunk por runtime: `piper` 500, `f5_tts`/`cosyvoice` 400, `supertonic` 400;
  `kokoro` (400 interno) y `chatterbox` (150 interno) se excluyen porque chunkean solos
  (`service.py:27-33`).
- Saneado de caracteres de ancho cero antes de sintetizar (`service.py:35-44`, `:221-247`).
- Voces por defecto hardcodeadas como fallback: kokoro `af_heart`, supertonic `M1`.
- `piper` emite **PCM crudo** (`pcm_s16le`), no WAV — y por eso la mezcla de música de
  fondo se omite para ese provider (`tts/service.py:120`, `:139-146`).

### 2.3 Aislamiento por proceso

`providers/process_isolation.py`:

- Un `ProcessPoolExecutor(max_workers=1, mp_context="spawn")` por clave de provider (`:74-86`).
- IPC = pickle sobre `concurrent.futures`; **`run_isolated` no tiene timeout** (`:30-46`).
- `BrokenExecutor` → reset del pool + `RuntimeError` con mensaje en español (`:37-41`).
- Reaper daemon cada 30 s, TTL `TTS_ISOLATION_IDLE_TTL_SECONDS` (def. 300 s, 0 desactiva);
  no mata claves con trabajo en vuelo (`:120-149`).
- Al matar: `terminate()` → 5 s → `kill()` → 2 s (`:99-117`).

### 2.4 Tablas

Cinco tablas + una de scores.

| Tabla | DDL | Filas propias del audio |
|---|---|---|
| `tts_models` | `bd/v2/add_tts_models.sql:3-31` | Catálogo de modelos y voces (`speakers` JSONB) |
| `benchmark_tts_model_scores` | `bd/v2/add_tts_models.sql:33-54` | Scores por benchmark |
| `podcast` | `bd/v2/podcast.sql:34-61` | Cabecera + estado + progreso + render |
| `podcast_speaker` | `bd/v2/podcast.sql:63-75` | Locutores por podcast |
| `bg_music_default` | `bd/v2/bg_music.sql:1-10` | Presets del sistema |
| `bg_music_user_track` | `bd/v2/bg_music.sql:12-22` | Pistas subidas por usuario |

**Hallazgo clave para la separación: no existe ninguna FK entrante.** Ninguna tabla fuera
de este conjunto apunta a `podcast`, `podcast_speaker`, `tts_models` ni `bg_music_*`. La
única relación cruzada declarada en el ORM es `Benchmark.tts_model_scores`
(`app/database/models/ai.py:572-573`), y vive del lado del audio.

FKs salientes (estas **sí** son el freno para separar la BD):

| Tabla | Columna | → | ON DELETE |
|---|---|---|---|
| `podcast` | `user_id` | `user.id` | — |
| `podcast` | `investigation_task_id` | `investigation_task.id` | SET NULL |
| `podcast` | `investigator_id` | `persona.id` | — |
| `podcast_speaker` | `narrator_id` | `persona.id` | — |
| `bg_music_user_track` | `user_id` | `user.id` | CASCADE |
| `benchmark_tts_model_scores` | `benchmark_id` | `benchmarks.id` | CASCADE |

Notar que `podcast.ai_models` es JSONB **sin FK** a `ai_model`, y `podcast.scope_id` /
`scope_type` son campos libres sin FK a tenant/user. Eso juega a favor.

**Deriva de esquema detectada — corregir antes de migrar** (una BD nueva creada desde
`bd/` no tendrá estas columnas y el ORM romperá):

- `podcast.bg_music_path`, `bg_music_volume`, `bg_music_enabled`, `bg_music_preset` existen
  solo en el modelo SQLAlchemy (`app/database/models/podcast.py:95-100`), sin DDL.
- `podcast_speaker.settings` idem (`podcast.py:148`).

### 2.5 Facturación

TTS y música de fondo **no cobran créditos**. Ambos servicios entran con
`include_credit_context=False` (`podcast/service.py:48-49`, `bg_music/service.py:44-45`).
El único consumo de créditos del pipeline ocurre un nivel más abajo, cuando el orquestador
delega las fases de texto a `AgentAiService` (`orchestrator.py:225,236,302`), que a su vez
llama a `log_and_charge_request` y escribe `request_log` / `ai_session_usage_ledger`.

Consecuencia de diseño: **la facturación no debe moverse.** Si el orquestador se va al
microservicio, sus fases LLM tienen que seguir pasando por la API principal vía HTTP para
que el cobro siga ocurriendo en un solo sitio.

---

## 3. Contrato congelado

Esto es lo que no puede cambiar. Cualquier refactor se valida contra esta tabla.

### 3.1 `/tts/v1/*` — guard `resource_guard("ai-agent")`, scope `canUse`

| Método | Path | Entrada | Salida (`data`) | Códigos |
|---|---|---|---|---|
| GET | `/tts/v1/models` | query `language`, `supports_voice_cloning`, `supports_emotion_tags` | `[{id, display_name, description, languages[], default_language, supports_voice_cloning, supports_emotion_tags, realtime_allowed}]`, con `length` | 200 |
| GET | `/tts/v1/models/{model_id}` | path UUID, query `language` | resumen + `speakers[]`, `settings_schema[]`, `emotion_tags[]` | 200, 404 `"Modelo TTS no encontrado"` |
| POST | `/tts/v1/generate` | `GenerateTtsRequestDTO` | `{success, audio_base64, audio_format, elapsed_seconds, model{…}}` | 200, 404, 400 realtime, 500 en stream |

`GenerateTtsRequestDTO`: `model_name?`, `model_id?` (al menos uno), `text` (≤20 000),
`language?`, `speaker?`, `realtime=false`, `settings?`, `background_audio?`,
`background_audio_volume=10` (0–100). Acepta además un body JSON crudo como string/bytes
(validador `before`).

`audio_format` = `"wav"`, salvo provider `piper` → `"pcm_s16le"`.

**`POST /tts/v1/generate` es `StreamingResponse` con `media_type="application/json"`**, que
emite bytes `b" "` de heartbeat mientras sintetiza y luego el JSON completo. Headers
`Cache-Control: no-cache`, `X-Accel-Buffering: no`. Si `NON_STREAM_HEARTBEAT_ENABLED=false`
degrada a JSON plano. El proxy de la fase 2 **tiene que retransmitir esto sin bufferizar**.

### 3.2 `/podcast/v1/*` — guard `ai-agent:canUse`; alias públicos `/public/{token}/podcast/v1/*`

| Método | Path | Status |
|---|---|---|
| GET | `/ai-models/defaults` | 200 |
| POST | `/attachments/upload` (multipart) | 200, 400 |
| GET | `/investigators`, `/personas` (+alias público) | 200 |
| POST | `/simple` (+alias) | **202** |
| GET | `/simple/{id}/status` (+alias) | 200, 404 |
| POST | `/` | **202** |
| GET | `/` (query `status`, `search`, `limit=50`, `offset=0`) | 200 |
| GET | `/{id}` | 200, 404 |
| GET | `/{id}/stream` | 200 — **StreamingResponse con heartbeat**, no SSE |
| DELETE | `/{id}` | 200 |
| GET | `/{id}/plan`, `/{id}/script` | 200, error de payload expirado |
| GET | `/{id}/segments` | 200 |
| POST | `/{id}/segments/reorder` | 200 |
| POST | `/{id}/segments` | **202** |
| DELETE | `/{id}/segments/{segment_id}` | 200 |
| GET | `/{id}/segments/{segment_id}/audio` | 200, 404 (expira a 24 h) |
| POST | `/{id}/segments/{segment_id}/trim` | 200, 404, 400 |
| PUT | `/{id}/segments/{segment_id}` | 200 |
| POST | `/{id}/restart` | **202**, 409 `"El guion expiro (24h)"` |
| POST | `/{id}/cancel` | 200 |
| POST | `/{id}/bg-music` (multipart) | 200, 400 |
| POST | `/{id}/bg-music/config` | 200, 400 |
| POST | `/{id}/render` | **202**, 422 si `final_render_public_ack` es false |
| GET | `/{id}/render` | 200 |

Serialización de podcast (28 campos, `podcast/service.py:70-119`) — copiar literal:
`id, title, topic, language, agent_mode, research_enabled, ai_models, status, current_step,
progress_percent, progress_message, last_error, final_render_url, final_render_public_ack,
has_bg_music, bg_music_enabled, bg_music_volume, bg_music_default_id,
bg_music_user_track_id, investigator_id, investigation_task_id, attachment_urls, speakers[],
created_at, updated_at`.

Los `202` son literales: la ruta declara `status_code=202` **y** el cuerpo lleva
`"code": 202`. Ambos se conservan.

El audio de segmento sale **en base64 dentro de JSON**, nunca como bytes crudos. El render
final sale como **URL pública de S3**, no como fichero.

### 3.3 `/bg-music/v1/*` — dos guards distintos

| Método | Path | Guard | Status |
|---|---|---|---|
| GET | `/defaults` | `bg-music-default:canRead` | 200 |
| POST | `/defaults` (multipart) | `bg-music-default:canCreate` | **201** |
| PUT | `/defaults/{id}` | `canUpdate` | 200, 404 |
| DELETE | `/defaults/{id}` | `canDelete` | 200, 404 |
| GET | `/library` (+alias público) | `ai-agent:canUse` | 200, 401 |
| POST | `/user-tracks` (multipart) | `ai-agent:canUse` | **201**, 409 (máx. 5), 400 |
| DELETE | `/user-tracks/{id}` | `ai-agent:canUse` | 200, 404 |

### 3.4 Envoltura común

Todas las respuestas pasan por `responseJson` (`app/helpers/response.py:37-49`):

```json
{"code": 200, "message": "Success", "length": 0, "data": {}, "extra": {}}
```

`length` solo se rellena en los listados que pasan `get_len=True`. El microservicio debe
producir exactamente esta envoltura, incluido `extra: {}`.

### 3.5 Consumidores internos que también son contrato

| Consumidor | Qué usa |
|---|---|
| `investigator_agent` | `POST /investigator/…/tasks/{task_id}/export/podcast` construye `CreatePodcastRequestDTO` e invoca `PodcastService.create_podcast` **en proceso** (`investigator_agent/service.py:447-505`) |
| `investigator_agent/repository.py:128-130` | `UPDATE podcast SET investigation_task_id = NULL` al borrar una tarea |
| `celery_worker/jobs/podcast.py:52-54` | Lee la fila `podcast` para el webhook |
| Admin dashboard | CRUD de `tts_models` vía `/tts-models` |

El primero es el acoplamiento más incómodo: una llamada Python directa entre módulos que,
tras la separación, pasa a ser una llamada HTTP.

---

## 4. Arquitectura destino

### 4.1 Decisión

Se separa en **dos fases**, y la fase 1 es la que da el 90 % del beneficio con el 10 % del
riesgo:

```
FASE 1 — herandro-audio-service (síntesis pura, sin estado, sin BD)
  ├── shared/tts_model/  (factory, providers, workers, mixer, normalizer)
  └── HTTP interno: POST /internal/v1/synthesize
  La API principal conserva rutas, DTOs, BD, Redis, Celery y orquestación.

FASE 2 — traslado del dominio podcast + bg_music + tablas
  ├── podcast/, bg_music/, jobs de Celery, tablas
  └── La API principal queda como fachada proxy de /podcast/v1 y /bg-music/v1
```

Razón de partir así, y no mover todo de golpe: el peso de RAM/CPU está **entero** en la
síntesis; el orquestador es I/O-bound (espera al LLM y a S3). Mover primero lo pesado libera
el servidor grande sin tocar ni una ruta pública ni una tabla.

### 4.2 Qué se mueve y qué se queda

| Componente | Fase 1 | Fase 2 |
|---|---|---|
| `shared/tts_model/` (providers, workers, aislamiento) | **Se mueve** | — |
| `shared/tts_model/bg_music_mixer.py` | **Se mueve** (la mezcla es DSP puro) | — |
| `shared/tts_model/text_normalizer.py` | **Se mueve** | — |
| `tts/router.py` + `tts/service.py` | Se queda (llama al servicio por HTTP) | Se mueve |
| `tts/dto/` | Se queda | Se copia (contrato) |
| `podcast/` completo | Se queda | **Se mueve** |
| `bg_music/` | Se queda | **Se mueve** |
| `admin/tts_model/` (CRUD del catálogo) | Se queda | Se queda — es dashboard admin, no audio |
| Tablas | Se quedan | **Se mueven** (§6) |
| Facturación / `AgentAiService` | Se queda | **Se queda** — el orquestador la llamará por HTTP |
| Keycloak / `resource_guard` | Se queda | Se queda en la fachada (§8) |

### 4.3 Diagrama de la fase 1

```
Cliente ──► herandro-services-api  (sin cambios de contrato)
              /tts/v1/generate
              /podcast/v1/*
                   │
                   │ resuelve el modelo en tts_models (BD)
                   │ resuelve música de fondo (BD + S3)
                   ▼
              POST http://audio-service:9100/internal/v1/synthesize
              { text, language, speaker, provider, model_params, background_audio_b64?, volume }
                   │
                   ▼
        herandro-audio-service  (red interna, sin puerto publicado)
              factory → provider → subproceso aislado → bytes
                   │
                   ▼
              { audio_base64, audio_format, elapsed_seconds }
```

Clave: el microservicio **no toca la BD ni S3 en la fase 1**. Recibe la fila de `tts_models`
ya resuelta como `model_params` y, si hay música de fondo, los bytes ya descargados. Eso lo
convierte en un servicio sin estado, trivialmente escalable y sin credenciales de BD.

---

## 5. Contrato interno API ↔ audio-service

Este contrato es nuevo y privado; puede evolucionar libremente. Los públicos no.

### 5.1 `POST /internal/v1/synthesize`

Auth: cabecera `X-Audio-Service-Token` contra `AUDIO_SERVICE_INTERNAL_TOKEN` (mismo patrón
que `CELERY_INTERNAL_JOB_TOKEN`, `celery_worker/router.py:41-49`). Sin token configurado,
el servicio no arranca — a diferencia del de Celery, aquí no se acepta el modo permisivo:
el servicio expone inferencia cara.

Request:

```json
{
  "text": "Texto ya saneado y normalizado por el llamador",
  "language": "es",
  "speaker": "af_heart",
  "provider": "kokoro",
  "model_params": {
    "name": "kokoro-82m",
    "model_path": null,
    "config_path": null,
    "model_repo_id": null,
    "model_filename": null,
    "model_weight_bytes": 88000000,
    "estimated_ram_mb": 512,
    "estimated_cpu_threads": 4,
    "size": 88000000,
    "speed": 1.0
  },
  "background_audio_base64": null,
  "background_audio_volume": 10
}
```

`model_params` es el mismo diccionario que hoy se construye en `tts/service.py:106-118`:
`{**item.params, **resolved_settings, name, model_path, config_path, model_repo_id,
model_filename, model_weight_bytes, estimated_ram_mb, size}`. Se envía tal cual — así el
microservicio no necesita conocer el esquema de `tts_models`.

Response 200:

```json
{
  "audio_base64": "…",
  "audio_format": "wav",
  "elapsed_seconds": 12.481,
  "provider": "kokoro"
}
```

Errores: `400` payload inválido, `422` provider no soportado (mismo texto que
`factory.py:28`), `503` subproceso muerto (mensaje de `process_isolation.py:41`),
`504` timeout (nuevo, ver §5.3).

Transporte: **`StreamingResponse` con heartbeat de espacios**, idéntico al de
`shared/tts_model/streaming.py`. La síntesis de un segmento largo con `chatterbox` puede
tardar minutos y cualquier proxy intermedio cortaría una respuesta muda (mismo problema ya
documentado en `api/CLAUDE.md`, sección de llamadas largas).

### 5.2 Endpoints auxiliares

| Método | Path | Uso |
|---|---|---|
| GET | `/internal/v1/health` | Liveness. No carga modelos. |
| GET | `/internal/v1/providers` | Lista las claves soportadas por el factory. Permite al admin dashboard validar `provider` al crear una fila en `tts_models`. |
| POST | `/internal/v1/unload` | `{force: bool}` → descarga modelos ociosos o todos. Espejo de `release_model_memory` (`app/main.py:130-151`). |
| GET | `/metrics` | Prometheus. Métricas `herandro_*` reetiquetadas con `service="audio"`. |

### 5.3 Timeout — corregir al migrar

Hoy `run_isolated` bloquea indefinidamente (`process_isolation.py:30-46`). En un servicio
remoto eso es un cuelgue silencioso. Al migrar se añade:

```python
future.result(timeout=settings.AUDIO_SYNTHESIS_TIMEOUT_SECONDS)  # def. 900
```

Al vencer: `_reset_executor(key)` + HTTP 504. El cliente (la API) lo traduce al mismo
mensaje de error que hoy produce un fallo de síntesis, para no alterar el contrato público.

### 5.4 Cliente en la API principal

Un solo fichero, `app/modules/ai/shared/tts_model/remote_client.py`, con la **misma firma**
que `TtsModelRuntimeService.synthesize`:

```python
class RemoteTtsRuntime:
    def synthesize(self, *, text: str, language: str | None = None,
                   speaker: str | None = None) -> bytes: ...
```

Se selecciona por env var: `TTS_RUNTIME_MODE=local|remote` (def. `local`). Con `local` el
código actual sigue vivo, sin tocar. Eso da el rollback instantáneo de la §12 y permite
desplegar el microservicio antes de confiar en él.

---

## 6. Estrategia de base de datos

Tres opciones. Recomendación explícita: **la B**, y solo en la fase 2.

| Opción | Descripción | Veredicto |
|---|---|---|
| A | El microservicio comparte la misma BD y las mismas tablas | Aceptable como paso intermedio; no resuelve el acoplamiento pero no rompe nada. Es lo que ocurre implícitamente si la fase 2 se hace sin tocar la BD. |
| **B** | **Schema propio `audio` en la misma instancia Postgres, con las FKs salientes degradadas a columnas sin constraint** | **Recomendada.** Separa responsabilidades y permite mover la instancia después con un `pg_dump` de un solo schema. |
| C | Instancia Postgres independiente desde el día uno | Prematuro. Obliga a resolver ya la integridad referencial hacia `user`/`persona`/`investigation_task` sin ganar nada que B no dé. |

### 6.1 Qué hacer con las seis FKs salientes

| FK | Tratamiento |
|---|---|
| `podcast.user_id → user.id` | Se degrada a `uuid` sin constraint. El microservicio nunca crea usuarios; recibe el `user_id` ya validado por Keycloak en la fachada. |
| `bg_music_user_track.user_id → user.id` (CASCADE) | Se degrada. **Se pierde el borrado en cascada**: hay que añadir un job de limpieza o un webhook `user.deleted`. Es el único comportamiento que se pierde de verdad — documentarlo. |
| `podcast.investigation_task_id → investigation_task.id` (SET NULL) | Se degrada. El `SET NULL` ya se hace además explícitamente en código (`investigator_agent/repository.py:128-130`), así que el comportamiento sobrevive. |
| `podcast.investigator_id → persona.id` | Se degrada. `persona` es catálogo de solo lectura para el audio. |
| `podcast_speaker.narrator_id → persona.id` | Ídem. |
| `benchmark_tts_model_scores.benchmark_id → benchmarks.id` (CASCADE) | **No se mueve.** Esta tabla es de benchmarking del admin dashboard, no del pipeline de audio. Se queda junto a `benchmarks`, y con ella se queda `tts_models`. |

Consecuencia de la última fila: **`tts_models` se queda en la API principal.** Es un
catálogo administrado desde el dashboard, con FK desde los scores de benchmark, y el
microservicio ya no lo necesita porque recibe `model_params` inline (§5.1). Mover el
catálogo solo añadiría una llamada de red al camino caliente.

### 6.2 Tablas que sí se mueven en la fase 2

`podcast`, `podcast_speaker`, `bg_music_default`, `bg_music_user_track`.

### 6.3 Corregir la deriva de esquema — bloqueante, antes de tocar nada

Estas columnas existen en el ORM y no en el DDL. Una BD nueva las echará de menos:

```sql
-- api/bd/v2/fix_podcast_schema_drift.sql
ALTER TABLE podcast ADD COLUMN IF NOT EXISTS bg_music_path    text;
ALTER TABLE podcast ADD COLUMN IF NOT EXISTS bg_music_volume  integer NOT NULL DEFAULT 10;
ALTER TABLE podcast ADD COLUMN IF NOT EXISTS bg_music_enabled boolean NOT NULL DEFAULT true;
ALTER TABLE podcast ADD COLUMN IF NOT EXISTS bg_music_preset  varchar(50) NOT NULL DEFAULT 'ambient_warm';
ALTER TABLE podcast_speaker ADD COLUMN IF NOT EXISTS settings jsonb NOT NULL DEFAULT '{}'::jsonb;
```

Aplicar también a `bd/migration.sql` de forma idempotente (regla del repo: un `v2/` sin su
equivalente en `migration.sql` produce deriva entre una BD fresca y una migrada).

### 6.4 Script de traslado al schema `audio` (fase 2)

```sql
-- 1. Schema y traslado sin copia de datos
CREATE SCHEMA IF NOT EXISTS audio;
ALTER TABLE public.podcast              SET SCHEMA audio;
ALTER TABLE public.podcast_speaker      SET SCHEMA audio;
ALTER TABLE public.bg_music_default     SET SCHEMA audio;
ALTER TABLE public.bg_music_user_track  SET SCHEMA audio;

-- 2. Degradar las FKs que cruzan el límite del servicio
ALTER TABLE audio.podcast             DROP CONSTRAINT IF EXISTS podcast_user_id_fkey;
ALTER TABLE audio.podcast             DROP CONSTRAINT IF EXISTS podcast_investigation_task_id_fkey;
ALTER TABLE audio.podcast             DROP CONSTRAINT IF EXISTS podcast_investigator_id_fkey;
ALTER TABLE audio.podcast_speaker     DROP CONSTRAINT IF EXISTS podcast_speaker_narrator_id_fkey;
ALTER TABLE audio.bg_music_user_track DROP CONSTRAINT IF EXISTS bg_music_user_track_user_id_fkey;

-- 3. La FK a tts_models cruza schemas pero no instancias: se puede conservar
--    mientras la BD sea una sola. Si se separa la instancia, degradarla también.
-- ALTER TABLE audio.podcast_speaker DROP CONSTRAINT podcast_speaker_tts_model_id_fkey;

-- 4. Índices y usuario de servicio
CREATE INDEX IF NOT EXISTS ix_podcast_scope_updated
  ON audio.podcast (scope_id, updated_at DESC);

CREATE ROLE audio_service LOGIN PASSWORD :'audio_pwd';
GRANT USAGE ON SCHEMA audio TO audio_service;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA audio TO audio_service;
-- Solo lectura sobre el catálogo que se queda en public:
GRANT USAGE ON SCHEMA public TO audio_service;
GRANT SELECT ON public.tts_models TO audio_service;
```

`SET SCHEMA` es una operación de catálogo: no copia datos, se ejecuta en milisegundos y es
reversible con el `SET SCHEMA public` inverso. Requiere un `ACCESS EXCLUSIVE lock` breve;
hacerlo en ventana de mantenimiento.

Del lado ORM: `__table_args__ = {"schema": "audio"}` en los cuatro modelos, o
`search_path=audio,public` en el DSN del microservicio (más limpio, cero cambios de código).

---

## 6bis. Infraestructura que el microservicio necesita — Postgres y Redis

Resumen de qué infraestructura toca el servicio en cada fase. Esto responde directamente a
"¿hace falta Postgres? ¿y Redis?".

| Recurso | Fase 1 (síntesis) | Fase 2 (dominio podcast) |
|---|---|---|
| **Postgres** | **No.** El servicio es sin estado; recibe `model_params` inline (§5.1) | **Sí.** Schema `audio` con `podcast`, `podcast_speaker`, `bg_music_default`, `bg_music_user_track`, + `SELECT` sobre `public.tts_models` |
| **Redis** | **No.** | **Sí.** Claves `podcast:{id}:*` (plan, research, guion, progreso, audio de segmentos, cancelación) |
| **S3** | **No.** La API le manda la música en base64 | **Sí.** Adjuntos, música de fondo, render final |
| **Celery** | No | Sí — expone los cuatro endpoints de job (§10) |
| **Keycloak** | No | No (§8.4) |

### Postgres

**Fase 1 no lo necesita, y ese es el punto.** Es lo que convierte al servicio de síntesis en
algo sin estado, replicable y sin credenciales de BD. El catálogo `tts_models` se consulta en
la API principal, que ya tiene la sesión abierta, y la fila resuelta viaja en el request.

**Fase 2 sí.** Ahí el orquestador escribe estado de podcast en cada transición
(`status`, `current_step`, `progress_percent`, `progress_message`, `last_error`,
`final_render_url`). Configuración:

- **Misma instancia Postgres, schema `audio`** (opción B de §6). DSN con
  `?options=-csearch_path%3Daudio,public` — así el ORM no necesita `__table_args__` y el
  código se mueve sin tocar los modelos.
- **Usuario de BD propio y con privilegio mínimo**: `audio_service`, con RW solo sobre el
  schema `audio` y **`SELECT` sobre `public.tts_models`**. Sin acceso a `user`, `ai_vendor`,
  `credit_*` ni `request_log`. El GRANT está en §6.4.
- Pool pequeño: el orquestador es I/O-bound sobre HTTP, no sobre SQL. `pool_size=5,
  max_overflow=5` sobra. Hoy la API usa el pool por defecto de SQLAlchemy, dimensionado para
  un servicio con mucho más tráfico.
- **`tts_models` no se mueve** — es catálogo administrado desde el dashboard y tiene FK
  desde `benchmark_tts_model_scores` (§6.1). El microservicio lo lee, no lo escribe.

Si más adelante se separa también la **instancia** (no solo el schema), entonces hay que
resolver dos cosas que hoy la misma instancia regala: la FK `podcast_speaker.tts_model_id →
tts_models.id` (degradar y validar en aplicación) y la lectura del catálogo (pasa a ser una
llamada HTTP a la API principal, cacheada). Mientras sea una sola instancia, ninguna de las
dos es problema.

### Redis

**Fase 1 no lo necesita.** La síntesis es una función pura: bytes de entrada, bytes de
salida. No hay nada que cachear entre llamadas salvo los modelos, que ya viven en RAM del
subproceso.

**Fase 2 sí, y es obligatorio** — el pipeline entero apoya su estado intermedio en Redis
(Apéndice B): plan, research, diseño editorial, guion, revisión de calidad, progreso, el
**audio de cada segmento en base64**, y la bandera de cancelación.

Decisiones a tomar:

- **Instancia o base lógica separada.** Recomendado: la misma instancia Redis con
  `REDIS_DB` distinto (p. ej. `1`). Las claves ya están namespaceadas por `podcast:{id}:`,
  así que el riesgo de colisión es nulo; separar la base evita que un `FLUSHDB` de otro
  módulo se lleve podcasts en vuelo.
- **Dimensionar la memoria.** Esto es lo que se suele pasar por alto: el audio de cada
  segmento se guarda **en base64 dentro de Redis** con TTL de 24 h. Un podcast de 40
  segmentos WAV a 24 kHz mono son decenas de MB por podcast, ×1,33 por el base64. Con
  varios podcasts concurrentes esto crece rápido. Antes de migrar: medir
  `MEMORY USAGE podcast:*` en producción y fijar `maxmemory` con política
  **`noeviction`** — con `allkeys-lru` Redis desalojaría segmentos a medio pipeline y el
  render saldría incompleto sin ningún error visible.
- **Persistencia.** La pérdida de Redis significa perder podcasts en curso; el código ya
  asume que no hay recuperación tras expirar (`redis_store.py:9-11`). Con AOF o RDB al menos
  se sobrevive a un reinicio del contenedor.
- **El TTL de 24 h es contrato**: `GET /{id}/segments/{sid}/audio` responde 404 pasado ese
  tiempo, y `POST /{id}/restart` responde 409 `"El guion expiro (24h)"`. No alargarlo ni
  acortarlo durante la migración.

## 7. Storage S3

No cambia de proveedor ni de rutas. El microservicio necesita las mismas credenciales.

| Objeto | Bucket | Escrito por | Fase |
|---|---|---|---|
| `bg-music/defaults/{key}` | público | `bg_music/service.py:67-75` | 2 |
| `bg-music/` (owner `{user_id}`) | público | `bg_music/service.py:131-139` | 2 |
| `ai/podcasts/attachments/{uuid}{ext}` | público | `podcast/service.py:638-650` | 2 |
| `ai/podcasts/bg/{podcast_id}` | privado, sobrescribe | `podcast/service.py:695-703` | 2 |
| `ai/podcasts/podcast_{id}.wav` | público, `generate_url=True` | `orchestrator.py:1192-1200` | 2 |

En la fase 1 el microservicio **no habla con S3**: si hay música de fondo, la API la
descarga y la envía en base64. Coste: un viaje extra de bytes por la red interna, a cambio
de cero credenciales de storage en el servicio de inferencia. En la fase 2 sí recibe
credenciales, porque el render final se escribe directo.

---

## 8. Autenticación — Keycloak

### 8.1 Cómo funciona hoy

Keycloak con **UMA (User-Managed Access)**: el permiso no se deduce de roles en el JWT, se
consulta a Keycloak como `resource#scope`. Realm `herandro`.

Cadena completa de un request autenticado:

```
Authorization: Bearer <JWT>
   │
   ├─ keycloak_context_middleware  (app/middleware/keycloak_context_middleware.py)
   │     decodifica el JWT, lo deja en request.state.keycloak_payload
   │     persiste el access token cifrado en user.keycloak_access_token_encrypted
   │
   ├─ resource_guard("ai-agent")("canUse")  →  PermissionChecker
   │     app/core/security.py:69-130
   │     1. _get_jwks()      → llaves públicas de Keycloak, cacheadas
   │     2. _decode_token()  → RS256, verify_iss + verify_exp, verify_aud=False
   │     3. _check_uma_permission() → POST al token endpoint de Keycloak
   │
   └─ RouteGuardService.find_module_by_route(include_credit_context=False)
         resuelve el usuario local POR EMAIL contra la tabla `user`
```

Detalles que condicionan la migración:

| Pieza | Comportamiento | Ref |
|---|---|---|
| Validación de firma | RS256 contra JWKS; `verify_iss=True`, `verify_exp=True`, **`verify_aud=False`** | `keycloak_service.py:100-111` |
| Cache de JWKS | En memoria, **sin TTL ni invalidación** — se cachea la primera vez y vive lo que viva el proceso | `keycloak_service.py:74-82` |
| Consulta UMA | `POST {token_url}` con `grant_type=urn:ietf:params:oauth:grant-type:uma-ticket`, `audience=KEYCLOAK_CLIENT_ID`, `permission="{resource}#{scope}"`, `response_mode=decision`, y el JWT del usuario como Bearer. Concedido = HTTP 200 | `keycloak_service.py:119-143` |
| Cache de permisos | 30 s, **solo se cachean los concedidos** (fail-closed: un deny se re-verifica siempre). Clave `(token, resource, scope)` | `keycloak_service.py:27-30,120-125` |
| Identidad local | `User.email.ilike(<email del token>)` — el puente entre Keycloak y la tabla `user` es el **email**, no el `sub` | `route_guard_service.py:70-79` |

### 8.2 Recursos y scopes que usa el audio

| Módulo | Recurso | Scopes | Ref |
|---|---|---|---|
| `tts/` | `ai-agent` | `canUse` | `tts/router.py:15` |
| `podcast/` | `ai-agent` | `canUse` | `podcast/router.py:26` |
| `bg_music/` — rutas de usuario | `ai-agent` | `canUse` | `bg_music/router.py:13` |
| `bg_music/` — rutas admin | `bg-music-default` | `canRead`, `canCreate`, `canUpdate`, `canDelete` | `bg_music/router.py:12` |
| `admin/tts_model/` (catálogo) | `ai-agent` | — | `admin/tts_model/router.py:32` |

`bg-music-default` es el **único recurso exclusivo del audio** en todo el realm; los demás
comparten `ai-agent` con agentes, RAG y personas. Ambos recursos ya existen en Keycloak y
**no hay que crear ni migrar nada en el realm** para las fases 1 y 2, porque la validación
sigue ocurriendo en la API principal.

Nota operativa: `resource_guard` acepta **exactamente un scope** por endpoint
(`security.py:224-230`); no hay OR de scopes.

### 8.3 Los tres modos de autenticación que atraviesan estas rutas

Las rutas de podcast y bg-music aceptan tres credenciales distintas. Las tres deben seguir
funcionando idénticas tras la migración.

**1. JWT de usuario (Bearer estándar).** Camino descrito arriba.

**2. API key pública `pak_...`** — tabla `public_api_key` (`app/database/models/core.py:126-140`):
`key_prefix` (8 chars, único) + `key_hash` (SHA-256 del secreto), FK a `user.id`, con
`is_active` / `revoked_at` / `last_used_at`. El middleware parte la key en
`pak_{prefix}_{secret}`, busca por prefijo y compara el hash
(`keycloak_context_middleware.py:73-82`). Resuelta la identidad, obtiene un **token de
servicio interno** con esta prioridad (`:181-215`):

1. `user.keycloak_access_token_encrypted` — el último Bearer válido del usuario, si sigue vigente;
2. `user.keycloak_refresh_token_encrypted` — offline token canjeado por un access token fresco **del propio usuario**;
3. Fallback: `client_credentials` (service token) y luego admin token.

El objetivo del orden es que el endpoint público actúe con **los scopes reales del usuario**,
no con los del cliente de servicio.

**3. Offline refresh token en el path** — `/public/{token}/podcast/v1/*`. Si el Bearer no
decodifica y la ruta es de IA, se intenta canjear como offline token
(`get_access_token_from_offline_token`, `keycloak_service.py:456-511`): `grant_type=refresh_token`
con `client_id=KEYCLOAK_CLIENT_ID` + `client_secret=KEYCLOAK_SECRET`. Si Keycloak rota el
refresh token, la respuesta lo devuelve en cabeceras:

- `X-AI-Offline-Refresh-Token` — el nuevo valor
- `X-AI-Offline-Refresh-Token-Rotated: true` — señal al cliente para persistirlo

**Estas dos cabeceras son contrato.** Un cliente que no las relea se queda sin acceso en
cuanto Keycloak rote. La fachada proxy de la fase 2 tiene que propagarlas.

Además, `PermissionChecker` **cortocircuita** cualquier path que empiece por `/public/`:
devuelve `{}` sin consultar UMA (`security.py:99-110`). La autorización de esas rutas
descansa por completo en la resolución de identidad del middleware.

### 8.4 Por qué la autenticación NO se mueve al microservicio

Cuatro dependencias duras, ninguna de ellas del dominio del audio:

1. **Tabla `user`** — para resolver identidad por email y para leer los tokens cifrados.
2. **`APP_ENCRYPTION_KEY`** — los campos `user.keycloak_access_token_encrypted` y
   `keycloak_refresh_token_encrypted` están cifrados con Fernet. Darle esa clave al servicio
   de inferencia le daría acceso a **todos** los secretos cifrados en reposo del sistema,
   incluidas las API keys de proveedores LLM (`ai_vendor.api_key_encrypted`,
   `app/database/models/ai.py:255`). Es un aumento de superficie de ataque sin ninguna
   contrapartida.
3. **Tabla `public_api_key`** — validación de las keys `pak_`.
4. **`KEYCLOAK_SECRET`** — necesario para canjear offline tokens.

Replicar esto en el microservicio significa duplicar la superficie de seguridad y repartir
`APP_ENCRYPTION_KEY` y `KEYCLOAK_SECRET` por más hosts. La decisión es la contraria:
**el microservicio no valida credenciales de usuario en absoluto.**

### 8.5 Hallazgos de seguridad detectados al mapear (independientes de la migración)

Salieron al auditar la cadena de auth. No los introduce la migración, pero conviene
resolverlos **antes**, porque después estarán repartidos entre dos servicios.

| # | Hallazgo | Impacto |
|---|---|---|
| 1 | **Los prefijos `usr_`/`prj_`/`ten_`/`tok_`/`plt_` saltan toda la validación.** `PermissionChecker` detecta el prefijo y devuelve `{}` sin JWKS, sin UMA y sin consulta a BD (`security.py:147-149`). El comentario dice que "se validan después en RouteGuardService", pero ahí no hay ningún tratamiento de prefijos: con payload vacío devuelve `{"found": False}` — no lanza. Ninguna tabla almacena estos tokens. | **Alto.** Aplica a las 50 rutas con `resource_guard`, incluidas todas las de audio. Un `Authorization: Bearer usr_loquesea` pasa el guard. |
| 2 | **JWKS se cachea para siempre**, sin TTL ni invalidación (`keycloak_service.py:74-82`). Una rotación de llaves en Keycloak rompe la validación hasta reiniciar el proceso. | Medio — disponibilidad. |
| 3 | **`verify_aud=False`** al decodificar (`keycloak_service.py:106`). Un token emitido por el mismo realm para otro cliente valida igual. | Medio. |
| 4 | **Auto-creación de usuarios desde el token** (`keycloak_context_middleware.py:254-278,442-445`): si el email del JWT no existe en `user`, se crea la fila. | Medio — cualquier identidad válida del realm materializa un usuario local. |
| 5 | **Contraseña de Postgres en claro en `docker-compose.yml`**, versionado en git (servicio `postgres`, `POSTGRES_PASSWORD`). `.env` y `.env-prod` sí están en `.gitignore` (`api/.gitignore:14,17`). | **Alto.** Rotar y mover a variable de Coolify, como ya se hace con `MONITOR_LOGS_KEY`. |

Relación con la migración: el punto 1 y el 5 hay que arreglarlos en la fase 0. El resto
puede esperar, pero el 2 empeora al haber dos procesos que cachean JWKS por separado.

Modelo resultante:

```
Cliente ──[Bearer Keycloak]──► API principal
                                 └─ resource_guard valida resource+scope
                                 └─ resuelve user_id / scope_id (RouteGuardService)
                                        │
                                        ├─[X-Audio-Service-Token]──► audio-service
                                        │   (red interna, sin puerto publicado)
```

El microservicio **no valida JWT de usuario**. Confía en el token interno compartido y en el
aislamiento de red, igual que `ollama` hoy (`docker-compose.yml`: red `ollama_net`, sin
`ports`). Si en el futuro se expone fuera de la red privada, ahí sí hay que meter validación
propia — y eso es un cambio de amenaza, no un detalle de despliegue.

Para la fase 2, la fachada en la API principal sigue ejecutando `resource_guard` antes de
proxear, y pasa el contexto ya resuelto en cabeceras:

| Cabecera | Contenido |
|---|---|
| `X-Audio-Service-Token` | Secreto compartido |
| `X-Herandro-User-Id` | `sub` del JWT |
| `X-Herandro-Scope-Id` | Resultado de `RouteGuardService` |
| `X-Herandro-Scope-Type` | `user` / `tenant` / `project` |
| `X-Request-Id` | Propagado para correlacionar logs (ya existe en `request_id_middleware`) |

---

## 9. Fachada proxy — cómo no romper a nadie en la fase 2

Cuando `podcast/` y `bg_music/` se muevan, las rutas públicas deben seguir respondiendo en
`herandro-services-api`. La fachada es un router delgado que reemplaza a
`podcast/router.py` y `bg_music/router.py`, conservando **path, método, DTO de entrada,
guard y `status_code`**, y devolviendo el cuerpo del microservicio sin tocarlo.

Requisitos no negociables de la fachada:

1. **Streaming pass-through.** `GET /podcast/v1/{id}/stream` y `POST /tts/v1/generate`
   deben retransmitirse chunk a chunk. Un `await response.json()` intermedio convierte un
   heartbeat de 20 minutos en un timeout. Usar `httpx.AsyncClient.stream` + un
   `StreamingResponse` que reenvíe el iterador, conservando `Cache-Control: no-cache` y
   `X-Accel-Buffering: no`.
2. **Multipart pass-through.** Cuatro rutas suben ficheros
   (`/podcast/v1/attachments/upload`, `/podcast/v1/{id}/bg-music`,
   `/bg-music/v1/defaults`, `/bg-music/v1/user-tracks`). Reenviar el stream del `UploadFile`
   sin materializarlo en memoria; hay ficheros de audio de decenas de MB.
   Ojo con `MultipartChatMiddleware`, que cachea el cuerpo multipart
   (`app/main.py`, stack de middleware nº 3) — verificar que no interfiera.
3. **Timeout ilimitado hacia el microservicio.** `timeout=None` en el cliente HTTP. Un
   podcast completo tarda minutos; capar el timeout produce fallos falsos. Es exactamente
   el problema ya documentado en `api/CLAUDE.md` para clientes externos.
4. **Códigos de estado literales.** `202` en seis rutas, `201` en dos, `409` en dos,
   `422` en una. Se propagan tal cual, sin remapear.
5. **Alias públicos.** `register_route_aliases` debe seguir registrando
   `/public/{token}/podcast/v1/*` y `/public/{token}/bg-music/v1/library` con
   `include_in_schema=False`.
6. **Errores.** El envoltorio de error de estas rutas es el legado
   (`{"error": …}` / `{"detail": …}`), **no** el sobre OpenAI. No cambiarlo.

Sobre `include_lazy_router`: las tres rutas ya se montan con `mount_prefix=""`
(`app/main.py:352,353,367,760-765,786`), así que los paths declarados en el router son los
paths finales. La fachada mantiene el mismo registro perezoso y el mismo orden — recordar
que las rutas específicas deben ir antes que las de `{id: UUID}`.

---

## 10. Celery

Los cuatro jobs (`generate`, `render`, `generate_and_deliver`, `resynthesize`) se despachan
con `send_dynamic_task` a la tarea `dynamic_job_runner`, cola `celery`
(`app/core/config.py:54-55`). Con `CELERY_USE_DYNAMIC_JOB_RUNNER=true` el worker **no
importa el código del job**: lo pide por HTTP a `/internal/celery/jobs/podcast/*`
(`celery_worker/router.py:82-140`).

Eso simplifica mucho la fase 2: el worker de Celery no cambia. Solo cambia a qué host apunta
para resolver esos cuatro jobs. Dos alternativas:

| Alternativa | Cómo | Cuándo |
|---|---|---|
| **Recomendada** | El microservicio expone `/internal/celery/jobs/podcast/*` con la misma firma, y el worker resuelve la URL base por job (o corre un worker dedicado apuntando al microservicio) | Fase 2 |
| Alternativa | La API principal mantiene los endpoints y proxea al microservicio | Solo si no se quiere tocar la config del worker |

Detalles a conservar sí o sí:

- Los endpoints de job responden con `heartbeat_stream` (`celery_worker/router.py:23-29`):
  sin esos espacios periódicos, un job de descarga de modelo + inferencia se corta por
  `RemoteDisconnected`.
- `_run_in_own_thread` usa un `ThreadPoolExecutor(max_workers=1)` desechable, **no**
  `asyncio.to_thread`, porque el job crea su propio event loop con `asyncio.run` y un hilo
  reciclado arrastraba estado de un loop cerrado (`router.py:32-39`).
- `generate_and_deliver` existe precisamente porque su ausencia dejaba podcasts en `DRAFT`
  para siempre sin error visible (comentario en `router.py:98-103`). No fusionarlo.
- El webhook (`jobs/podcast.py:51-73`) hace `requests.post(..., timeout=15)` y **traga
  excepciones a propósito**; el fallo de entrega no debe tumbar el job.

---

## 11. Despliegue

### 11.1 Dockerfile del microservicio

Hereda todo lo específico de audio del Dockerfile actual, y **solo eso**:

```dockerfile
FROM python:3.14
WORKDIR /app

# Dependencias de sistema exclusivas del audio (Dockerfile actual :19-20)
RUN apt-get update && apt-get install -y \
    build-essential libssl-dev libffi-dev python3-dev \
    curl cmake espeak-ng ffmpeg libjemalloc2 \
    && rm -rf /var/lib/apt/lists/*

# Binario piper (Dockerfile actual :24-27)
RUN curl -fsSL https://github.com/rhasspy/piper/releases/latest/download/piper_linux_x86_64.tar.gz \
    | tar -xz -C /usr/local/lib \
    && ln -s /usr/local/lib/piper/piper /usr/local/bin/piper

ENV POETRY_VIRTUALENVS_CREATE=false POETRY_VERSION="2.3.2"
RUN pip install --no-cache-dir "poetry==${POETRY_VERSION}"
COPY pyproject.toml poetry.lock* ./
RUN poetry install --no-interaction --no-ansi --only main --no-root

# kokoro-onnx: metadata declara <3.14 pero el wrapper ONNX corre en 3.14
RUN pip install --no-cache-dir --no-deps --ignore-requires-python "kokoro-onnx==0.5.0"

COPY . .

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
# jemalloc: sin él el RSS no vuelve al SO tras los picos de torch/numpy
ENV LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
ENV MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"
ENV OMP_NUM_THREADS=4 OPENBLAS_NUM_THREADS=4 MKL_NUM_THREADS=4 NUMEXPR_NUM_THREADS=4

EXPOSE 9100
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9100", \
     "--workers", "1", "--timeout-keep-alive", "900", "--timeout-graceful-shutdown", "900"]
```

`--workers 1` es deliberado: cada worker tendría su propio `ProcessPoolExecutor` y su propia
copia de los pesos en RAM. Para escalar, más réplicas del contenedor, no más workers.

### 11.2 `pyproject.toml` del microservicio

Solo lo que la síntesis necesita:

```
fastapi, uvicorn, pydantic, pydantic-settings, python-dotenv, python-multipart
numpy, soundfile, librosa
torch, torchaudio            (source pytorch-cpu — la wheel de PyPI arrastra ~7 GB de CUDA)
onnxruntime==1.24.4, huggingface-hub, transformers, tokenizers
f5-tts, supertonic, espeakng-loader, phonemizer-fork, num2words
prometheus-fastapi-instrumentator
requests                     (descarga de pesos)
boto3                        (solo fase 2)
```

Lo que **no** viaja: langchain/langgraph/deepagents, openai/anthropic, chromadb/qdrant,
sqlalchemy+psycopg2 (fase 1), celery, redis, keycloak, opencv, pandas, scikit-learn,
faster-whisper, pyannote-audio.

Y lo que la API principal puede soltar tras la fase 1: `onnxruntime`, `f5-tts`,
`supertonic`, `kokoro-onnx`, `espeakng-loader`, `phonemizer-fork`, `librosa`, `soundfile`,
`gtts`, `pyttsx3`, `num2words`, más `espeak-ng`, `ffmpeg` y el binario `piper` del
Dockerfile. `torch`/`torchaudio` **no** se pueden quitar: los siguen usando
`faster-whisper`, `pyannote-audio` (transcripción) y `sentence-transformers` (embeddings).

### 11.3 `docker-compose.yml`

```yaml
  audio-service:
    build:
      context: ./audio-service
      dockerfile: Dockerfile
    container_name: herandroAudioService
    restart: unless-stopped
    networks:
      - audio_net          # red privada, SIN ports publicados
    environment:
      AUDIO_SERVICE_INTERNAL_TOKEN: ${AUDIO_SERVICE_INTERNAL_TOKEN}
      AUDIO_SYNTHESIS_TIMEOUT_SECONDS: "900"
      TTS_MODEL_IDLE_TTL_SECONDS: "300"
      TTS_ISOLATION_IDLE_TTL_SECONDS: "300"
      TTS_REALTIME_MAX_MODEL_BYTES: "200000000"
      CHATTERBOX_ONNX_NUM_THREADS: "4"
      KOKORO_ONNX_CACHE_DIR: /models-cache/kokoro-onnx
      CHATTERBOX_ONNX_CACHE_DIR: /models-cache/chatterbox-onnx
      SUPERTONIC_CACHE_DIR: /models-cache/supertonic
      HF_HOME: /models-cache/huggingface
      MONITOR_LOGS_ENABLED: "true"
      MONITOR_LOGS_SERVICE: audio-service
    volumes:
      - tts_models_cache:/models-cache    # MISMO volumen: los pesos ya descargados se reutilizan
    deploy:
      resources:
        limits:
          cpus: '4.0'
          memory: 8g

  fastapi:
    networks:
      - audio_net                          # añadir a las que ya tiene
    environment:
      TTS_RUNTIME_MODE: remote
      AUDIO_SERVICE_BASE_URL: http://audio-service:9100
      AUDIO_SERVICE_INTERNAL_TOKEN: ${AUDIO_SERVICE_INTERNAL_TOKEN}
    deploy:
      resources:
        limits:
          cpus: '3.0'                      # bajar de 5.0: la inferencia ya no vive aquí
          memory: 6g                       # bajar de 10g

networks:
  audio_net:
    driver: bridge
```

Notas de despliegue:

- **Reutilizar el volumen `tts_models_cache`** evita re-descargar todos los pesos en el
  primer arranque. Si el microservicio va a otra máquina, copiar el volumen antes o asumir
  una primera síntesis lenta por modelo.
- Usar **nombre de servicio** (`audio-service`) como host DNS, no `container_name`: Coolify
  no respeta `container_name` como DNS — mismo error ya cometido y documentado con Ollama en
  el compose actual.
- Sin `ports:`. Como Ollama, solo alcanzable por la red privada.

### 11.4 Mover a un servidor más potente

El diseño lo contempla desde el inicio:

1. Desplegar `audio-service` en el host nuevo, con el mismo `AUDIO_SERVICE_INTERNAL_TOKEN`.
2. Cambiar `AUDIO_SERVICE_BASE_URL` a la IP/host privado del nuevo servidor.
3. **Cifrar el tránsito**: al salir de la red de Docker, el token compartido viaja por la
   red. O bien un túnel (WireGuard/Tailscale), o bien TLS con `mTLS`. Un secreto compartido
   sobre HTTP plano entre hosts no es aceptable.
4. Copiar el volumen de pesos, o dejar que se descarguen (`~4–6 GB` según providers activos).
5. Ajustar `CHATTERBOX_ONNX_NUM_THREADS` y `OMP_NUM_THREADS` al nuevo recuento de CPU.

Con GPU, además: cambiar el source de `torch` a la wheel CUDA y revisar los
`onnxruntime` providers (hoy fijados a CPU en `chatterbox_worker.py:166`).

---

## 12. Plan de ejecución

### Fase 0 — Preparación (sin cambios de comportamiento)

- [ ] Aplicar `bd/v2/fix_podcast_schema_drift.sql` y replicarlo en `bd/migration.sql` (§6.3).
- [ ] Añadir `run_isolated(..., timeout=…)` con `AUDIO_SYNTHESIS_TIMEOUT_SECONDS` (§5.3).
- [ ] Escribir tests de contrato (§13) contra la API actual y **guardar los golden files**.
- [ ] Auditar el catálogo `tts_models` en producción: qué providers hay activos de verdad.
      Si `cosyvoice` o `f5_tts` no tienen filas activas, no se portan.

### Fase 1 — Extraer la síntesis (sin BD, sin tablas, sin cambios de ruta)

- [ ] Crear el repo/carpeta `audio-service/` con `shared/tts_model/` completo:
      `factory.py`, `providers/`, `*_worker.py`, `process_isolation.py`, `bg_music_mixer.py`,
      `text_normalizer.py`, `streaming.py`, `resolution.py` (solo lo que no toca BD).
- [ ] Implementar `POST /internal/v1/synthesize` + `/health` + `/providers` + `/unload` + `/metrics`.
- [ ] Implementar `RemoteTtsRuntime` en la API con la firma idéntica y el flag
      `TTS_RUNTIME_MODE=local|remote`.
- [ ] Adaptar los tres llamadores: `tts/service.py:106-118`, `orchestrator.py` (síntesis de
      segmentos), y el mixer de fondo.
- [ ] Reetiquetar métricas Prometheus con `service="audio"`; ajustar `/system/model-memory`
      (`app/main.py:44-151`) para que consulte al microservicio en modo `remote` en lugar de
      leer `TtsModelRuntimeService._providers`.
- [ ] Desplegar con `TTS_RUNTIME_MODE=local` (el microservicio arriba pero sin tráfico).
- [ ] Conmutar a `remote`. Verificar con los golden files.
- [ ] Tras una semana estable: borrar del `pyproject.toml` de la API las deps listadas
      en §11.2 y las líneas de audio del `Dockerfile`.

### Fase 2 — Trasladar el dominio podcast

- [ ] Ejecutar el script de schema `audio` (§6.4) en ventana de mantenimiento.
- [ ] Mover `podcast/`, `bg_music/` y `jobs/podcast.py` al microservicio; añadirle SQLAlchemy,
      Redis, boto3 y el DSN con `search_path=audio,public`.
- [ ] Sustituir las llamadas a `AgentAiService` del orquestador
      (`orchestrator.py:225,236,302`) por llamadas HTTP a `/agent/v1/*` de la API principal,
      con `timeout=None` y `stream=false`. **La facturación sigue ocurriendo en la API.**
- [ ] Convertir `podcast/router.py` y `bg_music/router.py` de la API en la fachada proxy (§9).
- [ ] Convertir la llamada in-process de `investigator_agent/service.py:447-505` en una
      llamada HTTP a `POST /podcast/v1`.
- [ ] Sustituir `investigator_agent/repository.py:128-130` (`UPDATE podcast …`) por una
      llamada al microservicio, o por un evento.
- [ ] Repuntar los jobs de Celery (§10).
- [ ] Añadir la limpieza de `bg_music_user_track` huérfanas que antes hacía el `ON DELETE CASCADE`.
- [ ] Re-verificar los golden files completos.

### Fase 3 — Opcional: hardware dedicado

- [ ] Túnel cifrado o mTLS entre hosts (§11.4).
- [ ] Mover el volumen de pesos.
- [ ] Recalibrar límites de threads.

---

## 13. Verificación — tests de contrato

Un solo fichero, `tests/test_audio_contract.py`, ejecutado **antes y después** de cada fase,
comparando contra golden files capturados en la fase 0.

Qué asertar, como mínimo:

1. **Forma exacta del sobre**: las claves `code`, `message`, `length`, `data`, `extra` y su
   orden de aparición no cambian.
2. **Códigos de estado literales**: 202 en las seis rutas de creación/restart/render/segmento,
   201 en las dos de creación de bg-music, 409 en las dos de conflicto, 422 en render sin ack.
3. **Serialización de podcast**: los 28 campos, con los mismos nombres y tipos. Comparar
   `set(response["data"].keys())` contra el golden.
4. **`audio_format`**: `"wav"` para todo salvo `piper` → `"pcm_s16le"`.
5. **Streaming**: `POST /tts/v1/generate` responde con `Transfer-Encoding: chunked`, headers
   `Cache-Control: no-cache` y `X-Accel-Buffering: no`, y el cuerpo empieza con espacios
   antes del JSON cuando `NON_STREAM_HEARTBEAT_ENABLED=true`. Con `false`, JSON plano.
6. **Alias públicos**: `/public/{token}/podcast/v1/simple` responde lo mismo que la canónica.
7. **Mensajes de error en español, literales**: `"Modelo TTS no encontrado"`,
   `"El guion expiro (24h)"`, `"Proveedor TTS no soportado: …"`.
8. **Determinismo del audio** (el más importante y el más fácil de olvidar): para un texto,
   modelo, voz y settings fijos, el hash SHA-256 del audio antes y después de la migración.
   Kokoro y Piper son deterministas y el hash debe coincidir bit a bit. Chatterbox y F5-TTS
   llevan muestreo: fijar `seed` donde el provider lo acepte y, si no, comparar duración y
   RMS con tolerancia en lugar del hash.
9. **Mezcla de música de fondo**: mismo volumen relativo tras la mezcla; y que el caso
   `piper` siga **omitiendo** la mezcla en vez de fallar.

Además, una prueba de carga mínima antes de conmutar: 3 síntesis concurrentes de
`chatterbox` con un texto de ~150 caracteres, midiendo latencia p95 y RSS. En el estado
actual el `_synthesize_lock` las serializa; con el microservicio en `--workers 1` siguen
serializándose. **Esto es esperado y no es una regresión** — la ganancia de la fase 1 es
sacar el peso del proceso de la API, no paralelizar. Paralelizar es escalar réplicas, en
la fase 3.

---

## 14. Riesgos

| Riesgo | Impacto | Mitigación |
|---|---|---|
| El audio generado cambia sutilmente (chunking, normalización, semilla) | Alto — regresión silenciosa de calidad | Test 8 de §13; portar `_RUNTIME_CHUNK_LIMITS` y `_sanitize_text` literalmente, sin "mejorar" |
| Un proxy corta el streaming del heartbeat | Alto — timeouts en podcasts largos | Streaming pass-through obligatorio (§9.1); `X-Accel-Buffering: no`; revisar el idle timeout de Traefik/Coolify para la ruta del microservicio |
| Primer arranque descarga varios GB de pesos | Medio — primeras síntesis muy lentas | Reutilizar el volumen `tts_models_cache`; o pre-calentar con una síntesis por provider tras el deploy |
| Pérdida del `ON DELETE CASCADE` de `bg_music_user_track` | Medio — filas y objetos S3 huérfanos | Job de limpieza en la fase 2, en el checklist |
| Deriva de esquema no corregida | Alto — la BD nueva rompe el ORM | Fase 0, bloqueante |
| El token interno viaja en claro entre hosts | Alto (solo fase 3) | Túnel cifrado o mTLS antes de cruzar máquinas |
| `run_isolated` sin timeout se convierte en cuelgue remoto | Medio | §5.3, fase 0 |
| El orquestador deja de facturar al moverse | Alto — pérdida de ingresos silenciosa | Las fases LLM siguen pasando por `/agent/v1/*` de la API; asertar en el test que un podcast genera filas en `request_log` |

## 15. Rollback

- **Fase 1**: `TTS_RUNTIME_MODE=local` + redeploy. El código local sigue en el repo hasta
  que la fase 1 lleve una semana estable. Rollback en un cambio de variable de entorno.
- **Fase 2**: `ALTER TABLE audio.<t> SET SCHEMA public` revierte el traslado sin copiar
  datos; las FKs degradadas se recrean con los `ALTER TABLE ... ADD CONSTRAINT` inversos
  (guardar el script inverso junto al de ida). La fachada proxy se sustituye por el router
  original, que se conserva en git. Ventana de mantenimiento requerida.

---

## 16. Elección de lenguaje — comparativa y recomendación final

Las tres propuestas detalladas están en
[`PROPUESTA_AUDIO_RUST.md`](./PROPUESTA_AUDIO_RUST.md),
[`PROPUESTA_AUDIO_GO.md`](./PROPUESTA_AUDIO_GO.md) y
[`PROPUESTA_AUDIO_NESTJS.md`](./PROPUESTA_AUDIO_NESTJS.md). Resumen y decisión aquí.

### 16.1 El dato que hay que aceptar antes de comparar

**Ningún cambio de lenguaje acelera la inferencia.** Los seis providers ejecutan ONNX
Runtime o PyTorch, que son C++ compilado. Un `session.run()` tarda lo mismo desde Python,
Rust, Go o Node. Todo lo que se gana está en el proceso que rodea la inferencia.

Y ahí está el segundo dato incómodo: **las tres cuartas partes de la ineficiencia actual no
son culpa de Python.** Son cuatro decisiones de diseño concretas:

| Problema | Dónde | Coste |
|---|---|---|
| Mutex global que serializa **toda** la síntesis del proceso | `shared/tts_model/service.py:56` | Dos podcasts concurrentes se bloquean entre sí |
| Sin timeout en la síntesis aislada | `process_isolation.py:30-46` | Cuelgues indefinidos |
| Sin cola acotada ni backpressure | — | La cola es la RAM del proceso; el cliente recibe un corte de transporte, no un 503 |
| La desconexión del cliente no cancela nada | — | Una petición abandonada sigue quemando CPU hasta terminar |

Los cuatro se arreglan en Python en **días**, no en meses. Cualquiera de las tres
reescrituras que no los arregle primero está comprando con 3 meses de trabajo algo que
cuesta una semana.

### 16.2 Comparativa

| Criterio | Python (extraer) | Rust | Go | NestJS |
|---|---|---|---|---|
| RSS base del servidor | ~250-400 MB | ~15-30 MB | ~20-40 MB | ~60-100 MB |
| Imagen Docker | ~6-8 GB | ~200 MB | ~300 MB | ~500 MB |
| Concurrencia real | Con semáforo, sí (procesos) | Excelente | Muy buena | Requiere workers |
| Aislamiento anticrash | Sí (subprocesos) | Excelente | **No** (SIGSEGV mata el proceso) | **No** con threads |
| Ecosistema numérico | Excelente | Bueno (`ndarray`) | **Pobre** | Pobre |
| Binding ONNX | Oficial | No oficial (`ort`) | No oficial, cgo | **Oficial (Microsoft)** |
| Kokoro | Nativo | Port existente (Kokoros) | Desde cero | **`kokoro-js` listo** |
| Chatterbox | Nativo | Port 3-5 sem | Port 5-7 sem | Port 5-7 sem |
| F5-TTS / Supertonic | Nativo | **Inviable** | **Inviable** | **Inviable** |
| Control de carga | Manual | `tower` (declarativo) | `semaphore` + `context` | **BullMQ** (cola persistida) |
| Cancelación por cliente | Hay que escribirla | Sí | **Idiomática** | Sí |
| Conocimiento del equipo | Alto | Ninguno | Ninguno | **Alto** (Angular/TS) |
| Esfuerzo (solo kokoro+piper) | ~1-2 sem | 3-4 sem | 5-6 sem | **~3 sem** |
| Esfuerzo (con chatterbox) | ~1-2 sem | 2,5-3 meses | 3-4 meses | 3-3,5 meses |
| Riesgo de regresión de audio | **Nulo** | Alto | Alto | Alto |

### 16.3 El pivote: qué providers están activos en producción

Toda la decisión depende de una consulta:

```sql
SELECT provider, count(*) FROM tts_models WHERE is_active GROUP BY provider;
```

- **Solo `kokoro` y/o `piper`** → reescribir es viable. NestJS es la ruta más corta (3
  semanas, `kokoro-js` evita la fonemización), Rust la técnicamente mejor (3-4 semanas,
  mejor aislamiento y menor huella).
- **Con `chatterbox`** → el port del bucle ORT de 527 líneas es el 80 % del esfuerzo y trae
  riesgo real de regresión de calidad de audio. Solo Rust lo justifica, y no como primer paso.
- **Con `f5_tts`, `supertonic` o `cosyvoice`** → **ninguna reescritura es sustituta.**
  Obligaría a mantener dos servicios y dos stacks. Descartado.

### 16.4 Recomendación

**Ejecutar la fase 1 de este documento en Python, arreglando de paso los cuatro problemas de
§16.1. No reescribir todavía.**

Razones, en orden:

1. **La extracción es prerrequisito de las tres propuestas.** Lo que crea el valor es la
   frontera de red y el contrato `/internal/v1/synthesize`, no el lenguaje que hay detrás.
   Una vez existe esa frontera, sustituir la implementación es un ejercicio acotado,
   medible y reversible con una variable de entorno.
2. **Se obtiene la mayor parte del beneficio buscado sin riesgo de contrato.** Sacar la
   inferencia del proceso de la API, con semáforo por provider, cola acotada, timeout y
   cancelación: eso es control de carga, control de errores y reducción de consumo. Y el
   audio generado no cambia ni un bit, así que la validación es trivial.
3. **Reescribir antes de medir es adivinar.** Tras la fase 1 habrá métricas por provider
   (latencia p50/p95/p99, RSS, profundidad de cola, tasa de fallo). Con esos números, la
   decisión de lenguaje deja de ser una opinión.
4. **El riesgo que hunde estas migraciones no es técnico.** Es introducir un lenguaje que
   nadie del equipo depura en producción un domingo por la noche.

Cuándo sí reescribir, con criterios concretos y verificables:

| Si tras la fase 1 se observa… | Entonces |
|---|---|
| El RSS base del servicio de audio sigue siendo el problema, y el catálogo activo es solo kokoro/piper | **Rust** — 3-4 semanas, imagen de 200 MB |
| Hace falta cola persistida, prioridades y reintentos, y el equipo debe mantenerlo | **NestJS** — BullMQ, `kokoro-js`, TypeScript conocido |
| El problema medido es el `session.run()` | **Ninguno de los tres.** Es cuantización del modelo, tuning de threads de ORT, o GPU |
| Chatterbox domina el coste y su latencia es el cuello | Variante `q4` ya está en uso; probar `int8`, batching y GPU **antes** de considerar un port |

**Go queda descartado para la capa de inferencia** por su ecosistema numérico y porque un
SIGSEGV nativo se lleva el proceso entero — perdiendo la garantía que hoy da
`process_isolation.py`. Sería una buena elección para la capa de orquestación de la fase 2,
pero esa capa está atada a LangChain y por tanto a Python.

## Apéndice A — Variables de entorno del microservicio

| Variable | Def. | Origen |
|---|---|---|
| `AUDIO_SERVICE_INTERNAL_TOKEN` | — (obligatoria) | nueva |
| `AUDIO_SYNTHESIS_TIMEOUT_SECONDS` | 900 | nueva (§5.3) |
| `TTS_MODEL_IDLE_TTL_SECONDS` | 300 | `config.py:245` |
| `TTS_ISOLATION_IDLE_TTL_SECONDS` | 300 | `process_isolation.py:26` |
| `TTS_REALTIME_MAX_MODEL_BYTES` | 200000000 | `config.py:244` |
| `CHATTERBOX_ONNX_NUM_THREADS` | `cpu_count()-1` | `chatterbox_worker.py:162` |
| `KOKORO_ONNX_CACHE_DIR` | `~/.cache/kokoro-onnx` | `kokoro_worker.py:20-22` |
| `CHATTERBOX_ONNX_CACHE_DIR` | `~/.cache/chatterbox-onnx` | `chatterbox_worker.py:21-25` |
| `SUPERTONIC_CACHE_DIR` | — | compose actual |
| `HF_HOME` | — | compose actual |
| `COSYVOICE_TTS_COMMAND` | — | `providers/cosyvoice.py:21` |
| `NON_STREAM_HEARTBEAT_ENABLED` | true | `config.py` |
| `NON_STREAM_HEARTBEAT_INTERVAL_SECONDS` | 29 | `config.py` |
| **Fase 2** | | |
| `DATABASE_URL` (con `search_path=audio,public`) | — | — |
| `REDIS_URL` | — | claves `podcast:{id}:*`, TTL 86400 |
| `PODCAST_REDIS_TTL_SECONDS` | 86400 | `config.py:264` |
| `PODCAST_FINAL_RENDER_BUCKET` | `public` | `config.py:265` |
| `PODCAST_DEFAULT_*_MODEL` (7 fases) | `deepseek-v4-flash` | `config.py:266-272` |
| `PODCAST_PUBLIC_DEFAULT_TTS_MODEL_NAME` | `""` | `config.py:274` |
| `HERANDRO_STORAGE_*` | — | `core/storage.py` |
| `HSA_BASE_URL` (API principal, para las fases LLM) | — | nueva |

## Apéndice B — Claves de Redis del pipeline

Prefijo `podcast:{podcast_id}:`, TTL `PODCAST_REDIS_TTL_SECONDS` (86 400 s), sin
recuperación tras expirar — decisión explícita documentada en `redis_store.py:9-11`.

`plan`, `research`, `editorial_design`, `script`, `quality_review`, `progress`,
`segment:{segment_id}:audio` (`{audio_base64, pause_after_ms, order, duration_seconds}`),
`cancel` (`"1"`, TTL 3 600 s).

La cancelación es cooperativa: el bucle de síntesis consulta la bandera entre segmentos
(`orchestrator.py:857`). Si la síntesis pasa a ser remota, **la bandera se sigue
consultando en el orquestador**, no en el microservicio: un segmento en vuelo termina y la
cancelación se aplica en el siguiente. Ese es el comportamiento actual y no cambia.

## Apéndice C — Credenciales

Inventario por nombre. **Ningún valor va en este documento ni en el repo**; viven en `.env`,
`.env-prod` (ambos en `.gitignore`) y en las variables de Coolify.

| Credencial | Qué protege | ¿La necesita el microservicio? |
|---|---|---|
| `KEYCLOAK_CLIENT_ID` | `audience` de la consulta UMA, canje de offline token, service token | **No** — la auth se queda en la API (§8.4) |
| `KEYCLOAK_SECRET` | Canje de offline token (`grant_type=refresh_token`) y `client_credentials` | **No** |
| `KEYCLOAK_ADMIN_CLIENT_ID` / `_SECRET` | Solo administración de usuarios y roles (crear/actualizar/borrar, role-mappings) | **No** — nada del audio administra usuarios |
| `APP_ENCRYPTION_KEY` | Fernet sobre `user.keycloak_*_encrypted` y `ai_vendor.api_key_encrypted` | **No, y es deliberado** — dársela al servicio de inferencia le abriría todos los secretos en reposo |
| `DATABASE_URL` | Postgres | **Fase 2**, con usuario propio `audio_service` de privilegio mínimo |
| `REDIS_URL` / `REDIS_PASSWORD` | Redis | **Fase 2**, con `REDIS_DB` separado |
| `HERANDRO_STORAGE_ACCESS_KEY` / `_SECRET_KEY` | S3 (buckets `public` y `storage`) | **Fase 2** |
| `CELERY_INTERNAL_JOB_TOKEN` | Endpoints `/internal/celery/jobs/*` | **Fase 2** |
| `AUDIO_SERVICE_INTERNAL_TOKEN` | Nuevo — API ↔ microservicio | **Sí**, en ambos lados |
| `POSTGRES_PASSWORD` | Contenedor Postgres | En claro en `docker-compose.yml` versionado — rotar (§8.5, hallazgo 5) |
| `MONITOR_LOGS_KEY` | Replicación de logs | Sí, si se quiere el mismo pipeline de logs |
| `DEEPSEEK_API_KEY` y demás keys de proveedores LLM | Inferencia de texto | **No** — las fases LLM se quedan en la API |

Criterio: el microservicio de audio recibe **un secreto nuevo y propio** y, en la fase 2,
credenciales de datos con privilegio mínimo. No recibe ninguna credencial de identidad ni de
cifrado del sistema. Si mañana ese host se compromete, el radio de daño es el audio, no el
realm ni los secretos en reposo.

## Apéndice D — Salidas de red del pipeline

| Destino | Timeout | Sitio |
|---|---|---|
| Descarga de adjuntos | 20 s | `attachment_extractor.py:31` |
| Descarga de música de fondo por URL | 30 s | `orchestrator.py:1061,1074,1084` |
| Webhook de podcast terminado | 15 s | `jobs/podcast.py:65` |
| Pesos de Kokoro (GitHub releases) | — | `kokoro_worker.py:53` |
| Pesos de Chatterbox (HuggingFace) | — | `chatterbox_worker.py:105-120` |

Todos se mueven con su módulo. Los dos primeros y los pesos van al microservicio; el webhook
va con los jobs de Celery en la fase 2.
