Commit 11c8f9d6 by PLN (Algolia)

feat(douanier): make it callable — OpenAPI snapshot + zero-dep TS client (#32)

hexa can now call the audio API with types, not guesswork.

- clients/openapi.json — checked-in OpenAPI 3.1 snapshot (servers pinned to the
  public https://api.nech.pl/audio/v1), covering all 13 routes. A snapshot-drift
  guard test asserts it stays in sync with the live app (add a route → refresh or CI fails).
- clients/douanier.ts — a typed, ZERO-dependency client (global fetch/FormData/Blob;
  works in Node 18+, Vercel Functions, Edge). One method per engine (emotion,
  features, samples, grade, onsets, waveform, analyze, loudness, spectrum, naming),
  typed results, X-Douanier-Cache surfaced as result._cache, DouanierError on non-2xx.
  Typechecks clean under tsc --strict.
- clients/README.md — base URL + bearer, a Vercel Function example, the endpoint/
  scope table, curl, and the openapi-generator one-liner for full codegen.

60/60 tests (added the snapshot guard). The API is now self-describing
(/audio/v1/docs + openapi.json) and has a drop-in client.
parent f11bef94
# Calling Douanier (the nech.pl `audio` API)
Base URL: **`https://api.nech.pl/audio/v1`** · Auth: **`Authorization: Bearer <token>`**
(your platform token, provisioned by SRE for your tenant). Interactive docs +
schema: `https://api.nech.pl/audio/v1/docs` · `…/openapi.json` (snapshot:
[`openapi.json`](./openapi.json)).
Every analysis is a `multipart` POST with one `file` field (an audio clip) and
returns JSON. Identical audio is **cached** — the `X-Douanier-Cache: hit|miss`
header (and `result._cache` in the TS client) tells you which.
## TypeScript client (zero deps)
Drop [`douanier.ts`](./douanier.ts) into your project (Node 18+, Vercel
Functions, or Edge — it only uses global `fetch`/`FormData`/`Blob`).
```ts
import { Douanier } from "./douanier";
const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! });
// a clip from anywhere → a Blob
const clip = await fetch(trackUrl).then(r => r.blob());
const mood = await dz.emotion(clip); // { emotion: { valence, arousal, top, … } }
const spec = await dz.spectrum(clip, { bands: 64, frames: 200 }); // FFT-as-a-service
const wave = await dz.waveform(clip, { bins: 800 }); // render-ready envelope
const loud = await dz.loudness(clip); // { lufs_integrated, gain_to_target_db, … }
if (mood._cache === "hit") { /* came free from the cache */ }
```
### Vercel Function example
```ts
// app/api/mood/route.ts (Next.js App Router, Node runtime)
import { Douanier } from "@/lib/douanier";
export async function POST(req: Request) {
const clip = await req.blob();
const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! });
const { emotion } = await dz.emotion(clip);
return Response.json(emotion);
}
```
## Endpoints (scopes in parens)
| Method | Path | Returns | scope |
|---|---|---|---|
| GET | `/healthz` | status + engine availability | — |
| POST | `/analyze/emotion` | valence/arousal + top emotions | `emotion` |
| POST | `/features` | ~35-dim feature stack | `features` |
| POST | `/analyze/samples` | per-sample EDA + measured role | `samples` |
| POST | `/grade` | mechanical loop quality (tier + flags) | `grade` |
| POST | `/onsets` | onset times + tempo | `onsets` |
| POST | `/waveform` | `[min,max]` peaks + RMS envelope | `waveform` |
| POST | `/spectrum` | downsampled spectrogram (FFT-as-a-service) | `spectrum` |
| POST | `/loudness` | BS.1770 LUFS + peak + gain-to-target | `loudness` |
| POST | `/naming` | convention-compliant sample name | `naming` |
| POST | `/analyze` | emotion + features + sample in one call | `analyze` |
| POST | `/separate` | **503** until a GPU runner | `separate` |
## curl
```bash
curl -s -H "Authorization: Bearer $DOUANIER_TOKEN" \
-F file=@clip.wav https://api.nech.pl/audio/v1/loudness | jq
```
## Regenerating the snapshot / a full client
`openapi.json` here is a checked-in snapshot. To codegen a client in any language:
```bash
npx @openapitools/openapi-generator-cli generate \
-i armada/api/clients/openapi.json -g typescript-fetch -o ./generated
```
The hand-written `douanier.ts` is the lean, dependency-free alternative — prefer
it unless you specifically want generated models.
/**
* Douanier — typed client for the nech.pl `audio` API.
*
* Zero dependencies: uses the global `fetch` / `FormData` / `Blob` available in
* Node 18+, Vercel Functions, and the Edge runtime. Every analysis takes an
* audio clip (a `Blob` — e.g. from `await fetch(url).then(r => r.blob())`, or a
* `File` from an upload) and returns a typed JSON result. Identical audio is
* cached server-side: check `result._cache === "hit"`.
*
* const dz = new Douanier({ token: process.env.DOUANIER_TOKEN! });
* const mood = await dz.emotion(clip); // { valence, arousal, top, … }
* const spec = await dz.spectrum(clip, { bands: 64, frames: 200 });
*/
export interface DouanierOptions {
/** Platform bearer token (provisioned by SRE for your tenant). */
token: string;
/** Defaults to the public gateway path. */
baseUrl?: string;
/** Inject a custom fetch (tests / non-standard runtimes). */
fetchImpl?: typeof fetch;
}
export type Cache = "hit" | "miss";
type WithMeta<T> = T & { filename: string; content_id: string; _cache: Cache };
export interface Emotion {
valence: number; arousal: number;
top: [string, number][]; dist: Record<string, number>;
confidence: number; engine: string;
}
export interface Features { features: Record<string, number | string>; tiers: Record<string, string[]>; sr: number; engine: string; }
export interface SampleProfile {
profile: { rms_db: number; peak_db: number; centroid: number; bands: Record<string, number> };
role: "percs" | "bass" | "melodic" | "tops" | "atmos";
centroid: number | null; broadband: boolean; time_activity: number;
name_hint: string | null; sr: number; engine: string;
}
export interface Grade {
grade: { path: string; kind: "loop" | "one_shot"; grade: number; tier: "S" | "A" | "B" | "C" | "D";
sub: Record<string, number>; metrics: Record<string, number>; flags: string[] };
}
export interface Onsets { tempo_bpm: number; onsets: number[]; n_onsets: number; onset_rate: number; duration_s: number; sr: number; engine: string; }
export interface Waveform { bins: number; peaks: [number, number][]; rms: number[]; duration_s: number; sr: number; engine: string; }
export interface Analyze { emotion: Emotion; features: Record<string, number | string>; sample: SampleProfile; }
export interface Loudness {
lufs_integrated: number | null; peak_dbfs: number; true_peak_dbfs: number; rms_dbfs: number;
crest_db: number; gain_to_target_db: { streaming: number; club: number } | null;
channels: number; sr: number; engine: string;
}
export interface Spectrum { bands: number; frames: number; mel: boolean; band_hz: number[]; spec: number[][]; duration_s: number; sr: number; engine: string; }
export interface Naming { suggested_name: string; role: string; character: string; stem: string; lint: string[]; }
export class DouanierError extends Error {
constructor(public status: number, public detail: unknown) {
super(`Douanier ${status}: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
}
}
export class Douanier {
private base: string;
private token: string;
private f: typeof fetch;
constructor(opts: DouanierOptions) {
this.token = opts.token;
this.base = (opts.baseUrl ?? "https://api.nech.pl/audio/v1").replace(/\/$/, "");
this.f = opts.fetchImpl ?? fetch;
}
private async post<T>(path: string, clip: Blob, query: Record<string, unknown> = {},
filename = "clip.wav"): Promise<WithMeta<T>> {
const qs = Object.entries(query)
.filter(([, v]) => v !== undefined && v !== null && v !== "")
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
const form = new FormData();
form.append("file", clip, filename);
const res = await this.f(`${this.base}${path}${qs ? `?${qs}` : ""}`, {
method: "POST",
headers: { Authorization: `Bearer ${this.token}` },
body: form,
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new DouanierError(res.status, (body as any)?.detail ?? body);
(body as any)._cache = (res.headers.get("X-Douanier-Cache") as Cache) ?? "miss";
return body as WithMeta<T>;
}
/** Service health + per-engine availability (unauthenticated). */
async health(): Promise<any> {
const res = await this.f(`${this.base}/healthz`);
return res.json();
}
emotion(clip: Blob, filename?: string) { return this.post<Emotion & { emotion: Emotion }>("/analyze/emotion", clip, {}, filename); }
features(clip: Blob, o: { rhythm?: boolean } = {}) { return this.post<Features>("/features", clip, o); }
samples(clip: Blob, o: { name?: string } = {}) { return this.post<SampleProfile>("/analyze/samples", clip, o); }
grade(clip: Blob, o: { role?: string } = {}) { return this.post<Grade>("/grade", clip, o); }
onsets(clip: Blob) { return this.post<Onsets>("/onsets", clip); }
waveform(clip: Blob, o: { bins?: number } = {}) { return this.post<Waveform>("/waveform", clip, o); }
analyze(clip: Blob) { return this.post<Analyze>("/analyze", clip); }
loudness(clip: Blob) { return this.post<Loudness>("/loudness", clip); }
spectrum(clip: Blob, o: { bands?: number; frames?: number; mel?: boolean } = {}) { return this.post<Spectrum>("/spectrum", clip, o); }
naming(clip: Blob, o: { index?: number } = {}) { return this.post<Naming>("/naming", clip, o); }
}
export default Douanier;
"""Guard: the checked-in clients/openapi.json must cover the live app (#32).
If someone adds/removes a route without refreshing the snapshot, this fails —
so hexa's spec + generated clients never silently drift from the real API.
Refresh with the one-liner in clients/README.md.
"""
import json
from pathlib import Path
import app
HERE = Path(__file__).resolve().parent.parent
def test_snapshot_paths_match_live_app():
snap = json.loads((HERE / "clients" / "openapi.json").read_text())
live = app.app.openapi()
assert set(snap["paths"]) == set(live["paths"]), (
"clients/openapi.json is stale — re-dump it (see clients/README.md)"
)
def test_snapshot_advertises_public_server():
snap = json.loads((HERE / "clients" / "openapi.json").read_text())
assert snap["servers"][0]["url"] == "https://api.nech.pl/audio/v1"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment