feat(cli): the api provider speaks the HeyGen audio analysis contract (#5641)

* feat(cli): the api provider speaks the HeyGen audio analysis contract

The api rung now sends its key as x-api-key, a new Idempotency-Key on each
request, and 16 kHz mono 16-bit WAV: the channels are averaged and resampled
with a windowed sinc whose cutoff sits under 8 kHz, so nothing folds back in.
It reads the answer from a { "data": ... } envelope, or bare. Times stay in
seconds, and the answered times still snap onto beats measured on the signal
as decoded. The response type gains time_resolution_seconds.

* fix(cli): reject redirects from audio analysis requests

* fix(cli): preserve audio when stereo downmix cancels
This commit is contained in:
Xuanru Li
2026-10-15 06:54:29 +00:00
committed by GitHub
parent 1544b519a9
commit 124492e427
3 changed files with 220 additions and 18 deletions
+4 -1
View File
@@ -566,7 +566,10 @@ answers:
| describe | `api`, `apple-sound` |
- `api` is an audio analysis service, used when `HYPERFRAMES_AUDIO_API_URL` and
`HYPERFRAMES_AUDIO_API_KEY` are both set. The audio is uploaded. Every beat,
`HYPERFRAMES_AUDIO_API_KEY` are both set. The audio is uploaded as 16 kHz mono
WAV. Channels are averaged unless cancellation removes over 90% of the loudest
channel’s energy, in which case that channel is used. Requests carry the key in an `x-api-key` header and a new `Idempotency-Key` on each
request; the answer may come wrapped in `{ "data": … }`. Every beat,
downbeat or section boundary it answers moves onto the nearest beat signal
processing measured, when one is within a quarter beat; the rest are listed in
`snap.unsnapped`, and `snap.medianShiftMs` says how far the moved ones went.
@@ -11,6 +11,7 @@ import { analyzeSignal } from "../analyze.js";
import { apiProvider, type AudioApiRequest, type AudioApiResponse } from "./api.js";
const RATE = 22_050;
const UPLOAD_RATE = 16_000;
/** Clicks every half second from 0.25 s: a grid the signal tracker finds. */
function clicks(seconds: number): AudioSignal {
@@ -27,7 +28,9 @@ function clicks(seconds: number): AudioSignal {
interface Received {
method: string;
apiKey: string | undefined;
authorization: string | undefined;
idempotencyKey: string | undefined;
request: AudioApiRequest;
audio: { type: string; bytes: Uint8Array };
}
@@ -38,6 +41,7 @@ let reply: (request: AudioApiRequest) => { status: number; body: unknown } = ()
status: 500,
body: {},
});
let enveloped = true;
async function readForm(req: IncomingMessage) {
const chunks: Buffer[] = [];
@@ -57,9 +61,16 @@ async function readForm(req: IncomingMessage) {
beforeAll(async () => {
server = createServer(async (req, res) => {
const form = await readForm(req);
received.push({ method: req.method!, authorization: req.headers.authorization, ...form });
received.push({
method: req.method!,
apiKey: req.headers["x-api-key"] as string | undefined,
authorization: req.headers.authorization,
idempotencyKey: req.headers["idempotency-key"] as string | undefined,
...form,
});
const { status, body } = reply(form.request);
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
const sent = status === 200 && enveloped ? { data: body } : body;
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(sent));
});
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
});
@@ -68,6 +79,7 @@ afterAll(() => new Promise<void>((done) => server.close(() => done())));
afterEach(() => {
received = [];
enveloped = true;
delete process.env.HYPERFRAMES_AUDIO_API_URL;
delete process.env.HYPERFRAMES_AUDIO_API_KEY;
});
@@ -92,6 +104,34 @@ describe("apiProvider", () => {
expect(apiProvider.available!()).toEqual({ ok: true });
});
it("refuses redirects without sending the API key to another origin", async () => {
const forwarded: string[] = [];
const destination = createServer((req, res) => {
forwarded.push(req.headers["x-api-key"]?.toString() ?? "");
res.writeHead(200).end("{}");
});
await new Promise<void>((done) => destination.listen(0, "127.0.0.1", done));
const address = destination.address();
if (!address || typeof address === "string") throw new Error("No destination port");
const redirect = createServer((_req, res) => {
res.writeHead(307, { location: `http://127.0.0.1:${address.port}/` }).end();
});
await new Promise<void>((done) => redirect.listen(0, "127.0.0.1", done));
try {
const source = redirect.address();
if (!source || typeof source === "string") throw new Error("No redirect port");
process.env.HYPERFRAMES_AUDIO_API_URL = `http://127.0.0.1:${source.port}/`;
process.env.HYPERFRAMES_AUDIO_API_KEY = "test-key";
await expect(apiProvider.analyze("beats", clicks(1), {})).rejects.toThrow();
expect(forwarded).toEqual([]);
} finally {
await Promise.all([
new Promise<void>((done) => redirect.close(() => done())),
new Promise<void>((done) => destination.close(() => done())),
]);
}
});
it("posts the audio and the capabilities, and snaps each answered beat onto the signal's own", async () => {
configure();
const signal = clicks(12);
@@ -103,6 +143,7 @@ describe("apiProvider", () => {
body: {
version: 1,
source: "stand-in",
time_resolution_seconds: 1 / 32,
beats: { times: answered },
downbeats: { times: measured.filter((_, k) => k % 4 === 0).map((t) => t - 0.02), meter: 4 },
} satisfies AudioApiResponse,
@@ -114,15 +155,18 @@ describe("apiProvider", () => {
expect(received).toHaveLength(1);
const [call] = received;
expect(call).toMatchObject({ method: "POST", authorization: "Bearer test-key" });
expect(call).toMatchObject({ method: "POST", apiKey: "test-key", authorization: undefined });
expect(call!.idempotencyKey).toMatch(
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/,
);
expect(call!.request).toEqual({
version: 1,
capabilities: ["beats", "downbeats"],
audio: { sampleRate: RATE, channels: 1, duration: 12 },
audio: { sampleRate: UPLOAD_RATE, channels: 1, duration: 12 },
});
expect(call!.audio.type).toBe("audio/wav");
expect(Buffer.from(call!.audio.bytes.subarray(0, 4)).toString()).toBe("RIFF");
expect(call!.audio.bytes.length).toBe(44 + 12 * RATE * 2);
expect(call!.audio.bytes.length).toBe(44 + 12 * UPLOAD_RATE * 2);
expect(analysis.provenance).toEqual({ beats: "api", downbeats: "api", bpm: "api" });
const beats = analysis.values.beats as { times: number[]; snap: object; source: string };
@@ -201,6 +245,7 @@ describe("apiProvider", () => {
signalProvider,
]);
expect(received.map((r) => r.request.capabilities)).toEqual([["sections"], ["describe"]]);
expect(new Set(received.map((r) => r.idempotencyKey)).size).toBe(2);
expect(analysis.values.sections).toMatchObject({
spans: [
{ start: 0, end: beat, label: "intro" },
@@ -235,10 +280,96 @@ describe("apiProvider", () => {
expect(garbled.warnings[0]).toContain("api failed (the service answered beats in a shape");
});
it("never sends the key anywhere but the authorization header", async () => {
it("uploads the channels averaged to one at 16 kHz, with nothing above 8 kHz folded back in", async () => {
configure();
const rate = 44_100;
const tone = (hz: number) =>
Float32Array.from(
{ length: 2 * rate },
(_, k) => 0.3 * Math.sin((2 * Math.PI * hz * k) / rate),
);
const [low, side, high] = [tone(440), tone(2_000), tone(9_000)];
const left = low.map((v, k) => v + side[k]! + high[k]!);
const right = low.map((v, k) => v - side[k]! + high[k]!);
reply = () => ({ status: 200, body: { version: 1, describe: { tags: [] } } });
await analyzeSignal(
{ sampleRate: rate, channels: [left, right] },
{ capabilities: ["describe"] },
[apiProvider],
);
const wav = Buffer.from(received[0]!.audio.bytes);
expect([wav.readUInt16LE(22), wav.readUInt32LE(24), wav.readUInt16LE(34)]).toEqual([
1,
UPLOAD_RATE,
16,
]);
const samples = Array.from(
{ length: (wav.length - 44) / 2 },
(_, k) => wav.readInt16LE(44 + 2 * k) / 32767,
);
const level = (hz: number) => {
let re = 0;
let im = 0;
samples.forEach((v, k) => {
re += v * Math.cos((2 * Math.PI * hz * k) / UPLOAD_RATE);
im += v * Math.sin((2 * Math.PI * hz * k) / UPLOAD_RATE);
});
return (2 * Math.hypot(re, im)) / samples.length;
};
expect(level(440)).toBeCloseTo(0.3, 2);
expect(level(2_000)).toBeLessThan(0.005);
expect(level(7_000)).toBeLessThan(0.005);
});
it.each([
{ phase: Math.PI, quiet: 1, expected: 0.7 },
{ phase: Math.PI * 0.9, quiet: 1, expected: 0.7 },
{ phase: Math.PI, quiet: 0.8, expected: 0.7 },
{ phase: Math.PI / 2, quiet: 1, expected: 0.7 / Math.sqrt(2) },
{ phase: 0, quiet: 1, expected: 0.7 },
])(
"preserves stereo energy for phase $phase and channel gain $quiet",
async ({ phase, quiet, expected }) => {
configure();
const rate = 44_100;
const left = Float32Array.from(
{ length: rate },
(_, k) => 0.7 * quiet * Math.sin((2 * Math.PI * 440 * k) / rate),
);
const right = Float32Array.from(
{ length: rate },
(_, k) => 0.7 * Math.sin((2 * Math.PI * 440 * k) / rate + phase),
);
reply = () => ({ status: 200, body: { version: 1, describe: { tags: [] } } });
await analyzeSignal(
{ sampleRate: rate, channels: [left, right] },
{ capabilities: ["describe"] },
[apiProvider],
);
const wav = Buffer.from(received[0]!.audio.bytes);
expect(wav.readUInt32LE(24)).toBe(UPLOAD_RATE);
let energy = 0;
for (let k = 0; k < UPLOAD_RATE; k++) energy += (wav.readInt16LE(44 + 2 * k) / 32767) ** 2;
expect(Math.sqrt((2 * energy) / UPLOAD_RATE)).toBeCloseTo(expected, 2);
},
);
it("reads an answer sent without the data envelope too", async () => {
configure();
enveloped = false;
reply = () => ({ status: 200, body: { version: 1, describe: { text: "bare", tags: [] } } });
const analysis = await analyzeSignal(clicks(2), { capabilities: ["describe"] }, [apiProvider]);
expect(analysis.values.describe).toEqual({ text: "bare", tags: [] });
});
it("never sends the key anywhere but the x-api-key header", async () => {
configure();
reply = () => ({ status: 401, body: { error: { message: "bad key" } } });
const analysis = await analyzeSignal(clicks(8), { capabilities: ["describe"] }, [apiProvider]);
expect(JSON.stringify(analysis)).not.toContain("test-key");
const [call] = received;
expect(call).toMatchObject({ apiKey: "test-key", authorization: undefined });
expect(JSON.stringify(call!.request)).not.toContain("test-key");
});
});
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
medianInterval,
rhythm,
@@ -12,14 +13,14 @@ import { AVAILABLE, encodeWav, perSignal, rounded, unavailable } from "./shared.
/**
* The audio analysis service contract.
*
* Request: `POST <HYPERFRAMES_AUDIO_API_URL>`, exactly as configured, with
* `Authorization: Bearer <HYPERFRAMES_AUDIO_API_KEY>` and a `multipart/form-data` body of two parts:
* Request: `POST <HYPERFRAMES_AUDIO_API_URL>`, exactly as configured, with `x-api-key: <HYPERFRAMES_AUDIO_API_KEY>`,
* a new `Idempotency-Key` per request, and a `multipart/form-data` body of two parts:
* - `request`: `application/json`, an {@link AudioApiRequest};
* - `audio`: `audio/wav`, 16-bit PCM of the analyzed span, channels as decoded.
* - `audio`: `audio/wav`, 16-bit PCM of the analyzed span, mono at 16 kHz.
*
* Response: `200` with `application/json`, an {@link AudioApiResponse}. Every time is in seconds from the start of the
* uploaded audio. A capability the service cannot answer is left out of the response. Any other status is a failure;
* its body may be `{ "error": { "message": string } }`.
* Response: `200` with `application/json`, `{ "data": AudioApiResponse }`; a bare {@link AudioApiResponse} is read
* too. Every time is in seconds from the start of the uploaded audio. A capability the service cannot answer is left
* out of the response. Any other status is a failure; its body may be `{ "error": { "message": string } }`.
*/
export interface AudioApiRequest {
version: 1;
@@ -33,6 +34,8 @@ export interface AudioApiResponse {
version: 1;
/** What answered, as the service wants it reported. */
source?: string;
/** The finest step the service's times resolve, in seconds. */
time_resolution_seconds?: number;
beats?: { times: number[] };
downbeats?: { times: number[]; meter?: number | null };
sections?: { spans: { start: number; end: number; label?: string }[] };
@@ -43,6 +46,68 @@ const URL_ENV = "HYPERFRAMES_AUDIO_API_URL";
const KEY_ENV = "HYPERFRAMES_AUDIO_API_KEY";
const TIMEOUT_ENV = "HYPERFRAMES_AUDIO_API_TIMEOUT_MS";
const DEFAULT_TIMEOUT_MS = 120_000;
const UPLOAD_RATE = 16_000;
const SINC_ZEROS = 16;
const ROLLOFF = 0.9;
const SINC_STEPS = 512;
const HANN_SINC = Float32Array.from({ length: SINC_ZEROS * SINC_STEPS + 2 }, (_, k) => {
const x = k / SINC_STEPS;
if (x >= SINC_ZEROS) return 0;
const sinc = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
return sinc * (0.5 + 0.5 * Math.cos((Math.PI * x) / SINC_ZEROS));
});
/** Windowed-sinc resampling with its cutoff just under the lower Nyquist, so nothing above it folds back in. */
function resample(input: Float32Array, from: number, to: number): Float32Array {
if (from === to) return input;
const step = from / to;
const scale = Math.min(1, to / from) * ROLLOFF;
const reach = SINC_ZEROS / scale;
const output = new Float32Array(Math.round(input.length / step));
for (let i = 0; i < output.length; i++) {
const center = i * step;
const last = Math.min(input.length - 1, Math.floor(center + reach));
let sum = 0;
let weights = 0;
for (let n = Math.max(0, Math.ceil(center - reach)); n <= last; n++) {
const at = Math.abs(n - center) * scale * SINC_STEPS;
const k = Math.floor(at);
const weight = HANN_SINC[k]! + (at - k) * (HANN_SINC[k + 1]! - HANN_SINC[k]!);
sum += input[n]! * weight;
weights += weight;
}
output[i] = weights ? sum / weights : 0;
}
return output;
}
/** Preserve a channel when destructive interference removes over 90% of its energy. */
function monoInput(channels: readonly Float32Array[]): Float32Array {
const mono = new Float32Array(channels[0]?.length ?? 0);
let loudest: Float32Array = mono;
let loudestEnergy = 0;
for (const channel of channels) {
let energy = 0;
for (let i = 0; i < mono.length; i++) {
const value = channel[i]!;
mono[i]! += value / channels.length;
energy += value * value;
}
if (energy > loudestEnergy) {
loudest = channel;
loudestEnergy = energy;
}
}
const monoEnergy = mono.reduce((energy, value) => energy + value * value, 0);
return monoEnergy < loudestEnergy * 0.1 ? loudest : mono;
}
function uploaded(signal: AudioSignal): AudioSignal {
return {
sampleRate: UPLOAD_RATE,
channels: [resample(monoInput(signal.channels), signal.sampleRate, UPLOAD_RATE)],
};
}
/** Beats and downbeats come from one request; the others each from their own. */
const askedTogether = (capability: AudioApiCapability): AudioApiCapability[] =>
@@ -90,16 +155,17 @@ async function failure(response: Response): Promise<Error> {
return new Error(`the service answered ${response.status}${detail ? `: ${detail}` : ""}`);
}
async function ask(signal: AudioSignal, capabilities: AudioApiCapability[]) {
async function ask(analyzed: AudioSignal, capabilities: AudioApiCapability[]) {
const { url, key, timeoutMs } = config();
if (!url || !key) throw new Error(`${URL_ENV} and ${KEY_ENV} must both be set`);
const signal = uploaded(analyzed);
const request: AudioApiRequest = {
version: 1,
capabilities,
audio: {
sampleRate: signal.sampleRate,
channels: signal.channels.length,
duration: (signal.channels[0]?.length ?? 0) / signal.sampleRate,
channels: 1,
duration: signal.channels[0]!.length / signal.sampleRate,
},
};
const form = new FormData();
@@ -111,12 +177,14 @@ async function ask(signal: AudioSignal, capabilities: AudioApiCapability[]) {
);
const response = await fetch(url, {
method: "POST",
headers: { authorization: `Bearer ${key}` },
redirect: "error",
headers: { "x-api-key": key, "idempotency-key": randomUUID() },
body: form,
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw await failure(response);
return (await response.json()) as AudioApiResponse;
const body = (await response.json()) as { data?: AudioApiResponse } | null;
return (body?.data ?? body ?? {}) as AudioApiResponse;
}
const requests = new Map<string, (signal: AudioSignal) => Promise<AudioApiResponse>>();