puteroapix / src /services /audioConversionService.js
woiceatus's picture
install ffmpeg in docker file
416cae6
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";
}