From 810215ff8b720762d041fd71716ecdd32b9d9e44 Mon Sep 17 00:00:00 2001 From: Maya Date: Mon, 1 Sep 2025 13:32:15 +0300 Subject: [PATCH] feat: better audio bitrate handling detect bitrate of audio and uses it, falls back to ffmpeg's default bitrate on error, and defaults bitrate to 320kbps for converting from lossless codecs to lossy --- src/lib/converters/ffmpeg.svelte.ts | 128 ++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 27 deletions(-) diff --git a/src/lib/converters/ffmpeg.svelte.ts b/src/lib/converters/ffmpeg.svelte.ts index add4027..a3893c2 100644 --- a/src/lib/converters/ffmpeg.svelte.ts +++ b/src/lib/converters/ffmpeg.svelte.ts @@ -121,7 +121,8 @@ export class FFmpegConverter extends Converter { ); ffmpeg.terminate(); - return new VertFile(new File([output], input.name), to); + const outBuf = new Uint8Array(output).buffer.slice(0); + return new VertFile(new File([outBuf], outputFileName), to); } private async setupFFmpeg(input: VertFile): Promise { @@ -153,6 +154,46 @@ export class FFmpegConverter extends Converter { return ffmpeg; } + private async detectAudioBitrate(ffmpeg: FFmpeg): Promise { + const args = [ + "-v", + "quiet", + "-select_streams", + "a:0", + "-show_entries", + "stream=bit_rate", + "-of", + "default=noprint_wrappers=1:nokey=1", + "input", + ]; + + try { + let bitrate: number | null = null; + + const bitrateListener = (event: { message: string }) => { + if (bitrate !== null) return; + const n = parseInt(event.message.trim(), 10); + if (!n) return null; + bitrate = Math.round(n / 1000); + log( + ["converters", this.name], + `Detected stream audio bitrate: ${bitrate} kbps`, + ); + }; + + ffmpeg.on("log", bitrateListener); + + try { + await ffmpeg.ffprobe.call(ffmpeg, args); + return bitrate; + } finally { + ffmpeg.off("log", bitrateListener); + } + } catch { + return null; + } + } + private async buildConversionCommand( ffmpeg: FFmpeg, input: VertFile, @@ -161,13 +202,32 @@ export class FFmpegConverter extends Converter { const inputFormat = input.from.slice(1); const outputFormat = to.slice(1); + const lossless = ["flac", "alac", "wav"]; + let audioBitrateArgs: string[]; + if ( + lossless.includes(inputFormat) && + !lossless.includes(outputFormat) + ) { + audioBitrateArgs = ["-b:a", "320k"]; + } else { + const inputBitrate = await this.detectAudioBitrate(ffmpeg); + audioBitrateArgs = inputBitrate ? ["-b:a", `${inputBitrate}k`] : []; + } + // video to audio if (videoFormats.includes(inputFormat)) { log( ["converters", this.name], `Converting video ${input.from} to audio ${to}`, ); - return ["-i", "input", "-map", "0:a:0", "output" + to]; + return [ + "-i", + "input", + "-map", + "0:a:0", + ...audioBitrateArgs, + "output" + to, + ]; } // audio to video @@ -178,13 +238,13 @@ export class FFmpegConverter extends Converter { ); const hasAlbumArt = await this.extractAlbumArt(ffmpeg); + const codecArgs = toArgs(to); if (hasAlbumArt) { log( ["converters", this.name], "Using album art as video background", ); - const codecArgs = toArgs(to); return [ "-loop", "1", @@ -200,11 +260,11 @@ export class FFmpegConverter extends Converter { "-r", "1", ...codecArgs, + ...audioBitrateArgs, "output" + to, ]; } else { log(["converters", this.name], "Using solid color background"); - const codecArgs = toArgs(to); return [ "-f", "lavfi", @@ -218,14 +278,26 @@ export class FFmpegConverter extends Converter { "-r", "1", ...codecArgs, + ...audioBitrateArgs, "output" + to, ]; } } - // fallback - log(["converters", this.name], `Converting ${input.from} to ${to}`); - return ["-i", "input", "output" + to]; + // audio to audio + log( + ["converters", this.name], + `Converting audio ${input.from} to audio ${to}`, + ); + const { audio: audioCodec } = getCodecs(to); + return [ + "-i", + "input", + "-c:a", + audioCodec, + ...audioBitrateArgs, + "output" + to, + ]; } private async extractAlbumArt(ffmpeg: FFmpeg): Promise { @@ -318,26 +390,11 @@ const toArgs = (ext: string): string[] => { break; } - case "mpeg4": { - // for avi and divx - break; - } - case "mpeg2video": { // for mpeg, mpg, vob, mxf if (ext === ".mxf") args.push("-ar", "48000"); // force 48kHz sample rate break; } - - case "libtheora": { - // for ogv - break; - } - - case "wmv2": { - // for wmv - break; - } } args.push("-c:a", codecs.audio); @@ -352,6 +409,7 @@ const toArgs = (ext: string): string[] => { const getCodecs = (ext: string): { video: string; audio: string } => { switch (ext) { + // video <-> audio case ".mp4": case ".mkv": case ".mov": @@ -364,29 +422,45 @@ const getCodecs = (ext: string): { video: string; audio: string } => { case ".3gp": case ".3g2": return { video: "libx264", audio: "aac" }; - case ".wmv": return { video: "wmv2", audio: "wmav2" }; - case ".webm": case ".ogv": return { video: ext === ".webm" ? "libvpx" : "libtheora", audio: "libvorbis", }; - case ".avi": case ".divx": return { video: "mpeg4", audio: "libmp3lame" }; - case ".mpg": case ".mpeg": case ".vob": return { video: "mpeg2video", audio: "mp2" }; - case ".mxf": return { video: "mpeg2video", audio: "pcm_s16le" }; + // audio + case ".mp3": + return { video: "libx264", audio: "libmp3lame" }; + case ".flac": + return { video: "libx264", audio: "flac" }; + case ".wav": + return { video: "libx264", audio: "pcm_s16le" }; + case ".ogg": + case ".oga": + return { video: "libx264", audio: "libvorbis" }; + case ".opus": + return { video: "libx264", audio: "libopus" }; + case ".aac": + return { video: "libx264", audio: "aac" }; + case ".m4a": + return { video: "libx264", audio: "aac" }; + case ".alac": + return { video: "libx264", audio: "alac" }; + case ".wma": + return { video: "libx264", audio: "wmav2" }; + default: return { video: "libx264", audio: "aac" }; }