voxsheild 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/client.ts +307 -0
- package/models.ts +439 -0
- package/package.json +30 -0
- package/protocol.ts +67 -0
- package/stream.ts +249 -0
- package/tsconfig.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!-- Generated by tools/gen_sdk.py from the VoxVerify OpenAPI document.
|
|
2
|
+
Do not edit: `python tools/gen_sdk.py` overwrites it, and tests/test_sdk.py fails
|
|
3
|
+
first. An HTML comment because the registries render this file. -->
|
|
4
|
+
# voxsheild
|
|
5
|
+
|
|
6
|
+
Client for the **VoxVerify** voice-authenticity API — batch file analysis, the
|
|
7
|
+
pre-action risk gate, and live-call scoring over a WebSocket.
|
|
8
|
+
|
|
9
|
+
The classes keep the service's name, because that is what they are a client of: the API
|
|
10
|
+
paths, the environment variables and the OpenAPI document all say VoxVerify. `voxsheild`
|
|
11
|
+
is what you install and import.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install voxsheild
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Node 22.6 or newer. No build step — the package ships as the TypeScript it was generated as, and the modules import each other with `.ts` on the specifier so Node's type-stripping runs them directly.
|
|
18
|
+
|
|
19
|
+
## Analyse a recording
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { VoxVerify } from "voxsheild";
|
|
23
|
+
|
|
24
|
+
const vx = new VoxVerify({ baseUrl: "", apiKey: KEY }); // "" = same origin
|
|
25
|
+
const verdict = await vx.analyze(file);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Three verdicts, never two: `LIKELY_HUMAN`, `LIKELY_AI`, `INCONCLUSIVE`. The service
|
|
29
|
+
abstains rather than guessing when the fused score does not clear a validation-selected
|
|
30
|
+
threshold, or when the fusion weight behind it is split. Treat the estimate as evidence,
|
|
31
|
+
not proof — the absence of a synthesis cue is not evidence of human origin.
|
|
32
|
+
|
|
33
|
+
## Score a live call
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { LiveCall } from "voxsheild/stream";
|
|
37
|
+
|
|
38
|
+
const call = new LiveCall({ apiKey: KEY, sampleRate: 16000, codec: "opus" });
|
|
39
|
+
call.onRisk = (e) => console.warn(e.state, e.confidence, e.reason);
|
|
40
|
+
await call.open();
|
|
41
|
+
call.send(pcmFrame); // Int16Array, LE
|
|
42
|
+
const summary = await call.stop();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`call.droppedFrames` counts what the client refused to queue when the socket's send buffer was already backed up. A tab that buffers thirty seconds of audio is not monitoring a call, it is recording one late.
|
|
46
|
+
|
|
47
|
+
Frames are capped at 1,048,576 bytes, and both clients split at the cap rather than
|
|
48
|
+
let an oversized frame close the socket and cost the call every window it had
|
|
49
|
+
accumulated. The caps are generated from the document, so a deployment that tunes them
|
|
50
|
+
does not need this package patched.
|
|
51
|
+
|
|
52
|
+
## Auth
|
|
53
|
+
|
|
54
|
+
Keys are per-tenant and scoped, and the tenant comes off the key — no request body may
|
|
55
|
+
name one. 10 of the 18 documented operations require a scope; the batch
|
|
56
|
+
forensic routes are open by design. A 403 carrying `scope` means the key is real but
|
|
57
|
+
provisioned for a different surface, which is a different fix from a bad key.
|
|
58
|
+
|
|
59
|
+
## Version
|
|
60
|
+
|
|
61
|
+
`1.0.0`, which is the API document's version rather than a number this package
|
|
62
|
+
maintains separately. The request methods are generated from that document; the
|
|
63
|
+
live-call client is hand-written, because OpenAPI 3.1 has no vocabulary for a socket.
|
|
64
|
+
|
|
65
|
+
Prototype for research and evaluation. Do not present any output of the service as proof
|
|
66
|
+
of origin, and do not use it as the sole basis for an accusation.
|
package/client.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Generated by tools/gen_sdk.py from the VoxVerify OpenAPI document.
|
|
2
|
+
// Do not edit: your changes will be overwritten on the next run.
|
|
3
|
+
//
|
|
4
|
+
// The streaming client beside this file is hand-written -- OpenAPI cannot
|
|
5
|
+
// describe a WebSocket, so it is written against x-websocket-endpoints.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The VoxVerify request client.
|
|
9
|
+
*
|
|
10
|
+
* Method names are the server's operation ids in camelCase; parameter names that
|
|
11
|
+
* travel on the wire (query keys, body fields) keep the server's spelling exactly,
|
|
12
|
+
* because those are the strings the API reads.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const vx = new VoxVerify({ baseUrl: "http://127.0.0.1:8000", apiKey: "..." });
|
|
16
|
+
* const verdict = await vx.analyze(file);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* For live calls use `./stream.ts`, which is hand-written: this file only covers what
|
|
20
|
+
* OpenAPI can describe.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// The `.ts` on the specifier is deliberate. This SDK ships without a build step, to
|
|
24
|
+
// match the frontend it sits beside, so the file a caller imports is the file on disk:
|
|
25
|
+
// Node's own type-stripping resolves `./models.ts` and refuses `./models`. tsc accepts
|
|
26
|
+
// it under `allowImportingTsExtensions`, which the emitted tsconfig.json sets.
|
|
27
|
+
import type * as models from "./models.ts";
|
|
28
|
+
|
|
29
|
+
/** Where the key goes -- taken from the document's securitySchemes. */
|
|
30
|
+
export const API_KEY_HEADER = "X-API-Key";
|
|
31
|
+
export const API_KEY_QUERY = "api_key";
|
|
32
|
+
|
|
33
|
+
export type FileArg = Blob | File;
|
|
34
|
+
|
|
35
|
+
export interface VoxVerifyOptions {
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
apiKey?: string;
|
|
38
|
+
/** Injected for tests and for runtimes whose fetch is not global. */
|
|
39
|
+
fetch?: typeof globalThis.fetch;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A non-2xx response, carrying the scope the operation wanted. */
|
|
43
|
+
export class VoxVerifyError extends Error {
|
|
44
|
+
readonly status: number;
|
|
45
|
+
readonly detail: string;
|
|
46
|
+
readonly scope?: string;
|
|
47
|
+
|
|
48
|
+
constructor(status: number, detail: string, scope?: string) {
|
|
49
|
+
const needs = scope && (status === 401 || status === 403)
|
|
50
|
+
? ` (this operation requires the '${scope}' scope)` : "";
|
|
51
|
+
super(`HTTP ${status}: ${detail}${needs}`);
|
|
52
|
+
this.name = "VoxVerifyError";
|
|
53
|
+
this.status = status;
|
|
54
|
+
this.detail = detail;
|
|
55
|
+
this.scope = scope;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
interface CallOptions {
|
|
61
|
+
query?: Record<string, unknown>;
|
|
62
|
+
json?: unknown;
|
|
63
|
+
/** The scalar half of a multipart body, beside the uploads. */
|
|
64
|
+
form?: Record<string, unknown>;
|
|
65
|
+
files?: [string, FileArg | FileArg[]][];
|
|
66
|
+
scope?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class VoxVerify {
|
|
70
|
+
readonly baseUrl: string;
|
|
71
|
+
apiKey?: string;
|
|
72
|
+
private readonly doFetch: typeof globalThis.fetch;
|
|
73
|
+
|
|
74
|
+
constructor(options: VoxVerifyOptions = {}) {
|
|
75
|
+
this.baseUrl = (options.baseUrl ?? "").replace(/\/$/, "");
|
|
76
|
+
this.apiKey = options.apiKey;
|
|
77
|
+
this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async call<T>(verb: string, path: string, options: CallOptions = {}): Promise<T> {
|
|
81
|
+
const url = new URL(this.baseUrl + path, globalThis.location?.href ?? "http://127.0.0.1:8000");
|
|
82
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
83
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
84
|
+
}
|
|
85
|
+
const headers: Record<string, string> = {};
|
|
86
|
+
if (this.apiKey) headers[API_KEY_HEADER] = this.apiKey;
|
|
87
|
+
|
|
88
|
+
let body: BodyInit | undefined;
|
|
89
|
+
if (options.files?.length || options.form) {
|
|
90
|
+
const form = new FormData();
|
|
91
|
+
// Unset fields are omitted rather than sent empty: the server applies its own
|
|
92
|
+
// default, and `replace=""` would ask it to parse "" as a bool.
|
|
93
|
+
for (const [field, value] of Object.entries(options.form ?? {})) {
|
|
94
|
+
if (value !== undefined && value !== null) form.append(field, String(value));
|
|
95
|
+
}
|
|
96
|
+
for (const [field, value] of options.files ?? []) {
|
|
97
|
+
for (const one of Array.isArray(value) ? value : [value]) {
|
|
98
|
+
form.append(field, one, (one as File).name ?? "clip.wav");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
body = form; // no content-type: the browser sets the boundary
|
|
102
|
+
} else if (options.json !== undefined) {
|
|
103
|
+
headers["Content-Type"] = "application/json";
|
|
104
|
+
body = JSON.stringify(options.json);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const response = await this.doFetch(url.toString(), { method: verb, headers, body });
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
let detail = await response.text();
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(detail);
|
|
112
|
+
if (parsed?.detail) {
|
|
113
|
+
detail = Array.isArray(parsed.detail)
|
|
114
|
+
? parsed.detail.map((e: any) => `${(e.loc ?? []).join(".")}: ${e.msg}`).join("; ")
|
|
115
|
+
: String(parsed.detail);
|
|
116
|
+
}
|
|
117
|
+
} catch { /* not JSON: the text is the detail */ }
|
|
118
|
+
throw new VoxVerifyError(response.status, detail, options.scope);
|
|
119
|
+
}
|
|
120
|
+
if (response.status === 204) return undefined as T;
|
|
121
|
+
return (await response.json()) as T;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Upload and analyse one recording.
|
|
126
|
+
*
|
|
127
|
+
* `POST /api/v1/analyze`
|
|
128
|
+
* Open by design: Batch forensic analysis of a file the caller uploads.
|
|
129
|
+
*/
|
|
130
|
+
async analyze(audio: FileArg, query: { visuals?: boolean; segments?: boolean } = {}): Promise<models.AnalysisResponse> {
|
|
131
|
+
return this.call("POST", `/api/v1/analyze`, { query, files: [["audio", audio]] });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Compare 2-4 recordings side by side.
|
|
136
|
+
*
|
|
137
|
+
* `POST /api/v1/compare`
|
|
138
|
+
* Open by design: Two files the caller uploads, compared against each other.
|
|
139
|
+
*/
|
|
140
|
+
async compare(audio: FileArg[], query: { visuals?: boolean; segments?: boolean; labels?: string } = {}): Promise<models.backend__schemas__analysis__CompareResponse> {
|
|
141
|
+
return this.call("POST", `/api/v1/compare`, { query, files: [["audio", audio]] });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Names of every extracted forensic feature.
|
|
146
|
+
*
|
|
147
|
+
* `GET /api/v1/features`
|
|
148
|
+
* Open by design: The detector list and what each one measures — documentation about the model, identical for every caller.
|
|
149
|
+
*/
|
|
150
|
+
async featureList(): Promise<Record<string, unknown>> {
|
|
151
|
+
return this.call("GET", `/api/v1/features`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Service and model health.
|
|
156
|
+
*
|
|
157
|
+
* `GET /api/v1/health`
|
|
158
|
+
* Open by design: Liveness and configuration, including whether a generated dev key is in use.
|
|
159
|
+
*/
|
|
160
|
+
async health(): Promise<models.HealthResponse> {
|
|
161
|
+
return this.call("GET", `/api/v1/health`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Recent analyses, newest first.
|
|
166
|
+
*
|
|
167
|
+
* `GET /api/v1/history`
|
|
168
|
+
* Open by design: Recent batch analyses on this deployment.
|
|
169
|
+
*/
|
|
170
|
+
async history(query: { limit?: number } = {}): Promise<models.HistoryItem[]> {
|
|
171
|
+
return this.call("GET", `/api/v1/history`, { query });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Measured accuracy of this deployment.
|
|
176
|
+
*
|
|
177
|
+
* `GET /api/v1/report`
|
|
178
|
+
* Open by design: The measured evaluation: thresholds, operating points, and the held-out numbers behind every accuracy claim.
|
|
179
|
+
*/
|
|
180
|
+
async report(): Promise<Record<string, unknown>> {
|
|
181
|
+
return this.call("GET", `/api/v1/report`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Retrieve a stored analysis.
|
|
186
|
+
*
|
|
187
|
+
* `GET /api/v1/results/{analysis_id}`
|
|
188
|
+
* Open by design: Reads back one batch analysis by its opaque id.
|
|
189
|
+
*/
|
|
190
|
+
async getResult(analysisId: string): Promise<models.AnalysisResponse> {
|
|
191
|
+
return this.call("GET", `/api/v1/results/${analysisId}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Delete a stored analysis.
|
|
196
|
+
*
|
|
197
|
+
* `DELETE /api/v1/results/{analysis_id}`
|
|
198
|
+
* Open by design: Deletes one batch analysis.
|
|
199
|
+
*/
|
|
200
|
+
async deleteResult(analysisId: string): Promise<Record<string, unknown>> {
|
|
201
|
+
return this.call("DELETE", `/api/v1/results/${analysisId}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* List Sessions.
|
|
206
|
+
*
|
|
207
|
+
* `GET /api/v1/stream/sessions`
|
|
208
|
+
* Requires an API key scoped `admin`.
|
|
209
|
+
*/
|
|
210
|
+
async listSessions(): Promise<Record<string, unknown>> {
|
|
211
|
+
return this.call("GET", `/api/v1/stream/sessions`, { scope: "admin" });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Compare a recording against a contact's enrolled voice.
|
|
216
|
+
*
|
|
217
|
+
* `POST /api/v1/speaker/compare`
|
|
218
|
+
* Requires an API key scoped `enrol`.
|
|
219
|
+
*/
|
|
220
|
+
async compareToEnrolment(contactId: string, audio: FileArg): Promise<models.backend__schemas__speaker__CompareResponse> {
|
|
221
|
+
return this.call("POST", `/api/v1/speaker/compare`, { form: { contact_id: contactId }, files: [["audio", audio]], scope: "enrol" });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The reference voices this tenant holds.
|
|
226
|
+
*
|
|
227
|
+
* `GET /api/v1/speaker/contacts`
|
|
228
|
+
* Requires an API key scoped `enrol`.
|
|
229
|
+
*/
|
|
230
|
+
async contacts(): Promise<models.ContactListResponse> {
|
|
231
|
+
return this.call("GET", `/api/v1/speaker/contacts`, { scope: "enrol" });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Enrol a reference voice sample for a contact.
|
|
236
|
+
*
|
|
237
|
+
* `POST /api/v1/speaker/enrol`
|
|
238
|
+
* Requires an API key scoped `enrol`.
|
|
239
|
+
*/
|
|
240
|
+
async enrol(contactId: string, audio: FileArg, form: { replace?: boolean } = {}): Promise<models.EnrolResponse> {
|
|
241
|
+
return this.call("POST", `/api/v1/speaker/enrol`, { form: { contact_id: contactId, ...form }, files: [["audio", audio]], scope: "enrol" });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Delete a contact's enrolled voice.
|
|
246
|
+
*
|
|
247
|
+
* `DELETE /api/v1/speaker/enrolment`
|
|
248
|
+
* Requires an API key scoped `enrol`.
|
|
249
|
+
*/
|
|
250
|
+
async deleteEnrolment(query: { contact_id: string }): Promise<models.DeleteResponse> {
|
|
251
|
+
return this.call("DELETE", `/api/v1/speaker/enrolment`, { query, scope: "enrol" });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Decide whether an action may proceed, given the voice on the call.
|
|
256
|
+
*
|
|
257
|
+
* `POST /api/v1/risk/check`
|
|
258
|
+
* Requires an API key scoped `risk`.
|
|
259
|
+
*/
|
|
260
|
+
async riskCheck(body: models.RiskCheckRequest): Promise<models.RiskDecisionResponse> {
|
|
261
|
+
return this.call("POST", `/api/v1/risk/check`, { json: body, scope: "risk" });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* This tenant's recent gate decisions.
|
|
266
|
+
*
|
|
267
|
+
* `GET /api/v1/risk/decisions`
|
|
268
|
+
* Requires an API key scoped `risk`.
|
|
269
|
+
*/
|
|
270
|
+
async riskHistory(query: { limit?: number } = {}): Promise<models.RiskHistoryResponse> {
|
|
271
|
+
return this.call("GET", `/api/v1/risk/decisions`, { query, scope: "risk" });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One recorded decision, in full.
|
|
276
|
+
*
|
|
277
|
+
* `GET /api/v1/risk/decisions/{decision_id}`
|
|
278
|
+
* Requires an API key scoped `risk`.
|
|
279
|
+
*/
|
|
280
|
+
async riskDecision(decisionId: string): Promise<models.RiskDecisionResponse> {
|
|
281
|
+
return this.call("GET", `/api/v1/risk/decisions/${decisionId}`, { scope: "risk" });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The policies this deployment will enforce, and any that will not load.
|
|
286
|
+
*
|
|
287
|
+
* `GET /api/v1/risk/policies`
|
|
288
|
+
* Requires an API key scoped `risk`.
|
|
289
|
+
*/
|
|
290
|
+
async riskPolicies(): Promise<models.PolicyListResponse> {
|
|
291
|
+
return this.call("GET", `/api/v1/risk/policies`, { scope: "risk" });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Evaluate a policy without alerting or recording.
|
|
296
|
+
*
|
|
297
|
+
* `POST /api/v1/risk/preview`
|
|
298
|
+
* Requires an API key scoped `risk`.
|
|
299
|
+
*/
|
|
300
|
+
async riskPreview(body: models.RiskCheckRequest): Promise<models.RiskDecisionResponse> {
|
|
301
|
+
return this.call("POST", `/api/v1/risk/preview`, { json: body, scope: "risk" });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Documented but deliberately absent from this client:
|
|
307
|
+
// POST /api/v1/twilio/twiml -- signed by Twilio, not callable with an API key
|
package/models.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// Generated by tools/gen_sdk.py from the VoxVerify OpenAPI document.
|
|
2
|
+
// Do not edit: your changes will be overwritten on the next run.
|
|
3
|
+
//
|
|
4
|
+
// The streaming client beside this file is hand-written -- OpenAPI cannot
|
|
5
|
+
// describe a WebSocket, so it is written against x-websocket-endpoints.
|
|
6
|
+
|
|
7
|
+
/** Response and request shapes for the VoxVerify API. */
|
|
8
|
+
|
|
9
|
+
/** AudioInfo */
|
|
10
|
+
export interface AudioInfo {
|
|
11
|
+
format?: string | null;
|
|
12
|
+
subtype?: string | null;
|
|
13
|
+
source_sample_rate?: number | null;
|
|
14
|
+
source_channels?: number | null;
|
|
15
|
+
analysis_sample_rate: number;
|
|
16
|
+
transcoded?: boolean;
|
|
17
|
+
speech_ratio: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Thresholds */
|
|
21
|
+
export interface Thresholds {
|
|
22
|
+
/** Scores under this read as LIKELY_HUMAN */
|
|
23
|
+
human_below: number;
|
|
24
|
+
/** Scores over this read as LIKELY_AI */
|
|
25
|
+
ai_above: number;
|
|
26
|
+
/** Where these thresholds came from */
|
|
27
|
+
source: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** DecisionBlock */
|
|
31
|
+
export interface DecisionBlock {
|
|
32
|
+
verdict: "LIKELY_HUMAN" | "LIKELY_AI" | "INCONCLUSIVE";
|
|
33
|
+
label: string;
|
|
34
|
+
/** Why this verdict, in plain language */
|
|
35
|
+
reason: string;
|
|
36
|
+
confidence: "low" | "moderate" | "high";
|
|
37
|
+
thresholds: Thresholds;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** FusionBlock */
|
|
41
|
+
export interface FusionBlock {
|
|
42
|
+
ai_score: number;
|
|
43
|
+
human_score: number;
|
|
44
|
+
/** Fusion weights after re-normalising over available streams */
|
|
45
|
+
weights_used: Record<string, number>;
|
|
46
|
+
/** Where those weights came from: a validation search, or configured priors */
|
|
47
|
+
weights_source: string;
|
|
48
|
+
available_streams: string[];
|
|
49
|
+
unavailable_streams: Record<string, string>;
|
|
50
|
+
/** max - min across detector scores */
|
|
51
|
+
detector_spread: number;
|
|
52
|
+
/** Share of the fusion *weight* carried by detectors on the same side of 0.5 as the fused score */
|
|
53
|
+
detector_agreement: number;
|
|
54
|
+
/** Plain count of detectors agreeing with the fused score, for display */
|
|
55
|
+
streams_agreeing?: number;
|
|
56
|
+
/** How many detectors produced a score at all */
|
|
57
|
+
streams_scoring?: number;
|
|
58
|
+
/** True only when every contributing detector was fitted on labelled data */
|
|
59
|
+
calibrated: boolean;
|
|
60
|
+
score_label: string;
|
|
61
|
+
provenance_override: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Indicator */
|
|
65
|
+
export interface Indicator {
|
|
66
|
+
detector: string;
|
|
67
|
+
title: string;
|
|
68
|
+
subtitle: string;
|
|
69
|
+
status: string;
|
|
70
|
+
score: number | null;
|
|
71
|
+
lean: "ai" | "human" | "neutral" | "unavailable";
|
|
72
|
+
text: string;
|
|
73
|
+
cues?: string[];
|
|
74
|
+
provenance?: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** ModelStatus */
|
|
78
|
+
export interface ModelStatus {
|
|
79
|
+
status: "OK" | "UNAVAILABLE" | "ERROR";
|
|
80
|
+
detail: string;
|
|
81
|
+
provenance: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Segment */
|
|
85
|
+
export interface Segment {
|
|
86
|
+
start: number;
|
|
87
|
+
end: number;
|
|
88
|
+
ai_score: number | null;
|
|
89
|
+
human_score: number | null;
|
|
90
|
+
verdict: string;
|
|
91
|
+
speech_ratio: number;
|
|
92
|
+
detectors?: Record<string, number>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** SegmentConsistency */
|
|
96
|
+
export interface SegmentConsistency {
|
|
97
|
+
scored_windows: number;
|
|
98
|
+
std: number;
|
|
99
|
+
min?: number | null;
|
|
100
|
+
max?: number | null;
|
|
101
|
+
hybrid_risk: "low" | "elevated" | "unknown";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** SuspiciousRegion */
|
|
105
|
+
export interface SuspiciousRegion {
|
|
106
|
+
start: number;
|
|
107
|
+
end: number;
|
|
108
|
+
peak_ai_score: number;
|
|
109
|
+
window_count: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Watermark */
|
|
113
|
+
export interface Watermark {
|
|
114
|
+
/** Provenance check outcome */
|
|
115
|
+
status: "DETECTED" | "NOT_DETECTED" | "UNAVAILABLE" | "ERROR";
|
|
116
|
+
/** Provider that answered, if any */
|
|
117
|
+
provider?: string | null;
|
|
118
|
+
/** Human-readable, non-implicative wording */
|
|
119
|
+
message?: string;
|
|
120
|
+
confidence?: number | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** AnalysisResponse */
|
|
124
|
+
export interface AnalysisResponse {
|
|
125
|
+
analysis_id: string;
|
|
126
|
+
filename: string;
|
|
127
|
+
/** Analysed duration in seconds */
|
|
128
|
+
duration: number;
|
|
129
|
+
verdict: "LIKELY_HUMAN" | "LIKELY_AI" | "INCONCLUSIVE";
|
|
130
|
+
label: string;
|
|
131
|
+
/** Fused AI likelihood in [0, 1] */
|
|
132
|
+
ai_score: number;
|
|
133
|
+
human_score: number;
|
|
134
|
+
summary: string;
|
|
135
|
+
decision: DecisionBlock;
|
|
136
|
+
fusion: FusionBlock;
|
|
137
|
+
watermark: Watermark;
|
|
138
|
+
/** Per-detector AI likelihood; null when the detector did not run */
|
|
139
|
+
models: Record<string, number | null>;
|
|
140
|
+
model_status: Record<string, ModelStatus>;
|
|
141
|
+
indicators: Indicator[];
|
|
142
|
+
/** Caveats that must be shown alongside this result */
|
|
143
|
+
limitations: string[];
|
|
144
|
+
segments: Segment[];
|
|
145
|
+
suspicious_regions: SuspiciousRegion[];
|
|
146
|
+
segment_consistency: SegmentConsistency;
|
|
147
|
+
audio: AudioInfo;
|
|
148
|
+
/** Raw named forensic features */
|
|
149
|
+
features: Record<string, number>;
|
|
150
|
+
/** Waveform, spectrogram, average spectrum and pitch arrays */
|
|
151
|
+
visuals?: Record<string, unknown> | null;
|
|
152
|
+
model_version: string;
|
|
153
|
+
feature_version: string;
|
|
154
|
+
elapsed_ms: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Body_analyze */
|
|
158
|
+
export interface Body_analyze {
|
|
159
|
+
/** WAV, MP3, FLAC, OGG, AIFF or M4A */
|
|
160
|
+
audio: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Body_compare */
|
|
164
|
+
export interface Body_compare {
|
|
165
|
+
/** Between 2 and 4 audio files */
|
|
166
|
+
audio: string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Body_compare_to_enrolment */
|
|
170
|
+
export interface Body_compare_to_enrolment {
|
|
171
|
+
contact_id: string;
|
|
172
|
+
audio: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Body_enrol */
|
|
176
|
+
export interface Body_enrol {
|
|
177
|
+
/** The caller's own reference for this contact */
|
|
178
|
+
contact_id: string;
|
|
179
|
+
/** At least 3 s of the contact speaking */
|
|
180
|
+
audio: string;
|
|
181
|
+
/** Discard any existing reference for this contact instead of adding to it */
|
|
182
|
+
replace?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** CompareItem */
|
|
186
|
+
export interface CompareItem {
|
|
187
|
+
/** Display label, e.g */
|
|
188
|
+
label: string;
|
|
189
|
+
analysis?: AnalysisResponse | null;
|
|
190
|
+
error?: string | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What is stored for a contact */
|
|
194
|
+
export interface EnrolmentSummary {
|
|
195
|
+
/** `sha256(tenant | contact_id)`, truncated */
|
|
196
|
+
contact_ref: string;
|
|
197
|
+
/** Reference recordings folded into this voice */
|
|
198
|
+
samples: number;
|
|
199
|
+
/** Embedding windows across those recordings */
|
|
200
|
+
windows: number;
|
|
201
|
+
/** Total reference audio seen, in seconds */
|
|
202
|
+
seconds: number;
|
|
203
|
+
/** Dimensions of the stored voice direction */
|
|
204
|
+
dims: number;
|
|
205
|
+
/** `lda` for the measured projection, else `cosine` */
|
|
206
|
+
scoring: string;
|
|
207
|
+
created_at: number;
|
|
208
|
+
updated_at: number;
|
|
209
|
+
/** Always false */
|
|
210
|
+
audio_retained: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** ContactListResponse */
|
|
214
|
+
export interface ContactListResponse {
|
|
215
|
+
count: number;
|
|
216
|
+
contacts: EnrolmentSummary[];
|
|
217
|
+
note: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** DeleteResponse */
|
|
221
|
+
export interface DeleteResponse {
|
|
222
|
+
deleted: boolean;
|
|
223
|
+
contact_id: string;
|
|
224
|
+
contact_ref: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** EnrolResponse */
|
|
228
|
+
export interface EnrolResponse {
|
|
229
|
+
/** Echoed back from the request */
|
|
230
|
+
contact_id: string;
|
|
231
|
+
enrolled: boolean;
|
|
232
|
+
enrolment: EnrolmentSummary;
|
|
233
|
+
/** The operating point a later comparison will use, and whether it was measured on held-out… */
|
|
234
|
+
thresholds: Record<string, unknown>;
|
|
235
|
+
/** What these numbers do not cover */
|
|
236
|
+
caveats: string[];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** ErrorResponse */
|
|
240
|
+
export interface ErrorResponse {
|
|
241
|
+
detail: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** EvidenceBlock */
|
|
245
|
+
export interface EvidenceBlock {
|
|
246
|
+
/** SYNTHETIC | GENUINE | UNDECIDED */
|
|
247
|
+
state: string;
|
|
248
|
+
confidence: "low" | "moderate" | "high";
|
|
249
|
+
speech_sec: number;
|
|
250
|
+
windows: number;
|
|
251
|
+
band: string | null;
|
|
252
|
+
/** False means the verdict was reached against a configured prior rather than a threshold fitted… */
|
|
253
|
+
operating_point_measured: boolean;
|
|
254
|
+
ai_score: number | null;
|
|
255
|
+
session_id: string | null;
|
|
256
|
+
direction: string | null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** ValidationError */
|
|
260
|
+
export interface ValidationError {
|
|
261
|
+
loc: (string | number)[];
|
|
262
|
+
msg: string;
|
|
263
|
+
type: string;
|
|
264
|
+
input?: unknown;
|
|
265
|
+
ctx?: Record<string, unknown>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** HTTPValidationError */
|
|
269
|
+
export interface HTTPValidationError {
|
|
270
|
+
detail?: ValidationError[];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** HealthResponse */
|
|
274
|
+
export interface HealthResponse {
|
|
275
|
+
status: "ok" | "degraded";
|
|
276
|
+
model_version: string;
|
|
277
|
+
feature_version: string;
|
|
278
|
+
detectors: Record<string, unknown>;
|
|
279
|
+
provenance_providers: string[];
|
|
280
|
+
thresholds: Thresholds;
|
|
281
|
+
limits: Record<string, unknown>;
|
|
282
|
+
database: Record<string, unknown>;
|
|
283
|
+
/** Shape of the API-key configuration; never the keys themselves */
|
|
284
|
+
auth?: Record<string, unknown>;
|
|
285
|
+
/** Live-call window/hop, SPRT error targets and per-band operating points */
|
|
286
|
+
streaming?: Record<string, unknown>;
|
|
287
|
+
/** Pre-action gate posture: policies loaded, policy files that will not load, webhook state, and… */
|
|
288
|
+
risk?: Record<string, unknown>;
|
|
289
|
+
/** Enrolment posture: whether the identity thresholds in force were measured on held-out… */
|
|
290
|
+
speaker?: Record<string, unknown>;
|
|
291
|
+
/** Consent posture: the mode in force, whether it is enforced at the handshake, and which tenants… */
|
|
292
|
+
consent?: Record<string, unknown>;
|
|
293
|
+
/** What a retained row is allowed to say: the filename mode and the logging mode actually in… */
|
|
294
|
+
privacy?: Record<string, unknown>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** HistoryItem */
|
|
298
|
+
export interface HistoryItem {
|
|
299
|
+
id: string;
|
|
300
|
+
filename: string;
|
|
301
|
+
duration: number;
|
|
302
|
+
created_at: string;
|
|
303
|
+
verdict: "LIKELY_HUMAN" | "LIKELY_AI" | "INCONCLUSIVE";
|
|
304
|
+
ai_score: number;
|
|
305
|
+
human_score: number;
|
|
306
|
+
watermark_status: string;
|
|
307
|
+
model_version: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** NotificationBlock */
|
|
311
|
+
export interface NotificationBlock {
|
|
312
|
+
event_id: string;
|
|
313
|
+
event: string;
|
|
314
|
+
delivered: boolean;
|
|
315
|
+
status?: number | null;
|
|
316
|
+
error?: string | null;
|
|
317
|
+
/** Why nothing was sent: no URL, no signing secret, or a plaintext URL without the explicit… */
|
|
318
|
+
skipped?: string | null;
|
|
319
|
+
ms?: number | null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** PolicyBlock */
|
|
323
|
+
export interface PolicyBlock {
|
|
324
|
+
name: string | null;
|
|
325
|
+
version: number | null;
|
|
326
|
+
/** File that decided, including the tenant directory when a tenant override supplied it */
|
|
327
|
+
source?: string | null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** PolicyListResponse */
|
|
331
|
+
export interface PolicyListResponse {
|
|
332
|
+
default_policy: string;
|
|
333
|
+
outcomes: string[];
|
|
334
|
+
confidence_levels: string[];
|
|
335
|
+
policies: Record<string, unknown>[];
|
|
336
|
+
actions?: Record<string, unknown>;
|
|
337
|
+
/** Which file the map came from: the shared `_actions.yaml` or a tenant override that replaced it */
|
|
338
|
+
actions_source?: string;
|
|
339
|
+
/** Policy files on disk that will not load */
|
|
340
|
+
problems: string[];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** One pre-action question */
|
|
344
|
+
export interface RiskCheckRequest {
|
|
345
|
+
/** What the caller is about to do, e.g */
|
|
346
|
+
action: string;
|
|
347
|
+
/** A specific stream session */
|
|
348
|
+
session_id?: string | null;
|
|
349
|
+
/** SIP Call-ID */
|
|
350
|
+
call_id?: string | null;
|
|
351
|
+
/** Transaction amount, for policies with an escalation floor */
|
|
352
|
+
amount?: number | null;
|
|
353
|
+
/** ISO code for `amount` */
|
|
354
|
+
currency?: string | null;
|
|
355
|
+
/** The caller's own reference for this action, echoed into the audit record so a decision can be… */
|
|
356
|
+
reference?: string | null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** RuleBlock */
|
|
360
|
+
export interface RuleBlock {
|
|
361
|
+
/** Position of the matching rule in its policy file */
|
|
362
|
+
index: number;
|
|
363
|
+
when: Record<string, unknown>;
|
|
364
|
+
then: "allow" | "step_up" | "hold" | "deny";
|
|
365
|
+
notify: boolean;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** SubjectBlock */
|
|
369
|
+
export interface SubjectBlock {
|
|
370
|
+
source: "live" | "stored" | "none";
|
|
371
|
+
note: string;
|
|
372
|
+
/** Matching legs, including the one weighed */
|
|
373
|
+
candidates: number;
|
|
374
|
+
/** Stored evidence only: how long ago the session closed */
|
|
375
|
+
age_sec?: number | null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The gate's answer */
|
|
379
|
+
export interface RiskDecisionResponse {
|
|
380
|
+
decision_id: string;
|
|
381
|
+
created_at: string;
|
|
382
|
+
/** allow | step_up | hold | deny */
|
|
383
|
+
outcome: "allow" | "step_up" | "hold" | "deny";
|
|
384
|
+
action: string;
|
|
385
|
+
known_action: boolean;
|
|
386
|
+
/** Why, in order: the rule's own wording, then how the evidence was found, then which policy was… */
|
|
387
|
+
reasons: string[];
|
|
388
|
+
policy: PolicyBlock;
|
|
389
|
+
selection: Record<string, unknown>;
|
|
390
|
+
rule: RuleBlock | null;
|
|
391
|
+
/** Set when an unmeasured operating point softened the outcome, so a softened deny is… */
|
|
392
|
+
downgraded_from?: "allow" | "step_up" | "hold" | "deny" | null;
|
|
393
|
+
evidence: EvidenceBlock | null;
|
|
394
|
+
evidence_source: "live" | "stored" | "none";
|
|
395
|
+
subject: SubjectBlock;
|
|
396
|
+
notification: NotificationBlock | null;
|
|
397
|
+
tenant: string | null;
|
|
398
|
+
call_id: string | null;
|
|
399
|
+
reference: string | null;
|
|
400
|
+
amount: number | null;
|
|
401
|
+
currency: string | null;
|
|
402
|
+
model_version: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** RiskHistoryResponse */
|
|
406
|
+
export interface RiskHistoryResponse {
|
|
407
|
+
count: number;
|
|
408
|
+
decisions: Record<string, unknown>[];
|
|
409
|
+
stats: Record<string, unknown>;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** CompareResponse */
|
|
413
|
+
export interface backend__schemas__analysis__CompareResponse {
|
|
414
|
+
comparison_id: string;
|
|
415
|
+
items: CompareItem[];
|
|
416
|
+
/** Samples ordered by AI likelihood, highest first */
|
|
417
|
+
ranking: Record<string, unknown>[];
|
|
418
|
+
/** Difference between the highest and lowest score */
|
|
419
|
+
spread: number;
|
|
420
|
+
notes: string[];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** An identity finding, deliberately not a score */
|
|
424
|
+
export interface backend__schemas__speaker__CompareResponse {
|
|
425
|
+
/** MATCH, DRIFT or UNDECIDED against the enrolled reference; UNKNOWN_CONTACT when nothing is… */
|
|
426
|
+
state: "MATCH" | "DRIFT" | "UNDECIDED" | "UNKNOWN_CONTACT" | "UNAVAILABLE";
|
|
427
|
+
label: string;
|
|
428
|
+
/** The cosine and the threshold it was compared to */
|
|
429
|
+
reason: string;
|
|
430
|
+
/** Mean per-window cosine against the reference direction */
|
|
431
|
+
cosine?: number | null;
|
|
432
|
+
contact_ref: string | null;
|
|
433
|
+
enrolment?: EnrolmentSummary | null;
|
|
434
|
+
thresholds: Record<string, unknown>;
|
|
435
|
+
caveats: string[];
|
|
436
|
+
/** Always false */
|
|
437
|
+
affects_ai_score: boolean;
|
|
438
|
+
}
|
|
439
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "voxsheild",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Client for the VoxVerify voice-authenticity API: batch analysis, the risk gate, and live calls.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./client.ts",
|
|
8
|
+
"./stream": "./stream.ts",
|
|
9
|
+
"./protocol": "./protocol.ts",
|
|
10
|
+
"./models": "./models.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"client.ts",
|
|
14
|
+
"models.ts",
|
|
15
|
+
"protocol.ts",
|
|
16
|
+
"stream.ts",
|
|
17
|
+
"tsconfig.json"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.6"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"parse": "node --input-type=module --experimental-strip-types -e \"await import('./client.ts'); await import('./stream.ts'); console.log('ok')\""
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.6.0"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
package/protocol.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Generated by tools/gen_sdk.py from the VoxVerify OpenAPI document.
|
|
2
|
+
// Do not edit: your changes will be overwritten on the next run.
|
|
3
|
+
//
|
|
4
|
+
// The streaming client beside this file is hand-written -- OpenAPI cannot
|
|
5
|
+
// describe a WebSocket, so it is written against x-websocket-endpoints.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The live-call protocol, as published in the document's `x-websocket-endpoints`.
|
|
9
|
+
*
|
|
10
|
+
* Generated. The stream clients beside this file are hand-written and import
|
|
11
|
+
* these constants rather than restating them, so a deployment that tunes its
|
|
12
|
+
* session caps re-generates one file and both clients follow.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const STREAM_PATH = "/api/v1/stream" as const;
|
|
16
|
+
|
|
17
|
+
export const TWILIO_STREAM_PATH = "/api/v1/twilio/stream" as const;
|
|
18
|
+
|
|
19
|
+
export const REQUIRED_SCOPE = "stream" as const;
|
|
20
|
+
|
|
21
|
+
export const ENCODING = "pcm_s16le" as const;
|
|
22
|
+
|
|
23
|
+
export const CAPS = {
|
|
24
|
+
"max_frame_bytes": 1048576,
|
|
25
|
+
"hello_timeout_sec": 10.0,
|
|
26
|
+
"max_session_sec": 1800.0,
|
|
27
|
+
"max_session_bytes": 125829120,
|
|
28
|
+
"note": "Per-session duration and byte caps are enforced because forked call audio is call recording: an unbounded session is a memory risk and a compliance one."
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export const CLOSE_CODES = {
|
|
32
|
+
"1000": "normal close after the summary was sent",
|
|
33
|
+
"1011": "server aborted mid-stream; the reason is sent as an error",
|
|
34
|
+
"4400": "no hello within the timeout, or a malformed one",
|
|
35
|
+
"4401": "no key",
|
|
36
|
+
"4403": "key lacks the 'stream' scope",
|
|
37
|
+
"4409": "session duration or byte cap reached",
|
|
38
|
+
"4413": "a single frame exceeded max_frame_bytes"
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export const EVENTS = [
|
|
42
|
+
"ready",
|
|
43
|
+
"window",
|
|
44
|
+
"risk",
|
|
45
|
+
"identity",
|
|
46
|
+
"error",
|
|
47
|
+
"summary",
|
|
48
|
+
"status"
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
export const CONTROL_MESSAGES = {
|
|
52
|
+
"stop | close | end": "graceful close; queued audio is drained first",
|
|
53
|
+
"status | ping": "the current summary, without closing"
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export const HELLO_FIELDS = {
|
|
57
|
+
"session_id": "optional; generated when omitted",
|
|
58
|
+
"call_id": "optional SIP Call-ID, the handle the risk gate joins on",
|
|
59
|
+
"contact_id": "optional; the identifier this contact was enrolled with via POST /speaker/enrol. When present the call is also compared against that reference voice and `identity` events are emitted. Hashed on arrival and never stored; no identity result can move ai_score.",
|
|
60
|
+
"sample_rate": "integer, 4000-192000 (default 16000)",
|
|
61
|
+
"codec": "optional label, e.g. opus / pcm_mulaw \u2014 decides the band",
|
|
62
|
+
"direction": "inbound | outbound",
|
|
63
|
+
"encoding": "pcm_s16le (aliases: pcm16, l16) \u2014 transcode mu-law/A-law at the transport edge"
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
export const MAX_FRAME_BYTES: number = CAPS.max_frame_bytes;
|
|
67
|
+
export const HELLO_TIMEOUT_SEC: number = CAPS.hello_timeout_sec;
|
package/stream.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-call client for `WS /api/v1/stream`, for the browser.
|
|
3
|
+
*
|
|
4
|
+
* HAND-WRITTEN, unlike `client.ts` beside it. OpenAPI 3.1 has no vocabulary for a
|
|
5
|
+
* bidirectional socket, so there is nothing for a generator to read -- and the parts
|
|
6
|
+
* that matter on a live call are not in any schema anyway: who gives way when the
|
|
7
|
+
* microphone outruns the network, and what happens to audio still in flight when the
|
|
8
|
+
* operator presses stop.
|
|
9
|
+
*
|
|
10
|
+
* Every constant enforced here comes from `protocol.ts`, which *is* generated from the
|
|
11
|
+
* server's `x-websocket-endpoints` block, so a deployment that tunes its caps
|
|
12
|
+
* regenerates one file and this client follows.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const call = new LiveCall({ baseUrl: "", apiKey: key, sampleRate: 16000, codec: "opus" });
|
|
16
|
+
* call.onRisk = (e) => console.warn(e.state, e.confidence, e.reason);
|
|
17
|
+
* await call.open();
|
|
18
|
+
* call.send(pcmFrame); // Int16Array, little-endian
|
|
19
|
+
* const summary = await call.stop();
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { CLOSE_CODES, ENCODING, MAX_FRAME_BYTES, REQUIRED_SCOPE, STREAM_PATH } from "./protocol.ts";
|
|
24
|
+
|
|
25
|
+
export interface LiveCallOptions {
|
|
26
|
+
/** Same-origin by default: the dashboard is served by the API. */
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
/** A key scoped `stream`. Required -- the socket refuses an anonymous caller. */
|
|
29
|
+
apiKey: string;
|
|
30
|
+
/** Frames must arrive at this rate; the server resamples to its own. */
|
|
31
|
+
sampleRate?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Codec label, e.g. `opus` or `pcm_mulaw`. Not cosmetic: this, not the sample
|
|
34
|
+
* rate, picks the measured operating point. A G.711 leg unpacked to 16 kHz is
|
|
35
|
+
* still narrowband audio, and calling it `opus` scores it against a threshold
|
|
36
|
+
* fitted on a distribution it was not drawn from.
|
|
37
|
+
*/
|
|
38
|
+
codec?: string;
|
|
39
|
+
/** SIP Call-ID, if there is one. The handle the risk gate joins on. */
|
|
40
|
+
callId?: string;
|
|
41
|
+
sessionId?: string;
|
|
42
|
+
direction?: "inbound" | "outbound";
|
|
43
|
+
/**
|
|
44
|
+
* How much unsent audio may sit in the socket's buffer before frames are dropped.
|
|
45
|
+
* The server's own queue is bounded for the same reason: a detection that arrives
|
|
46
|
+
* after the call ended is not a detection.
|
|
47
|
+
*/
|
|
48
|
+
maxBufferedBytes?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StreamEvent {
|
|
52
|
+
type: "ready" | "window" | "risk" | "error" | "summary" | "status";
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The server closed the socket, with the code it closed on. */
|
|
57
|
+
export class StreamClosed extends Error {
|
|
58
|
+
readonly code: number;
|
|
59
|
+
readonly reason: string;
|
|
60
|
+
|
|
61
|
+
constructor(code: number, reason?: string) {
|
|
62
|
+
const described = reason || (CLOSE_CODES as Record<string, string>)[String(code)]
|
|
63
|
+
|| "closed without a reason";
|
|
64
|
+
super(`stream closed ${code}: ${described}`);
|
|
65
|
+
this.name = "StreamClosed";
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.reason = described;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `http(s)://host` -> `ws(s)://host/api/v1/stream`; `""` -> same origin. */
|
|
72
|
+
export function wsUrl(baseUrl: string, apiKey: string, path: string = STREAM_PATH): string {
|
|
73
|
+
const origin = baseUrl || globalThis.location?.origin || "http://127.0.0.1:8000";
|
|
74
|
+
const url = new URL(path, origin);
|
|
75
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
76
|
+
// The key goes in the query string here, and only here, because the browser
|
|
77
|
+
// WebSocket API cannot set a request header on the handshake. It lands in the
|
|
78
|
+
// server's access log as a result, which is why every other call in this SDK sends
|
|
79
|
+
// it as a header instead.
|
|
80
|
+
url.searchParams.set("api_key", apiKey);
|
|
81
|
+
return url.toString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class LiveCall {
|
|
85
|
+
ready?: StreamEvent;
|
|
86
|
+
summary?: StreamEvent;
|
|
87
|
+
/** Frames this client dropped for backpressure. The server counts its own. */
|
|
88
|
+
droppedFrames = 0;
|
|
89
|
+
|
|
90
|
+
onEvent?: (event: StreamEvent) => void;
|
|
91
|
+
onWindow?: (event: StreamEvent) => void;
|
|
92
|
+
onRisk?: (event: StreamEvent) => void;
|
|
93
|
+
onSummary?: (event: StreamEvent) => void;
|
|
94
|
+
onError?: (error: StreamClosed) => void;
|
|
95
|
+
|
|
96
|
+
private socket?: WebSocket;
|
|
97
|
+
private readonly options: Required<Pick<LiveCallOptions, "sampleRate" | "direction" | "maxBufferedBytes">> & LiveCallOptions;
|
|
98
|
+
private summaryWaiters: Array<(event: StreamEvent | undefined) => void> = [];
|
|
99
|
+
|
|
100
|
+
constructor(options: LiveCallOptions) {
|
|
101
|
+
this.options = {
|
|
102
|
+
sampleRate: 16000,
|
|
103
|
+
direction: "inbound",
|
|
104
|
+
maxBufferedBytes: 8 * MAX_FRAME_BYTES,
|
|
105
|
+
...options,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The server's id for this leg -- the handle `/risk/check` joins on. */
|
|
110
|
+
get sessionId(): string | undefined {
|
|
111
|
+
return this.ready?.session_id as string | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get isOpen(): boolean {
|
|
115
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Connect, send the hello, and resolve on the server's `ready`.
|
|
120
|
+
*
|
|
121
|
+
* Rejects rather than resolving on a socket that closes first, which is the case
|
|
122
|
+
* worth getting right: a key without the `stream` scope closes 4403 during what
|
|
123
|
+
* looks like a successful connection, and a client that resolved anyway would sit
|
|
124
|
+
* there sending audio into a closed socket.
|
|
125
|
+
*/
|
|
126
|
+
async open(): Promise<StreamEvent> {
|
|
127
|
+
const url = wsUrl(this.options.baseUrl ?? "", this.options.apiKey);
|
|
128
|
+
const socket = new WebSocket(url);
|
|
129
|
+
socket.binaryType = "arraybuffer";
|
|
130
|
+
this.socket = socket;
|
|
131
|
+
|
|
132
|
+
const hello = {
|
|
133
|
+
sample_rate: this.options.sampleRate,
|
|
134
|
+
codec: this.options.codec,
|
|
135
|
+
call_id: this.options.callId,
|
|
136
|
+
session_id: this.options.sessionId,
|
|
137
|
+
direction: this.options.direction,
|
|
138
|
+
encoding: ENCODING,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return await new Promise<StreamEvent>((resolve, reject) => {
|
|
142
|
+
let settled = false;
|
|
143
|
+
socket.onopen = () => socket.send(JSON.stringify(
|
|
144
|
+
Object.fromEntries(Object.entries(hello).filter(([, v]) => v != null))));
|
|
145
|
+
|
|
146
|
+
socket.onmessage = (message) => {
|
|
147
|
+
const event = JSON.parse(String(message.data)) as StreamEvent;
|
|
148
|
+
this.dispatch(event);
|
|
149
|
+
if (!settled && event.type === "ready") {
|
|
150
|
+
settled = true;
|
|
151
|
+
this.ready = event;
|
|
152
|
+
resolve(event);
|
|
153
|
+
} else if (!settled && event.type === "error") {
|
|
154
|
+
settled = true;
|
|
155
|
+
reject(new StreamClosed(4400, String(event.detail ?? "the server refused the hello")));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
socket.onclose = (closed) => {
|
|
160
|
+
this.releaseWaiters();
|
|
161
|
+
const failure = new StreamClosed(closed.code, closed.reason);
|
|
162
|
+
if (!settled) {
|
|
163
|
+
settled = true;
|
|
164
|
+
reject(failure);
|
|
165
|
+
} else if (closed.code !== 1000 && closed.code !== 1001) {
|
|
166
|
+
this.onError?.(failure);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private dispatch(event: StreamEvent): void {
|
|
173
|
+
this.onEvent?.(event);
|
|
174
|
+
if (event.type === "window") this.onWindow?.(event);
|
|
175
|
+
else if (event.type === "risk") this.onRisk?.(event);
|
|
176
|
+
else if (event.type === "summary") {
|
|
177
|
+
this.summary = event;
|
|
178
|
+
this.onSummary?.(event);
|
|
179
|
+
this.releaseWaiters(event);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private releaseWaiters(event?: StreamEvent): void {
|
|
184
|
+
const waiting = this.summaryWaiters;
|
|
185
|
+
this.summaryWaiters = [];
|
|
186
|
+
for (const resolve of waiting) resolve(event ?? this.summary);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Send one frame of 16-bit little-endian PCM.
|
|
191
|
+
*
|
|
192
|
+
* Two things happen here that the server would otherwise do less kindly. Frames
|
|
193
|
+
* larger than the published cap are split, because the server's answer to an
|
|
194
|
+
* oversized frame is to close the socket (4413) and the call loses every window it
|
|
195
|
+
* had accumulated. And when unsent audio is already piling up in the socket buffer
|
|
196
|
+
* the frame is dropped and counted, rather than queued: a tab that buffers thirty
|
|
197
|
+
* seconds of audio is not monitoring a call, it is recording one late.
|
|
198
|
+
*/
|
|
199
|
+
send(pcm: Int16Array | ArrayBuffer): void {
|
|
200
|
+
const socket = this.socket;
|
|
201
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
202
|
+
if (socket.bufferedAmount > this.options.maxBufferedBytes) {
|
|
203
|
+
this.droppedFrames += 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const buffer = pcm instanceof Int16Array
|
|
207
|
+
? pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength) as ArrayBuffer
|
|
208
|
+
: pcm;
|
|
209
|
+
for (let start = 0; start < buffer.byteLength; start += MAX_FRAME_BYTES) {
|
|
210
|
+
socket.send(buffer.slice(start, Math.min(start + MAX_FRAME_BYTES, buffer.byteLength)));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Ask for the current summary without closing. Arrives as a `status` event. */
|
|
215
|
+
status(): void {
|
|
216
|
+
if (this.isOpen) this.socket!.send(JSON.stringify({ action: "status" }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Say stop and wait for the summary.
|
|
221
|
+
*
|
|
222
|
+
* The waiting is the point: the server drains audio it has already queued before it
|
|
223
|
+
* summarises, so closing the socket on stop would discard the last few windows --
|
|
224
|
+
* on a short call, the ones that decided it. Resolves undefined if the socket dies
|
|
225
|
+
* first, which is honest: there is no verdict in that case, and returning a partial
|
|
226
|
+
* one would invent evidence.
|
|
227
|
+
*/
|
|
228
|
+
async stop(timeoutMs = 30000): Promise<StreamEvent | undefined> {
|
|
229
|
+
if (this.summary) return this.summary;
|
|
230
|
+
if (!this.isOpen) return undefined;
|
|
231
|
+
this.socket!.send(JSON.stringify({ action: "stop" }));
|
|
232
|
+
return await new Promise<StreamEvent | undefined>((resolve) => {
|
|
233
|
+
const timer = setTimeout(() => resolve(this.summary), timeoutMs);
|
|
234
|
+
this.summaryWaiters.push((event) => {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
resolve(event);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Hang up without waiting. Use `stop()` unless the call is already lost. */
|
|
242
|
+
close(): void {
|
|
243
|
+
this.socket?.close(1000, "client closed");
|
|
244
|
+
this.socket = undefined;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The scope a key needs to open this socket: `stream`. */
|
|
249
|
+
export const STREAM_SCOPE = REQUIRED_SCOPE;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": [
|
|
5
|
+
"ES2022",
|
|
6
|
+
"DOM"
|
|
7
|
+
],
|
|
8
|
+
"module": "ESNext",
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"exactOptionalPropertyTypes": false,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"verbatimModuleSyntax": true
|
|
16
|
+
},
|
|
17
|
+
"include": [
|
|
18
|
+
"*.ts"
|
|
19
|
+
]
|
|
20
|
+
}
|