| 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"; |
| } |
|
|