@vocalstack/js-sdk 2.0.0-alpha.0 → 2.0.0-alpha.2
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 +7 -2
- package/SKILL.md +13 -6
- package/dist/errors.d.ts +0 -18
- package/dist/errors.js +0 -18
- package/dist/http.d.ts +0 -32
- package/dist/http.js +26 -24
- package/dist/index.d.ts +5 -19
- package/dist/index.js +4 -18
- package/dist/mic.d.ts +1 -49
- package/dist/mic.js +0 -22
- package/dist/realtime.d.ts +4 -59
- package/dist/realtime.js +192 -152
- package/dist/resources.d.ts +86 -211
- package/dist/resources.js +130 -178
- package/dist/subscription.d.ts +0 -17
- package/dist/subscription.js +1 -12
- package/dist/ws.d.ts +0 -3
- package/dist/ws.js +0 -3
- package/package.json +3 -7
- package/skills/vocalstack/SKILL.md +13 -6
package/dist/realtime.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Capabilities, CommittedSegment, CreateSessionResponse, Fix, LatencyMode, SentenceSegment, ServerFrame, SessionOptions, SessionStats, SpeakerLabels } from '@vocalstack/contract';
|
|
1
|
+
import type { Capabilities, CommittedSegment, CreateSessionResponse, Fix, LatencyMode, SentenceSegment, ServerFrame, SessionOptions, SessionStats, SpeakerLabels, WarmRequest } from '@vocalstack/contract';
|
|
2
2
|
import { VocalStackError } from './errors.js';
|
|
3
3
|
import { type FileInput } from './file.js';
|
|
4
4
|
import type { CallOptions, Transport } from './http.js';
|
|
@@ -10,26 +10,12 @@ export interface ConnectParams {
|
|
|
10
10
|
streamUrl: string;
|
|
11
11
|
options?: SessionOptions;
|
|
12
12
|
readyDeadlineMs?: number;
|
|
13
|
-
/** Fires on every `session.loading` frame while the engine warms up
|
|
14
|
-
* (model download / load after a machine wake) — lets a UI show
|
|
15
|
-
* "warming up" instead of a generic connecting state. Each frame also
|
|
16
|
-
* re-arms the ready deadline, so a long cold load only fails once the
|
|
17
|
-
* engine goes silent, not merely slow. */
|
|
18
13
|
onLoading?(stage: 'download' | 'model'): void;
|
|
19
|
-
/** Every server text frame as it arrives, parsed, before the iterator
|
|
20
|
-
* sees it: the lifecycle frames the iterator keeps to itself
|
|
21
|
-
* (`session.loading`, `session.ready`, `session.done`, `error`) and the
|
|
22
|
-
* transcript frames alike. For logs and protocol views; the iterator
|
|
23
|
-
* is still the way to read the transcript. */
|
|
24
14
|
onFrame?(frame: ServerFrame): void;
|
|
25
15
|
}
|
|
26
|
-
|
|
27
|
-
* with `unavailable` (the database is waking; retry shortly) says so; any
|
|
28
|
-
* other 503 — a bare one from a saturated proxy, or no body at all — is
|
|
29
|
-
* `capacity_exceeded`. So is a 502 or 504 without the API's error body:
|
|
30
|
-
* the proxy in front of the engines found none free in time. Every other
|
|
31
|
-
* status is its own envelope. */
|
|
16
|
+
export declare function engineLost(closeCode?: number): VocalStackError;
|
|
32
17
|
export declare function upgradeRefused(status: number | undefined, body: string): VocalStackError;
|
|
18
|
+
export declare const RESTART_CLOSE = 1012;
|
|
33
19
|
export declare class RealtimeSession implements AsyncIterable<SessionEvent> {
|
|
34
20
|
#private;
|
|
35
21
|
readonly sessionId: string;
|
|
@@ -38,26 +24,7 @@ export declare class RealtimeSession implements AsyncIterable<SessionEvent> {
|
|
|
38
24
|
sendAudio(chunk: Float32Array | ArrayBuffer | Uint8Array): void;
|
|
39
25
|
sendFile(source: AsyncIterable<Uint8Array>, chunkBytes?: number): Promise<void>;
|
|
40
26
|
pipe(source: AsyncIterable<Float32Array | ArrayBuffer | Uint8Array> | ReadableStream<Uint8Array>): Promise<void>;
|
|
41
|
-
/**
|
|
42
|
-
* Capture clock heartbeat: `pcm` = cumulative samples captured so far
|
|
43
|
-
* (16kHz-equivalent), `wall` = seconds since capture start. Lets the
|
|
44
|
-
* server detect capture loss (throttled tab, device switch) and mark
|
|
45
|
-
* the transcript with honest gaps instead of gluing across them.
|
|
46
|
-
* `streamMicrophone`/`captureMicrophone` call this automatically.
|
|
47
|
-
*/
|
|
48
27
|
sendClock(pcm: number, wall: number): void;
|
|
49
|
-
/**
|
|
50
|
-
* Options update during the realtime session. Omitted fields are unchanged.
|
|
51
|
-
* `latencyMode` switches the decode model policy from the next
|
|
52
|
-
* engine step; `spokenLanguage` changes the transcription language from the
|
|
53
|
-
* next engine step (`''` or `'auto'` = auto-detect); `speakerLabels`
|
|
54
|
-
* turns speaker labelling on (`'on'` or `'auto'`) or off (`'off'`) from the next
|
|
55
|
-
* engine step — enabling labels speakers from that point on;
|
|
56
|
-
* `translate` replaces the whole target-language list (`[]` turns
|
|
57
|
-
* translation off). Applies to subsequent transcript —
|
|
58
|
-
* already-delivered segments and settled sentences are not
|
|
59
|
-
* re-transcribed, re-attributed or re-translated.
|
|
60
|
-
*/
|
|
61
28
|
update(changes: {
|
|
62
29
|
latencyMode?: LatencyMode;
|
|
63
30
|
spokenLanguage?: string;
|
|
@@ -73,60 +40,38 @@ export declare class RealtimeSession implements AsyncIterable<SessionEvent> {
|
|
|
73
40
|
[Symbol.dispose](): void;
|
|
74
41
|
}
|
|
75
42
|
export interface ConnectOptions {
|
|
76
|
-
/** See {@link ConnectParams.onLoading}: engine warm-up progress. */
|
|
77
43
|
onLoading?(stage: 'download' | 'model'): void;
|
|
78
|
-
/** See {@link ConnectParams.onFrame}: every server frame, lifecycle included. */
|
|
79
44
|
onFrame?(frame: ServerFrame): void;
|
|
80
|
-
/** Overrides the client-wide `readyDeadlineMs` for this socket. */
|
|
81
45
|
readyDeadlineMs?: number;
|
|
82
46
|
}
|
|
83
47
|
export interface TranscribeOptions extends Omit<SessionOptions, 'input' | 'audio'> {
|
|
84
|
-
/** The realtime session, once connected (to `cancel()` it, say). */
|
|
85
48
|
onSession?(session: RealtimeSession): void;
|
|
86
49
|
onProgress?(progress: {
|
|
87
50
|
processedS: number;
|
|
88
51
|
totalS?: number;
|
|
89
52
|
}): void;
|
|
90
53
|
onCommit?(segment: CommittedSegment): void;
|
|
91
|
-
/** A committed segment's translation arrived; the segment (by `seq`)
|
|
92
|
-
* already has its `translations` filled in. */
|
|
93
54
|
onTranslated?(seq: number, translations: Record<string, string>, sources?: Record<string, string>): void;
|
|
94
|
-
/** The engine rewrote a committed segment; it is patched in place first. */
|
|
95
55
|
onAmended?(seq: number, text: string, speaker: number, fixes: Fix[]): void;
|
|
96
|
-
/** A tentative speaker label (`speakerProvisional`) settled: `verdict` is
|
|
97
|
-
* "confirmed", "amended" or "unverified". The segment is patched first. */
|
|
98
56
|
onSpeakerSettled?(seq: number, speaker: number, verdict: string, confidence?: number): void;
|
|
99
|
-
/** Every sentence upsert (`sentences.enabled`); the returned `sentences`
|
|
100
|
-
* list is kept up to date (dropped sentences are removed). */
|
|
101
57
|
onSentence?(sentence: SentenceSegment): void;
|
|
102
58
|
readyDeadlineMs?: number;
|
|
103
59
|
}
|
|
104
60
|
export interface RealtimeTranscript {
|
|
105
61
|
segments: CommittedSegment[];
|
|
106
|
-
/** Sentence upserts folded into their final state (empty unless
|
|
107
|
-
* `sentences.enabled`). */
|
|
108
62
|
sentences: SentenceSegment[];
|
|
109
63
|
text: string;
|
|
110
64
|
doneReason: 'eof' | 'max_duration' | 'cancelled' | null;
|
|
111
65
|
stats: SessionStats | null;
|
|
112
66
|
}
|
|
113
|
-
/** `vs.realtime`: a realtime session is a signed token plus one socket.
|
|
114
|
-
* Nothing is stored: the transcript is what the socket sends. */
|
|
115
67
|
export declare class Realtime {
|
|
116
68
|
#private;
|
|
117
69
|
constructor(t: Transport);
|
|
118
|
-
/** `POST /v1/realtime/sessions`: validate the options, get a signed `streamUrl`. */
|
|
119
70
|
createSession(options?: SessionOptions, call?: CallOptions): Promise<CreateSessionResponse>;
|
|
120
|
-
/** `createSession`, then open the socket and wait for `session.ready`. */
|
|
121
71
|
connect(options?: SessionOptions, connect?: ConnectOptions): Promise<RealtimeSession>;
|
|
122
|
-
/** `GET /v1/realtime/capabilities`: languages, audio formats, limits. */
|
|
123
72
|
capabilities(): Promise<Capabilities>;
|
|
124
|
-
|
|
125
|
-
* session and collect the transcript. Nothing is stored; for a stored,
|
|
126
|
-
* shareable transcription use `vs.transcriptions.create`. */
|
|
73
|
+
warm(req: WarmRequest): Promise<void>;
|
|
127
74
|
transcribeFile(file: FileInput, opts?: TranscribeOptions): Promise<RealtimeTranscript>;
|
|
128
|
-
/** The same for a link the engine fetches itself. */
|
|
129
75
|
transcribeUrl(url: string, opts?: TranscribeOptions): Promise<RealtimeTranscript>;
|
|
130
76
|
}
|
|
131
|
-
/** Upsert one sentence frame into a list kept in sentenceId order. */
|
|
132
77
|
export declare function applySentence(list: SentenceSegment[], sentence: SentenceSegment): SentenceSegment[];
|
package/dist/realtime.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The realtime session: `Realtime` (`vs.realtime`: create a session, or
|
|
3
|
-
* transcribe a file or a URL over one) and `RealtimeSession` (its socket).
|
|
4
|
-
*/
|
|
5
1
|
import { parseServerFrame } from '@vocalstack/contract';
|
|
6
2
|
import { capacityExceeded, fromEnvelope, VocalStackError } from './errors.js';
|
|
7
3
|
import { fileChunks } from './file.js';
|
|
8
4
|
import { openWebSocket, WS_OPEN } from './ws.js';
|
|
9
5
|
const DEFAULT_READY_DEADLINE_MS = 30_000;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* the proxy in front of the engines found none free in time. Every other
|
|
15
|
-
* status is its own envelope. */
|
|
6
|
+
export function engineLost(closeCode) {
|
|
7
|
+
const how = closeCode ? ` (close ${closeCode})` : '';
|
|
8
|
+
return new VocalStackError('engine_lost', `The connection to the transcription engine closed before the realtime session finished${how}; open a new realtime session to continue.`, { retryable: true });
|
|
9
|
+
}
|
|
16
10
|
export function upgradeRefused(status, body) {
|
|
17
11
|
let parsed;
|
|
18
12
|
try {
|
|
@@ -32,6 +26,96 @@ export function upgradeRefused(status, body) {
|
|
|
32
26
|
return capacityExceeded(`stream host answered ${status} (no engine answered in time)`);
|
|
33
27
|
return fromEnvelope(parsed, status);
|
|
34
28
|
}
|
|
29
|
+
export const RESTART_CLOSE = 1012;
|
|
30
|
+
const RESTART_TRIES = 3;
|
|
31
|
+
const MAX_PENDING_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
async function openReady(url, deadlineMs, onLoading, onFrame, attach) {
|
|
33
|
+
const ws = await openWebSocket(url);
|
|
34
|
+
ws.binaryType = 'arraybuffer';
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
let settled = false;
|
|
37
|
+
let sawFrame = false;
|
|
38
|
+
let rejecting = false;
|
|
39
|
+
let timer;
|
|
40
|
+
const fail = (err) => {
|
|
41
|
+
if (settled)
|
|
42
|
+
return;
|
|
43
|
+
settled = true;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
try {
|
|
46
|
+
ws.close();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
}
|
|
50
|
+
reject(err);
|
|
51
|
+
};
|
|
52
|
+
const arm = () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
timer = setTimeout(() => {
|
|
55
|
+
fail(sawFrame
|
|
56
|
+
? new VocalStackError('engine_unavailable', `engine did not become ready within ${deadlineMs}ms`, { retryable: true })
|
|
57
|
+
: capacityExceeded(`no response from the engine within ${deadlineMs}ms — every machine is ` +
|
|
58
|
+
`busy with another session, or one is stuck holding a slot`));
|
|
59
|
+
}, deadlineMs);
|
|
60
|
+
};
|
|
61
|
+
arm();
|
|
62
|
+
ws.addEventListener('message', (ev) => {
|
|
63
|
+
if (settled || typeof ev.data !== 'string')
|
|
64
|
+
return;
|
|
65
|
+
sawFrame = true;
|
|
66
|
+
arm();
|
|
67
|
+
let frame;
|
|
68
|
+
try {
|
|
69
|
+
frame = parseServerFrame(ev.data);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!frame)
|
|
75
|
+
return;
|
|
76
|
+
onFrame(frame);
|
|
77
|
+
if (frame.type === 'session.loading') {
|
|
78
|
+
onLoading?.(frame.stage);
|
|
79
|
+
}
|
|
80
|
+
else if (frame.type === 'session.ready') {
|
|
81
|
+
if (!settled) {
|
|
82
|
+
settled = true;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
attach(ws);
|
|
85
|
+
resolve();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (frame.type === 'error') {
|
|
89
|
+
fail(new VocalStackError(frame.code, frame.message, {
|
|
90
|
+
retryable: frame.retryable,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
const emitterOn = ws
|
|
95
|
+
.on?.bind(ws);
|
|
96
|
+
emitterOn?.('unexpected-response', (_req, res) => {
|
|
97
|
+
rejecting = true;
|
|
98
|
+
const status = res?.statusCode;
|
|
99
|
+
let body = '';
|
|
100
|
+
if (res && typeof res.on === 'function') {
|
|
101
|
+
res.on('data', (c) => (body += c.toString()));
|
|
102
|
+
res.on('end', () => fail(upgradeRefused(status, body)));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
fail(upgradeRefused(status, ''));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
ws.addEventListener('close', () => {
|
|
109
|
+
if (rejecting)
|
|
110
|
+
return;
|
|
111
|
+
fail(new VocalStackError('engine_unavailable', 'connection closed before ready', {
|
|
112
|
+
retryable: true,
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
ws.addEventListener('error', () => {
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
35
119
|
export class RealtimeSession {
|
|
36
120
|
sessionId;
|
|
37
121
|
#ws;
|
|
@@ -41,108 +125,85 @@ export class RealtimeSession {
|
|
|
41
125
|
#stats = null;
|
|
42
126
|
#error = null;
|
|
43
127
|
#closed = false;
|
|
128
|
+
#closedByUs = false;
|
|
44
129
|
#onFrame;
|
|
45
|
-
|
|
130
|
+
#params;
|
|
131
|
+
#reconnecting = false;
|
|
132
|
+
#pending = [];
|
|
133
|
+
#pendingBytes = 0;
|
|
134
|
+
#reconnects = 0;
|
|
135
|
+
constructor(sessionId, ws, params) {
|
|
46
136
|
this.sessionId = sessionId;
|
|
47
137
|
this.#ws = ws;
|
|
48
|
-
this.#onFrame = onFrame;
|
|
138
|
+
this.#onFrame = params.onFrame;
|
|
139
|
+
this.#params = params;
|
|
49
140
|
}
|
|
50
141
|
static async connect(params) {
|
|
51
142
|
const deadlineMs = params.readyDeadlineMs ?? DEFAULT_READY_DEADLINE_MS;
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const fail = (err) => {
|
|
61
|
-
if (settled)
|
|
62
|
-
return;
|
|
63
|
-
settled = true;
|
|
64
|
-
clearTimeout(timer);
|
|
65
|
-
try {
|
|
66
|
-
ws.close();
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
}
|
|
70
|
-
reject(err);
|
|
71
|
-
};
|
|
72
|
-
const arm = () => {
|
|
73
|
-
clearTimeout(timer);
|
|
74
|
-
timer = setTimeout(() => {
|
|
75
|
-
fail(sawFrame
|
|
76
|
-
? new VocalStackError('engine_unavailable', `engine did not become ready within ${deadlineMs}ms`, { retryable: true })
|
|
77
|
-
: capacityExceeded(`no response from the engine within ${deadlineMs}ms — every machine is ` +
|
|
78
|
-
`busy with another session, or one is stuck holding a slot`));
|
|
79
|
-
}, deadlineMs);
|
|
80
|
-
};
|
|
81
|
-
arm();
|
|
82
|
-
ws.addEventListener('message', (ev) => {
|
|
83
|
-
// Once ready, #listen reads the socket; this one stops re-arming
|
|
84
|
-
// the deadline (it used to leave a timer running for 30 s).
|
|
85
|
-
if (settled || typeof ev.data !== 'string')
|
|
86
|
-
return;
|
|
87
|
-
sawFrame = true;
|
|
88
|
-
arm();
|
|
89
|
-
let frame;
|
|
90
|
-
try {
|
|
91
|
-
frame = parseServerFrame(ev.data);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (!frame)
|
|
97
|
-
return;
|
|
98
|
-
session.#frame(frame);
|
|
99
|
-
if (frame.type === 'session.loading') {
|
|
100
|
-
params.onLoading?.(frame.stage);
|
|
101
|
-
}
|
|
102
|
-
else if (frame.type === 'session.ready') {
|
|
103
|
-
if (!settled) {
|
|
104
|
-
settled = true;
|
|
105
|
-
clearTimeout(timer);
|
|
106
|
-
session.#listen();
|
|
107
|
-
resolve();
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
else if (frame.type === 'error') {
|
|
111
|
-
fail(new VocalStackError(frame.code, frame.message, {
|
|
112
|
-
retryable: frame.retryable,
|
|
113
|
-
}));
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
const emitterOn = ws
|
|
117
|
-
.on?.bind(ws);
|
|
118
|
-
emitterOn?.('unexpected-response', (_req, res) => {
|
|
119
|
-
rejecting = true;
|
|
120
|
-
const status = res?.statusCode;
|
|
121
|
-
let body = '';
|
|
122
|
-
if (res && typeof res.on === 'function') {
|
|
123
|
-
res.on('data', (c) => (body += c.toString()));
|
|
124
|
-
res.on('end', () => fail(upgradeRefused(status, body)));
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
fail(upgradeRefused(status, ''));
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
ws.addEventListener('close', () => {
|
|
131
|
-
if (rejecting)
|
|
132
|
-
return;
|
|
133
|
-
// No HTTP answer at all (a refused upgrade is `unexpected-response`
|
|
134
|
-
// above): the connection dropped on the way, as the Python and PHP
|
|
135
|
-
// SDKs say it — retryable, not a bad token.
|
|
136
|
-
fail(new VocalStackError('engine_unavailable', 'connection closed before ready', {
|
|
137
|
-
retryable: true,
|
|
138
|
-
}));
|
|
139
|
-
});
|
|
140
|
-
ws.addEventListener('error', () => {
|
|
141
|
-
});
|
|
143
|
+
const early = [];
|
|
144
|
+
let session;
|
|
145
|
+
await openReady(params.streamUrl, deadlineMs, params.onLoading, (f) => early.push(f), (ws) => {
|
|
146
|
+
const s = new RealtimeSession(params.sessionId, ws, params);
|
|
147
|
+
for (const f of early)
|
|
148
|
+
s.#frame(f);
|
|
149
|
+
s.#listen();
|
|
150
|
+
session = s;
|
|
142
151
|
});
|
|
143
152
|
return session;
|
|
144
153
|
}
|
|
145
|
-
|
|
154
|
+
async #reconnect(code) {
|
|
155
|
+
this.#reconnecting = true;
|
|
156
|
+
const deadlineMs = this.#params.readyDeadlineMs ?? DEFAULT_READY_DEADLINE_MS;
|
|
157
|
+
while (this.#reconnects < RESTART_TRIES && !this.#closedByUs) {
|
|
158
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** this.#reconnects));
|
|
159
|
+
this.#reconnects++;
|
|
160
|
+
if (this.#closedByUs)
|
|
161
|
+
break;
|
|
162
|
+
try {
|
|
163
|
+
await openReady(this.#params.streamUrl, deadlineMs, this.#params.onLoading, (f) => this.#frame(f), (ws) => {
|
|
164
|
+
this.#ws = ws;
|
|
165
|
+
this.#reconnecting = false;
|
|
166
|
+
this.#listen();
|
|
167
|
+
const waiting = this.#pending;
|
|
168
|
+
this.#pending = [];
|
|
169
|
+
this.#pendingBytes = 0;
|
|
170
|
+
for (const d of waiting)
|
|
171
|
+
this.#send(d);
|
|
172
|
+
});
|
|
173
|
+
if (this.#closedByUs)
|
|
174
|
+
this.close();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
if (e instanceof VocalStackError && !e.retryable) {
|
|
179
|
+
this.#fail(e);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.#fail(engineLost(code));
|
|
185
|
+
}
|
|
186
|
+
#fail(err) {
|
|
187
|
+
this.#reconnecting = false;
|
|
188
|
+
this.#pending = [];
|
|
189
|
+
this.#closed = true;
|
|
190
|
+
if (!this.#endReason && !this.#error && !this.#closedByUs)
|
|
191
|
+
this.#error = err;
|
|
192
|
+
this.#wake?.();
|
|
193
|
+
}
|
|
194
|
+
#send(data) {
|
|
195
|
+
if (this.#reconnecting) {
|
|
196
|
+
const n = typeof data === 'string' ? data.length : data.byteLength;
|
|
197
|
+
if (this.#pendingBytes + n <= MAX_PENDING_BYTES) {
|
|
198
|
+
this.#pending.push(data);
|
|
199
|
+
this.#pendingBytes += n;
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (this.#ws.readyState !== WS_OPEN)
|
|
204
|
+
return;
|
|
205
|
+
this.#ws.send(data);
|
|
206
|
+
}
|
|
146
207
|
#frame(frame) {
|
|
147
208
|
try {
|
|
148
209
|
this.#onFrame?.(frame);
|
|
@@ -151,8 +212,9 @@ export class RealtimeSession {
|
|
|
151
212
|
}
|
|
152
213
|
}
|
|
153
214
|
#listen() {
|
|
154
|
-
this.#ws
|
|
155
|
-
|
|
215
|
+
const sock = this.#ws;
|
|
216
|
+
sock.addEventListener('message', (ev) => {
|
|
217
|
+
if (sock !== this.#ws || typeof ev.data !== 'string')
|
|
156
218
|
return;
|
|
157
219
|
let frame;
|
|
158
220
|
try {
|
|
@@ -192,31 +254,42 @@ export class RealtimeSession {
|
|
|
192
254
|
break;
|
|
193
255
|
}
|
|
194
256
|
});
|
|
195
|
-
this.#ws
|
|
257
|
+
const ws = this.#ws;
|
|
258
|
+
ws.addEventListener('close', (ev) => {
|
|
259
|
+
if (ws !== this.#ws)
|
|
260
|
+
return;
|
|
261
|
+
const code = typeof ev?.code === 'number' ? ev.code : undefined;
|
|
262
|
+
if (code === RESTART_CLOSE && !this.#endReason && !this.#error && !this.#closedByUs) {
|
|
263
|
+
void this.#reconnect(code);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
196
266
|
this.#closed = true;
|
|
267
|
+
if (!this.#endReason && !this.#error && !this.#closedByUs) {
|
|
268
|
+
this.#error = engineLost(code);
|
|
269
|
+
}
|
|
197
270
|
this.#wake?.();
|
|
198
271
|
});
|
|
199
272
|
}
|
|
200
273
|
sendAudio(chunk) {
|
|
201
|
-
if (this.#ws.readyState !== WS_OPEN)
|
|
202
|
-
return;
|
|
203
274
|
const data = chunk instanceof Float32Array
|
|
204
275
|
? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
205
276
|
: chunk;
|
|
206
|
-
this.#
|
|
277
|
+
this.#send(this.#reconnecting ? (data instanceof Uint8Array ? data.slice() : data.slice(0)) : data);
|
|
207
278
|
}
|
|
208
279
|
async sendFile(source, chunkBytes = 256 * 1024) {
|
|
209
280
|
const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
210
281
|
for await (const chunk of source) {
|
|
211
282
|
for (let off = 0; off < chunk.byteLength; off += chunkBytes) {
|
|
283
|
+
while (this.#reconnecting)
|
|
284
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
212
285
|
if (this.#ws.readyState !== WS_OPEN)
|
|
213
286
|
return;
|
|
214
287
|
while (this.#ws.bufferedAmount > MAX_BUFFERED) {
|
|
215
288
|
await new Promise((r) => setTimeout(r, 10));
|
|
216
|
-
if (this.#ws.readyState !== WS_OPEN)
|
|
289
|
+
if (this.#ws.readyState !== WS_OPEN && !this.#reconnecting)
|
|
217
290
|
return;
|
|
218
291
|
}
|
|
219
|
-
this.#
|
|
292
|
+
this.#send(chunk.subarray(off, Math.min(off + chunkBytes, chunk.byteLength)));
|
|
220
293
|
}
|
|
221
294
|
}
|
|
222
295
|
this.eof();
|
|
@@ -230,34 +303,11 @@ export class RealtimeSession {
|
|
|
230
303
|
}
|
|
231
304
|
this.eof();
|
|
232
305
|
}
|
|
233
|
-
/**
|
|
234
|
-
* Capture clock heartbeat: `pcm` = cumulative samples captured so far
|
|
235
|
-
* (16kHz-equivalent), `wall` = seconds since capture start. Lets the
|
|
236
|
-
* server detect capture loss (throttled tab, device switch) and mark
|
|
237
|
-
* the transcript with honest gaps instead of gluing across them.
|
|
238
|
-
* `streamMicrophone`/`captureMicrophone` call this automatically.
|
|
239
|
-
*/
|
|
240
306
|
sendClock(pcm, wall) {
|
|
241
|
-
|
|
242
|
-
this.#ws.send(JSON.stringify({ type: 'clock', pcm: Math.floor(pcm), wall }));
|
|
243
|
-
}
|
|
307
|
+
this.#send(JSON.stringify({ type: 'clock', pcm: Math.floor(pcm), wall }));
|
|
244
308
|
}
|
|
245
|
-
/**
|
|
246
|
-
* Options update during the realtime session. Omitted fields are unchanged.
|
|
247
|
-
* `latencyMode` switches the decode model policy from the next
|
|
248
|
-
* engine step; `spokenLanguage` changes the transcription language from the
|
|
249
|
-
* next engine step (`''` or `'auto'` = auto-detect); `speakerLabels`
|
|
250
|
-
* turns speaker labelling on (`'on'` or `'auto'`) or off (`'off'`) from the next
|
|
251
|
-
* engine step — enabling labels speakers from that point on;
|
|
252
|
-
* `translate` replaces the whole target-language list (`[]` turns
|
|
253
|
-
* translation off). Applies to subsequent transcript —
|
|
254
|
-
* already-delivered segments and settled sentences are not
|
|
255
|
-
* re-transcribed, re-attributed or re-translated.
|
|
256
|
-
*/
|
|
257
309
|
update(changes) {
|
|
258
|
-
|
|
259
|
-
return;
|
|
260
|
-
this.#ws.send(JSON.stringify({
|
|
310
|
+
this.#send(JSON.stringify({
|
|
261
311
|
type: 'session.update',
|
|
262
312
|
...(changes.latencyMode !== undefined && { latencyMode: changes.latencyMode }),
|
|
263
313
|
...(changes.spokenLanguage !== undefined && { spokenLanguage: changes.spokenLanguage }),
|
|
@@ -266,14 +316,10 @@ export class RealtimeSession {
|
|
|
266
316
|
}));
|
|
267
317
|
}
|
|
268
318
|
eof() {
|
|
269
|
-
|
|
270
|
-
this.#ws.send(JSON.stringify({ type: 'eof' }));
|
|
271
|
-
}
|
|
319
|
+
this.#send(JSON.stringify({ type: 'eof' }));
|
|
272
320
|
}
|
|
273
321
|
cancel() {
|
|
274
|
-
|
|
275
|
-
this.#ws.send(JSON.stringify({ type: 'cancel' }));
|
|
276
|
-
}
|
|
322
|
+
this.#send(JSON.stringify({ type: 'cancel' }));
|
|
277
323
|
}
|
|
278
324
|
get doneReason() {
|
|
279
325
|
return this.#endReason;
|
|
@@ -283,6 +329,7 @@ export class RealtimeSession {
|
|
|
283
329
|
}
|
|
284
330
|
close() {
|
|
285
331
|
this.#closed = true;
|
|
332
|
+
this.#closedByUs = true;
|
|
286
333
|
try {
|
|
287
334
|
this.#ws.close();
|
|
288
335
|
}
|
|
@@ -326,18 +373,14 @@ async function* readableToAsyncIterable(stream) {
|
|
|
326
373
|
reader.releaseLock();
|
|
327
374
|
}
|
|
328
375
|
}
|
|
329
|
-
/** `vs.realtime`: a realtime session is a signed token plus one socket.
|
|
330
|
-
* Nothing is stored: the transcript is what the socket sends. */
|
|
331
376
|
export class Realtime {
|
|
332
377
|
#t;
|
|
333
378
|
constructor(t) {
|
|
334
379
|
this.#t = t;
|
|
335
380
|
}
|
|
336
|
-
/** `POST /v1/realtime/sessions`: validate the options, get a signed `streamUrl`. */
|
|
337
381
|
createSession(options = {}, call = {}) {
|
|
338
382
|
return this.#t.json('POST', '/v1/realtime/sessions', { body: options, ...call });
|
|
339
383
|
}
|
|
340
|
-
/** `createSession`, then open the socket and wait for `session.ready`. */
|
|
341
384
|
async connect(options = {}, connect = {}) {
|
|
342
385
|
const created = await this.createSession(options);
|
|
343
386
|
return RealtimeSession.connect({
|
|
@@ -349,13 +392,12 @@ export class Realtime {
|
|
|
349
392
|
onFrame: connect.onFrame,
|
|
350
393
|
});
|
|
351
394
|
}
|
|
352
|
-
/** `GET /v1/realtime/capabilities`: languages, audio formats, limits. */
|
|
353
395
|
capabilities() {
|
|
354
396
|
return this.#t.json('GET', '/v1/realtime/capabilities');
|
|
355
397
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
398
|
+
async warm(req) {
|
|
399
|
+
await this.#t.json('POST', '/v1/realtime/warm', { body: req });
|
|
400
|
+
}
|
|
359
401
|
async transcribeFile(file, opts = {}) {
|
|
360
402
|
const { onSession, readyDeadlineMs, handlers, options } = split(opts);
|
|
361
403
|
const session = await this.connect({ ...options, input: { kind: 'file' } }, { readyDeadlineMs });
|
|
@@ -363,7 +405,6 @@ export class Realtime {
|
|
|
363
405
|
await session.sendFile(fileChunks(file));
|
|
364
406
|
return collectTranscript(session, handlers);
|
|
365
407
|
}
|
|
366
|
-
/** The same for a link the engine fetches itself. */
|
|
367
408
|
async transcribeUrl(url, opts = {}) {
|
|
368
409
|
const { onSession, readyDeadlineMs, handlers, options } = split(opts);
|
|
369
410
|
const session = await this.connect({ ...options, input: { kind: 'url', url } }, { readyDeadlineMs });
|
|
@@ -376,7 +417,6 @@ function split(opts) {
|
|
|
376
417
|
const handlers = { onProgress, onCommit, onTranslated, onAmended, onSpeakerSettled, onSentence };
|
|
377
418
|
return { onSession, readyDeadlineMs, handlers, options };
|
|
378
419
|
}
|
|
379
|
-
/** Upsert one sentence frame into a list kept in sentenceId order. */
|
|
380
420
|
export function applySentence(list, sentence) {
|
|
381
421
|
const i = list.findIndex((s) => s.sentenceId === sentence.sentenceId);
|
|
382
422
|
if (sentence.status === 'dropped') {
|