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;
{
"openapi": "3.1.0",
"info": {
"title": "Douanier \u2014 the `audio` sub-API of the nech.pl platform",
"description": "Customs gate over the fleet's audio-intelligence engines. Served publicly at /audio/v1/...; the platform gateway does central auth + metering and injects X-Tenant/X-Scopes. See SRE.md + DEPLOY.md.",
"version": "0.1.0",
"x-engines": "emotion features samples grade onsets waveform analyze loudness spectrum naming separate"
},
"paths": {
"/healthz": {
"get": {
"summary": "Healthz",
"operationId": "healthz_healthz_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/me": {
"get": {
"summary": "Whoami",
"description": "Echo the caller's gateway-resolved identity \u2014 handy for onboarding smoke tests.",
"operationId": "whoami_me_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/analyze/emotion": {
"post": {
"summary": "Analyze Emotion",
"description": "Upload a short audio clip \u2192 valence/arousal + top emotions. Cached (#26):\nthe same audio returns instantly on a repeat instead of recomputing.",
"operationId": "analyze_emotion_analyze_emotion_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_analyze_emotion_analyze_emotion_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/features": {
"post": {
"summary": "Features",
"description": "Upload a clip \u2192 the ~35-dim audio feature stack (spectral moments, MFCCs,\nchroma/key, envelope, + rhythm when `rhythm=true`). Torch-free, cached.",
"operationId": "features_features_post",
"parameters": [
{
"name": "rhythm",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": true,
"title": "Rhythm"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_features_features_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/analyze/samples": {
"post": {
"summary": "Analyze Samples",
"description": "Upload a one-shot/loop \u2192 per-sample EDA + MEASURED role (percs|bass|melodic|\ntops|atmos) from the spectrum, never the name. Optional `name` only\ndisambiguates breaks/drums. Torch-free, cached.",
"operationId": "analyze_samples_analyze_samples_post",
"parameters": [
{
"name": "name",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "Name"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_analyze_samples_analyze_samples_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/onsets": {
"post": {
"summary": "Onsets",
"description": "Upload audio \u2192 onset hit times (s) + tempo + onset rate. The cheapest tier\n(no model): rhythmic hits for visual sync / slicing. Torch-free, cached.",
"operationId": "onsets_onsets_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_onsets_onsets_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/waveform": {
"post": {
"summary": "Waveform",
"description": "Upload audio \u2192 render-ready waveform: per-bin [min,max] + a 0..1 RMS energy\nenvelope (`?bins=`, \u22644000). For audio-reactive visuals. Torch-free, cached.",
"operationId": "waveform_waveform_post",
"parameters": [
{
"name": "bins",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 800,
"title": "Bins"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_waveform_waveform_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/analyze": {
"post": {
"summary": "Analyze All",
"description": "One call \u2192 emotion + features + sample role for a clip (fewer round-trips\nfor hexa). Each is the same engine the dedicated routes use. Cached as a unit.",
"operationId": "analyze_all_analyze_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_analyze_all_analyze_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/grade": {
"post": {
"summary": "Grade",
"description": "Upload a loop/one-shot \u2192 the Foundry's MECHANICAL quality grade: composite\n0..1 + S/A/B/C/D tier, per-rule sub-scores (seam click, zero-crossing\ncleanliness, DC, bar self-consistency, level, bass mono-compat) + flags.\n`role` ('bass'|'drums'|\u2026) is a hint. WAV/FLAC preferred. Torch-free, cached.",
"operationId": "grade_grade_post",
"parameters": [
{
"name": "role",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "Role"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_grade_grade_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/loudness": {
"post": {
"summary": "Loudness",
"description": "Upload audio \u2192 BS.1770 integrated LUFS + sample/true peak + crest + the\ngain (dB) to hit each delivery target (-14 streaming, -9 club). Torch-free, cached.",
"operationId": "loudness_loudness_post",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_loudness_loudness_post"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/spectrum": {
"post": {
"summary": "Spectrum",
"description": "FFT-as-a-service: a downsampled, render-ready spectrogram (`bands`\u00d7`frames`,\n0..1). `mel=` perceptual vs log-linear; `frames=1` \u2192 a single averaged FFT\nspectrum. Bounded payload \u2192 caches cheaply. Torch-free.",
"operationId": "spectrum_spectrum_post",
"parameters": [
{
"name": "bands",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 64,
"title": "Bands"
}
},
{
"name": "frames",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 200,
"title": "Frames"
}
},
{
"name": "mel",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"default": true,
"title": "Mel"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_spectrum_spectrum_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/naming": {
"post": {
"summary": "Naming",
"description": "Upload a sample \u2192 a convention-compliant name (NN_role_character) derived\nfrom the MEASURED role + character, not the file name. Torch-free, cached.",
"operationId": "naming_naming_post",
"parameters": [
{
"name": "index",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1,
"title": "Index"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_naming_naming_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/separate": {
"post": {
"summary": "Separate",
"description": "Stem separation (demucs/roformer). erable has no GPU and no CPU-viable path,\nso this is a deliberate 503 until a GPU-runner backend is wired (env-selected,\nsame response shape). The route exists now so hexa can code against it and get\na clean, documented \"compute pending\" rather than a 404.",
"operationId": "separate_separate_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Body_analyze_all_analyze_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_analyze_all_analyze_post"
},
"Body_analyze_emotion_analyze_emotion_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_analyze_emotion_analyze_emotion_post"
},
"Body_analyze_samples_analyze_samples_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_analyze_samples_analyze_samples_post"
},
"Body_features_features_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_features_features_post"
},
"Body_grade_grade_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_grade_grade_post"
},
"Body_loudness_loudness_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_loudness_loudness_post"
},
"Body_naming_naming_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_naming_naming_post"
},
"Body_onsets_onsets_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_onsets_onsets_post"
},
"Body_spectrum_spectrum_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_spectrum_spectrum_post"
},
"Body_waveform_waveform_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_waveform_waveform_post"
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
},
"input": {
"title": "Input"
},
"ctx": {
"type": "object",
"title": "Context"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
}
}
},
"servers": [
{
"url": "https://api.nech.pl/audio/v1",
"description": "nech.pl platform \u2014 audio sub-API"
}
]
}
\ No newline at end of file
"""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