@pyai/sdk 0.2.3 → 0.3.1
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 +35 -19
- package/dist/index.d.ts +97 -45
- package/dist/index.js +91 -69
- package/package.json +18 -3
- package/src/index.ts +200 -101
package/dist/index.js
CHANGED
|
@@ -20,6 +20,10 @@ export class PyAIError extends Error {
|
|
|
20
20
|
this.requestId = requestId;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
function normalizeVoice(value) {
|
|
24
|
+
const voiceId = String(value.voice_id ?? value.id ?? "");
|
|
25
|
+
return { ...value, voice_id: voiceId, id: voiceId };
|
|
26
|
+
}
|
|
23
27
|
/**
|
|
24
28
|
* The runtime list of accepted `audio.speech` formats (mirrors {@link SpeechFormat}),
|
|
25
29
|
* for building dropdowns / validating input before a request. Branch on the
|
|
@@ -28,6 +32,13 @@ export class PyAIError extends Error {
|
|
|
28
32
|
export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711_ulaw", "g711_alaw"];
|
|
29
33
|
/** Sample rates (Hz) the server accepts for `audio.speech` (`g711_*` is always 8 kHz). */
|
|
30
34
|
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000];
|
|
35
|
+
function assertActiveSpeechParams(params) {
|
|
36
|
+
for (const field of ["speed", "seed", "temperature"]) {
|
|
37
|
+
if (params[field] !== undefined) {
|
|
38
|
+
throw new Error(`${field} is reserved but not active on Speak`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
31
42
|
/* ------------------------------------------------------------------------- *
|
|
32
43
|
* Stable enums, mirror the server so callers branch on named constants, not
|
|
33
44
|
* magic strings, and a contract change surfaces in one place. These are plain
|
|
@@ -36,6 +47,8 @@ export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000];
|
|
|
36
47
|
* ------------------------------------------------------------------------- */
|
|
37
48
|
/** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
|
|
38
49
|
export const HearFrameType = {
|
|
50
|
+
/** Applied endpointing configuration and validation warnings. */
|
|
51
|
+
ConfigAck: "config_ack",
|
|
39
52
|
/** Eager live hypothesis for the current utterance. */
|
|
40
53
|
Partial: "partial",
|
|
41
54
|
/** Partial whose prefix has stabilized (won't be revised). */
|
|
@@ -69,7 +82,7 @@ export const ErrorCode = {
|
|
|
69
82
|
Unauthorized: "unauthorized",
|
|
70
83
|
Forbidden: "forbidden",
|
|
71
84
|
OriginNotAllowed: "origin_not_allowed",
|
|
72
|
-
|
|
85
|
+
InvalidSessionLabel: "invalid_session_label",
|
|
73
86
|
CreditExhausted: "credit_exhausted",
|
|
74
87
|
KeyBudgetExceeded: "key_budget_exceeded",
|
|
75
88
|
InsufficientQuota: "insufficient_quota",
|
|
@@ -83,8 +96,10 @@ export const ErrorCode = {
|
|
|
83
96
|
/**
|
|
84
97
|
* A live Hear streaming-STT session. Hides the frame protocol: stream audio
|
|
85
98
|
* with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
|
|
86
|
-
* callbacks,
|
|
87
|
-
*
|
|
99
|
+
* callbacks, update the silence floor with
|
|
100
|
+
* {@link HearStream.configureEndpointing}, force-finalize with
|
|
101
|
+
* {@link HearStream.commit}, and flush+close with {@link HearStream.close}.
|
|
102
|
+
* Construct via `pyai.audio.transcriptions.stream()`.
|
|
88
103
|
*/
|
|
89
104
|
export class HearStream {
|
|
90
105
|
ws;
|
|
@@ -92,27 +107,15 @@ export class HearStream {
|
|
|
92
107
|
closed = false;
|
|
93
108
|
constructor(url, subprotocol, opts) {
|
|
94
109
|
this.opts = opts;
|
|
110
|
+
if (opts.grounding) {
|
|
111
|
+
throw new Error("Cue grounding is not active on the serving Hear stream; omit grounding until the API reference marks it active");
|
|
112
|
+
}
|
|
95
113
|
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
96
114
|
if (!WS) {
|
|
97
115
|
throw new Error("No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()");
|
|
98
116
|
}
|
|
99
117
|
this.ws = new WS(url, [subprotocol]);
|
|
100
118
|
this.ws.onopen = () => {
|
|
101
|
-
if (opts.grounding) {
|
|
102
|
-
try {
|
|
103
|
-
const cfg = { type: "config", grounding: true };
|
|
104
|
-
if (opts.groundingK != null)
|
|
105
|
-
cfg.grounding_k = opts.groundingK;
|
|
106
|
-
if (opts.groundingMinScore != null)
|
|
107
|
-
cfg.grounding_min_score = opts.groundingMinScore;
|
|
108
|
-
if (opts.groundingTimeoutMs != null)
|
|
109
|
-
cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
|
|
110
|
-
this.ws.send(JSON.stringify(cfg));
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
/* surfaced via onerror */
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
119
|
opts.onOpen?.();
|
|
117
120
|
};
|
|
118
121
|
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
@@ -135,6 +138,9 @@ export class HearStream {
|
|
|
135
138
|
return;
|
|
136
139
|
}
|
|
137
140
|
switch (frame.type) {
|
|
141
|
+
case HearFrameType.ConfigAck:
|
|
142
|
+
this.opts.onConfigAck?.(frame);
|
|
143
|
+
break;
|
|
138
144
|
case HearFrameType.Partial:
|
|
139
145
|
case HearFrameType.PartialStable:
|
|
140
146
|
this.opts.onPartial?.(frame);
|
|
@@ -158,6 +164,10 @@ export class HearStream {
|
|
|
158
164
|
sendAudio(chunk) {
|
|
159
165
|
this.ws.send(chunk);
|
|
160
166
|
}
|
|
167
|
+
/** Change the minimum trailing-pause floor without reconnecting. */
|
|
168
|
+
configureEndpointing(endpointingMs) {
|
|
169
|
+
this.ws.send(JSON.stringify({ type: "config", endpointing_ms: endpointingMs }));
|
|
170
|
+
}
|
|
161
171
|
/** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
|
|
162
172
|
commit() {
|
|
163
173
|
this.ws.send(JSON.stringify({ type: "commit" }));
|
|
@@ -274,13 +284,6 @@ export const OmniEvent = {
|
|
|
274
284
|
};
|
|
275
285
|
const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
|
|
276
286
|
const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
|
|
277
|
-
function omniTranscriptRole(value) {
|
|
278
|
-
if (value === "user" || value === "caller" || value === "human")
|
|
279
|
-
return "user";
|
|
280
|
-
if (value === "assistant" || value === "agent")
|
|
281
|
-
return "assistant";
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
287
|
function omniTranscriptText(value) {
|
|
285
288
|
return typeof value === "string"
|
|
286
289
|
&& value.length > 0
|
|
@@ -289,7 +292,14 @@ function omniTranscriptText(value) {
|
|
|
289
292
|
? value
|
|
290
293
|
: null;
|
|
291
294
|
}
|
|
292
|
-
|
|
295
|
+
function omniTranscriptRole(value) {
|
|
296
|
+
if (value === "user" || value === "caller" || value === "human")
|
|
297
|
+
return "user";
|
|
298
|
+
if (value === "assistant" || value === "agent")
|
|
299
|
+
return "assistant";
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/** Normalize the live UTF-8 `0x02` text body and bounded legacy JSON bodies. */
|
|
293
303
|
export function normalizeOmniTranscriptBody(bytes) {
|
|
294
304
|
if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES)
|
|
295
305
|
return null;
|
|
@@ -300,12 +310,15 @@ export function normalizeOmniTranscriptBody(bytes) {
|
|
|
300
310
|
catch {
|
|
301
311
|
return null;
|
|
302
312
|
}
|
|
313
|
+
// The serving engine's canonical payload is plain UTF-8 caller text, not
|
|
314
|
+
// JSON. Each frame is a delta for the current caller turn.
|
|
303
315
|
if (!decoded.trimStart().startsWith("{")) {
|
|
304
316
|
const text = omniTranscriptText(decoded);
|
|
305
317
|
return text
|
|
306
318
|
? { event: "transcript", role: "user", text, final: false, mode: "delta" }
|
|
307
319
|
: null;
|
|
308
320
|
}
|
|
321
|
+
// Keep accepting a bounded direct object for older bridges and recordings.
|
|
309
322
|
let value;
|
|
310
323
|
try {
|
|
311
324
|
value = JSON.parse(decoded);
|
|
@@ -316,6 +329,9 @@ export function normalizeOmniTranscriptBody(bytes) {
|
|
|
316
329
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
317
330
|
return null;
|
|
318
331
|
const payload = value;
|
|
332
|
+
if (payload.type !== undefined
|
|
333
|
+
|| (payload.event !== undefined && payload.event !== "transcript"))
|
|
334
|
+
return null;
|
|
319
335
|
const role = omniTranscriptRole(payload.role ?? payload.speaker);
|
|
320
336
|
const mode = typeof payload.delta === "string" ? "delta" : "replace";
|
|
321
337
|
const text = omniTranscriptText(mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript);
|
|
@@ -454,7 +470,11 @@ export class OmniConnection {
|
|
|
454
470
|
}
|
|
455
471
|
if (tag === 0x03) {
|
|
456
472
|
try {
|
|
457
|
-
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
|
|
473
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1)));
|
|
474
|
+
if (parsed?.event === OmniEvent.Transcript) {
|
|
475
|
+
this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
458
478
|
this.dispatchFrame(parsed);
|
|
459
479
|
}
|
|
460
480
|
catch {
|
|
@@ -466,22 +486,15 @@ export class OmniConnection {
|
|
|
466
486
|
this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
|
|
467
487
|
return;
|
|
468
488
|
}
|
|
469
|
-
|
|
470
|
-
try {
|
|
471
|
-
this.dispatchFrame(JSON.parse(data));
|
|
472
|
-
}
|
|
473
|
-
catch {
|
|
474
|
-
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
475
|
-
}
|
|
489
|
+
this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
|
|
476
490
|
}
|
|
477
491
|
dispatchFrame(frame) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
492
|
+
const eventName = frame.event;
|
|
493
|
+
if (typeof eventName !== "string" || !eventName) {
|
|
494
|
+
this.opts.onError?.(new Error("Omni server control frame is missing its event key"));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
this.opts.onEvent?.(frame);
|
|
485
498
|
switch (eventName) {
|
|
486
499
|
case OmniEvent.Hello:
|
|
487
500
|
this.opts.onHello?.(frame);
|
|
@@ -522,7 +535,10 @@ export class OmniConnection {
|
|
|
522
535
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
523
536
|
*/
|
|
524
537
|
configure(cfg) {
|
|
525
|
-
|
|
538
|
+
const payload = { ...cfg };
|
|
539
|
+
delete payload.type;
|
|
540
|
+
delete payload.event;
|
|
541
|
+
this.ws.send(omniControlFrame({ type: "configure", ...payload }));
|
|
526
542
|
}
|
|
527
543
|
/**
|
|
528
544
|
* Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
|
|
@@ -558,7 +574,12 @@ export class OmniConnection {
|
|
|
558
574
|
* never `event`.
|
|
559
575
|
*/
|
|
560
576
|
send(frame) {
|
|
561
|
-
|
|
577
|
+
if (typeof frame.type !== "string" || !frame.type) {
|
|
578
|
+
throw new TypeError("Omni client control frames must have a non-empty type key");
|
|
579
|
+
}
|
|
580
|
+
const payload = { ...frame };
|
|
581
|
+
delete payload.event;
|
|
582
|
+
this.ws.send(omniControlFrame(payload));
|
|
562
583
|
}
|
|
563
584
|
/** Close the session. */
|
|
564
585
|
close(code = WSCloseCode.Normal, reason = "") {
|
|
@@ -652,25 +673,27 @@ export class PyAI {
|
|
|
652
673
|
};
|
|
653
674
|
// --- voices -------------------------------------------------------------
|
|
654
675
|
voices = {
|
|
655
|
-
list: (params = {}) => {
|
|
676
|
+
list: async (params = {}) => {
|
|
656
677
|
const q = new URLSearchParams();
|
|
657
678
|
if (params.gender)
|
|
658
679
|
q.set("gender", params.gender);
|
|
659
680
|
if (params.region)
|
|
660
681
|
q.set("region", params.region);
|
|
661
682
|
const qs = q.toString();
|
|
662
|
-
|
|
683
|
+
const page = await this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
|
|
684
|
+
return { ...page, data: page.data.map(normalizeVoice) };
|
|
663
685
|
},
|
|
664
|
-
get: (id) => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
|
|
686
|
+
get: async (id) => normalizeVoice(await this.getJson(`/v1/voices/${encodeURIComponent(id)}`)),
|
|
665
687
|
};
|
|
666
688
|
// --- audio --------------------------------------------------------------
|
|
667
689
|
audio = {
|
|
668
690
|
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
669
691
|
speech: async (params) => {
|
|
692
|
+
assertActiveSpeechParams(params);
|
|
670
693
|
const res = await this.request("/v1/audio/speech", {
|
|
671
694
|
method: "POST",
|
|
672
695
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
673
|
-
body: JSON.stringify({ model: "pyai-
|
|
696
|
+
body: JSON.stringify({ model: "pyai-speak", ...params }),
|
|
674
697
|
});
|
|
675
698
|
return res.arrayBuffer();
|
|
676
699
|
},
|
|
@@ -683,10 +706,11 @@ export class PyAI {
|
|
|
683
706
|
* iterable of Uint8Array chunks.
|
|
684
707
|
*/
|
|
685
708
|
speechStream: async (params) => {
|
|
709
|
+
assertActiveSpeechParams(params);
|
|
686
710
|
const res = await this.request("/v1/audio/speech", {
|
|
687
711
|
method: "POST",
|
|
688
712
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
689
|
-
body: JSON.stringify({ model: "pyai-
|
|
713
|
+
body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
|
|
690
714
|
});
|
|
691
715
|
if (!res.body)
|
|
692
716
|
throw new PyAIError(res.status, "Response had no body to stream");
|
|
@@ -756,7 +780,7 @@ export class PyAI {
|
|
|
756
780
|
clones = {
|
|
757
781
|
/** List the org's cloned voices. */
|
|
758
782
|
list: () => this.getJson("/v1/voice/clones"),
|
|
759
|
-
/** Enroll a custom voice from reference audio (>= ~10s). Scope `
|
|
783
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
|
|
760
784
|
create: async (params) => {
|
|
761
785
|
const form = new FormData();
|
|
762
786
|
form.set("name", params.name);
|
|
@@ -776,7 +800,7 @@ export class PyAI {
|
|
|
776
800
|
throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
777
801
|
return match;
|
|
778
802
|
},
|
|
779
|
-
/** Delete a cloned voice (tenant-isolated). Scope `
|
|
803
|
+
/** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
|
|
780
804
|
delete: async (id) => {
|
|
781
805
|
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
782
806
|
},
|
|
@@ -985,34 +1009,31 @@ export class PyAI {
|
|
|
985
1009
|
query.format = opts.format;
|
|
986
1010
|
if (opts.rate)
|
|
987
1011
|
query.rate = String(opts.rate);
|
|
988
|
-
const url = this.realtimeURL({
|
|
1012
|
+
const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
|
|
989
1013
|
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
990
1014
|
return new OmniConnection(url, sub, opts);
|
|
991
1015
|
},
|
|
992
1016
|
};
|
|
993
1017
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
994
|
-
/** Build the
|
|
1018
|
+
/** Build the canonical Omni WebSocket URL. */
|
|
995
1019
|
realtimeURL(opts = {}) {
|
|
996
1020
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
997
1021
|
const q = new URLSearchParams(opts.query ?? {});
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
q.set("session_label", opts.sessionLabel);
|
|
1005
|
-
else if (opts.agentId)
|
|
1006
|
-
q.set("agent_id", opts.agentId); // deprecated alias
|
|
1007
|
-
if (!q.has("format"))
|
|
1008
|
-
q.set("format", "pcm16");
|
|
1009
|
-
if (!q.has("rate"))
|
|
1010
|
-
q.set("rate", "24000");
|
|
1011
|
-
const qs = q.toString();
|
|
1012
|
-
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
1022
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
1023
|
+
// browser-grade PCM16/24kHz.
|
|
1024
|
+
for (const key of ["agent", "agent_id", "agentId", "model", "access_token"]) {
|
|
1025
|
+
if (q.has(key)) {
|
|
1026
|
+
throw new Error(`Omni query parameter "${key}" is not supported; use sessionLabel, format/rate, or api_key`);
|
|
1027
|
+
}
|
|
1013
1028
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1029
|
+
if (opts.sessionLabel)
|
|
1030
|
+
q.set("session_label", opts.sessionLabel);
|
|
1031
|
+
if (!q.has("format"))
|
|
1032
|
+
q.set("format", "pcm16");
|
|
1033
|
+
if (!q.has("rate"))
|
|
1034
|
+
q.set("rate", "24000");
|
|
1035
|
+
const qs = q.toString();
|
|
1036
|
+
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
1016
1037
|
}
|
|
1017
1038
|
/** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
|
|
1018
1039
|
realtimeSubprotocol() {
|
|
@@ -1022,6 +1043,7 @@ export class PyAI {
|
|
|
1022
1043
|
hearStreamURL(opts = {}) {
|
|
1023
1044
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1024
1045
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1046
|
+
q.set("protocol", "pyai-hear-v1");
|
|
1025
1047
|
if (opts.model)
|
|
1026
1048
|
q.set("model", opts.model);
|
|
1027
1049
|
if (opts.language)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyai/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
"bin": {
|
|
15
15
|
"pyai": "dist/cli.js"
|
|
16
16
|
},
|
|
17
|
-
"files": [
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
18
22
|
"repository": {
|
|
19
23
|
"type": "git",
|
|
20
24
|
"url": "git+https://github.com/atomsai/pyai-platform-backend.git",
|
|
@@ -31,7 +35,18 @@
|
|
|
31
35
|
"engines": {
|
|
32
36
|
"node": ">=18"
|
|
33
37
|
},
|
|
34
|
-
"keywords": [
|
|
38
|
+
"keywords": [
|
|
39
|
+
"pyai",
|
|
40
|
+
"voice-ai",
|
|
41
|
+
"tts",
|
|
42
|
+
"stt",
|
|
43
|
+
"speech",
|
|
44
|
+
"voice-agents",
|
|
45
|
+
"realtime",
|
|
46
|
+
"transcription",
|
|
47
|
+
"compliance",
|
|
48
|
+
"openai-compatible"
|
|
49
|
+
],
|
|
35
50
|
"license": "MIT",
|
|
36
51
|
"devDependencies": {
|
|
37
52
|
"@types/node": "^22.0.0",
|