@opengeni/api-router 0.21.14 → 0.22.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/dist/app.d.ts +1 -0
- package/dist/app.js +1 -1
- package/dist/{chunk-R5PDSH2A.js → chunk-HWXJW5C7.js} +1034 -75
- package/dist/chunk-HWXJW5C7.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/routes/transcription-recordings.d.ts +3 -0
- package/dist/transcription/segmenter.d.ts +10 -0
- package/dist/transcription/service.d.ts +5 -0
- package/package.json +11 -11
- package/src/app.ts +36 -2
- package/src/mcp/server.ts +1 -1
- package/src/routes/transcription-recordings.ts +722 -0
- package/src/routes/transcriptions.ts +2 -0
- package/src/transcription/providers/azure-openai.ts +4 -3
- package/src/transcription/providers/codex-subscription.ts +4 -1
- package/src/transcription/providers/openai.ts +7 -2
- package/src/transcription/segmenter.ts +260 -0
- package/src/transcription/service.ts +111 -10
- package/dist/chunk-R5PDSH2A.js.map +0 -1
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
import { type ApiRouteDeps, requireAccessGrant, TranscriptionServiceError } from "@opengeni/core";
|
|
6
6
|
import { getWorkspace } from "@opengeni/db";
|
|
7
7
|
import type { Hono } from "hono";
|
|
8
|
+
import { registerResumableTranscriptionRoutes } from "./transcription-recordings";
|
|
8
9
|
|
|
9
10
|
export function registerTranscriptionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
11
|
+
registerResumableTranscriptionRoutes(app, deps);
|
|
10
12
|
app.post("/v1/workspaces/:workspaceId/transcriptions", async (c) => {
|
|
11
13
|
const workspaceId = c.req.param("workspaceId");
|
|
12
14
|
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:create");
|
|
@@ -13,13 +13,14 @@ export function createAzureOpenAiTranscriptionProvider(input: {
|
|
|
13
13
|
const url = `${input.endpoint}/openai/deployments/${encodeURIComponent(input.deployment)}/audio/transcriptions?api-version=${encodeURIComponent(input.apiVersion)}`;
|
|
14
14
|
return {
|
|
15
15
|
id: "azure-openai",
|
|
16
|
+
supportsServerDeadline: true,
|
|
16
17
|
available: () => Boolean(input.apiKey || input.adToken),
|
|
17
|
-
async transcribe({ audio, mimeType, filename, signal }) {
|
|
18
|
+
async transcribe({ audio, mimeType, filename, requestId, signal }) {
|
|
18
19
|
const form = new FormData();
|
|
19
20
|
form.append("file", new Blob([Uint8Array.from(audio).buffer], { type: mimeType }), filename);
|
|
20
21
|
const headers: Record<string, string> = input.apiKey
|
|
21
|
-
? { "api-key": input.apiKey }
|
|
22
|
-
: { Authorization: `Bearer ${input.adToken}
|
|
22
|
+
? { "api-key": input.apiKey, "x-opengeni-request-id": requestId }
|
|
23
|
+
: { Authorization: `Bearer ${input.adToken}`, "x-opengeni-request-id": requestId };
|
|
23
24
|
let response: Response;
|
|
24
25
|
try {
|
|
25
26
|
response = await fetchImpl(url, {
|
|
@@ -36,9 +36,10 @@ export function createCodexSubscriptionTranscriptionProvider(input: {
|
|
|
36
36
|
});
|
|
37
37
|
return {
|
|
38
38
|
id: "codex-subscription",
|
|
39
|
+
supportsServerDeadline: true,
|
|
39
40
|
experimental: true,
|
|
40
41
|
available: probe,
|
|
41
|
-
async transcribe({ audio, mimeType, filename, workspaceId, signal }) {
|
|
42
|
+
async transcribe({ audio, mimeType, filename, workspaceId, requestId, signal }) {
|
|
42
43
|
const account = (await listCodexAccountStatuses(input.db, workspaceId)).find(
|
|
43
44
|
(candidate) => candidate.isActive && candidate.status === "active",
|
|
44
45
|
);
|
|
@@ -73,6 +74,8 @@ export function createCodexSubscriptionTranscriptionProvider(input: {
|
|
|
73
74
|
originator: CODEX_ORIGINATOR,
|
|
74
75
|
"User-Agent": `${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION}`,
|
|
75
76
|
version: CODEX_CLIENT_VERSION,
|
|
77
|
+
// Observability only; the upstream API is not treated as idempotent.
|
|
78
|
+
"x-opengeni-request-id": requestId,
|
|
76
79
|
},
|
|
77
80
|
body: form,
|
|
78
81
|
...(signal ? { signal } : {}),
|
|
@@ -9,8 +9,9 @@ export function createOpenAiTranscriptionProvider(input: {
|
|
|
9
9
|
const fetchImpl = input.fetch ?? fetch;
|
|
10
10
|
return {
|
|
11
11
|
id: "openai",
|
|
12
|
+
supportsServerDeadline: true,
|
|
12
13
|
available: () => true,
|
|
13
|
-
async transcribe({ audio, mimeType, filename, signal }) {
|
|
14
|
+
async transcribe({ audio, mimeType, filename, requestId, signal }) {
|
|
14
15
|
const form = new FormData();
|
|
15
16
|
form.append("file", audioBlob(audio, mimeType), filename);
|
|
16
17
|
form.append("model", input.model);
|
|
@@ -18,7 +19,11 @@ export function createOpenAiTranscriptionProvider(input: {
|
|
|
18
19
|
try {
|
|
19
20
|
response = await fetchImpl(`${input.baseUrl}/audio/transcriptions`, {
|
|
20
21
|
method: "POST",
|
|
21
|
-
headers: {
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
24
|
+
// Observability only; the upstream API is not treated as idempotent.
|
|
25
|
+
"x-opengeni-request-id": requestId,
|
|
26
|
+
},
|
|
22
27
|
body: form,
|
|
23
28
|
...(signal ? { signal } : {}),
|
|
24
29
|
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { PreparedTranscriptionSegment, TranscriptionSegmenter } from "@opengeni/core";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
|
|
9
|
+
const STDERR_MAX_BYTES = 64 * 1024;
|
|
10
|
+
|
|
11
|
+
export class TranscriptionSegmenterError extends Error {
|
|
12
|
+
readonly name = "TranscriptionSegmenterError";
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
message: string,
|
|
16
|
+
readonly code: "unavailable" | "invalid_audio" | "cancelled" | "too_large" | "unknown",
|
|
17
|
+
readonly retryable: boolean,
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createFfmpegTranscriptionSegmenter(input: {
|
|
24
|
+
ffmpegPath: string;
|
|
25
|
+
}): TranscriptionSegmenter {
|
|
26
|
+
let availability: Promise<boolean> | null = null;
|
|
27
|
+
return {
|
|
28
|
+
available() {
|
|
29
|
+
availability ??= commandSucceeds(input.ffmpegPath, ["-version"]);
|
|
30
|
+
return availability;
|
|
31
|
+
},
|
|
32
|
+
async *segment(request): AsyncIterable<PreparedTranscriptionSegment> {
|
|
33
|
+
if (request.signal?.aborted) {
|
|
34
|
+
throw new TranscriptionSegmenterError(
|
|
35
|
+
"Audio segmentation was cancelled",
|
|
36
|
+
"cancelled",
|
|
37
|
+
true,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
!Number.isSafeInteger(request.providerSegmentSeconds) ||
|
|
42
|
+
request.providerSegmentSeconds <= 0 ||
|
|
43
|
+
!Number.isSafeInteger(request.totalDurationMilliseconds) ||
|
|
44
|
+
request.totalDurationMilliseconds <= 0
|
|
45
|
+
) {
|
|
46
|
+
throw new TranscriptionSegmenterError(
|
|
47
|
+
"Audio segmentation bounds are invalid",
|
|
48
|
+
"invalid_audio",
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
Math.ceil(request.totalDurationMilliseconds / (request.providerSegmentSeconds * 1_000)) >
|
|
54
|
+
1_000
|
|
55
|
+
) {
|
|
56
|
+
throw new TranscriptionSegmenterError(
|
|
57
|
+
"Audio exceeds the bounded segment projection",
|
|
58
|
+
"too_large",
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const directory = await mkdtemp(join(tmpdir(), "opengeni-transcription-"));
|
|
63
|
+
const inputPath = join(
|
|
64
|
+
directory,
|
|
65
|
+
`recording.${extensionForMimeType(request.sourceMimeType)}`,
|
|
66
|
+
);
|
|
67
|
+
const outputPattern = join(directory, "segment-%06d.wav");
|
|
68
|
+
try {
|
|
69
|
+
await writeChunks(inputPath, request.chunks, request.signal);
|
|
70
|
+
const result = await runCommand(
|
|
71
|
+
input.ffmpegPath,
|
|
72
|
+
[
|
|
73
|
+
"-nostdin",
|
|
74
|
+
"-hide_banner",
|
|
75
|
+
"-loglevel",
|
|
76
|
+
"error",
|
|
77
|
+
"-y",
|
|
78
|
+
"-i",
|
|
79
|
+
inputPath,
|
|
80
|
+
"-map",
|
|
81
|
+
"0:a:0",
|
|
82
|
+
"-vn",
|
|
83
|
+
"-ac",
|
|
84
|
+
"1",
|
|
85
|
+
"-ar",
|
|
86
|
+
"16000",
|
|
87
|
+
"-c:a",
|
|
88
|
+
"pcm_s16le",
|
|
89
|
+
"-f",
|
|
90
|
+
"segment",
|
|
91
|
+
"-segment_time",
|
|
92
|
+
String(request.providerSegmentSeconds),
|
|
93
|
+
"-reset_timestamps",
|
|
94
|
+
"1",
|
|
95
|
+
outputPattern,
|
|
96
|
+
],
|
|
97
|
+
request.signal,
|
|
98
|
+
);
|
|
99
|
+
if (result.cancelled) {
|
|
100
|
+
throw new TranscriptionSegmenterError(
|
|
101
|
+
"Audio segmentation was cancelled",
|
|
102
|
+
"cancelled",
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (result.spawnError) {
|
|
107
|
+
throw new TranscriptionSegmenterError(
|
|
108
|
+
"Audio segmentation is unavailable",
|
|
109
|
+
"unavailable",
|
|
110
|
+
true,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (result.exitCode !== 0) {
|
|
114
|
+
throw new TranscriptionSegmenterError(
|
|
115
|
+
result.stderr || "Audio could not be decoded",
|
|
116
|
+
"invalid_audio",
|
|
117
|
+
false,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const files = (await readdir(directory))
|
|
121
|
+
.filter((file) => /^segment-[0-9]{6}\.wav$/.test(file))
|
|
122
|
+
.sort();
|
|
123
|
+
if (files.length === 0 || files.length > 1_000) {
|
|
124
|
+
throw new TranscriptionSegmenterError(
|
|
125
|
+
"Audio did not produce a bounded segment set",
|
|
126
|
+
"invalid_audio",
|
|
127
|
+
false,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const segmentMilliseconds = request.providerSegmentSeconds * 1_000;
|
|
131
|
+
for (let segmentNumber = 0; segmentNumber < files.length; segmentNumber += 1) {
|
|
132
|
+
if (request.signal?.aborted) {
|
|
133
|
+
throw new TranscriptionSegmenterError(
|
|
134
|
+
"Audio segmentation was cancelled",
|
|
135
|
+
"cancelled",
|
|
136
|
+
true,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const bytes = new Uint8Array(await readFile(join(directory, files[segmentNumber]!)));
|
|
140
|
+
if (bytes.byteLength === 0) {
|
|
141
|
+
throw new TranscriptionSegmenterError(
|
|
142
|
+
"Audio produced an empty segment",
|
|
143
|
+
"invalid_audio",
|
|
144
|
+
false,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const startMilliseconds = segmentNumber * segmentMilliseconds;
|
|
148
|
+
const remaining = request.totalDurationMilliseconds - startMilliseconds;
|
|
149
|
+
if (remaining <= 0) {
|
|
150
|
+
throw new TranscriptionSegmenterError(
|
|
151
|
+
"Audio segment count exceeds the declared duration",
|
|
152
|
+
"invalid_audio",
|
|
153
|
+
false,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
yield {
|
|
157
|
+
segmentNumber,
|
|
158
|
+
startMilliseconds,
|
|
159
|
+
durationMilliseconds: Math.min(segmentMilliseconds, remaining),
|
|
160
|
+
mimeType: "audio/wav",
|
|
161
|
+
bytes,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof TranscriptionSegmenterError) throw error;
|
|
166
|
+
throw new TranscriptionSegmenterError(
|
|
167
|
+
error instanceof Error ? error.message : "Audio segmentation failed",
|
|
168
|
+
request.signal?.aborted ? "cancelled" : "unknown",
|
|
169
|
+
true,
|
|
170
|
+
);
|
|
171
|
+
} finally {
|
|
172
|
+
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function writeChunks(
|
|
179
|
+
path: string,
|
|
180
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const stream = createWriteStream(path, { flags: "wx" });
|
|
184
|
+
try {
|
|
185
|
+
for await (const chunk of chunks) {
|
|
186
|
+
if (signal?.aborted) {
|
|
187
|
+
throw new TranscriptionSegmenterError(
|
|
188
|
+
"Audio segmentation was cancelled",
|
|
189
|
+
"cancelled",
|
|
190
|
+
true,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!stream.write(chunk)) await once(stream, "drain");
|
|
194
|
+
}
|
|
195
|
+
stream.end();
|
|
196
|
+
await once(stream, "close");
|
|
197
|
+
} catch (error) {
|
|
198
|
+
stream.destroy();
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extensionForMimeType(mimeType: string): string {
|
|
204
|
+
switch (mimeType.trim().toLowerCase().split(";", 1)[0]) {
|
|
205
|
+
case "audio/mp4":
|
|
206
|
+
case "audio/m4a":
|
|
207
|
+
return "mp4";
|
|
208
|
+
case "audio/ogg":
|
|
209
|
+
return "ogg";
|
|
210
|
+
case "audio/mpeg":
|
|
211
|
+
case "audio/mp3":
|
|
212
|
+
return "mp3";
|
|
213
|
+
case "audio/wav":
|
|
214
|
+
case "audio/x-wav":
|
|
215
|
+
return "wav";
|
|
216
|
+
case "audio/webm":
|
|
217
|
+
default:
|
|
218
|
+
return "webm";
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
|
|
223
|
+
const result = await runCommand(command, args);
|
|
224
|
+
return !result.spawnError && result.exitCode === 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function runCommand(
|
|
228
|
+
command: string,
|
|
229
|
+
args: string[],
|
|
230
|
+
signal?: AbortSignal,
|
|
231
|
+
): Promise<{ exitCode: number | null; stderr: string; spawnError: boolean; cancelled: boolean }> {
|
|
232
|
+
return await new Promise((resolve) => {
|
|
233
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
234
|
+
let stderr = Buffer.alloc(0);
|
|
235
|
+
let spawnError = false;
|
|
236
|
+
let settled = false;
|
|
237
|
+
const onAbort = () => child.kill("SIGKILL");
|
|
238
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
239
|
+
if (signal?.aborted) onAbort();
|
|
240
|
+
child.stderr.on("data", (chunk: Uint8Array) => {
|
|
241
|
+
if (stderr.byteLength >= STDERR_MAX_BYTES) return;
|
|
242
|
+
const remaining = STDERR_MAX_BYTES - stderr.byteLength;
|
|
243
|
+
stderr = Buffer.concat([stderr, Buffer.from(chunk).subarray(0, remaining)]);
|
|
244
|
+
});
|
|
245
|
+
child.once("error", () => {
|
|
246
|
+
spawnError = true;
|
|
247
|
+
});
|
|
248
|
+
child.once("close", (exitCode) => {
|
|
249
|
+
if (settled) return;
|
|
250
|
+
settled = true;
|
|
251
|
+
signal?.removeEventListener("abort", onAbort);
|
|
252
|
+
resolve({
|
|
253
|
+
exitCode,
|
|
254
|
+
stderr: stderr.toString("utf8").trim(),
|
|
255
|
+
spawnError,
|
|
256
|
+
cancelled: signal?.aborted ?? false,
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
filenameForMimeType,
|
|
5
5
|
isAcceptedMimeType,
|
|
6
6
|
normalizeMimeType,
|
|
7
|
+
TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS,
|
|
7
8
|
type TranscriptionAvailabilityContext,
|
|
8
9
|
type TranscriptionProvider,
|
|
9
10
|
type TranscriptionService,
|
|
@@ -20,6 +21,10 @@ export function createTranscriptionService(input: {
|
|
|
20
21
|
fetch?: typeof fetch;
|
|
21
22
|
codexFetch?: typeof fetch;
|
|
22
23
|
probeCodex?: (context?: TranscriptionAvailabilityContext) => boolean | Promise<boolean>;
|
|
24
|
+
/** Test seam for exercising timeout and late-completion behavior quickly. */
|
|
25
|
+
providerRequestTimeoutMilliseconds?: number;
|
|
26
|
+
/** Test seam for evaluating persisted absolute deadlines after delayed setup. */
|
|
27
|
+
now?: () => Date;
|
|
23
28
|
}): TranscriptionService {
|
|
24
29
|
const providers: TranscriptionProvider[] = resolveVoiceInputProviderRegistry(input.settings).map(
|
|
25
30
|
(config) => {
|
|
@@ -51,6 +56,9 @@ export function createTranscriptionService(input: {
|
|
|
51
56
|
maxSizeBytes: input.settings.voiceInputMaxSizeBytes,
|
|
52
57
|
acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES],
|
|
53
58
|
};
|
|
59
|
+
const providerRequestTimeoutMilliseconds =
|
|
60
|
+
input.providerRequestTimeoutMilliseconds ?? TRANSCRIPTION_PROVIDER_REQUEST_TIMEOUT_MILLISECONDS;
|
|
61
|
+
const now = input.now ?? (() => new Date());
|
|
54
62
|
return {
|
|
55
63
|
limits: () => limits,
|
|
56
64
|
async available(context) {
|
|
@@ -58,6 +66,9 @@ export function createTranscriptionService(input: {
|
|
|
58
66
|
Boolean,
|
|
59
67
|
);
|
|
60
68
|
},
|
|
69
|
+
async selectProvider(context) {
|
|
70
|
+
return (await firstAvailable(providers, context))?.id ?? null;
|
|
71
|
+
},
|
|
61
72
|
async transcribe(request) {
|
|
62
73
|
const mimeType = normalizeMimeType(request.mimeType);
|
|
63
74
|
if (!isAcceptedMimeType(mimeType, limits.acceptedMimeTypes)) {
|
|
@@ -83,23 +94,65 @@ export function createTranscriptionService(input: {
|
|
|
83
94
|
message: "Invalid audio duration.",
|
|
84
95
|
});
|
|
85
96
|
}
|
|
86
|
-
const provider =
|
|
87
|
-
workspaceId: request.workspaceId
|
|
88
|
-
|
|
97
|
+
const provider = request.providerId
|
|
98
|
+
? await exactAvailable(providers, request.providerId, { workspaceId: request.workspaceId })
|
|
99
|
+
: await firstAvailable(providers, { workspaceId: request.workspaceId });
|
|
89
100
|
if (!provider) {
|
|
90
101
|
throw new TranscriptionServiceError({
|
|
91
102
|
code: "unavailable",
|
|
92
103
|
message: "Transcription is unavailable.",
|
|
93
104
|
});
|
|
94
105
|
}
|
|
106
|
+
if (provider.supportsServerDeadline !== true) {
|
|
107
|
+
throw new TranscriptionServiceError({
|
|
108
|
+
code: "unavailable",
|
|
109
|
+
message: "Transcription provider does not support bounded requests.",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
95
112
|
const startedAt = performance.now();
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
const remainingMilliseconds = request.providerDeadlineAt
|
|
114
|
+
? remainingTranscriptionProviderRequestMilliseconds(request.providerDeadlineAt, now())
|
|
115
|
+
: providerRequestTimeoutMilliseconds;
|
|
116
|
+
if (
|
|
117
|
+
request.providerDeadlineAt &&
|
|
118
|
+
(!Number.isFinite(remainingMilliseconds) || remainingMilliseconds <= 0)
|
|
119
|
+
) {
|
|
120
|
+
throw new TranscriptionServiceError({
|
|
121
|
+
code: "timeout",
|
|
122
|
+
message: "Transcription provider deadline expired.",
|
|
123
|
+
retryable: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const deadline = createProviderRequestDeadline(request.signal, remainingMilliseconds);
|
|
127
|
+
let result: { text: string; languages: string[] };
|
|
128
|
+
try {
|
|
129
|
+
result = await provider.transcribe({
|
|
130
|
+
audio: request.audio,
|
|
131
|
+
mimeType,
|
|
132
|
+
filename: filenameForMimeType(mimeType),
|
|
133
|
+
workspaceId: request.workspaceId,
|
|
134
|
+
requestId: request.requestId,
|
|
135
|
+
signal: deadline.signal,
|
|
136
|
+
});
|
|
137
|
+
if (deadline.timedOut && !request.signal?.aborted) {
|
|
138
|
+
throw new TranscriptionServiceError({
|
|
139
|
+
code: "timeout",
|
|
140
|
+
message: "Transcription provider timed out.",
|
|
141
|
+
retryable: true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (deadline.timedOut && !request.signal?.aborted) {
|
|
146
|
+
throw new TranscriptionServiceError({
|
|
147
|
+
code: "timeout",
|
|
148
|
+
message: "Transcription provider timed out.",
|
|
149
|
+
retryable: true,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
} finally {
|
|
154
|
+
deadline.dispose();
|
|
155
|
+
}
|
|
103
156
|
return {
|
|
104
157
|
...result,
|
|
105
158
|
providerId: provider.id,
|
|
@@ -110,6 +163,45 @@ export function createTranscriptionService(input: {
|
|
|
110
163
|
};
|
|
111
164
|
}
|
|
112
165
|
|
|
166
|
+
export function remainingTranscriptionProviderRequestMilliseconds(
|
|
167
|
+
providerDeadlineAt: Date,
|
|
168
|
+
now: Date,
|
|
169
|
+
): number {
|
|
170
|
+
return providerDeadlineAt.getTime() - now.getTime();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function createProviderRequestDeadline(
|
|
174
|
+
parentSignal: AbortSignal | undefined,
|
|
175
|
+
timeoutMilliseconds: number,
|
|
176
|
+
): { signal: AbortSignal; readonly timedOut: boolean; dispose: () => void } {
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
let timedOut = false;
|
|
179
|
+
const timeout = setTimeout(
|
|
180
|
+
() => {
|
|
181
|
+
timedOut = true;
|
|
182
|
+
controller.abort(new DOMException("Transcription provider timed out", "TimeoutError"));
|
|
183
|
+
},
|
|
184
|
+
Math.max(1, Math.ceil(timeoutMilliseconds)),
|
|
185
|
+
);
|
|
186
|
+
const abortFromParent = () => {
|
|
187
|
+
controller.abort(parentSignal?.reason);
|
|
188
|
+
};
|
|
189
|
+
if (parentSignal) {
|
|
190
|
+
if (parentSignal.aborted) abortFromParent();
|
|
191
|
+
else parentSignal.addEventListener("abort", abortFromParent, { once: true });
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
signal: controller.signal,
|
|
195
|
+
get timedOut() {
|
|
196
|
+
return timedOut;
|
|
197
|
+
},
|
|
198
|
+
dispose: () => {
|
|
199
|
+
clearTimeout(timeout);
|
|
200
|
+
parentSignal?.removeEventListener("abort", abortFromParent);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
113
205
|
async function firstAvailable(
|
|
114
206
|
providers: readonly TranscriptionProvider[],
|
|
115
207
|
context: TranscriptionAvailabilityContext,
|
|
@@ -119,3 +211,12 @@ async function firstAvailable(
|
|
|
119
211
|
}
|
|
120
212
|
return null;
|
|
121
213
|
}
|
|
214
|
+
|
|
215
|
+
async function exactAvailable(
|
|
216
|
+
providers: readonly TranscriptionProvider[],
|
|
217
|
+
providerId: string,
|
|
218
|
+
context: TranscriptionAvailabilityContext,
|
|
219
|
+
) {
|
|
220
|
+
const provider = providers.find((candidate) => candidate.id === providerId);
|
|
221
|
+
return provider && (await provider.available(context)) ? provider : null;
|
|
222
|
+
}
|