# 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 /features` — audio → the ~35-dim feature stack (spectral/MFCC/chroma/key/envelope + rhythm); `?rhythm=` (scope `features`)
- `POST /analyze/samples` — one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic|tops|atmos); `?name=` only disambiguates breaks/drums (scope `samples`)
- `POST /grade` — loop/one-shot → the Foundry's mechanical quality grade (0..1 + S/A/B/C/D tier, per-rule sub-scores + flags); `?role=` hint (scope `grade`)
- `POST /onsets` — audio → onset hit times + tempo + onset rate (rhythmic sync / slicing) (scope `onsets`)
- `POST /waveform` — audio → render-ready `[min,max]` peaks + 0..1 RMS envelope; `?bins=` ≤4000 (scope `waveform`)
- `POST /analyze` — one call → emotion + features + sample role (fewer round-trips) (scope `analyze`)
- `POST /loudness` — audio → BS.1770 integrated LUFS + sample/true peak + crest + gain-to-target (-14/-9) (scope `loudness`)
- `POST /spectrum` — **FFT-as-a-service**: downsampled spectrogram `bands`×`frames`, 0..1; `?mel=` `?bands=` `?frames=` (`frames=1` = avg spectrum) (scope `spectrum`)
- `POST /naming` — sample → convention-compliant name (NN_role_character) from MEASURED role+character; `?index=` (scope `naming`)
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)

All three analyze routes are **torch-free** (librosa) and **content-addressed
cached** (#26): the same clip recomputes once, then serves `X-Nech-Cache: hit`.
- `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-Nech-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).
