assemblyai 4.35.4 → 4.36.4
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/CHANGELOG.md +25 -0
- package/README.md +98 -0
- package/dist/assemblyai.streaming.umd.js +28 -11
- package/dist/assemblyai.streaming.umd.min.js +1 -1
- package/dist/assemblyai.umd.js +488 -83
- package/dist/assemblyai.umd.min.js +1 -1
- package/dist/browser.mjs +430 -78
- package/dist/bun.mjs +420 -67
- package/dist/deno.mjs +420 -67
- package/dist/index.cjs +481 -76
- package/dist/index.mjs +479 -77
- package/dist/node.cjs +418 -62
- package/dist/node.mjs +416 -63
- package/dist/services/base.d.ts +1 -0
- package/dist/services/index.d.ts +7 -1
- package/dist/services/streaming/service.d.ts +2 -1
- package/dist/services/sync/index.d.ts +1 -0
- package/dist/services/sync/service.d.ts +48 -0
- package/dist/streaming.browser.mjs +24 -9
- package/dist/streaming.cjs +27 -10
- package/dist/streaming.mjs +27 -10
- package/dist/types/asyncapi.generated.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/services/index.d.ts +1 -0
- package/dist/types/streaming/index.d.ts +14 -4
- package/dist/types/sync/index.d.ts +133 -0
- package/dist/utils/errors/index.d.ts +1 -0
- package/dist/utils/errors/sync.d.ts +20 -0
- package/dist/workerd.mjs +424 -71
- package/package.json +1 -1
- package/src/services/base.ts +18 -8
- package/src/services/index.ts +19 -0
- package/src/services/streaming/service.ts +22 -3
- package/src/services/sync/index.ts +1 -0
- package/src/services/sync/service.ts +369 -0
- package/src/types/asyncapi.generated.ts +6 -1
- package/src/types/index.ts +1 -0
- package/src/types/services/index.ts +1 -0
- package/src/types/streaming/index.ts +16 -3
- package/src/types/sync/index.ts +145 -0
- package/src/utils/errors/index.ts +2 -0
- package/src/utils/errors/sync.ts +25 -0
package/src/services/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { BaseServiceParams } from "..";
|
|
2
|
+
import { SyncTranscriber } from "./sync";
|
|
3
|
+
import { SyncTranscriptError } from "../utils/errors";
|
|
2
4
|
import { LemurService } from "./lemur";
|
|
3
5
|
import {
|
|
4
6
|
RealtimeTranscriber,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
|
|
24
26
|
const defaultBaseUrl = "https://api.assemblyai.com";
|
|
25
27
|
const defaultStreamingUrl = "https://streaming.assemblyai.com";
|
|
28
|
+
const defaultSyncUrl = "https://sync.assemblyai.com";
|
|
26
29
|
|
|
27
30
|
class AssemblyAI {
|
|
28
31
|
/**
|
|
@@ -50,6 +53,11 @@ class AssemblyAI {
|
|
|
50
53
|
*/
|
|
51
54
|
public streaming: StreamingTranscriberFactory;
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The synchronous transcription service.
|
|
58
|
+
*/
|
|
59
|
+
public sync: SyncTranscriber;
|
|
60
|
+
|
|
53
61
|
/**
|
|
54
62
|
* Create a new AssemblyAI client.
|
|
55
63
|
* @param params - The parameters for the service, including the API key and base URL, if any.
|
|
@@ -69,11 +77,22 @@ class AssemblyAI {
|
|
|
69
77
|
...params,
|
|
70
78
|
baseUrl: params.streamingBaseUrl || defaultStreamingUrl,
|
|
71
79
|
});
|
|
80
|
+
|
|
81
|
+
let syncBaseUrl = params.syncBaseUrl || defaultSyncUrl;
|
|
82
|
+
if (syncBaseUrl.endsWith("/")) {
|
|
83
|
+
syncBaseUrl = syncBaseUrl.slice(0, -1);
|
|
84
|
+
}
|
|
85
|
+
this.sync = new SyncTranscriber({
|
|
86
|
+
...params,
|
|
87
|
+
baseUrl: syncBaseUrl,
|
|
88
|
+
});
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
export {
|
|
76
93
|
AssemblyAI,
|
|
94
|
+
SyncTranscriber,
|
|
95
|
+
SyncTranscriptError,
|
|
77
96
|
LemurService,
|
|
78
97
|
RealtimeTranscriberFactory,
|
|
79
98
|
RealtimeTranscriber,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
StreamingForceEndpoint,
|
|
21
21
|
StreamingKeepAlive,
|
|
22
22
|
WarningEvent,
|
|
23
|
+
HeartbeatEvent,
|
|
23
24
|
} from "../..";
|
|
24
25
|
import type { VadDetector, VadFrame } from "../../types/streaming/dual-channel";
|
|
25
26
|
import { EnergyVad } from "./energy-vad";
|
|
@@ -164,10 +165,16 @@ export class StreamingTranscriber {
|
|
|
164
165
|
throw new Error("API key or temporary token is required.");
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
const
|
|
168
|
-
|
|
168
|
+
const isSelfDescribing =
|
|
169
|
+
params.encoding === "opus" ||
|
|
170
|
+
params.encoding === "ogg_opus" ||
|
|
171
|
+
params.encoding === "aac";
|
|
172
|
+
if (
|
|
173
|
+
params.sampleRate === undefined &&
|
|
174
|
+
(!isSelfDescribing || params.channels)
|
|
175
|
+
) {
|
|
169
176
|
throw new Error(
|
|
170
|
-
'`sampleRate` is required; it may only be omitted when `encoding` is "opus" or "
|
|
177
|
+
'`sampleRate` is required; it may only be omitted when `encoding` is "opus", "ogg_opus", or "aac" (these streams are self-describing) and dual-channel mode is not used.',
|
|
171
178
|
);
|
|
172
179
|
}
|
|
173
180
|
|
|
@@ -283,6 +290,13 @@ export class StreamingTranscriber {
|
|
|
283
290
|
searchParams.set("format_turns", this.params.formatTurns.toString());
|
|
284
291
|
}
|
|
285
292
|
|
|
293
|
+
if (this.params.sessionHeartbeat !== undefined) {
|
|
294
|
+
searchParams.set(
|
|
295
|
+
"session_heartbeat",
|
|
296
|
+
this.params.sessionHeartbeat.toString(),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
286
300
|
if (this.params.encoding) {
|
|
287
301
|
searchParams.set("encoding", this.params.encoding.toString());
|
|
288
302
|
}
|
|
@@ -474,6 +488,7 @@ export class StreamingTranscriber {
|
|
|
474
488
|
listener: (event: SpeakerRevisionEvent) => void,
|
|
475
489
|
): void;
|
|
476
490
|
on(event: "warning", listener: (event: WarningEvent) => void): void;
|
|
491
|
+
on(event: "heartbeat", listener: (event: HeartbeatEvent) => void): void;
|
|
477
492
|
on(event: "vad", listener: (event: VadFrame) => void): void;
|
|
478
493
|
on(event: "error", listener: (error: Error) => void): void;
|
|
479
494
|
on(event: "close", listener: (code: number, reason: string) => void): void;
|
|
@@ -693,6 +708,10 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c
|
|
|
693
708
|
this.listeners.warning?.(warning);
|
|
694
709
|
break;
|
|
695
710
|
}
|
|
711
|
+
case "Heartbeat": {
|
|
712
|
+
this.listeners.heartbeat?.(message);
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
696
715
|
case "Termination": {
|
|
697
716
|
this.sessionTerminatedResolve?.();
|
|
698
717
|
break;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./service";
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { readFile } from "#fs";
|
|
2
|
+
import { BaseService } from "../base";
|
|
3
|
+
import {
|
|
4
|
+
BaseServiceParams,
|
|
5
|
+
SyncAudioInput,
|
|
6
|
+
SyncTranscribeOptions,
|
|
7
|
+
SyncTranscriptResponse,
|
|
8
|
+
SyncTranscriptionConfig,
|
|
9
|
+
} from "../..";
|
|
10
|
+
import { defaultSyncSpeechModel } from "../../types/sync";
|
|
11
|
+
import { SyncTranscriptError } from "../../utils/errors/sync";
|
|
12
|
+
import { getPath } from "../../utils/path";
|
|
13
|
+
|
|
14
|
+
// Canonical paths since the sync API gained a /v1 prefix (#18103); the
|
|
15
|
+
// unprefixed routes remain served for SDK versions that predate it.
|
|
16
|
+
const transcribeEndpoint = "/v1/transcribe";
|
|
17
|
+
const warmEndpoint = "/v1/warm";
|
|
18
|
+
const modelHeader = "X-AAI-Model";
|
|
19
|
+
// Kept above the server's 30 s deadline so the client doesn't race it.
|
|
20
|
+
const defaultTimeoutMs = 60_000;
|
|
21
|
+
const warmTimeoutMs = 10_000;
|
|
22
|
+
const maxPromptLength = 4096;
|
|
23
|
+
const maxKeytermsPromptLength = 2048;
|
|
24
|
+
const maxContextTurns = 100;
|
|
25
|
+
const maxContextLength = 4096;
|
|
26
|
+
// Extensions that signal raw S16LE PCM rather than a WAV container.
|
|
27
|
+
const pcmSuffixes = [".pcm", ".raw"];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The synchronous transcription service: audio in, transcript out,
|
|
31
|
+
* one request.
|
|
32
|
+
*
|
|
33
|
+
* Unlike `client.transcripts` (which submits a job to the async API and
|
|
34
|
+
* polls for completion), `SyncTranscriber` posts the audio to the sync
|
|
35
|
+
* API and returns the finished transcript in the HTTP response. There is no
|
|
36
|
+
* job id or status to poll. Accepts a local file path, raw audio bytes, a
|
|
37
|
+
* Blob, or a readable stream — but not a URL.
|
|
38
|
+
*/
|
|
39
|
+
export class SyncTranscriber extends BaseService {
|
|
40
|
+
/**
|
|
41
|
+
* Create a new synchronous transcription service.
|
|
42
|
+
* @param params - The parameters to use for the service.
|
|
43
|
+
*/
|
|
44
|
+
constructor(params: BaseServiceParams) {
|
|
45
|
+
super(params);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Transcribe audio and return the finished transcript in one request.
|
|
50
|
+
* @param audio - A local file path, raw audio bytes, a Blob, or a readable
|
|
51
|
+
* stream. Raw PCM also requires `sample_rate` and `channels` on the config.
|
|
52
|
+
* @param config - Options for this transcription request.
|
|
53
|
+
* @param options - Client-side options, such as the request timeout.
|
|
54
|
+
* @returns A promise that resolves to the finished transcript.
|
|
55
|
+
* @throws SyncTranscriptError when the request fails.
|
|
56
|
+
*/
|
|
57
|
+
async transcribe(
|
|
58
|
+
audio: SyncAudioInput,
|
|
59
|
+
config: SyncTranscriptionConfig = {},
|
|
60
|
+
options: SyncTranscribeOptions = {},
|
|
61
|
+
): Promise<SyncTranscriptResponse> {
|
|
62
|
+
const { bytes, filename, contentType } = await resolveAudio(audio, config);
|
|
63
|
+
|
|
64
|
+
const body = new FormData();
|
|
65
|
+
body.append(
|
|
66
|
+
"audio",
|
|
67
|
+
new Blob([bytes as BlobPart], { type: contentType }),
|
|
68
|
+
filename,
|
|
69
|
+
);
|
|
70
|
+
const configJson = buildConfigJson(config);
|
|
71
|
+
if (configJson) {
|
|
72
|
+
body.append(
|
|
73
|
+
"config",
|
|
74
|
+
new Blob([JSON.stringify(configJson)], { type: "application/json" }),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const response = await this.fetchResponse(transcribeEndpoint, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
body,
|
|
81
|
+
headers: { [modelHeader]: config.model ?? defaultSyncSpeechModel },
|
|
82
|
+
signal: AbortSignal.timeout(options.timeout ?? defaultTimeoutMs),
|
|
83
|
+
});
|
|
84
|
+
if (response.status !== 200) throw await errorFromResponse(response);
|
|
85
|
+
return (await response.json()) as SyncTranscriptResponse;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Open the connection to the sync API ahead of time.
|
|
90
|
+
*
|
|
91
|
+
* The sync API is a single request/response, so a `transcribe()` that
|
|
92
|
+
* opens its connection on demand pays the full DNS + TCP + TLS handshake
|
|
93
|
+
* on the critical path. Call `warm()` as soon as you know audio is coming —
|
|
94
|
+
* typically while the clip is still being recorded — so the next
|
|
95
|
+
* `transcribe()` reuses the already-open connection. `warm()` is idempotent
|
|
96
|
+
* and cheap; call it shortly before `transcribe()` so the pooled connection
|
|
97
|
+
* hasn't idled out.
|
|
98
|
+
* @param params - Optionally the model to route the probe to, so the warmed
|
|
99
|
+
* connection lands on the same backend as the eventual transcription.
|
|
100
|
+
* @returns A promise that resolves to `true` once the connection is open
|
|
101
|
+
* (any HTTP response — even a non-200 — means the socket is
|
|
102
|
+
* established), or `false` if the connection could not be opened.
|
|
103
|
+
*/
|
|
104
|
+
async warm(params?: { model?: string }): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
await this.fetchResponse(warmEndpoint, {
|
|
107
|
+
method: "GET",
|
|
108
|
+
headers: { [modelHeader]: params?.model ?? defaultSyncSpeechModel },
|
|
109
|
+
signal: AbortSignal.timeout(warmTimeoutMs),
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type ResolvedAudio = {
|
|
119
|
+
bytes: Uint8Array;
|
|
120
|
+
filename: string;
|
|
121
|
+
contentType: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read the audio input into bytes and decide its multipart content type.
|
|
126
|
+
*
|
|
127
|
+
* PCM is selected when the source has a `.pcm`/`.raw` extension or when
|
|
128
|
+
* `sample_rate`/`channels` are set on the config (the fields the sync API
|
|
129
|
+
* requires only for raw PCM) — and both must then be present. Everything
|
|
130
|
+
* else is treated as a WAV container. URLs are rejected — the sync API has
|
|
131
|
+
* no URL ingestion.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveAudio(
|
|
134
|
+
input: SyncAudioInput,
|
|
135
|
+
config: SyncTranscriptionConfig,
|
|
136
|
+
): Promise<ResolvedAudio> {
|
|
137
|
+
let bytes: Uint8Array;
|
|
138
|
+
let filename: string | undefined;
|
|
139
|
+
let suffix = "";
|
|
140
|
+
|
|
141
|
+
if (typeof input === "string") {
|
|
142
|
+
if (/^https?:\/\//i.test(input)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
"SyncTranscriber does not accept URLs. Pass a local file path or " +
|
|
145
|
+
"audio bytes, or use client.transcripts for URL/async transcription.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (input.startsWith("data:")) {
|
|
149
|
+
bytes = dataUrlToBytes(input);
|
|
150
|
+
} else {
|
|
151
|
+
const path = getPath(input) ?? input;
|
|
152
|
+
bytes = await readStream(await readFile(path));
|
|
153
|
+
filename = basename(path);
|
|
154
|
+
suffix = extname(filename);
|
|
155
|
+
}
|
|
156
|
+
} else if (input instanceof Uint8Array) {
|
|
157
|
+
bytes = input;
|
|
158
|
+
} else if (input instanceof ArrayBuffer) {
|
|
159
|
+
bytes = new Uint8Array(input);
|
|
160
|
+
} else if (input instanceof Blob) {
|
|
161
|
+
bytes = new Uint8Array(await input.arrayBuffer());
|
|
162
|
+
// File instances carry a name; the File global itself needs Node >= 20.
|
|
163
|
+
const name = (input as { name?: string }).name;
|
|
164
|
+
if (name) {
|
|
165
|
+
filename = basename(name);
|
|
166
|
+
suffix = extname(filename);
|
|
167
|
+
}
|
|
168
|
+
} else if (isWebReadableStream(input)) {
|
|
169
|
+
bytes = await readStream(input);
|
|
170
|
+
} else if (isAsyncIterable(input)) {
|
|
171
|
+
bytes = await readAsyncIterable(input);
|
|
172
|
+
// fs.ReadStream carries the path it was opened from.
|
|
173
|
+
const path = (input as { path?: string | Buffer }).path;
|
|
174
|
+
if (typeof path === "string") {
|
|
175
|
+
filename = basename(path);
|
|
176
|
+
suffix = extname(filename);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
throw new TypeError("unsupported audio input type");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const wantsPcm =
|
|
183
|
+
config.sample_rate !== undefined || config.channels !== undefined;
|
|
184
|
+
const isPcm = pcmSuffixes.includes(suffix) || wantsPcm;
|
|
185
|
+
if (
|
|
186
|
+
isPcm &&
|
|
187
|
+
(config.sample_rate === undefined || config.channels === undefined)
|
|
188
|
+
) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
"raw PCM audio requires both sample_rate and channels in the config",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const contentType = isPcm ? "audio/pcm" : "audio/wav";
|
|
195
|
+
if (!filename) filename = isPcm ? "audio.pcm" : "audio.wav";
|
|
196
|
+
|
|
197
|
+
return { bytes, filename, contentType };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Serialize the config to the JSON `config` part, validating and normalizing
|
|
202
|
+
* field values to match the server's caps. The routing `model` is never
|
|
203
|
+
* included — it travels in the `X-AAI-Model` header. Returns `undefined`
|
|
204
|
+
* when there is nothing to send, so the part can be omitted entirely.
|
|
205
|
+
*/
|
|
206
|
+
function buildConfigJson(
|
|
207
|
+
config: SyncTranscriptionConfig,
|
|
208
|
+
): Record<string, unknown> | undefined {
|
|
209
|
+
if (config.prompt !== undefined && config.prompt.length > maxPromptLength) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`prompt exceeds ${maxPromptLength} characters (got ${config.prompt.length})`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const json: Record<string, unknown> = {};
|
|
216
|
+
if (config.prompt !== undefined) json["prompt"] = config.prompt;
|
|
217
|
+
const keytermsPrompt = normalizeKeytermsPrompt(config.keyterms_prompt);
|
|
218
|
+
if (keytermsPrompt) json["keyterms_prompt"] = keytermsPrompt;
|
|
219
|
+
const context = normalizeConversationContext(config.conversation_context);
|
|
220
|
+
if (context) json["conversation_context"] = context;
|
|
221
|
+
if (config.language_codes !== undefined)
|
|
222
|
+
json["language_codes"] = config.language_codes;
|
|
223
|
+
if (config.sample_rate !== undefined)
|
|
224
|
+
json["sample_rate"] = config.sample_rate;
|
|
225
|
+
if (config.channels !== undefined) json["channels"] = config.channels;
|
|
226
|
+
if (config.timestamps !== undefined) json["timestamps"] = config.timestamps;
|
|
227
|
+
|
|
228
|
+
return Object.keys(json).length > 0 ? json : undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeKeytermsPrompt(
|
|
232
|
+
keytermsPrompt?: string[],
|
|
233
|
+
): string[] | undefined {
|
|
234
|
+
if (!keytermsPrompt) return undefined;
|
|
235
|
+
const terms = keytermsPrompt
|
|
236
|
+
.map((term) => term.trim())
|
|
237
|
+
.filter((term) => term.length > 0);
|
|
238
|
+
const total = terms.reduce((sum, term) => sum + term.length, 0);
|
|
239
|
+
if (total > maxKeytermsPromptLength) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`keyterms_prompt exceeds ${maxKeytermsPromptLength} characters (got ${total})`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return terms.length > 0 ? terms : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function normalizeConversationContext(
|
|
248
|
+
context?: string | string[],
|
|
249
|
+
): string[] | undefined {
|
|
250
|
+
if (context === undefined) return undefined;
|
|
251
|
+
let turns = (typeof context === "string" ? [context] : context)
|
|
252
|
+
.map((turn) => turn.trim())
|
|
253
|
+
.filter((turn) => turn.length > 0);
|
|
254
|
+
let total = turns.reduce((sum, turn) => sum + turn.length, 0);
|
|
255
|
+
// Over-cap context is trimmed oldest-first, never rejected.
|
|
256
|
+
while (
|
|
257
|
+
turns.length > 0 &&
|
|
258
|
+
(turns.length > maxContextTurns || total > maxContextLength)
|
|
259
|
+
) {
|
|
260
|
+
total -= turns[0].length;
|
|
261
|
+
turns = turns.slice(1);
|
|
262
|
+
}
|
|
263
|
+
return turns.length > 0 ? turns : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build a SyncTranscriptError from a non-200 response. The primary format
|
|
268
|
+
* is an RFC 9457 problem-details body (`status`/`title`/`detail`); legacy
|
|
269
|
+
* `{error_code, message}` and `{detail}`-only bodies are also accepted.
|
|
270
|
+
*/
|
|
271
|
+
async function errorFromResponse(
|
|
272
|
+
response: Response,
|
|
273
|
+
): Promise<SyncTranscriptError> {
|
|
274
|
+
let errorCode: string | undefined;
|
|
275
|
+
let message: string | undefined;
|
|
276
|
+
|
|
277
|
+
const text = await response.text();
|
|
278
|
+
try {
|
|
279
|
+
const body = JSON.parse(text);
|
|
280
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
281
|
+
if (typeof body.error_code === "string") errorCode = body.error_code;
|
|
282
|
+
if (errorCode === undefined && typeof body.title === "string") {
|
|
283
|
+
errorCode = body.title.toLowerCase().replace(/ /g, "_");
|
|
284
|
+
}
|
|
285
|
+
if (typeof body.detail === "string") message = body.detail;
|
|
286
|
+
else if (typeof body.message === "string") message = body.message;
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
if (text) message = text;
|
|
290
|
+
}
|
|
291
|
+
if (!message) {
|
|
292
|
+
message = `sync transcription failed with status ${response.status}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const retryHeader = response.headers.get("retry-after");
|
|
296
|
+
const retryAfter =
|
|
297
|
+
retryHeader && /^\d+$/.test(retryHeader)
|
|
298
|
+
? parseInt(retryHeader, 10)
|
|
299
|
+
: undefined;
|
|
300
|
+
|
|
301
|
+
return new SyncTranscriptError(
|
|
302
|
+
message,
|
|
303
|
+
response.status,
|
|
304
|
+
errorCode,
|
|
305
|
+
retryAfter,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function basename(path: string): string {
|
|
310
|
+
return path.split(/[\\/]/).pop() ?? path;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function extname(filename: string): string {
|
|
314
|
+
const dotIndex = filename.lastIndexOf(".");
|
|
315
|
+
return dotIndex > 0 ? filename.slice(dotIndex).toLowerCase() : "";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function dataUrlToBytes(dataUrl: string): Uint8Array {
|
|
319
|
+
const base64 = dataUrl.split(",")[1];
|
|
320
|
+
const binary = atob(base64);
|
|
321
|
+
const bytes = new Uint8Array(binary.length);
|
|
322
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
323
|
+
return bytes;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isWebReadableStream(
|
|
327
|
+
input: unknown,
|
|
328
|
+
): input is ReadableStream<Uint8Array> {
|
|
329
|
+
return typeof (input as ReadableStream<Uint8Array>)?.getReader === "function";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isAsyncIterable(input: unknown): input is AsyncIterable<Uint8Array> {
|
|
333
|
+
return (
|
|
334
|
+
typeof (input as AsyncIterable<Uint8Array>)?.[Symbol.asyncIterator] ===
|
|
335
|
+
"function"
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function readStream(
|
|
340
|
+
stream: ReadableStream<Uint8Array>,
|
|
341
|
+
): Promise<Uint8Array> {
|
|
342
|
+
const chunks: Uint8Array[] = [];
|
|
343
|
+
const reader = stream.getReader();
|
|
344
|
+
for (;;) {
|
|
345
|
+
const { done, value } = await reader.read();
|
|
346
|
+
if (done) break;
|
|
347
|
+
chunks.push(value);
|
|
348
|
+
}
|
|
349
|
+
return concatChunks(chunks);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function readAsyncIterable(
|
|
353
|
+
iterable: AsyncIterable<Uint8Array>,
|
|
354
|
+
): Promise<Uint8Array> {
|
|
355
|
+
const chunks: Uint8Array[] = [];
|
|
356
|
+
for await (const chunk of iterable) chunks.push(chunk);
|
|
357
|
+
return concatChunks(chunks);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
|
361
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
362
|
+
const bytes = new Uint8Array(total);
|
|
363
|
+
let offset = 0;
|
|
364
|
+
for (const chunk of chunks) {
|
|
365
|
+
bytes.set(chunk, offset);
|
|
366
|
+
offset += chunk.length;
|
|
367
|
+
}
|
|
368
|
+
return bytes;
|
|
369
|
+
}
|
|
@@ -25,7 +25,12 @@ export type AudioData = ArrayBufferLike;
|
|
|
25
25
|
* The encoding of the audio data
|
|
26
26
|
* @defaultValue "pcm_s16"le
|
|
27
27
|
*/
|
|
28
|
-
export type AudioEncoding =
|
|
28
|
+
export type AudioEncoding =
|
|
29
|
+
| "pcm_s16le"
|
|
30
|
+
| "pcm_mulaw"
|
|
31
|
+
| "opus"
|
|
32
|
+
| "ogg_opus"
|
|
33
|
+
| "aac";
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* Configure the threshold for how long to wait before ending an utterance. Default is 700ms.
|
package/src/types/index.ts
CHANGED
|
@@ -94,8 +94,8 @@ export type StreamingTranscriberParams = {
|
|
|
94
94
|
connectionRetryDelay?: number;
|
|
95
95
|
/**
|
|
96
96
|
* Required for PCM encodings (and for dual-channel mode). May be omitted
|
|
97
|
-
* for
|
|
98
|
-
* and the server ignores the value.
|
|
97
|
+
* for self-describing encodings (`opus`, `ogg_opus`, `aac`) — the stream
|
|
98
|
+
* carries its own rate and the server ignores the value.
|
|
99
99
|
*/
|
|
100
100
|
sampleRate?: number;
|
|
101
101
|
encoding?: AudioEncoding;
|
|
@@ -108,6 +108,7 @@ export type StreamingTranscriberParams = {
|
|
|
108
108
|
maxTurnSilence?: number;
|
|
109
109
|
vadThreshold?: number;
|
|
110
110
|
formatTurns?: boolean;
|
|
111
|
+
sessionHeartbeat?: boolean;
|
|
111
112
|
filterProfanity?: boolean;
|
|
112
113
|
keyterms?: string[];
|
|
113
114
|
keytermsPrompt?: string[];
|
|
@@ -184,6 +185,7 @@ export type StreamingEvents =
|
|
|
184
185
|
| "llmGatewayResponse"
|
|
185
186
|
| "speakerRevision"
|
|
186
187
|
| "warning"
|
|
188
|
+
| "heartbeat"
|
|
187
189
|
| "vad"
|
|
188
190
|
| "error";
|
|
189
191
|
|
|
@@ -195,6 +197,7 @@ export type StreamingListeners = {
|
|
|
195
197
|
llmGatewayResponse?: (event: LLMGatewayResponseEvent) => void;
|
|
196
198
|
speakerRevision?: (event: SpeakerRevisionEvent) => void;
|
|
197
199
|
warning?: (event: WarningEvent) => void;
|
|
200
|
+
heartbeat?: (event: HeartbeatEvent) => void;
|
|
198
201
|
vad?: (event: VadFrame) => void;
|
|
199
202
|
error?: (error: Error) => void;
|
|
200
203
|
};
|
|
@@ -370,6 +373,7 @@ export type StreamingUpdateConfiguration = {
|
|
|
370
373
|
max_turn_silence?: number;
|
|
371
374
|
vad_threshold?: number;
|
|
372
375
|
format_turns?: boolean;
|
|
376
|
+
session_heartbeat?: boolean;
|
|
373
377
|
keyterms_prompt?: string[];
|
|
374
378
|
prompt?: string;
|
|
375
379
|
agent_context?: string;
|
|
@@ -404,6 +408,14 @@ export type WarningEvent = {
|
|
|
404
408
|
warning: string;
|
|
405
409
|
};
|
|
406
410
|
|
|
411
|
+
export type HeartbeatEvent = {
|
|
412
|
+
type: "Heartbeat";
|
|
413
|
+
total_audio_received_ms: number;
|
|
414
|
+
total_duration_ms: number;
|
|
415
|
+
realtime_factor: number;
|
|
416
|
+
max_speech_probability: number;
|
|
417
|
+
};
|
|
418
|
+
|
|
407
419
|
export type LLMGatewayResponseEvent = {
|
|
408
420
|
type: "LLMGatewayResponse";
|
|
409
421
|
turn_order: number;
|
|
@@ -443,7 +455,8 @@ export type StreamingEventMessage =
|
|
|
443
455
|
| LLMGatewayResponseEvent
|
|
444
456
|
| SpeakerRevisionEvent
|
|
445
457
|
| ErrorEvent
|
|
446
|
-
| WarningEvent
|
|
458
|
+
| WarningEvent
|
|
459
|
+
| HeartbeatEvent;
|
|
447
460
|
|
|
448
461
|
export type StreamingOperationMessage =
|
|
449
462
|
| StreamingUpdateConfiguration
|