(
response: Result<OutputType<TModel>>,
)
| 87 | } |
| 88 | |
| 89 | private async transformResponse( |
| 90 | response: Result<OutputType<TModel>>, |
| 91 | ): Promise<TTSResult> { |
| 92 | const data = response.data as Record<string, unknown> |
| 93 | |
| 94 | // fal returns { audio: { url, content_type } } or { audio_url: string } |
| 95 | let audioUrl: string | undefined |
| 96 | let contentType: string | undefined |
| 97 | |
| 98 | if ( |
| 99 | data['audio'] && |
| 100 | typeof data['audio'] === 'object' && |
| 101 | 'url' in data['audio'] |
| 102 | ) { |
| 103 | const audioObj = data['audio'] as { url: string; content_type?: string } |
| 104 | audioUrl = audioObj.url |
| 105 | contentType = audioObj.content_type |
| 106 | } else if (typeof data.audio_url === 'string') { |
| 107 | audioUrl = data.audio_url |
| 108 | } |
| 109 | |
| 110 | if (!audioUrl) { |
| 111 | throw new Error('Audio URL not found in fal TTS response') |
| 112 | } |
| 113 | |
| 114 | // Fetch the audio and convert to base64 to match TTSResult contract. |
| 115 | // Using a chunked helper here — spreading Uint8Array into btoa exceeds |
| 116 | // V8's argument limit (~65k) for any realistic TTS clip. |
| 117 | const audioResponse = await fetch(audioUrl) |
| 118 | if (!audioResponse.ok) { |
| 119 | throw new Error( |
| 120 | `Failed to fetch audio from ${audioUrl}: ${audioResponse.status} ${audioResponse.statusText}`, |
| 121 | ) |
| 122 | } |
| 123 | const arrayBuffer = await audioResponse.arrayBuffer() |
| 124 | const base64 = arrayBufferToBase64(arrayBuffer) |
| 125 | |
| 126 | // Strip parameters like `; charset=...` from contentType, and only use |
| 127 | // the URL extension as a fallback when it looks like a real extension. |
| 128 | const contentTypeMime = contentType?.split(';')[0]?.trim() |
| 129 | const safeUrlExtension = extractUrlExtension(audioUrl) |
| 130 | // Prefer URL-derived extension when available (more canonical for file |
| 131 | // consumers), otherwise derive from the content-type mime subtype, then |
| 132 | // fall back to `wav`. Normalize `mpeg` → `mp3` so the format field is a |
| 133 | // usable file extension rather than the IANA subtype. |
| 134 | const rawFormat = |
| 135 | safeUrlExtension || contentTypeMime?.split('/')[1] || 'wav' |
| 136 | const format = rawFormat === 'mpeg' ? 'mp3' : rawFormat |
| 137 | |
| 138 | const usage = buildFalUsage(takeBillableUnits(response.requestId)) |
| 139 | |
| 140 | return { |
| 141 | id: response.requestId || this.generateId(), |
| 142 | model: this.model, |
| 143 | audio: base64, |
| 144 | format, |
| 145 | contentType: contentTypeMime || `audio/${format}`, |
| 146 | ...(usage ? { usage } : {}), |
no test coverage detected