@opengeni/api-router 1.3.0 → 1.4.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.
Files changed (42) hide show
  1. package/dist/access-grant-rls.d.ts +7 -0
  2. package/dist/app.d.ts +2 -2
  3. package/dist/app.js +1 -1
  4. package/dist/azure-live.d.ts +28 -0
  5. package/dist/{chunk-IZEGLLKZ.js → chunk-F3LQ6VWR.js} +1334 -486
  6. package/dist/{chunk-IZEGLLKZ.js.map → chunk-F3LQ6VWR.js.map} +1 -1
  7. package/dist/codemode.d.ts +3 -3
  8. package/dist/http/api-error.d.ts +14 -0
  9. package/dist/index.js +1 -1
  10. package/dist/integrations/oauth-profiles.d.ts +3 -1
  11. package/dist/routes/sessions.d.ts +2 -0
  12. package/dist/transcription/billing-refusal.d.ts +8 -0
  13. package/dist/transcription/normalize.d.ts +23 -0
  14. package/dist/transcription/providers/azure-mai.d.ts +12 -0
  15. package/dist/transcription/providers/azure-openai.d.ts +4 -0
  16. package/dist/transcription/providers/openai.d.ts +16 -0
  17. package/dist/transcription/service.d.ts +10 -1
  18. package/dist/transcription/wav-duration.d.ts +8 -0
  19. package/package.json +20 -20
  20. package/src/access-grant-rls.ts +18 -10
  21. package/src/app.ts +62 -8
  22. package/src/azure-live.ts +103 -0
  23. package/src/codemode.ts +3 -5
  24. package/src/gateway-realtime.ts +11 -7
  25. package/src/http/api-error.ts +36 -1
  26. package/src/integrations/oauth-client.ts +9 -17
  27. package/src/integrations/oauth-profiles.ts +17 -6
  28. package/src/routes/editable-artifacts.ts +8 -0
  29. package/src/routes/session-artifact-associations.ts +80 -6
  30. package/src/routes/sessions.ts +263 -66
  31. package/src/routes/transcription-recordings.ts +71 -4
  32. package/src/routes/transcriptions.ts +26 -2
  33. package/src/routes/workspaces.ts +49 -7
  34. package/src/sandbox/channel-a.ts +22 -0
  35. package/src/transcription/billing-refusal.ts +14 -0
  36. package/src/transcription/normalize.ts +77 -0
  37. package/src/transcription/providers/azure-mai.ts +98 -0
  38. package/src/transcription/providers/azure-openai.ts +16 -7
  39. package/src/transcription/providers/openai.ts +79 -5
  40. package/src/transcription/segmenter.ts +7 -1
  41. package/src/transcription/service.ts +215 -10
  42. package/src/transcription/wav-duration.ts +39 -0
@@ -1,3 +1,9 @@
1
+ import { AZURE_LIVE_MODEL_ID } from "../azure-live";
2
+ import {
3
+ createRealtimeVoiceBilling,
4
+ deploymentRealtimeVoice,
5
+ realtimeVoiceOfferProblem,
6
+ } from "@opengeni/core";
1
7
  import { withDirectModelProviders } from "@opengeni/config";
2
8
  import { listConnectionsMetadata } from "@opengeni/db";
3
9
  import {
@@ -950,16 +956,52 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
950
956
  : { available: false, unavailableReason: credentialReason };
951
957
  };
952
958
  const gatewayModels = Object.values(AI_GATEWAY_REALTIME_MODELS);
