@pyai/sdk 0.2.2 → 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 +108 -43
- package/dist/index.js +179 -70
- package/package.json +18 -3
- package/src/index.ts +292 -102
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" }));
|
|
@@ -272,6 +282,77 @@ export const OmniEvent = {
|
|
|
272
282
|
/** Server fault frame. */
|
|
273
283
|
Error: "error",
|
|
274
284
|
};
|
|
285
|
+
const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
|
|
286
|
+
const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
|
|
287
|
+
function omniTranscriptText(value) {
|
|
288
|
+
return typeof value === "string"
|
|
289
|
+
&& value.length > 0
|
|
290
|
+
&& value.length <= OMNI_TRANSCRIPT_MAX_CHARS
|
|
291
|
+
&& !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
|
292
|
+
? value
|
|
293
|
+
: null;
|
|
294
|
+
}
|
|
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. */
|
|
303
|
+
export function normalizeOmniTranscriptBody(bytes) {
|
|
304
|
+
if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES)
|
|
305
|
+
return null;
|
|
306
|
+
let decoded;
|
|
307
|
+
try {
|
|
308
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
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.
|
|
315
|
+
if (!decoded.trimStart().startsWith("{")) {
|
|
316
|
+
const text = omniTranscriptText(decoded);
|
|
317
|
+
return text
|
|
318
|
+
? { event: "transcript", role: "user", text, final: false, mode: "delta" }
|
|
319
|
+
: null;
|
|
320
|
+
}
|
|
321
|
+
// Keep accepting a bounded direct object for older bridges and recordings.
|
|
322
|
+
let value;
|
|
323
|
+
try {
|
|
324
|
+
value = JSON.parse(decoded);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
330
|
+
return null;
|
|
331
|
+
const payload = value;
|
|
332
|
+
if (payload.type !== undefined
|
|
333
|
+
|| (payload.event !== undefined && payload.event !== "transcript"))
|
|
334
|
+
return null;
|
|
335
|
+
const role = omniTranscriptRole(payload.role ?? payload.speaker);
|
|
336
|
+
const mode = typeof payload.delta === "string" ? "delta" : "replace";
|
|
337
|
+
const text = omniTranscriptText(mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript);
|
|
338
|
+
if (!role || !text)
|
|
339
|
+
return null;
|
|
340
|
+
if (payload.final !== undefined && typeof payload.final !== "boolean")
|
|
341
|
+
return null;
|
|
342
|
+
if (payload.sequence !== undefined
|
|
343
|
+
&& (typeof payload.sequence !== "number"
|
|
344
|
+
|| !Number.isSafeInteger(payload.sequence)
|
|
345
|
+
|| payload.sequence < 0))
|
|
346
|
+
return null;
|
|
347
|
+
return {
|
|
348
|
+
event: "transcript",
|
|
349
|
+
role,
|
|
350
|
+
text,
|
|
351
|
+
final: payload.final === true,
|
|
352
|
+
mode,
|
|
353
|
+
...(payload.sequence === undefined ? {} : { sequence: payload.sequence }),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
275
356
|
/** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
|
|
276
357
|
* array). Returns null for a Blob or unknown (can't be read synchronously). */
|
|
277
358
|
function omniToBytes(data) {
|
|
@@ -324,6 +405,8 @@ export class OmniConnection {
|
|
|
324
405
|
closed = false;
|
|
325
406
|
/** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
|
|
326
407
|
blobTail = Promise.resolve();
|
|
408
|
+
/** Serializes inbound Blob decoding so browser frames stay ordered. */
|
|
409
|
+
inboundTail = Promise.resolve();
|
|
327
410
|
constructor(url, subprotocol, opts) {
|
|
328
411
|
this.opts = opts;
|
|
329
412
|
const WS = opts.webSocket ?? globalThis.WebSocket;
|
|
@@ -342,22 +425,34 @@ export class OmniConnection {
|
|
|
342
425
|
}
|
|
343
426
|
opts.onOpen?.();
|
|
344
427
|
};
|
|
345
|
-
this.ws.onmessage = (ev) =>
|
|
428
|
+
this.ws.onmessage = (ev) => {
|
|
429
|
+
const reportDecodeError = (error) => {
|
|
430
|
+
opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
|
|
431
|
+
};
|
|
432
|
+
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
|
433
|
+
this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
void this.handleMessage(ev.data).catch(reportDecodeError);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
346
439
|
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
347
440
|
this.ws.onclose = (ev) => {
|
|
348
441
|
this.closed = true;
|
|
349
442
|
opts.onClose?.(ev.code, ev.reason);
|
|
350
443
|
};
|
|
351
444
|
}
|
|
352
|
-
handleMessage(data) {
|
|
445
|
+
async handleMessage(data) {
|
|
353
446
|
// Server → client binary frames are TYPE-TAGGED by their first byte:
|
|
354
|
-
// 0x01 = agent audio (PCM16) · 0x02 = transcript
|
|
447
|
+
// 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
|
|
355
448
|
// (Treating every binary frame as audio, the old behavior, plays the
|
|
356
449
|
// 0x03/0x02 frames as a glitch and drops every event/transcript.)
|
|
357
450
|
if (typeof data !== "string") {
|
|
358
|
-
const bytes =
|
|
451
|
+
const bytes = typeof Blob !== "undefined" && data instanceof Blob
|
|
452
|
+
? new Uint8Array(await data.arrayBuffer())
|
|
453
|
+
: omniToBytes(data);
|
|
359
454
|
if (!bytes) {
|
|
360
|
-
this.opts.
|
|
455
|
+
this.opts.onError?.(new Error("Unsupported Omni binary frame"));
|
|
361
456
|
return;
|
|
362
457
|
}
|
|
363
458
|
const tag = bytes[0];
|
|
@@ -365,36 +460,41 @@ export class OmniConnection {
|
|
|
365
460
|
this.opts.onAudio?.(bytes.slice(1)); // copy → aligned PCM16
|
|
366
461
|
return;
|
|
367
462
|
}
|
|
368
|
-
if (tag === 0x02
|
|
463
|
+
if (tag === 0x02) {
|
|
464
|
+
const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
|
|
465
|
+
if (transcript)
|
|
466
|
+
this.dispatchFrame(transcript);
|
|
467
|
+
else
|
|
468
|
+
this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (tag === 0x03) {
|
|
369
472
|
try {
|
|
370
|
-
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
}
|
|
478
|
+
this.dispatchFrame(parsed);
|
|
373
479
|
}
|
|
374
480
|
catch {
|
|
375
481
|
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
376
482
|
}
|
|
377
483
|
return;
|
|
378
484
|
}
|
|
379
|
-
|
|
485
|
+
const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
|
|
486
|
+
this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
|
|
380
487
|
return;
|
|
381
488
|
}
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
this.dispatchFrame(JSON.parse(data));
|
|
385
|
-
}
|
|
386
|
-
catch {
|
|
387
|
-
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
388
|
-
}
|
|
489
|
+
this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
|
|
389
490
|
}
|
|
390
491
|
dispatchFrame(frame) {
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
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);
|
|
398
498
|
switch (eventName) {
|
|
399
499
|
case OmniEvent.Hello:
|
|
400
500
|
this.opts.onHello?.(frame);
|
|
@@ -435,7 +535,10 @@ export class OmniConnection {
|
|
|
435
535
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
436
536
|
*/
|
|
437
537
|
configure(cfg) {
|
|
438
|
-
|
|
538
|
+
const payload = { ...cfg };
|
|
539
|
+
delete payload.type;
|
|
540
|
+
delete payload.event;
|
|
541
|
+
this.ws.send(omniControlFrame({ type: "configure", ...payload }));
|
|
439
542
|
}
|
|
440
543
|
/**
|
|
441
544
|
* Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
|
|
@@ -471,7 +574,12 @@ export class OmniConnection {
|
|
|
471
574
|
* never `event`.
|
|
472
575
|
*/
|
|
473
576
|
send(frame) {
|
|
474
|
-
|
|
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));
|
|
475
583
|
}
|
|
476
584
|
/** Close the session. */
|
|
477
585
|
close(code = WSCloseCode.Normal, reason = "") {
|
|
@@ -565,25 +673,27 @@ export class PyAI {
|
|
|
565
673
|
};
|
|
566
674
|
// --- voices -------------------------------------------------------------
|
|
567
675
|
voices = {
|
|
568
|
-
list: (params = {}) => {
|
|
676
|
+
list: async (params = {}) => {
|
|
569
677
|
const q = new URLSearchParams();
|
|
570
678
|
if (params.gender)
|
|
571
679
|
q.set("gender", params.gender);
|
|
572
680
|
if (params.region)
|
|
573
681
|
q.set("region", params.region);
|
|
574
682
|
const qs = q.toString();
|
|
575
|
-
|
|
683
|
+
const page = await this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
|
|
684
|
+
return { ...page, data: page.data.map(normalizeVoice) };
|
|
576
685
|
},
|
|
577
|
-
get: (id) => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
|
|
686
|
+
get: async (id) => normalizeVoice(await this.getJson(`/v1/voices/${encodeURIComponent(id)}`)),
|
|
578
687
|
};
|
|
579
688
|
// --- audio --------------------------------------------------------------
|
|
580
689
|
audio = {
|
|
581
690
|
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
582
691
|
speech: async (params) => {
|
|
692
|
+
assertActiveSpeechParams(params);
|
|
583
693
|
const res = await this.request("/v1/audio/speech", {
|
|
584
694
|
method: "POST",
|
|
585
695
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
586
|
-
body: JSON.stringify({ model: "pyai-
|
|
696
|
+
body: JSON.stringify({ model: "pyai-speak", ...params }),
|
|
587
697
|
});
|
|
588
698
|
return res.arrayBuffer();
|
|
589
699
|
},
|
|
@@ -596,10 +706,11 @@ export class PyAI {
|
|
|
596
706
|
* iterable of Uint8Array chunks.
|
|
597
707
|
*/
|
|
598
708
|
speechStream: async (params) => {
|
|
709
|
+
assertActiveSpeechParams(params);
|
|
599
710
|
const res = await this.request("/v1/audio/speech", {
|
|
600
711
|
method: "POST",
|
|
601
712
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
602
|
-
body: JSON.stringify({ model: "pyai-
|
|
713
|
+
body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
|
|
603
714
|
});
|
|
604
715
|
if (!res.body)
|
|
605
716
|
throw new PyAIError(res.status, "Response had no body to stream");
|
|
@@ -669,7 +780,7 @@ export class PyAI {
|
|
|
669
780
|
clones = {
|
|
670
781
|
/** List the org's cloned voices. */
|
|
671
782
|
list: () => this.getJson("/v1/voice/clones"),
|
|
672
|
-
/** Enroll a custom voice from reference audio (>= ~10s). Scope `
|
|
783
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
|
|
673
784
|
create: async (params) => {
|
|
674
785
|
const form = new FormData();
|
|
675
786
|
form.set("name", params.name);
|
|
@@ -689,7 +800,7 @@ export class PyAI {
|
|
|
689
800
|
throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
690
801
|
return match;
|
|
691
802
|
},
|
|
692
|
-
/** Delete a cloned voice (tenant-isolated). Scope `
|
|
803
|
+
/** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
|
|
693
804
|
delete: async (id) => {
|
|
694
805
|
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
695
806
|
},
|
|
@@ -898,34 +1009,31 @@ export class PyAI {
|
|
|
898
1009
|
query.format = opts.format;
|
|
899
1010
|
if (opts.rate)
|
|
900
1011
|
query.rate = String(opts.rate);
|
|
901
|
-
const url = this.realtimeURL({
|
|
1012
|
+
const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
|
|
902
1013
|
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
903
1014
|
return new OmniConnection(url, sub, opts);
|
|
904
1015
|
},
|
|
905
1016
|
};
|
|
906
1017
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
907
|
-
/** Build the
|
|
1018
|
+
/** Build the canonical Omni WebSocket URL. */
|
|
908
1019
|
realtimeURL(opts = {}) {
|
|
909
1020
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
910
1021
|
const q = new URLSearchParams(opts.query ?? {});
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
q.set("session_label", opts.sessionLabel);
|
|
918
|
-
else if (opts.agentId)
|
|
919
|
-
q.set("agent_id", opts.agentId); // deprecated alias
|
|
920
|
-
if (!q.has("format"))
|
|
921
|
-
q.set("format", "pcm16");
|
|
922
|
-
if (!q.has("rate"))
|
|
923
|
-
q.set("rate", "24000");
|
|
924
|
-
const qs = q.toString();
|
|
925
|
-
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
|
+
}
|
|
926
1028
|
}
|
|
927
|
-
|
|
928
|
-
|
|
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}` : ""}`;
|
|
929
1037
|
}
|
|
930
1038
|
/** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
|
|
931
1039
|
realtimeSubprotocol() {
|
|
@@ -935,6 +1043,7 @@ export class PyAI {
|
|
|
935
1043
|
hearStreamURL(opts = {}) {
|
|
936
1044
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
937
1045
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1046
|
+
q.set("protocol", "pyai-hear-v1");
|
|
938
1047
|
if (opts.model)
|
|
939
1048
|
q.set("model", opts.model);
|
|
940
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",
|