# Douanier — the `audio` sub-API of the nech.pl platform 🛂

The customs gate over the fleet's audio-intelligence engines: a self-hosted HTTP
API that turns a URL or a clip into **stems, loops, emotion reads, sample
analysis, and features**. Named after Le Douanier Rousseau. `api.nech.pl` is a
multi-API platform; Douanier is the internal codename for its **`audio`** sub-API,
served publicly at **`https://api.nech.pl/audio/v1/...`**.

Productizes the [Foundry](../../tools/foundry/) + the tide-table "ears"
(`emotion_ontology`, `sample_semantics`, the feature stack) for **hexa** (Shipow)
and future seats. Full design + decisions: memory `project_douanier_api`, EPIC
task **#21**, [`SRE.md`](./SRE.md) (hosting handoff) and [`DEPLOY.md`](./DEPLOY.md)
(the erable container contract).

## Layout

```
armada/api/
  app.py          FastAPI app — routes at root (gateway owns /audio/v1), + /metrics
  config.py       paths · root_path · gateway header names · limits (env-overridable)
  engines/
    ears.py       emotion read (CLAP) = sample_semantics embed → emotion_ontology.score
    ears_light.py torch-free V/A baseline (librosa) — the erable engine
  cache.py        content-addressed result cache (the margin lever)
  Dockerfile      the CPU-only, torch-free deploy image (~1 GB)
  tests/          smoke + engine tests (no CLAP/torch needed — heavy call mocked)
```

## Two platform rules (SRE update 2026-06-25)

- **Public path `/audio/v1/...`; we serve at root.** The gateway strips the prefix
  and proxies to us at `/`. We set `root_path=/audio/v1` so docs/OpenAPI advertise
  the public paths.
- **Central auth — no token store of ours.** The platform authenticates the bearer
  at the edge and injects `X-Tenant` / `X-Scopes`. We bind loopback-only and trust
  them. (This supersedes the old SQLite bearer auth; metering is the platform's job.)

## Run (dev)

```bash
python3 -m venv --system-site-packages ~/.virtualenvs/douanier   # once
~/.virtualenvs/douanier/bin/pip install -r requirements.txt      # once

cd armada/api
# DEV bearer fallback (no gateway in front): any request with this bearer = wildcard
DOUANIER_DEV_TOKEN=devsecret DOUANIER_ROOT_PATH="" \
  ~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
```

Endpoints (internal root paths shown; public = `/audio/v1` + path):

- `GET  /healthz` — status + engine availability (unauthenticated)
- `GET  /metrics` — Prometheus text for the erable scraper (unauthenticated)
- `GET  /me` — echo the caller's gateway identity (any authenticated tenant)
- `POST /analyze/emotion` — `multipart` audio → `{valence, arousal, top…}` (scope `emotion`)
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)
- `GET  /docs` — Swagger UI

```bash
# as the gateway calls us (injected identity):
curl -s -H "X-Tenant: hexa" -H "X-Scopes: emotion" \
  -F file=@clip.wav http://127.0.0.1:8780/analyze/emotion | jq
# or, in pure local dev, the bearer fallback:
curl -s -H "Authorization: Bearer devsecret" \
  -F file=@clip.wav http://127.0.0.1:8780/analyze/emotion | jq
```

### Emotion engine selection

`DOUANIER_EMOTION_ENGINE` picks the V/A backend (identical response shape):

- `light` (default in the image / erable) — librosa-heuristic V/A, **no torch**,
  fits a ~2 GB-RAM container. Arousal from energy/tempo/brightness, valence from
  major/minor mode; mapped onto the emotion anchors. A baseline — a precise tier
  (Essentia, or CLAP on a GPU runner) lands later behind the same shape.
- `clap` (dev box) — CLAP/torch, rich, ~4 GB, needs a GPU.

## Deploy

The torch-free image and the SRE handoff (build, volume, env-file, loopback
publish, cache cap, onboarding) are in [`DEPLOY.md`](./DEPLOY.md):

```bash
docker build -t douanier:latest .            # ~1 GB, light engine only
docker run -d -p 127.0.0.1:9780:9780 -v /srv/douanier/data:/data douanier:latest
curl -s https://api.nech.pl/audio/v1/healthz # green once SRE wires the keep-alive
```

## Test

```bash
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q
```

## Status

Done: scaffold + emotion endpoint (#22/#36), content-addressed cache (#26) — a
repeat clip returns in ~2 ms (measured 5663× on a real run; `X-Douanier-Cache:
hit`), **torch-free light V/A engine + CPU Docker image + platform contract
(`/audio/v1`, central-auth headers, `/metrics`, `/separate` 503) (#37)**. The
image is ~1 GB and the SRE edge is live and waiting.

Superseded by the platform: our own SQLite bearer auth (#23) and per-token quota
(#24) — central auth/metering owns these now (the `auth.py` token store + CLI
remain for local dev only). Queued *under* the public shape without changing it:
async jobs + worker (#25), signed artifact URLs (#27), `/features`+`/samples`
(#28), `/sources`→`/separate`→`/loops` heavy chain (#29–#31), OpenAPI client (#32).