953
- const models = [
954
- ...gatewayModels.map((model, index) => ({
959
+ // Deployment-funded voice is listed only when this deployment can offer
960
+ // it (configured, and priced while credits are enforced). Hosted GPT Live
961
+ // replaces the managed Gateway choices when configured.
962
+ const managedCandidates = [
963
+ {
964
+ id: AZURE_LIVE_MODEL_ID,
965
+ label: "GPT Live 1",
966
+ description: "Realtime voice with session delegation",
967
+ },
968
+ ...gatewayModels.map((model) => ({
955
969
  id: model.managedModelId,
956
970
  label: model.label,
957
- provider: "OpenGeni" as const,
958
971
  description: model.description,
959
- ...availability(
960
- Boolean(deps.settings.vercelAiGatewayApiKey),
961
- "Opengeni Gateway voice is not configured",
962
- ),
972
+ })),
973
+ ]
974
+ .filter((model) => {
975
+ const voice = deploymentRealtimeVoice(deps.settings, model.id);
976
+ return voice !== null && realtimeVoiceOfferProblem(deps.settings, voice) === null;
977
+ })
978
+ .filter((model, _index, offered) =>
979
+ offered.some((candidate) => candidate.id === AZURE_LIVE_MODEL_ID)
980
+ ? model.id === AZURE_LIVE_MODEL_ID
981
+ : true,
982
+ );
983
+ const creditStanding =
984
+ managedCandidates.length > 0
985
+ ? await createRealtimeVoiceBilling({
986
+ db: deps.db,
987
+ settings: deps.settings,
988
+ }).creditStanding(grant.accountId)
989
+ : "none";
990
+ const hasCredits = creditStanding === "spendable";
991
+ const models = [
992
+ ...managedCandidates.map((model, index) => ({
993
+ ...model,
994
+ provider: "OpenGeni" as const,
995
+ ...(hasCredits
996
+ ? { available: true, unavailableReason: null, unavailableCode: null }
997
+ : {
998
+ available: false,
999
+ unavailableReason:
1000
+ creditStanding === "promotional_only"
1001
+ ? "Promotional credits don't cover live voice. Add credits to use it."
1002
+ : "Add Opengeni credits to use live voice",
1003
+ unavailableCode: "insufficient_credits",
1004
+ }),
963
1005
  recommended: index === 0,
964
1006
  })),
965
1007
  {
@@ -69,6 +69,7 @@ import {
69
69
  SandboxResumeIdentityMismatchError,
70
70
  SandboxResumeIdentityUnavailableError,
71
71
  RoutingActiveRouteChangedError,
72
+ RoutingMutationOutputRejectedError,
72
73
  RoutingWorkspaceRootChangedError,
73
74
  SelfhostedWorkspaceRootChangedError,
74
75
  ChannelAConflictError,
@@ -1214,6 +1215,20 @@ export function mapChannelAError(error: unknown, waitSignal?: AbortSignal): unkn
1214
1215
  error instanceof SandboxResumeIdentityUnavailableError
1215
1216
  )
1216
1217
  return new HTTPException(409, { message: error.message });
1218
+ if (error instanceof RoutingMutationOutputRejectedError) {
1219
+ const revoked = error.reasonCode === "authority_revoked";
1220
+ return new ApiHttpError(revoked ? 403 : 409, {
1221
+ code: revoked ? "forbidden" : "conflict",
1222
+ message: error.message,
1223
+ retryable: false,
1224
+ outcomeUnknown: false,
1225
+ details: {
1226
+ code: error.code,
1227
+ reasonCode: error.reasonCode,
1228
+ physicalOutcome: "resolved",
1229
+ },
1230
+ });
1231
+ }
1217
1232
  if (
1218
1233
  error instanceof RoutingActiveRouteChangedError ||
1219
1234
  error instanceof RoutingWorkspaceRootChangedError ||
@@ -1309,6 +1324,13 @@ export function channelAOperationFailureDiagnostic(
1309
1324
  errorCode: "sandbox_channel_a_cancelled",
1310
1325
  };
1311
1326
  }
1327
+ if (error instanceof RoutingMutationOutputRejectedError) {
1328
+ return {
1329
+ reason: "request_rejected",
1330
+ status: error.reasonCode === "authority_revoked" ? 403 : 409,
1331
+ errorCode: "sandbox_channel_a_operation_failed",
1332
+ };
1333
+ }
1312
1334
  if (error instanceof SandboxProviderReadLockUnavailableError) {
1313
1335
  return {
1314
1336
  reason: "provider_read_busy",
@@ -0,0 +1,14 @@
1
+ import type { TranscriptionBillingRefusedError } from "@opengeni/core";
2
+ import type { Context } from "hono";
3
+
4
+ /**
5
+ * Same body shape as session admission refusals (`code` + `message`, plus
6
+ * allowance `scope`/`resetsAt`), so SDK/React clients classify it as a
7
+ * definitive credit/allowance refusal rather than a retryable failure.
8
+ */
9
+ export function transcriptionBillingRefusal(
10
+ c: Context,
11
+ error: TranscriptionBillingRefusedError,
12
+ ): Response {
13
+ return c.json({ code: error.code, message: error.message, ...error.details }, error.status);
14
+ }
@@ -0,0 +1,77 @@
1
+ import { TranscriptionServiceError } from "@opengeni/core";
2
+ import { createFfmpegTranscriptionSegmenter, TranscriptionSegmenterError } from "./segmenter";
3
+ import { wavDurationSeconds } from "./wav-duration";
4
+
5
+ /**
6
+ * Decode one uploaded recording to 16 kHz mono PCM WAV with a hard decode
7
+ * ceiling, and measure its duration from the bytes the server produced.
8
+ */
9
+ export type TranscriptionAudioNormalizer = (input: {
10
+ audio: Uint8Array;
11
+ mimeType: string;
12
+ /** Longest accepted recording; anything longer is refused, never truncated. */
13
+ maxDurationSeconds: number;
14
+ signal?: AbortSignal | undefined;
15
+ }) => Promise<{ bytes: Uint8Array; durationSeconds: number }>;
16
+
17
+ /**
18
+ * ffmpeg decodes at most `maxDurationSeconds + 1` seconds (`-t`), so a long or
19
+ * hostile upload costs bounded CPU and disk. Output that reaches that ceiling
20
+ * was longer than the limit and is refused as `too_large` before any provider
21
+ * call, so the transcript the user receives is never silently cut short.
22
+ */
23
+ export function createFfmpegAudioNormalizer(input: {
24
+ ffmpegPath: string;
25
+ }): TranscriptionAudioNormalizer {
26
+ const segmenter = createFfmpegTranscriptionSegmenter({ ffmpegPath: input.ffmpegPath });
27
+ return async ({ audio, mimeType, maxDurationSeconds, signal }) => {
28
+ if (!Number.isFinite(maxDurationSeconds) || maxDurationSeconds <= 0) {
29
+ throw new TranscriptionServiceError({
30
+ code: "unavailable",
31
+ message: "Voice input duration limit is not configured.",
32
+ });
33
+ }
34
+ const decodeSeconds = Math.ceil(maxDurationSeconds) + 1;
35
+ // One segment strictly longer than the decode ceiling: never a second file.
36
+ const segmentSeconds = decodeSeconds + 1;
37
+ let normalized: Uint8Array | undefined;
38
+ try {
39
+ for await (const segment of segmenter.segment({
40
+ sourceMimeType: mimeType,
41
+ chunks: (async function* () {
42
+ yield audio;
43
+ })(),
44
+ providerSegmentSeconds: segmentSeconds,
45
+ totalDurationMilliseconds: segmentSeconds * 1_000,
46
+ maxDecodeSeconds: decodeSeconds,
47
+ ...(signal ? { signal } : {}),
48
+ })) {
49
+ if (normalized) {
50
+ throw new TranscriptionServiceError({ code: "too_large", message: "Audio is too long." });
51
+ }
52
+ normalized = segment.bytes;
53
+ }
54
+ } catch (error) {
55
+ if (error instanceof TranscriptionSegmenterError) {
56
+ throw new TranscriptionServiceError({
57
+ code: error.code === "unknown" ? "invalid_audio" : error.code,
58
+ message: "Audio could not be prepared for transcription.",
59
+ retryable: error.retryable,
60
+ });
61
+ }
62
+ throw error;
63
+ }
64
+ const durationSeconds = normalized ? wavDurationSeconds(normalized) : null;
65
+ if (!normalized || durationSeconds === null || !(durationSeconds > 0)) {
66
+ throw new TranscriptionServiceError({
67
+ code: "invalid_audio",
68
+ message: "Audio could not be decoded.",
69
+ });
70
+ }
71
+ // Reaching the decode ceiling (within one 20 ms frame) means the source ran past it.
72
+ if (durationSeconds > decodeSeconds - 0.02) {
73
+ throw new TranscriptionServiceError({ code: "too_large", message: "Audio is too long." });
74
+ }
75
+ return { bytes: normalized, durationSeconds };
76
+ };
77
+ }
@@ -0,0 +1,98 @@
1
+ import type { VoiceInputPricing } from "@opengeni/config";
2
+ import type { TranscriptionProvider } from "@opengeni/core";
3
+ import { createFfmpegAudioNormalizer } from "../normalize";
4
+ import { fetchError, responseError } from "./openai";
5
+
6
+ /** Azure Speech file transcription; credentials and model selection stay server-side. */
7
+ export function createMaiTranscriptionProvider(input: {
8
+ endpoint: string;
9
+ apiKey: string;
10
+ apiVersion: string;
11
+ model: string;
12
+ pricing?: VoiceInputPricing | null;
13
+ ffmpegPath: string;
14
+ fetch?: typeof fetch;
15
+ }): TranscriptionProvider {
16
+ const fetchImpl = input.fetch ?? fetch;
17
+ const normalize = createFfmpegAudioNormalizer({ ffmpegPath: input.ffmpegPath });
18
+ return {
19
+ id: "azure-mai",
20
+ supportsServerDeadline: true,
21
+ deploymentFunded: { model: input.model, pricing: input.pricing ?? null },
22
+ available: () => Boolean(input.apiKey),
23
+ async transcribe({ audio, mimeType, filename, requestId, signal }) {
24
+ let bytes = audio;
25
+ let contentType = mimeType;
26
+ let name = filename;
27
+ // Resumable segments and credit-billed one-shot uploads already arrive
28
+ // as server-normalized WAV. Anything else (credit billing off) gets the
29
+ // same bounded decoder before entering Speech's file API.
30
+ if (!["audio/wav", "audio/x-wav", "audio/mpeg", "audio/flac"].includes(mimeType)) {
31
+ const normalized = await normalize({
32
+ audio,
33
+ mimeType,
34
+ maxDurationSeconds: 600,
35
+ ...(signal ? { signal } : {}),
36
+ });
37
+ bytes = normalized.bytes;
38
+ contentType = "audio/wav";
39
+ name = "audio.wav";
40
+ }
41
+ const form = new FormData();
42
+ form.append("audio", new Blob([Uint8Array.from(bytes).buffer], { type: contentType }), name);
43
+ form.append(
44
+ "definition",
45
+ JSON.stringify({ enhancedMode: { enabled: true, model: input.model } }),
46
+ );
47
+ let response: Response;
48
+ try {
49
+ response = await fetchImpl(
50
+ `${input.endpoint.replace(/\/+$/, "")}/speechtotext/transcriptions:transcribe?api-version=${encodeURIComponent(input.apiVersion)}`,
51
+ {
52
+ method: "POST",
53
+ headers: {
54
+ "Ocp-Apim-Subscription-Key": input.apiKey,
55
+ "x-opengeni-request-id": requestId,
56
+ },
57
+ body: form,
58
+ ...(signal ? { signal } : {}),
59
+ },
60
+ );
61
+ } catch (error) {
62
+ throw fetchError(error);
63
+ }
64
+ if (!response.ok) throw responseError(response.status);
65
+ const body = await response.json().catch(() => null);
66
+ if (
67
+ !body ||
68
+ !Array.isArray(body.combinedPhrases) ||
69
+ !body.combinedPhrases.every(
70
+ (phrase: unknown) =>
71
+ typeof phrase === "object" &&
72
+ phrase !== null &&
73
+ "text" in phrase &&
74
+ typeof phrase.text === "string",
75
+ )
76
+ ) {
77
+ throw responseError(502);
78
+ }
79
+ return {
80
+ usage:
81
+ typeof body.durationMilliseconds === "number" &&
82
+ Number.isFinite(body.durationMilliseconds) &&
83
+ body.durationMilliseconds >= 0
84
+ ? { kind: "duration" as const, seconds: body.durationMilliseconds / 1000 }
85
+ : null,
86
+ text: body.combinedPhrases.map((phrase: { text: string }) => phrase.text).join("\n"),
87
+ languages: [
88
+ ...new Set<string>(
89
+ (Array.isArray(body.phrases) ? body.phrases : []).flatMap(
90
+ (phrase: { locale?: unknown }) =>
91
+ typeof phrase?.locale === "string" && phrase.locale ? [phrase.locale] : [],
92
+ ),
93
+ ),
94
+ ],
95
+ };
96
+ },
97
+ };
98
+ }
@@ -1,5 +1,6 @@
1
+ import { voiceInputModelReportsDurationOnly, type VoiceInputPricing } from "@opengeni/config";
1
2
  import type { TranscriptionProvider } from "@opengeni/core";
2
- import { fetchError, responseError } from "./openai";
3
+ import { fetchError, parseTranscriptionResponseBody, responseError } from "./openai";
3
4
 
4
5
  export function createAzureOpenAiTranscriptionProvider(input: {
5
6
  endpoint: string;
@@ -7,17 +8,27 @@ export function createAzureOpenAiTranscriptionProvider(input: {
7
8
  apiVersion: string;
8
9
  apiKey: string | null;
9
10
  adToken: string | null;
11
+ /** Underlying model of the deployment; defaults to the deployment name. */
12
+ model?: string;
13
+ pricing?: VoiceInputPricing | null;
10
14
  fetch?: typeof fetch;
11
15
  }): TranscriptionProvider {
12
16
  const fetchImpl = input.fetch ?? fetch;
13
- const url = `${input.endpoint}/openai/deployments/${encodeURIComponent(input.deployment)}/audio/transcriptions?api-version=${encodeURIComponent(input.apiVersion)}`;
17
+ const model = input.model ?? input.deployment;
18
+ const endpoint = input.endpoint.replace(/\/+$/, "");
19
+ const url = `${endpoint}/openai/deployments/${encodeURIComponent(input.deployment)}/audio/transcriptions?api-version=${encodeURIComponent(input.apiVersion)}`;
14
20
  return {
15
21
  id: "azure-openai",
16
22
  supportsServerDeadline: true,
23
+ deploymentFunded: { model, pricing: input.pricing ?? null },
17
24
  available: () => Boolean(input.apiKey || input.adToken),
18
25
  async transcribe({ audio, mimeType, filename, requestId, signal }) {
19
26
  const form = new FormData();
20
27
  form.append("file", new Blob([Uint8Array.from(audio).buffer], { type: mimeType }), filename);
28
+ // Whisper reports its billed audio duration only in verbose_json.
29
+ if (voiceInputModelReportsDurationOnly(model)) {
30
+ form.append("response_format", "verbose_json");
31
+ }
21
32
  const headers: Record<string, string> = input.apiKey
22
33
  ? { "api-key": input.apiKey, "x-opengeni-request-id": requestId }
23
34
  : { Authorization: `Bearer ${input.adToken}`, "x-opengeni-request-id": requestId };
@@ -34,13 +45,11 @@ export function createAzureOpenAiTranscriptionProvider(input: {
34
45
  }
35
46
  if (!response.ok) throw responseError(response.status);
36
47
  const body = await response.json().catch(() => null);
37
- if (!body || typeof body.text !== "string") {
48
+ const parsed = parseTranscriptionResponseBody(body);
49
+ if (!parsed) {
38
50
  throw responseError(502);
39
51
  }
40
- return {
41
- text: body.text,
42
- languages: typeof body.language === "string" && body.language ? [body.language] : [],
43
- };
52
+ return parsed;
44
53
  },
45
54
  };
46
55
  }
@@ -1,20 +1,31 @@
1
+ import {
2
+ voiceInputModelReportsDurationOnly,
3
+ type VoiceInputPricing,
4
+ type VoiceInputUsage,
5
+ } from "@opengeni/config";
1
6
  import { type TranscriptionProvider, TranscriptionServiceError } from "@opengeni/core";
2
7
 
3
8
  export function createOpenAiTranscriptionProvider(input: {
4
9
  apiKey: string;
5
10
  baseUrl: string;
6
11
  model: string;
12
+ pricing?: VoiceInputPricing | null;
7
13
  fetch?: typeof fetch;
8
14
  }): TranscriptionProvider {
9
15
  const fetchImpl = input.fetch ?? fetch;
10
16
  return {
11
17
  id: "openai",
12
18
  supportsServerDeadline: true,
19
+ deploymentFunded: { model: input.model, pricing: input.pricing ?? null },
13
20
  available: () => true,
14
21
  async transcribe({ audio, mimeType, filename, requestId, signal }) {
15
22
  const form = new FormData();
16
23
  form.append("file", audioBlob(audio, mimeType), filename);
17
24
  form.append("model", input.model);
25
+ // Whisper reports its billed audio duration only in verbose_json.
26
+ if (voiceInputModelReportsDurationOnly(input.model)) {
27
+ form.append("response_format", "verbose_json");
28
+ }
18
29
  let response: Response;
19
30
  try {
20
31
  response = await fetchImpl(`${input.baseUrl}/audio/transcriptions`, {
@@ -32,20 +43,83 @@ export function createOpenAiTranscriptionProvider(input: {
32
43
  }
33
44
  if (!response.ok) throw responseError(response.status);
34
45
  const body = await response.json().catch(() => null);
35
- if (!body || typeof body.text !== "string") {
46
+ const parsed = parseTranscriptionResponseBody(body);
47
+ if (!parsed) {
36
48
  throw new TranscriptionServiceError({
37
49
  code: "provider",
38
50
  message: "Invalid transcription response.",
39
51
  });
40
52
  }
41
- return {
42
- text: body.text,
43
- languages: typeof body.language === "string" && body.language ? [body.language] : [],
44
- };
53
+ return parsed;
45
54
  },
46
55
  };
47
56
  }
48
57
 
58
+ /**
59
+ * OpenAI-compatible `/audio/transcriptions` JSON (OpenAI and Azure OpenAI).
60
+ * `usage` is `{type:"tokens", input_tokens, input_token_details:{audio_tokens,
61
+ * text_tokens}, output_tokens, total_tokens}` for the gpt-4o-transcribe family
62
+ * or `{type:"duration", seconds}` for duration-billed models; Whisper
63
+ * `verbose_json` carries the processed audio `duration`. Unusable usage is
64
+ * dropped (null), never guessed.
65
+ */
66
+ export function parseTranscriptionResponseBody(
67
+ body: unknown,
68
+ ): { text: string; languages: string[]; usage: VoiceInputUsage | null } | null {
69
+ if (!body || typeof body !== "object") return null;
70
+ const record = body as Record<string, unknown>;
71
+ if (typeof record.text !== "string") return null;
72
+ const languages =
73
+ typeof record.language === "string" && record.language
74
+ ? [record.language]
75
+ : Array.isArray(record.languages)
76
+ ? record.languages.flatMap((language) => {
77
+ const code = typeof language === "string" ? language : language?.code;
78
+ return typeof code === "string" && code ? [code] : [];
79
+ })
80
+ : [];
81
+ return { text: record.text, languages, usage: parseTranscriptionUsage(record) };
82
+ }
83
+
84
+ export function parseTranscriptionUsage(body: Record<string, unknown>): VoiceInputUsage | null {
85
+ const usage = body.usage;
86
+ if (usage && typeof usage === "object") {
87
+ const record = usage as Record<string, unknown>;
88
+ if (record.type === "tokens") {
89
+ const inputTokens = count(record.input_tokens);
90
+ const outputTokens = count(record.output_tokens);
91
+ if (inputTokens !== null && outputTokens !== null) {
92
+ const details =
93
+ record.input_token_details && typeof record.input_token_details === "object"
94
+ ? (record.input_token_details as Record<string, unknown>)
95
+ : {};
96
+ return {
97
+ kind: "tokens",
98
+ inputTokens,
99
+ audioInputTokens: count(details.audio_tokens) ?? 0,
100
+ textInputTokens: count(details.text_tokens) ?? 0,
101
+ outputTokens,
102
+ };
103
+ }
104
+ }
105
+ if (record.type === "duration" && seconds(record.seconds) !== null) {
106
+ return { kind: "duration", seconds: seconds(record.seconds)! };
107
+ }
108
+ }
109
+ const duration = seconds(body.duration);
110
+ return duration === null ? null : { kind: "duration", seconds: duration };
111
+ }
112
+
113
+ function count(value: unknown): number | null {
114
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
115
+ }
116
+
117
+ function seconds(value: unknown): number | null {
118
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value < 86_400
119
+ ? value
120
+ : null;
121
+ }
122
+
49
123
  function audioBlob(audio: Uint8Array, mimeType: string): Blob {
50
124
  return new Blob([Uint8Array.from(audio).buffer], { type: mimeType });
51
125
  }
@@ -41,7 +41,9 @@ export function createFfmpegTranscriptionSegmenter(input: {
41
41
  !Number.isSafeInteger(request.providerSegmentSeconds) ||
42
42
  request.providerSegmentSeconds <= 0 ||
43
43
  !Number.isSafeInteger(request.totalDurationMilliseconds) ||
44
- request.totalDurationMilliseconds <= 0
44
+ request.totalDurationMilliseconds <= 0 ||
45
+ (request.maxDecodeSeconds !== undefined &&
46
+ (!Number.isSafeInteger(request.maxDecodeSeconds) || request.maxDecodeSeconds <= 0))
45
47
  ) {
46
48
  throw new TranscriptionSegmenterError(
47
49
  "Audio segmentation bounds are invalid",
@@ -77,6 +79,10 @@ export function createFfmpegTranscriptionSegmenter(input: {
77
79
  "-y",
78
80
  "-i",
79
81
  inputPath,
82
+ // Output-side ceiling: audio past it is never decoded or written.
83
+ ...(request.maxDecodeSeconds !== undefined
84
+ ? ["-t", String(request.maxDecodeSeconds)]
85
+ : []),
80
86
  "-map",
81
87
  "0:a:0",
82
88
  "-vn",