This maintained guide turns every MP3 in one directory into an MP4 with either a still cover or a generated color frame. It is designed for Bash on GNU/Linux and handles spaces, tabs, newlines, and mixed-case .mp3 extensions without parsing ls output.
The script deliberately creates a new output directory, writes each candidate into a same-filesystem temporary directory, verifies it with ffprobe, and only then renames it to its final name. The input files are never modified. Keep a backup anyway: a conversion should not be your only copy of the audio.
Table of Contents
First Ask Whether You Need Video
MP4 is a container, not a request to improve audio quality. Adding a still frame is useful when a destination requires a video stream. If the destination accepts audio-only uploads, an M4A file is simpler and smaller because it does not contain a fabricated video track:
ffmpeg -nostdin -hide_banner -loglevel error -n \
-i "input.mp3" -map 0:a:0 -map_metadata 0 \
-c:a aac -b:a 192k -movflags +faststart "output.m4a"
Transcoding lossy MP3 to lossy AAC cannot restore quality. Keep the MP3 master, and choose the AAC bitrate according to the source and destination rather than assuming a larger number improves the original.
Safe Batch Script: Cover Image or Generated Color
Save the following as mp3-to-mp4.sh, then run chmod +x mp3-to-mp4.sh. Its third argument chooses cover or color. A cover can have odd dimensions: the filter fits it within 1280×720, pads the result to even dimensions required by common H.264/yuv420p workflows, and sets square pixels.
#!/usr/bin/env bash
set -Eeuo pipefail
input_dir=${1:?"usage: $0 INPUT_DIR NEW_OUTPUT_DIR [cover|color] [COVER_IMAGE]"}
output_dir=${2:?"usage: $0 INPUT_DIR NEW_OUTPUT_DIR [cover|color] [COVER_IMAGE]"}
mode=${3:-cover}
cover=${4:-cover.png}
[[ -d "$input_dir" ]] || { printf 'not a directory: %s\n' "$input_dir" >&2; exit 2; }
[[ ! -e "$output_dir" ]] || {
printf 'output must be a fresh path: %s\n' "$output_dir" >&2
exit 2
}
case "$mode" in
cover)
[[ -f "$cover" ]] || { printf 'cover not found: %s\n' "$cover" >&2; exit 2; }
video_args=(-loop 1 -framerate 1 -i "$cover")
video_filter='scale=1280:720:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2:(ow-iw)/2:(oh-ih)/2,setsar=1'
video_title='Cover image'
;;
color)
video_args=(-f lavfi -i 'color=c=black:s=1280x720:r=1')
video_filter='setsar=1'
video_title='Generated black frame'
;;
*)
printf 'mode must be cover or color: %s\n' "$mode" >&2
exit 2
;;
esac
# Plain mkdir intentionally fails if another process creates the path first.
mkdir -- "$output_dir"
found=false
while IFS= read -r -d '' input; do
found=true
base=${input##*/}
stem=${base%.*}
final="$output_dir/$stem.mp4"
[[ ! -e "$final" ]] || { printf 'refusing to overwrite: %s\n' "$final" >&2; exit 3; }
workdir=$(mktemp -d "$output_dir/.ffmpeg-work.XXXXXX")
partial="$workdir/output.partial.mp4"
(
trap 'rm -f -- "$partial"; rmdir -- "$workdir" 2>/dev/null || true' EXIT
ffmpeg -nostdin -hide_banner -loglevel error -n \
"${video_args[@]}" -i "$input" \
-map 0:v:0 -map 1:a:0 -map_metadata 1 \
-vf "$video_filter" \
-c:v libx264 -preset medium -tune stillimage -crf 23 -pix_fmt yuv420p \
-c:a aac -b:a 192k \
-metadata:s:v:0 handler_name="$video_title" \
-metadata:s:a:0 handler_name="$stem" \
-shortest -movflags +faststart "$partial"
video_codec=$(ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name -of default=nw=1:nk=1 "$partial")
audio_codec=$(ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name -of default=nw=1:nk=1 "$partial")
video_count=$(ffprobe -v error -select_streams v \
-show_entries stream=index -of csv=p=0 "$partial" | awk 'END { print NR + 0 }')
audio_count=$(ffprobe -v error -select_streams a \
-show_entries stream=index -of csv=p=0 "$partial" | awk 'END { print NR + 0 }')
duration=$(ffprobe -v error -show_entries format=duration \
-of default=nw=1:nk=1 "$partial")
[[ "$video_codec" == h264 && "$audio_codec" == aac ]]
[[ "$video_count" == 1 && "$audio_count" == 1 ]]
awk -v d="$duration" 'BEGIN { exit !(d + 0 > 0) }'
# The temporary file and destination share a filesystem, so this rename is atomic.
[[ ! -e "$final" ]] || { printf 'refusing to overwrite: %s\n' "$final" >&2; exit 3; }
mv -- "$partial" "$final"
rmdir -- "$workdir"
trap - EXIT
)
printf 'created: %s\n' "$final"
done < <(find "$input_dir" -maxdepth 1 -type f -iname '*.mp3' -print0)
[[ "$found" == true ]] || { printf 'no MP3 files found in %s\n' "$input_dir" >&2; exit 4; }
For a cover image:
./mp3-to-mp4.sh ./input ./output-cover cover ./cover.png
For a generated black frame, use a different fresh output directory:
./mp3-to-mp4.sh ./input ./output-color color
Explicit -map options select exactly one video and one audio stream. -map_metadata 1 copies global metadata from the MP3 input, while the two stream-level handler_name options label the tracks. -shortest stops when the finite audio ends, and +faststart moves MP4 initialization metadata near the beginning for progressive download.
Why AAC Is the Default
MP3 audio can be carried in some MP4 workflows, but support depends on the consumer. AAC in MP4 is the more interoperable default. If a known target explicitly accepts MP3 audio in MP4 and avoiding another lossy encode matters, replace -c:a aac -b:a 192k with -c:a copy, then validate the result in that target as well as with:
audio_codec=$(ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name -of default=nw=1:nk=1 "output.mp4")
[[ "$audio_codec" == mp3 ]] || { printf 'unexpected audio codec: %s\n' "$audio_codec" >&2; exit 1; }
Do not infer playback compatibility from a successful mux alone. Test the exact browser, app, editing system, or upload service that will receive the file.
Reproducible Smoke Test
This test creates a short sine-wave MP3 and an odd-sized cover image, uses a filename containing both a space and a newline, and exercises both modes. Run it from the directory containing the saved script:
set -Eeuo pipefail
test_root=$(mktemp -d)
trap 'rm -rf -- "$test_root"' EXIT
mkdir -- "$test_root/input"
fixture="$test_root/input/"$'tone one\nline.mp3'
ffmpeg -nostdin -hide_banner -loglevel error \
-f lavfi -i 'sine=frequency=440:duration=1.5' \
-c:a libmp3lame -q:a 4 "$fixture"
ffmpeg -nostdin -hide_banner -loglevel error \
-f lavfi -i 'color=c=navy:s=641x359' -frames:v 1 "$test_root/cover.png"
./mp3-to-mp4.sh "$test_root/input" "$test_root/output-cover" cover "$test_root/cover.png"
./mp3-to-mp4.sh "$test_root/input" "$test_root/output-color" color
ffprobe -v error -show_entries stream=index,codec_type,codec_name,width,height \
-show_entries format=duration -of json \
"$test_root/output-cover/"$'tone one\nline.mp4'
Expect one H.264 video stream, one AAC audio stream, positive duration, and even video dimensions. The script performs those machine checks before making each output visible under its final name; this final JSON is useful evidence for a human review.
Operational Notes
- The output directory must not already exist. This prevents accidental reuse and makes duplicate stems—such as
song.mp3andsong.MP3—fail instead of silently replacing a result. find ... -print0plusread -d ''keeps arbitrary filenames intact. Parsinglsis not safe for filenames containing whitespace or newlines.- A
.partial.mp4is created below the destination directory, so the finalmvremains on one filesystem. Failed candidates are removed by the subshell’s exit trap. - The script does not recurse. Remove
-maxdepth 1only if you also design how subdirectories and duplicate basenames should map into the destination. ffmpeg -nrefuses to overwrite the temporary candidate, and the final path is checked twice. Do not replace it with-yin unattended jobs unless overwriting is genuinely intended.
Primary Documentation
- FFmpeg command-line syntax, stream selection, and `-map`
- FFmpeg video filters, including `color`, `scale`, `pad`, and `setsar`
- FFmpeg MOV/MP4 muxer options, including `faststart`
- FFmpeg AAC encoder documentation
- ffprobe documentation
Original 2017/2023 Export (Verbatim Archive)
The maintained guide above replaces unsafe filename parsing and adds compatibility and verification boundaries. The complete visible body from the repository’s 2023 export of the 2017 post is preserved below for provenance. Line endings and trailing whitespace are normalized; the commands are historical and should not be copied in preference to the maintained version.
~~~markdown
Solution 1:
`ls *.mp3 | while read mp3File ; do outputFile=$(basename “${mp3File}” .mp3) ; ffmpeg -i “${mp3File}” -loop 1 -i image.png -c:a copy -c:v libx264 -shortest “${outputFile}”.mp4 ; done
`
Solution 2:
`
$ mkdir out
$ for f in *.mp3; do ffmpeg -f lavfi -i color=s=160×120:r=2 -i “$f” \
-c:v libx264 -preset ultrafast -c:a copy -shortest \
out/”${f%.mp3}.mp4″; done
`
~~~
