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