@tanstack/ai-grok 0.6.7 → 0.7.0
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/esm/adapters/image.js +36 -17
- package/dist/esm/adapters/image.js.map +1 -1
- package/dist/esm/adapters/summarize.js +51 -22
- package/dist/esm/adapters/summarize.js.map +1 -1
- package/dist/esm/adapters/text.js +25 -10
- package/dist/esm/adapters/text.js.map +1 -1
- package/dist/esm/adapters/transcription.d.ts +84 -0
- package/dist/esm/adapters/transcription.js +109 -0
- package/dist/esm/adapters/transcription.js.map +1 -0
- package/dist/esm/adapters/tts.d.ts +70 -0
- package/dist/esm/adapters/tts.js +137 -0
- package/dist/esm/adapters/tts.js.map +1 -0
- package/dist/esm/audio/transcription-provider-options.d.ts +41 -0
- package/dist/esm/audio/tts-provider-options.d.ts +42 -0
- package/dist/esm/index.d.ts +8 -2
- package/dist/esm/index.js +17 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/model-meta.d.ts +6 -0
- package/dist/esm/model-meta.js +22 -1
- package/dist/esm/model-meta.js.map +1 -1
- package/dist/esm/realtime/adapter.d.ts +21 -0
- package/dist/esm/realtime/adapter.js +816 -0
- package/dist/esm/realtime/adapter.js.map +1 -0
- package/dist/esm/realtime/index.d.ts +4 -0
- package/dist/esm/realtime/realtime-contract.d.ts +30 -0
- package/dist/esm/realtime/token.d.ts +22 -0
- package/dist/esm/realtime/token.js +73 -0
- package/dist/esm/realtime/token.js.map +1 -0
- package/dist/esm/realtime/types.d.ts +95 -0
- package/dist/esm/utils/audio.d.ts +23 -0
- package/dist/esm/utils/audio.js +171 -0
- package/dist/esm/utils/audio.js.map +1 -0
- package/dist/esm/utils/index.d.ts +1 -0
- package/package.json +6 -3
- package/src/adapters/image.ts +41 -19
- package/src/adapters/summarize.ts +56 -25
- package/src/adapters/text.ts +26 -9
- package/src/adapters/transcription.ts +233 -0
- package/src/adapters/tts.ts +260 -0
- package/src/audio/transcription-provider-options.ts +54 -0
- package/src/audio/tts-provider-options.ts +44 -0
- package/src/index.ts +50 -1
- package/src/model-meta.ts +54 -0
- package/src/realtime/adapter.ts +1215 -0
- package/src/realtime/index.ts +18 -0
- package/src/realtime/realtime-contract.ts +46 -0
- package/src/realtime/token.ts +131 -0
- package/src/realtime/types.ts +105 -0
- package/src/utils/audio.ts +217 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Token adapter for server-side use
|
|
2
|
+
export { grokRealtimeToken } from './token'
|
|
3
|
+
|
|
4
|
+
// Client adapter for browser use
|
|
5
|
+
export { grokRealtime } from './adapter'
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export type {
|
|
9
|
+
GrokRealtimeVoice,
|
|
10
|
+
GrokRealtimeTokenOptions,
|
|
11
|
+
GrokRealtimeOptions,
|
|
12
|
+
GrokTurnDetection,
|
|
13
|
+
GrokSemanticVADConfig,
|
|
14
|
+
GrokServerVADConfig,
|
|
15
|
+
} from './types'
|
|
16
|
+
|
|
17
|
+
// Re-export the realtime model type from the single source of truth.
|
|
18
|
+
export type { GrokRealtimeModel } from '../model-meta'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyClientTool,
|
|
3
|
+
AudioVisualization,
|
|
4
|
+
RealtimeEvent,
|
|
5
|
+
RealtimeEventHandler,
|
|
6
|
+
RealtimeSessionConfig,
|
|
7
|
+
RealtimeToken,
|
|
8
|
+
} from '@tanstack/ai'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Structural contract for the `RealtimeAdapter` / `RealtimeConnection` types
|
|
12
|
+
* from `@tanstack/ai-client`.
|
|
13
|
+
*
|
|
14
|
+
* We duplicate the shapes here (and verify structural compatibility in a
|
|
15
|
+
* dev-only type check — see `tests/realtime-contract.drift.test-d.ts`) so that
|
|
16
|
+
* `@tanstack/ai-grok` does not impose `@tanstack/ai-client` as a `peerDependency`.
|
|
17
|
+
* Consumers only need `@tanstack/ai-client` at the point where they actually
|
|
18
|
+
* construct a `RealtimeClient`, not when they import this adapter.
|
|
19
|
+
*
|
|
20
|
+
* If `@tanstack/ai-client` ever changes these interfaces, the drift check
|
|
21
|
+
* will fail and we must update this file in lockstep.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface RealtimeAdapter {
|
|
25
|
+
provider: string
|
|
26
|
+
connect: (
|
|
27
|
+
token: RealtimeToken,
|
|
28
|
+
clientTools?: ReadonlyArray<AnyClientTool>,
|
|
29
|
+
) => Promise<RealtimeConnection>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RealtimeConnection {
|
|
33
|
+
disconnect: () => Promise<void>
|
|
34
|
+
startAudioCapture: () => Promise<void>
|
|
35
|
+
stopAudioCapture: () => void
|
|
36
|
+
sendText: (text: string) => void
|
|
37
|
+
sendImage: (imageData: string, mimeType: string) => void
|
|
38
|
+
sendToolResult: (callId: string, result: string) => void
|
|
39
|
+
updateSession: (config: Partial<RealtimeSessionConfig>) => void
|
|
40
|
+
interrupt: () => void
|
|
41
|
+
on: <TEvent extends RealtimeEvent>(
|
|
42
|
+
event: TEvent,
|
|
43
|
+
handler: RealtimeEventHandler<TEvent>,
|
|
44
|
+
) => () => void
|
|
45
|
+
getAudioVisualization: () => AudioVisualization
|
|
46
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { resolveDebugOption } from '@tanstack/ai/adapter-internals'
|
|
2
|
+
import { getGrokApiKeyFromEnv } from '../utils'
|
|
3
|
+
import type { RealtimeToken, RealtimeTokenAdapter } from '@tanstack/ai'
|
|
4
|
+
import type { GrokRealtimeModel } from '../model-meta'
|
|
5
|
+
import type {
|
|
6
|
+
GrokRealtimeSessionResponse,
|
|
7
|
+
GrokRealtimeTokenOptions,
|
|
8
|
+
} from './types'
|
|
9
|
+
|
|
10
|
+
const GROK_REALTIME_CLIENT_SECRETS_URL =
|
|
11
|
+
'https://api.x.ai/v1/realtime/client_secrets'
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 15_000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a Grok realtime token adapter.
|
|
17
|
+
*
|
|
18
|
+
* Generates ephemeral client secrets for browser-side WebRTC connections to
|
|
19
|
+
* the xAI Voice Agent API.
|
|
20
|
+
*
|
|
21
|
+
* @param options - Configuration options for the realtime session.
|
|
22
|
+
* @returns A RealtimeTokenAdapter for use with `realtimeToken()`.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* import { realtimeToken } from '@tanstack/ai'
|
|
27
|
+
* import { grokRealtimeToken } from '@tanstack/ai-grok'
|
|
28
|
+
*
|
|
29
|
+
* const token = await realtimeToken({
|
|
30
|
+
* adapter: grokRealtimeToken({ model: 'grok-voice-fast-1.0' }),
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function grokRealtimeToken(
|
|
35
|
+
options: GrokRealtimeTokenOptions = {},
|
|
36
|
+
): RealtimeTokenAdapter {
|
|
37
|
+
const apiKey = getGrokApiKeyFromEnv()
|
|
38
|
+
const logger = resolveDebugOption(options.debug)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
provider: 'grok',
|
|
42
|
+
|
|
43
|
+
async generateToken(): Promise<RealtimeToken> {
|
|
44
|
+
const model: GrokRealtimeModel = options.model ?? 'grok-voice-fast-1.0'
|
|
45
|
+
|
|
46
|
+
logger.request(`activity=realtimeToken provider=grok model=${model}`, {
|
|
47
|
+
provider: 'grok',
|
|
48
|
+
model,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// xAI docs (docs.x.ai/developers/rest-api-reference/inference/voice)
|
|
52
|
+
// specify the body as `{ session: { model } }`. `expires_after` is
|
|
53
|
+
// available to shorten the default 600s TTL but we don't expose it
|
|
54
|
+
// yet — the caller can still call `generateToken()` more often if
|
|
55
|
+
// they want a shorter-lived session.
|
|
56
|
+
const requestBody: Record<string, unknown> = {
|
|
57
|
+
session: { model },
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Abort the fetch if xAI never responds. Without this the whole
|
|
61
|
+
// realtime connect flow hangs forever on a dead endpoint.
|
|
62
|
+
const controller = new AbortController()
|
|
63
|
+
const timeout = setTimeout(
|
|
64
|
+
() =>
|
|
65
|
+
controller.abort(new Error('Grok realtime token request timed out')),
|
|
66
|
+
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(GROK_REALTIME_CLIENT_SECRETS_URL, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${apiKey}`,
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify(requestBody),
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const errorText = await response.text()
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Grok realtime session creation failed: ${response.status} ${errorText}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sessionData = (await response.json()) as
|
|
88
|
+
| Partial<GrokRealtimeSessionResponse>
|
|
89
|
+
| undefined
|
|
90
|
+
|
|
91
|
+
// Validate shape before dereferencing — xAI could return an error
|
|
92
|
+
// envelope with 200 status, or a partial response under protocol drift.
|
|
93
|
+
const clientSecret = sessionData?.client_secret
|
|
94
|
+
if (
|
|
95
|
+
!clientSecret ||
|
|
96
|
+
typeof clientSecret.value !== 'string' ||
|
|
97
|
+
typeof clientSecret.expires_at !== 'number' ||
|
|
98
|
+
!Number.isFinite(clientSecret.expires_at)
|
|
99
|
+
) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
'Grok realtime session response missing or malformed `client_secret`',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
const sessionModel = sessionData.model ?? model
|
|
105
|
+
|
|
106
|
+
// xAI docs describe `expires_at` as a unix timestamp in seconds, but
|
|
107
|
+
// in practice different deployments have returned milliseconds. Treat
|
|
108
|
+
// any value that already looks like ms (>1e12 ≈ Sep 2001 in ms) as ms.
|
|
109
|
+
const raw = clientSecret.expires_at
|
|
110
|
+
const expiresAt = raw > 1e12 ? raw : raw * 1000
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
provider: 'grok',
|
|
114
|
+
token: clientSecret.value,
|
|
115
|
+
expiresAt,
|
|
116
|
+
config: {
|
|
117
|
+
model: sessionModel,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.errors('grok.realtimeToken fatal', {
|
|
122
|
+
error,
|
|
123
|
+
source: 'grok.realtimeToken',
|
|
124
|
+
})
|
|
125
|
+
throw error
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timeout)
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { DebugOption, VADConfig } from '@tanstack/ai'
|
|
2
|
+
import type { GrokRealtimeModel } from '../model-meta'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Grok realtime voice options (Voice Agent API).
|
|
6
|
+
* https://docs.x.ai/developers/model-capabilities/audio/voice-agent
|
|
7
|
+
*/
|
|
8
|
+
export type GrokRealtimeVoice = 'eve' | 'ara' | 'rex' | 'sal' | 'leo'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Grok semantic VAD configuration.
|
|
12
|
+
*/
|
|
13
|
+
export interface GrokSemanticVADConfig {
|
|
14
|
+
type: 'semantic_vad'
|
|
15
|
+
/** Eagerness level for turn detection */
|
|
16
|
+
eagerness?: 'low' | 'medium' | 'high'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Grok server VAD configuration.
|
|
21
|
+
*/
|
|
22
|
+
export interface GrokServerVADConfig extends VADConfig {
|
|
23
|
+
type: 'server_vad'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Grok turn detection configuration.
|
|
28
|
+
*/
|
|
29
|
+
export type GrokTurnDetection =
|
|
30
|
+
| GrokSemanticVADConfig
|
|
31
|
+
| GrokServerVADConfig
|
|
32
|
+
| null
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Options for the Grok realtime token adapter.
|
|
36
|
+
*/
|
|
37
|
+
export interface GrokRealtimeTokenOptions {
|
|
38
|
+
/** Model to use (default: 'grok-voice-fast-1.0'). */
|
|
39
|
+
model?: GrokRealtimeModel
|
|
40
|
+
/**
|
|
41
|
+
* Enable debug logging for token creation.
|
|
42
|
+
*
|
|
43
|
+
* - `true`: log all categories via the default `ConsoleLogger`
|
|
44
|
+
* - `false`: silence everything including errors
|
|
45
|
+
* - `DebugConfig`: per-category toggles plus an optional custom `logger`
|
|
46
|
+
* - omitted: only the `errors` category is active (default behaviour)
|
|
47
|
+
*/
|
|
48
|
+
debug?: DebugOption
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Options for the Grok realtime client adapter.
|
|
53
|
+
*/
|
|
54
|
+
export interface GrokRealtimeOptions {
|
|
55
|
+
/** Connection mode (default: 'webrtc' in browser). */
|
|
56
|
+
connectionMode?: 'webrtc' | 'websocket'
|
|
57
|
+
/**
|
|
58
|
+
* Enable debug logging for this adapter.
|
|
59
|
+
*
|
|
60
|
+
* - `true`: log all categories via the default `ConsoleLogger`
|
|
61
|
+
* - `false`: silence everything including errors
|
|
62
|
+
* - `DebugConfig`: per-category toggles plus an optional custom `logger`
|
|
63
|
+
* - omitted: only the `errors` category is active (default behaviour)
|
|
64
|
+
*/
|
|
65
|
+
debug?: DebugOption
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Grok realtime session response from the `/v1/realtime/client_secrets`
|
|
70
|
+
* endpoint. Shape matches OpenAI's `/v1/realtime/sessions` response since
|
|
71
|
+
* xAI advertises its voice agent API as OpenAI-realtime-compatible.
|
|
72
|
+
*/
|
|
73
|
+
export interface GrokRealtimeSessionResponse {
|
|
74
|
+
id: string
|
|
75
|
+
object: string
|
|
76
|
+
model: string
|
|
77
|
+
modalities: Array<string>
|
|
78
|
+
instructions: string
|
|
79
|
+
voice: string
|
|
80
|
+
input_audio_format: string
|
|
81
|
+
output_audio_format: string
|
|
82
|
+
input_audio_transcription: {
|
|
83
|
+
model: string
|
|
84
|
+
} | null
|
|
85
|
+
turn_detection: {
|
|
86
|
+
type: string
|
|
87
|
+
threshold?: number
|
|
88
|
+
prefix_padding_ms?: number
|
|
89
|
+
silence_duration_ms?: number
|
|
90
|
+
eagerness?: string
|
|
91
|
+
} | null
|
|
92
|
+
tools: Array<{
|
|
93
|
+
type: string
|
|
94
|
+
name: string
|
|
95
|
+
description: string
|
|
96
|
+
parameters: Record<string, unknown>
|
|
97
|
+
}>
|
|
98
|
+
tool_choice: string
|
|
99
|
+
temperature: number
|
|
100
|
+
max_response_output_tokens: number | string
|
|
101
|
+
client_secret: {
|
|
102
|
+
value: string
|
|
103
|
+
expires_at: number
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coerce the various audio input shapes accepted by `TranscriptionOptions.audio`
|
|
3
|
+
* into a `File` suitable for `multipart/form-data` uploads.
|
|
4
|
+
*
|
|
5
|
+
* For base64 string inputs we require an explicit MIME type — either via a
|
|
6
|
+
* `data:<mime>;base64,<payload>` URI prefix, or via the caller-provided
|
|
7
|
+
* `audioFormat` parameter. Bare base64 without either is rejected, because
|
|
8
|
+
* silently defaulting to `audio/mpeg` misreports non-mp3 audio to the server.
|
|
9
|
+
*
|
|
10
|
+
* The same rule applies to raw `ArrayBuffer` inputs: the caller must supply
|
|
11
|
+
* an `audioFormat` so we know what MIME type and extension to use.
|
|
12
|
+
*/
|
|
13
|
+
export function toAudioFile(
|
|
14
|
+
audio: string | File | Blob | ArrayBuffer,
|
|
15
|
+
audioFormat?: string,
|
|
16
|
+
): File {
|
|
17
|
+
if (typeof File !== 'undefined' && audio instanceof File) {
|
|
18
|
+
// Prefer the caller-supplied `audioFormat` over a potentially empty or
|
|
19
|
+
// incorrect `File.type` — callers pass `audioFormat` precisely because
|
|
20
|
+
// they have more context than the browser does about the payload. If
|
|
21
|
+
// neither is set, fall through to the Blob-style error path below.
|
|
22
|
+
if (audioFormat) {
|
|
23
|
+
const mimeType = toMimeType(audioFormat)
|
|
24
|
+
return new File([audio], `audio.${extensionFor(mimeType)}`, {
|
|
25
|
+
type: mimeType,
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
if (audio.type) {
|
|
29
|
+
return audio
|
|
30
|
+
}
|
|
31
|
+
throw new Error(
|
|
32
|
+
'toAudioFile cannot infer type for File input with empty .type — pass an explicit audioFormat (e.g. "mp3", "wav", "audio/mpeg")',
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof Blob !== 'undefined' && audio instanceof Blob) {
|
|
37
|
+
// Mirror the ArrayBuffer / bare-base64 paths: prefer the explicit
|
|
38
|
+
// audioFormat argument over the Blob's (often empty) .type. We refuse to
|
|
39
|
+
// fall back to `application/octet-stream` because that mislabels audio
|
|
40
|
+
// for the server.
|
|
41
|
+
const mimeType = audioFormat
|
|
42
|
+
? toMimeType(audioFormat)
|
|
43
|
+
: audio.type || undefined
|
|
44
|
+
if (!mimeType) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'toAudioFile cannot infer type for Blob input with empty .type — pass an explicit audioFormat (e.g. "mp3", "wav", "audio/mpeg")',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return new File([audio], `audio.${extensionFor(mimeType)}`, {
|
|
50
|
+
type: mimeType,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (audio instanceof ArrayBuffer) {
|
|
55
|
+
if (!audioFormat) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
'toAudioFile cannot infer type for ArrayBuffer input — pass an explicit audioFormat (e.g. "mp3", "wav", "audio/mpeg")',
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
const mimeType = toMimeType(audioFormat)
|
|
61
|
+
return new File([audio], `audio.${extensionFor(mimeType)}`, {
|
|
62
|
+
type: mimeType,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof audio === 'string') {
|
|
67
|
+
if (audio.startsWith('data:')) {
|
|
68
|
+
const [header, base64Data] = audio.split(',')
|
|
69
|
+
// Fail loudly on malformed data: URIs instead of silently defaulting
|
|
70
|
+
// to `audio/mpeg` — the file's contract is that we never mislabel
|
|
71
|
+
// audio for the server.
|
|
72
|
+
const headerMatch = header?.match(/data:([^;]+)/)
|
|
73
|
+
const uriMimeType = headerMatch?.[1]
|
|
74
|
+
if (!uriMimeType) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Malformed data: URI in toAudioFile: cannot parse MIME type — expected data:<mime>[;charset=…][;base64],<payload>',
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (base64Data === undefined || base64Data.trim() === '') {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'Malformed data: URI in toAudioFile: missing base64 payload after comma',
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
// Caller-supplied `audioFormat` wins over the URI-embedded MIME: the
|
|
85
|
+
// caller has more context (the URI MIME may be wrong, or a generic
|
|
86
|
+
// `application/octet-stream`).
|
|
87
|
+
const mimeType = audioFormat ? toMimeType(audioFormat) : uriMimeType
|
|
88
|
+
const buffer = base64ToArrayBuffer(base64Data)
|
|
89
|
+
return new File([buffer], `audio.${extensionFor(mimeType)}`, {
|
|
90
|
+
type: mimeType,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!audioFormat) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
'toAudioFile requires a data: URI (e.g. data:audio/wav;base64,...) or an explicit audioFormat argument — bare base64 strings have no MIME type to infer',
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const buffer = base64ToArrayBuffer(audio)
|
|
101
|
+
const mimeType = toMimeType(audioFormat)
|
|
102
|
+
return new File([buffer], `audio.${extensionFor(mimeType)}`, {
|
|
103
|
+
type: mimeType,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new Error('Invalid audio input type')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toMimeType(audioFormat: string): string {
|
|
111
|
+
// Accept either "audio/…" strings or bare extensions like "mp3".
|
|
112
|
+
if (audioFormat.includes('/')) return audioFormat
|
|
113
|
+
const ext = audioFormat.toLowerCase()
|
|
114
|
+
switch (ext) {
|
|
115
|
+
case 'mp3':
|
|
116
|
+
return 'audio/mpeg'
|
|
117
|
+
case 'wav':
|
|
118
|
+
return 'audio/wav'
|
|
119
|
+
case 'ogg':
|
|
120
|
+
return 'audio/ogg'
|
|
121
|
+
case 'opus':
|
|
122
|
+
return 'audio/opus'
|
|
123
|
+
case 'flac':
|
|
124
|
+
return 'audio/flac'
|
|
125
|
+
case 'aac':
|
|
126
|
+
return 'audio/aac'
|
|
127
|
+
case 'mp4':
|
|
128
|
+
return 'audio/mp4'
|
|
129
|
+
case 'm4a':
|
|
130
|
+
return 'audio/mp4'
|
|
131
|
+
case 'webm':
|
|
132
|
+
return 'audio/webm'
|
|
133
|
+
case 'pcm':
|
|
134
|
+
return 'audio/L16'
|
|
135
|
+
case 'mulaw':
|
|
136
|
+
return 'audio/basic'
|
|
137
|
+
case 'alaw':
|
|
138
|
+
return 'audio/x-alaw-basic'
|
|
139
|
+
default:
|
|
140
|
+
return `audio/${ext}`
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function extensionFor(mimeType: string): string {
|
|
145
|
+
switch (mimeType) {
|
|
146
|
+
case 'audio/mpeg':
|
|
147
|
+
return 'mp3'
|
|
148
|
+
case 'audio/wav':
|
|
149
|
+
case 'audio/x-wav':
|
|
150
|
+
return 'wav'
|
|
151
|
+
case 'audio/ogg':
|
|
152
|
+
return 'ogg'
|
|
153
|
+
case 'audio/opus':
|
|
154
|
+
return 'opus'
|
|
155
|
+
case 'audio/flac':
|
|
156
|
+
return 'flac'
|
|
157
|
+
case 'audio/aac':
|
|
158
|
+
return 'aac'
|
|
159
|
+
case 'audio/mp4':
|
|
160
|
+
return 'm4a'
|
|
161
|
+
case 'audio/webm':
|
|
162
|
+
return 'webm'
|
|
163
|
+
case 'audio/L16':
|
|
164
|
+
return 'pcm'
|
|
165
|
+
case 'audio/basic':
|
|
166
|
+
return 'mulaw'
|
|
167
|
+
case 'audio/x-alaw-basic':
|
|
168
|
+
return 'alaw'
|
|
169
|
+
default: {
|
|
170
|
+
const slash = mimeType.indexOf('/')
|
|
171
|
+
if (slash === -1) return 'bin'
|
|
172
|
+
return mimeType.slice(slash + 1) || 'bin'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Cross-runtime ArrayBuffer → base64 conversion.
|
|
179
|
+
*
|
|
180
|
+
* Uses Node's `Buffer` when available (fastest path on server) and falls
|
|
181
|
+
* back to `btoa` + chunked `String.fromCharCode` everywhere else (browser,
|
|
182
|
+
* Cloudflare Workers, Bun, Deno). Chunking is required because a very
|
|
183
|
+
* large audio buffer spread into `String.fromCharCode(...bytes)` in one
|
|
184
|
+
* call can hit `Maximum call stack size exceeded`.
|
|
185
|
+
*/
|
|
186
|
+
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
187
|
+
if (typeof Buffer !== 'undefined') {
|
|
188
|
+
return Buffer.from(buffer).toString('base64')
|
|
189
|
+
}
|
|
190
|
+
const bytes = new Uint8Array(buffer)
|
|
191
|
+
const chunkSize = 0x8000
|
|
192
|
+
let binary = ''
|
|
193
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
194
|
+
const end = Math.min(i + chunkSize, bytes.length)
|
|
195
|
+
binary += String.fromCharCode.apply(
|
|
196
|
+
null,
|
|
197
|
+
bytes.subarray(i, end) as unknown as Array<number>,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
return btoa(binary)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
|
204
|
+
let binary: string
|
|
205
|
+
try {
|
|
206
|
+
binary = atob(base64)
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
209
|
+
throw new Error(`Invalid base64 input to toAudioFile: ${msg}`)
|
|
210
|
+
}
|
|
211
|
+
const buffer = new ArrayBuffer(binary.length)
|
|
212
|
+
const bytes = new Uint8Array(buffer)
|
|
213
|
+
for (let i = 0; i < binary.length; i++) {
|
|
214
|
+
bytes[i] = binary.charCodeAt(i)
|
|
215
|
+
}
|
|
216
|
+
return buffer
|
|
217
|
+
}
|
package/src/utils/index.ts
CHANGED