Spaces:
Runtime error
Runtime error
| import { promises as fs } from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { spawn } from "node:child_process"; | |
| import ffmpegPath from "ffmpeg-static"; | |
| import { HttpError } from "../utils/httpError.js"; | |
| function runFfmpeg(inputPath, outputPath, outputArgs) { | |
| return new Promise((resolve, reject) => { | |
| const child = spawn(ffmpegPath, [ | |
| "-y", | |
| "-i", | |
| inputPath, | |
| "-vn", | |
| ...outputArgs, | |
| outputPath | |
| ]); | |
| let stderr = ""; | |
| child.stderr.on("data", (chunk) => { | |
| stderr += chunk.toString(); | |
| }); | |
| child.on("error", (error) => { | |
| reject(new HttpError(400, "Failed to convert audio to mp3.", error.message)); | |
| }); | |
| child.on("close", (code) => { | |
| if (code === 0) { | |
| resolve(); | |
| return; | |
| } | |
| reject(new HttpError(400, "Failed to convert audio to mp3.", stderr.trim() || undefined)); | |
| }); | |
| }); | |
| } | |
| export function createAudioConversionService({ fetchImpl = fetch, maxAudioDownloadMb = 25 } = {}) { | |
| const maxBytes = maxAudioDownloadMb * 1024 * 1024; | |
| return { | |
| async downloadAndConvertToMp3Base64(url) { | |
| const parsedUrl = new URL(url); | |
| if (!["http:", "https:"].includes(parsedUrl.protocol)) { | |
| throw new HttpError(400, "Audio URL must use http or https."); | |
| } | |
| const response = await fetchImpl(url); | |
| if (!response.ok) { | |
| throw new HttpError(400, `Failed to download audio URL: ${response.status} ${response.statusText}`); | |
| } | |
| const audioBuffer = Buffer.from(await response.arrayBuffer()); | |
| if (audioBuffer.length > maxBytes) { | |
| throw new HttpError(413, `Audio URL exceeded ${maxAudioDownloadMb}MB download limit.`); | |
| } | |
| const inputFormat = inferAudioFormatFromUrl(url) || inferAudioFormatFromMimeType(response.headers.get("content-type")) || "unknown"; | |
| return transcodeAudioBuffer(audioBuffer, inputFormat); | |
| }, | |
| async normalizeBase64Audio({ data, format }) { | |
| const audioBuffer = Buffer.from(data, "base64"); | |
| if (audioBuffer.length === 0) { | |
| throw new HttpError(400, "Audio input must include base64 data."); | |
| } | |
| if (audioBuffer.length > maxBytes) { | |
| throw new HttpError(413, `Audio input exceeded ${maxAudioDownloadMb}MB upload limit.`); | |
| } | |
| if (!["mp3", "wav", "m4a"].includes(format)) { | |
| throw new HttpError(400, "Audio input format must be mp3, wav, or m4a."); | |
| } | |
| try { | |
| return await transcodeAudioBuffer(audioBuffer, format); | |
| } catch (error) { | |
| if (error instanceof HttpError) { | |
| throw error; | |
| } | |
| throw new HttpError(400, `Failed to normalize audio input as ${format}.`, error.message); | |
| } | |
| } | |
| }; | |
| async function transcodeAudioBuffer(audioBuffer, inputFormat) { | |
| const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "oapix-audio-")); | |
| const normalizedInputFormat = normalizeInputFormat(inputFormat); | |
| const inputPath = path.join(tempDir, `input-media.${normalizedInputFormat === "unknown" ? "bin" : normalizedInputFormat}`); | |
| const outputPath = path.join(tempDir, "output.mp3"); | |
| try { | |
| console.log(`audio conversion started: format=${normalizedInputFormat}, target=mp3`); | |
| await fs.writeFile(inputPath, audioBuffer); | |
| await runFfmpeg(inputPath, outputPath, ffmpegOutputArgs()); | |
| const convertedBuffer = await fs.readFile(outputPath); | |
| console.log(`audio conversion successful: format=${normalizedInputFormat}->mp3`); | |
| return { | |
| data: convertedBuffer.toString("base64"), | |
| format: "mp3" | |
| }; | |
| } catch (error) { | |
| const detail = error instanceof Error ? error.message : String(error); | |
| console.error(`audio conversion failed: format=${normalizedInputFormat}, target=mp3, error=${detail}`); | |
| throw error; | |
| } finally { | |
| await fs.rm(tempDir, { force: true, recursive: true }); | |
| } | |
| } | |
| } | |
| function ffmpegOutputArgs() { | |
| return ["-acodec", "libmp3lame", "-q:a", "4"]; | |
| } | |
| function inferAudioFormatFromUrl(url) { | |
| try { | |
| const pathname = new URL(url).pathname.toLowerCase(); | |
| if (pathname.endsWith(".m4a")) { | |
| return "m4a"; | |
| } | |
| if (pathname.endsWith(".wav")) { | |
| return "wav"; | |
| } | |
| if (pathname.endsWith(".mp3")) { | |
| return "mp3"; | |
| } | |
| } catch (_error) { | |
| return "unknown"; | |
| } | |
| return "unknown"; | |
| } | |
| function inferAudioFormatFromMimeType(mimeType) { | |
| const value = String(mimeType || "").split(";")[0].trim().toLowerCase(); | |
| if (value === "audio/mp4" || value === "audio/x-m4a") { | |
| return "m4a"; | |
| } | |
| if (value === "audio/wav" || value === "audio/x-wav") { | |
| return "wav"; | |
| } | |
| if (value === "audio/mpeg" || value === "audio/mp3") { | |
| return "mp3"; | |
| } | |
| return "unknown"; | |
| } | |
| function normalizeInputFormat(format) { | |
| return ["m4a", "wav", "mp3"].includes(format) ? format : "unknown"; | |
| } | |