@tanstack/ai-fal 0.6.17 → 0.7.1

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.
@@ -22,25 +22,91 @@ function getFalApiKeyFromEnv() {
22
22
  return key;
23
23
  }
24
24
  function configureFalClient(config) {
25
- if (config?.proxyUrl) {
26
- fal.config({
27
- proxyUrl: config.proxyUrl
28
- });
29
- } else {
30
- const apiKey = config?.apiKey ?? getFalApiKeyFromEnv();
31
- if (!apiKey) {
32
- throw new Error("API key is required");
25
+ const apiKey = config?.apiKey ?? getFalApiKeyFromEnv();
26
+ fal.config({
27
+ credentials: apiKey,
28
+ ...config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}
29
+ });
30
+ }
31
+ function generateId(prefix) {
32
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}`;
33
+ }
34
+ function extractUrlExtension(url) {
35
+ let pathname;
36
+ try {
37
+ const parsed = new URL(url);
38
+ pathname = parsed.pathname;
39
+ } catch {
40
+ pathname = url.split("?")[0].split("#")[0];
41
+ }
42
+ const normalized = pathname.replace(/\/+$/, "");
43
+ if (!normalized.includes("/")) return void 0;
44
+ const lastSegment = normalized.split("/").pop();
45
+ if (!lastSegment) return void 0;
46
+ const extension = lastSegment.split(".").pop();
47
+ if (!extension || extension === lastSegment) return void 0;
48
+ return /^[a-z0-9]{2,5}$/i.test(extension) ? extension : void 0;
49
+ }
50
+ function deriveAudioContentType(explicitContentType, url) {
51
+ const stripped = explicitContentType?.split(";")[0]?.trim();
52
+ if (stripped) return stripped;
53
+ const ext = extractUrlExtension(url)?.toLowerCase();
54
+ switch (ext) {
55
+ case "mp3":
56
+ return "audio/mpeg";
57
+ case "wav":
58
+ return "audio/wav";
59
+ case "ogg":
60
+ case "oga":
61
+ return "audio/ogg";
62
+ case "flac":
63
+ return "audio/flac";
64
+ case "aac":
65
+ return "audio/aac";
66
+ case "m4a":
67
+ case "mp4":
68
+ return "audio/mp4";
69
+ case "webm":
70
+ return "audio/webm";
71
+ default:
72
+ return "audio/mpeg";
73
+ }
74
+ }
75
+ function dataUrlToBlob(value) {
76
+ if (!value.startsWith("data:")) return void 0;
77
+ const commaIndex = value.indexOf(",");
78
+ if (commaIndex === -1) return void 0;
79
+ const header = value.slice(5, commaIndex);
80
+ const payload = value.slice(commaIndex + 1);
81
+ const isBase64 = /;base64$/i.test(header);
82
+ const mimeType = header.split(";")[0] || "application/octet-stream";
83
+ if (isBase64) {
84
+ const binary = atob(payload);
85
+ const bytes = new Uint8Array(binary.length);
86
+ for (let i = 0; i < binary.length; i += 1) {
87
+ bytes[i] = binary.charCodeAt(i);
33
88
  }
34
- fal.config({
35
- credentials: apiKey
36
- });
89
+ return new Blob([bytes], { type: mimeType });
37
90
  }
91
+ return new Blob([decodeURIComponent(payload)], { type: mimeType });
38
92
  }
39
- function generateId(prefix) {
40
- return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`;
93
+ function arrayBufferToBase64(bytes) {
94
+ if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") {
95
+ return Buffer.from(bytes).toString("base64");
96
+ }
97
+ const view = new Uint8Array(bytes);
98
+ let binary = "";
99
+ for (let i = 0; i < view.byteLength; i += 1) {
100
+ binary += String.fromCharCode(view[i]);
101
+ }
102
+ return btoa(binary);
41
103
  }
42
104
  export {
105
+ arrayBufferToBase64,
43
106
  configureFalClient,
107
+ dataUrlToBlob,
108
+ deriveAudioContentType,
109
+ extractUrlExtension,
44
110
  generateId,
45
111
  getFalApiKeyFromEnv
46
112
  };
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sources":["../../../src/utils/client.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\n\nexport interface FalClientConfig {\n apiKey: string\n proxyUrl?: string\n}\n\ninterface EnvObject {\n FAL_KEY?: string\n}\n\ninterface WindowWithEnv {\n env?: EnvObject\n}\n\nfunction getEnvironment(): EnvObject | undefined {\n if (typeof globalThis !== 'undefined') {\n const win = (globalThis as { window?: WindowWithEnv }).window\n if (win?.env) {\n return win.env\n }\n }\n if (typeof process !== 'undefined') {\n return process.env as EnvObject\n }\n return undefined\n}\n\nexport function getFalApiKeyFromEnv(): string {\n const env = getEnvironment()\n const key = env?.FAL_KEY\n\n if (!key) {\n throw new Error(\n 'FAL_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',\n )\n }\n\n return key\n}\n\nexport function configureFalClient(config?: FalClientConfig): void {\n if (config?.proxyUrl) {\n fal.config({\n proxyUrl: config.proxyUrl,\n })\n } else {\n const apiKey = config?.apiKey ?? getFalApiKeyFromEnv()\n if (!apiKey) {\n throw new Error('API key is required')\n }\n fal.config({\n credentials: apiKey,\n })\n }\n}\n\nexport function generateId(prefix: string): string {\n return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`\n}\n"],"names":[],"mappings":";AAeA,SAAS,iBAAwC;AAC/C,MAAI,OAAO,eAAe,aAAa;AACrC,UAAM,MAAO,WAA0C;AACvD,QAAI,KAAK,KAAK;AACZ,aAAO,IAAI;AAAA,IACb;AAAA,EACF;AACA,MAAI,OAAO,YAAY,aAAa;AAClC,WAAO,QAAQ;AAAA,EACjB;AACA,SAAO;AACT;AAEO,SAAS,sBAA8B;AAC5C,QAAM,MAAM,eAAA;AACZ,QAAM,MAAM,KAAK;AAEjB,MAAI,CAAC,KAAK;AACR,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAAA,EAEJ;AAEA,SAAO;AACT;AAEO,SAAS,mBAAmB,QAAgC;AACjE,MAAI,QAAQ,UAAU;AACpB,QAAI,OAAO;AAAA,MACT,UAAU,OAAO;AAAA,IAAA,CAClB;AAAA,EACH,OAAO;AACL,UAAM,SAAS,QAAQ,UAAU,oBAAA;AACjC,QAAI,CAAC,QAAQ;AACX,YAAM,IAAI,MAAM,qBAAqB;AAAA,IACvC;AACA,QAAI,OAAO;AAAA,MACT,aAAa;AAAA,IAAA,CACd;AAAA,EACH;AACF;AAEO,SAAS,WAAW,QAAwB;AACjD,SAAO,GAAG,MAAM,IAAI,KAAK,KAAK,IAAI,KAAK,OAAA,EAAS,SAAS,EAAE,EAAE,UAAU,CAAC,CAAC;AAC3E;"}
1
+ {"version":3,"file":"client.js","sources":["../../../src/utils/client.ts"],"sourcesContent":["import { fal } from '@fal-ai/client'\n\nexport interface FalClientConfig {\n apiKey: string\n proxyUrl?: string\n}\n\ninterface EnvObject {\n FAL_KEY?: string\n}\n\ninterface WindowWithEnv {\n env?: EnvObject\n}\n\nfunction getEnvironment(): EnvObject | undefined {\n if (typeof globalThis !== 'undefined') {\n const win = (globalThis as { window?: WindowWithEnv }).window\n if (win?.env) {\n return win.env\n }\n }\n if (typeof process !== 'undefined') {\n return process.env as EnvObject\n }\n return undefined\n}\n\nexport function getFalApiKeyFromEnv(): string {\n const env = getEnvironment()\n const key = env?.FAL_KEY\n\n if (!key) {\n throw new Error(\n 'FAL_KEY is required. Please set it in your environment variables or use the factory function with an explicit API key.',\n )\n }\n\n return key\n}\n\nexport function configureFalClient(config?: FalClientConfig): void {\n const apiKey = config?.apiKey ?? getFalApiKeyFromEnv()\n fal.config({\n credentials: apiKey,\n ...(config?.proxyUrl ? { proxyUrl: config.proxyUrl } : {}),\n })\n}\n\nexport function generateId(prefix: string): string {\n return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2)}`\n}\n\n/**\n * Extract a safe file extension from a URL. Strips query strings, URL\n * fragments, and any trailing slashes, and only returns the extension when\n * it looks like a real one (2-5 alphanumeric chars). Returns undefined\n * otherwise so callers can fall back to a default.\n */\nexport function extractUrlExtension(url: string): string | undefined {\n // Parse via URL when possible so we only look at the pathname and never\n // mistake a TLD (e.g. the `.com` in `https://x.com/`) for a file extension.\n let pathname: string\n try {\n const parsed = new URL(url)\n pathname = parsed.pathname\n } catch {\n // Fall back to treating the input as a raw path when URL parsing fails\n // (e.g. the caller passed a bare path). Still strip ?query and #fragment.\n pathname = url.split('?')[0]!.split('#')[0]!\n }\n // Drop trailing slashes so `/path/audio.mp3/` still yields `mp3`.\n const normalized = pathname.replace(/\\/+$/, '')\n // Require at least one `/` — otherwise we're looking at an empty pathname\n // (bare-host URLs like `https://x.com/` land here after stripping the\n // trailing slash).\n if (!normalized.includes('/')) return undefined\n const lastSegment = normalized.split('/').pop()\n if (!lastSegment) return undefined\n const extension = lastSegment.split('.').pop()\n if (!extension || extension === lastSegment) return undefined\n return /^[a-z0-9]{2,5}$/i.test(extension) ? extension : undefined\n}\n\n/**\n * Derive a reasonable audio content-type. Prefers the explicit MIME (stripped\n * of parameters), then an extension-based lookup for common audio formats,\n * otherwise falls back to audio/mpeg — fal URLs virtually always serve mp3.\n */\nexport function deriveAudioContentType(\n explicitContentType: string | undefined,\n url: string,\n): string {\n const stripped = explicitContentType?.split(';')[0]?.trim()\n if (stripped) return stripped\n\n const ext = extractUrlExtension(url)?.toLowerCase()\n switch (ext) {\n case 'mp3':\n return 'audio/mpeg'\n case 'wav':\n return 'audio/wav'\n case 'ogg':\n case 'oga':\n return 'audio/ogg'\n case 'flac':\n return 'audio/flac'\n case 'aac':\n return 'audio/aac'\n case 'm4a':\n case 'mp4':\n return 'audio/mp4'\n case 'webm':\n return 'audio/webm'\n default:\n return 'audio/mpeg'\n }\n}\n\n/**\n * Decode a `data:` URL into a Blob so fal-client can auto-upload it via\n * `fal.storage.upload`. fal's inference API rejects data URLs with a 422\n * \"Unsupported data URL\", so we convert them before handing them off.\n *\n * Supports both base64 and URL-encoded data URLs. Returns `undefined` for\n * anything that isn't a data URL, so callers can fall through to other\n * handling (http URLs are passed to fal as-is).\n */\nexport function dataUrlToBlob(value: string): Blob | undefined {\n if (!value.startsWith('data:')) return undefined\n const commaIndex = value.indexOf(',')\n if (commaIndex === -1) return undefined\n\n const header = value.slice(5, commaIndex)\n const payload = value.slice(commaIndex + 1)\n const isBase64 = /;base64$/i.test(header)\n const mimeType = header.split(';')[0] || 'application/octet-stream'\n\n if (isBase64) {\n const binary = atob(payload)\n const bytes = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i += 1) {\n bytes[i] = binary.charCodeAt(i)\n }\n return new Blob([bytes], { type: mimeType })\n }\n\n return new Blob([decodeURIComponent(payload)], { type: mimeType })\n}\n\n/**\n * Convert an ArrayBuffer to base64 in a cross-runtime way.\n *\n * The naive `btoa(String.fromCharCode(...bytes))` form blows up V8's argument\n * limit (~65k) on realistic audio payloads, so we either use `Buffer` (Node /\n * Bun) or walk the byte array in a single loop (browser).\n */\nexport function arrayBufferToBase64(bytes: ArrayBuffer): string {\n if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {\n return Buffer.from(bytes).toString('base64')\n }\n const view = new Uint8Array(bytes)\n let binary = ''\n for (let i = 0; i < view.byteLength; i += 1) {\n binary += String.fromCharCode(view[i]!)\n }\n return btoa(binary)\n}\n"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- export { getFalApiKeyFromEnv, configureFalClient, generateId, type FalClientConfig, } from './client.js';
1
+ export { getFalApiKeyFromEnv, configureFalClient, generateId, arrayBufferToBase64, dataUrlToBlob, extractUrlExtension, deriveAudioContentType, type FalClientConfig, } from './client.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-fal",
3
- "version": "0.6.17",
3
+ "version": "0.7.1",
4
4
  "description": "fal.ai adapter for TanStack AI",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -28,7 +28,12 @@
28
28
  "tanstack",
29
29
  "adapter",
30
30
  "image-generation",
31
- "video-generation"
31
+ "video-generation",
32
+ "audio-generation",
33
+ "text-to-speech",
34
+ "speech-to-text",
35
+ "tts",
36
+ "transcription"
32
37
  ],
33
38
  "dependencies": {
34
39
  "@fal-ai/client": "^1.9.4"
@@ -36,10 +41,10 @@
36
41
  "devDependencies": {
37
42
  "@vitest/coverage-v8": "4.0.14",
38
43
  "vite": "^7.2.7",
39
- "@tanstack/ai": "0.13.0"
44
+ "@tanstack/ai": "0.15.0"
40
45
  },
41
46
  "peerDependencies": {
42
- "@tanstack/ai": "0.13.0"
47
+ "@tanstack/ai": "0.15.0"
43
48
  },
44
49
  "scripts": {
45
50
  "build": "vite build",
@@ -0,0 +1,148 @@
1
+ import { fal } from '@fal-ai/client'
2
+ import { BaseAudioAdapter } from '@tanstack/ai/adapters'
3
+ import {
4
+ configureFalClient,
5
+ deriveAudioContentType,
6
+ generateId as utilGenerateId,
7
+ } from '../utils'
8
+ import type { OutputType, Result } from '@fal-ai/client'
9
+ import type {
10
+ AudioGenerationOptions,
11
+ AudioGenerationResult,
12
+ } from '@tanstack/ai'
13
+ import type { FalClientConfig } from '../utils'
14
+ import type { FalModel, FalModelInput } from '../model-meta'
15
+
16
+ /**
17
+ * Provider options for audio generation, excluding fields TanStack AI handles.
18
+ */
19
+ export type FalAudioProviderOptions<TModel extends string> = Omit<
20
+ FalModelInput<TModel>,
21
+ 'prompt'
22
+ >
23
+
24
+ /**
25
+ * Fal audio models don't agree on the name for "how long should this be."
26
+ * Most accept seconds via `duration`, but a few use model-specific fields —
27
+ * e.g. ElevenLabs wants milliseconds via `music_length_ms`, Stable Audio wants
28
+ * `seconds_total`. Explicit per-model entries override the default; user
29
+ * `modelOptions` still win over either.
30
+ */
31
+ const DURATION_FRAGMENT_BUILDERS: Record<
32
+ string,
33
+ (seconds: number) => Record<string, unknown>
34
+ > = {
35
+ 'fal-ai/elevenlabs/music': (seconds) => ({ music_length_ms: seconds * 1000 }),
36
+ 'fal-ai/stable-audio-25/text-to-audio': (seconds) => ({
37
+ seconds_total: seconds,
38
+ }),
39
+ }
40
+
41
+ function buildDurationFragment(
42
+ model: string,
43
+ duration: number | undefined,
44
+ ): Record<string, unknown> {
45
+ if (duration == null) return {}
46
+ const builder = DURATION_FRAGMENT_BUILDERS[model]
47
+ return builder ? builder(duration) : { duration }
48
+ }
49
+
50
+ /**
51
+ * fal.ai audio generation adapter.
52
+ *
53
+ * Supports fal.ai audio models like diffrhythm (music), sound effects, etc.
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * const adapter = falAudio('fal-ai/diffrhythm')
58
+ * const result = await generateAudio({
59
+ * adapter,
60
+ * prompt: 'An upbeat electronic track with synths',
61
+ * duration: 10,
62
+ * })
63
+ * ```
64
+ */
65
+ export class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<
66
+ TModel,
67
+ FalAudioProviderOptions<TModel>
68
+ > {
69
+ readonly name = 'fal' as const
70
+
71
+ constructor(model: TModel, config?: FalClientConfig) {
72
+ super(model, {})
73
+ configureFalClient(config)
74
+ }
75
+
76
+ async generateAudio(
77
+ options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,
78
+ ): Promise<AudioGenerationResult> {
79
+ const { logger } = options
80
+ logger.request(`activity=generateAudio provider=fal model=${this.model}`, {
81
+ provider: 'fal',
82
+ model: this.model,
83
+ })
84
+ try {
85
+ const input = this.buildInput(options)
86
+ const result = await fal.subscribe(this.model, { input })
87
+ return this.transformResponse(result)
88
+ } catch (error) {
89
+ logger.errors('fal.generateAudio fatal', {
90
+ error,
91
+ source: 'fal.generateAudio',
92
+ })
93
+ throw error
94
+ }
95
+ }
96
+
97
+ private buildInput(
98
+ options: AudioGenerationOptions<FalAudioProviderOptions<TModel>>,
99
+ ): FalModelInput<TModel> {
100
+ return {
101
+ ...buildDurationFragment(this.model, options.duration),
102
+ ...options.modelOptions,
103
+ prompt: options.prompt,
104
+ } as FalModelInput<TModel>
105
+ }
106
+
107
+ protected override generateId(): string {
108
+ return utilGenerateId(this.name)
109
+ }
110
+
111
+ private transformResponse(
112
+ response: Result<OutputType<TModel>>,
113
+ ): AudioGenerationResult {
114
+ const data = response.data as Record<string, unknown>
115
+
116
+ // fal returns { audio: { url, content_type } } or { audio_url: string }
117
+ let audioUrl: string | undefined
118
+ let contentType: string | undefined
119
+
120
+ if (data.audio && typeof data.audio === 'object' && 'url' in data.audio) {
121
+ const audioObj = data.audio as { url: string; content_type?: string }
122
+ audioUrl = audioObj.url
123
+ contentType = audioObj.content_type
124
+ } else if (typeof data.audio_url === 'string') {
125
+ audioUrl = data.audio_url
126
+ }
127
+
128
+ if (!audioUrl) {
129
+ throw new Error('Audio URL not found in fal audio generation response')
130
+ }
131
+
132
+ return {
133
+ id: response.requestId || this.generateId(),
134
+ model: this.model,
135
+ audio: {
136
+ url: audioUrl,
137
+ contentType: deriveAudioContentType(contentType, audioUrl),
138
+ },
139
+ }
140
+ }
141
+ }
142
+
143
+ export function falAudio<TModel extends FalModel>(
144
+ model: TModel,
145
+ config?: FalClientConfig,
146
+ ): FalAudioAdapter<TModel> {
147
+ return new FalAudioAdapter(model, config)
148
+ }
@@ -46,7 +46,7 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
46
46
  readonly name = 'fal' as const
47
47
 
48
48
  constructor(model: TModel, config?: FalClientConfig) {
49
- super({}, model)
49
+ super(model, {})
50
50
  configureFalClient(config)
51
51
  }
52
52
 
@@ -110,6 +110,13 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
110
110
  images.push(this.parseImage(data.image))
111
111
  }
112
112
 
113
+ if (images.length === 0) {
114
+ throw new Error(
115
+ 'Unexpected fal image response shape. Expected images[] or image{}. Got keys: ' +
116
+ Object.keys(data).join(','),
117
+ )
118
+ }
119
+
113
120
  return {
114
121
  id: response.requestId || this.generateId(),
115
122
  model: this.model,
@@ -117,16 +124,29 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<
117
124
  }
118
125
  }
119
126
 
120
- private parseImage(img: { url: string }): GeneratedImage {
121
- const url = img.url
122
- // Check if it's a base64 data URL
127
+ private parseImage(img: unknown): GeneratedImage {
128
+ let url: string
129
+ if (typeof img === 'string') {
130
+ url = img
131
+ } else if (
132
+ img &&
133
+ typeof img === 'object' &&
134
+ 'url' in img &&
135
+ typeof (img as { url: unknown }).url === 'string'
136
+ ) {
137
+ url = (img as { url: string }).url
138
+ } else {
139
+ throw new Error(
140
+ `Invalid image payload from fal response: expected string or { url: string }, received ${
141
+ img === null ? 'null' : typeof img
142
+ }`,
143
+ )
144
+ }
145
+
123
146
  if (url.startsWith('data:')) {
124
147
  const base64Match = url.match(/^data:image\/[^;]+;base64,(.+)$/)
125
- if (base64Match) {
126
- return {
127
- b64Json: base64Match[1],
128
- url,
129
- }
148
+ if (base64Match && base64Match[1]) {
149
+ return { b64Json: base64Match[1] }
130
150
  }
131
151
  }
132
152
  return { url }
@@ -0,0 +1,147 @@
1
+ import { fal } from '@fal-ai/client'
2
+ import { BaseTTSAdapter } from '@tanstack/ai/adapters'
3
+ import {
4
+ arrayBufferToBase64,
5
+ configureFalClient,
6
+ extractUrlExtension,
7
+ generateId as utilGenerateId,
8
+ } from '../utils'
9
+ import type { OutputType, Result } from '@fal-ai/client'
10
+ import type { TTSOptions, TTSResult } from '@tanstack/ai'
11
+ import type { FalClientConfig } from '../utils'
12
+ import type { FalModel, FalModelInput } from '../model-meta'
13
+
14
+ /**
15
+ * Provider options for TTS, excluding fields TanStack AI handles.
16
+ */
17
+ export type FalSpeechProviderOptions<TModel extends string> = Omit<
18
+ FalModelInput<TModel>,
19
+ 'prompt' | 'text'
20
+ >
21
+
22
+ /**
23
+ * fal.ai text-to-speech adapter.
24
+ *
25
+ * Supports fal.ai TTS models like kokoro, elevenlabs, etc.
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const adapter = falSpeech('fal-ai/kokoro/american-english')
30
+ * const result = await generateSpeech({
31
+ * adapter,
32
+ * text: 'Hello, world!',
33
+ * voice: 'af_heart',
34
+ * })
35
+ * ```
36
+ */
37
+ export class FalSpeechAdapter<TModel extends FalModel> extends BaseTTSAdapter<
38
+ TModel,
39
+ FalSpeechProviderOptions<TModel>
40
+ > {
41
+ readonly name = 'fal' as const
42
+
43
+ constructor(model: TModel, config?: FalClientConfig) {
44
+ super(model, {})
45
+ configureFalClient(config)
46
+ }
47
+
48
+ async generateSpeech(
49
+ options: TTSOptions<FalSpeechProviderOptions<TModel>>,
50
+ ): Promise<TTSResult> {
51
+ const { logger } = options
52
+ logger.request(`activity=generateSpeech provider=fal model=${this.model}`, {
53
+ provider: 'fal',
54
+ model: this.model,
55
+ })
56
+ try {
57
+ const input = this.buildInput(options)
58
+ const result = await fal.subscribe(this.model, { input })
59
+ return await this.transformResponse(result)
60
+ } catch (error) {
61
+ logger.errors('fal.generateSpeech fatal', {
62
+ error,
63
+ source: 'fal.generateSpeech',
64
+ })
65
+ throw error
66
+ }
67
+ }
68
+
69
+ private buildInput(
70
+ options: TTSOptions<FalSpeechProviderOptions<TModel>>,
71
+ ): FalModelInput<TModel> {
72
+ const input = {
73
+ ...options.modelOptions,
74
+ // Map text to both prompt and text fields (different models use different fields)
75
+ prompt: options.text,
76
+ text: options.text,
77
+ ...(options.voice ? { voice: options.voice } : {}),
78
+ ...(options.speed ? { speed: options.speed } : {}),
79
+ } as FalModelInput<TModel>
80
+ return input
81
+ }
82
+
83
+ protected override generateId(): string {
84
+ return utilGenerateId(this.name)
85
+ }
86
+
87
+ private async transformResponse(
88
+ response: Result<OutputType<TModel>>,
89
+ ): Promise<TTSResult> {
90
+ const data = response.data as Record<string, unknown>
91
+
92
+ // fal returns { audio: { url, content_type } } or { audio_url: string }
93
+ let audioUrl: string | undefined
94
+ let contentType: string | undefined
95
+
96
+ if (data.audio && typeof data.audio === 'object' && 'url' in data.audio) {
97
+ const audioObj = data.audio as { url: string; content_type?: string }
98
+ audioUrl = audioObj.url
99
+ contentType = audioObj.content_type
100
+ } else if (typeof data.audio_url === 'string') {
101
+ audioUrl = data.audio_url
102
+ }
103
+
104
+ if (!audioUrl) {
105
+ throw new Error('Audio URL not found in fal TTS response')
106
+ }
107
+
108
+ // Fetch the audio and convert to base64 to match TTSResult contract.
109
+ // Using a chunked helper here — spreading Uint8Array into btoa exceeds
110
+ // V8's argument limit (~65k) for any realistic TTS clip.
111
+ const audioResponse = await fetch(audioUrl)
112
+ if (!audioResponse.ok) {
113
+ throw new Error(
114
+ `Failed to fetch audio from ${audioUrl}: ${audioResponse.status} ${audioResponse.statusText}`,
115
+ )
116
+ }
117
+ const arrayBuffer = await audioResponse.arrayBuffer()
118
+ const base64 = arrayBufferToBase64(arrayBuffer)
119
+
120
+ // Strip parameters like `; charset=...` from contentType, and only use
121
+ // the URL extension as a fallback when it looks like a real extension.
122
+ const contentTypeMime = contentType?.split(';')[0]?.trim()
123
+ const safeUrlExtension = extractUrlExtension(audioUrl)
124
+ // Prefer URL-derived extension when available (more canonical for file
125
+ // consumers), otherwise derive from the content-type mime subtype, then
126
+ // fall back to `wav`. Normalize `mpeg` → `mp3` so the format field is a
127
+ // usable file extension rather than the IANA subtype.
128
+ const rawFormat =
129
+ safeUrlExtension || contentTypeMime?.split('/')[1] || 'wav'
130
+ const format = rawFormat === 'mpeg' ? 'mp3' : rawFormat
131
+
132
+ return {
133
+ id: response.requestId || this.generateId(),
134
+ model: this.model,
135
+ audio: base64,
136
+ format,
137
+ contentType: contentTypeMime || `audio/${format}`,
138
+ }
139
+ }
140
+ }
141
+
142
+ export function falSpeech<TModel extends FalModel>(
143
+ model: TModel,
144
+ config?: FalClientConfig,
145
+ ): FalSpeechAdapter<TModel> {
146
+ return new FalSpeechAdapter(model, config)
147
+ }