To replace the audio in a video with ffmpeg and leave the picture alone: ffmpeg -i video.mp4 -i audio.wav -map 0:v -map 1:a -c:v copy -c:a aac -shortest out.mp4. The two -i options open the clip and the new sound as input 0 and input 1. -map 0:v takes the video from the clip and -map 1:a takes the audio from the new file, so the clip’s old sound is left out. -c:v copy copies the picture without decoding it, -c:a aac encodes the new sound to AAC, which ffmpeg 4.4.2 lists as the MP4 muxer’s default audio codec, and -shortest ends the file when its shortest stream ends. In the run below the picture came out with the same SHA256 hash as before, and the sound stopped 0.038 s before the picture did.

ffmpeg -i video.mp4 -i audio.wav -map 0:v -map 1:a -c:v copy -c:a aac -shortest out.mp4

The test clip and the new sound

Every command on this page ran with ffmpeg 4.4.2, the version Ubuntu 22.04 ships, in an empty directory, one after another in the same shell. The inputs are made by ffmpeg itself from its lavfi test sources, so you can rebuild them: a 2 s clip in the 1080x1920 shape of a Short with a 220 Hz tone as its own sound, and a 3 s 440 Hz tone as the new sound. No downloaded media and no font is used. The new sound is deliberately longer than the clip, and deliberately a WAV file, because both cause trouble later on.

# A 2 s 1080x1920 test clip with its own sound (a 220 Hz tone), and the new sound:
# a 3 s 440 Hz tone as 16-bit PCM WAV, longer than the clip on purpose.
# -threads 1 is only there so the picture hashes below are identical on every run.
ffmpeg -v error -f lavfi -i "testsrc2=s=1080x1920:r=30:d=2" \
  -f lavfi -i "sine=frequency=220:sample_rate=48000:duration=2" \
  -c:v libx264 -threads 1 -pix_fmt yuv420p -c:a aac video.mp4
ffmpeg -v error -f lavfi -i "sine=frequency=440:sample_rate=48000:duration=3" audio.wav
for f in video.mp4 audio.wav; do
  ffprobe -v error -of csv=p=0 -show_entries stream=index,codec_type,codec_name,channels,duration "$f"
done
0,h264,video,2.000000
1,aac,audio,1,2.000000
0,pcm_s16le,audio,1,3.000000

The clip has an H.264 video stream and a mono AAC audio stream, both 2.000000 s long. The WAV has one stream, pcm_s16le (uncompressed 16-bit samples), 3.000000 s long.

Replace the audio, and prove the picture was not touched

Run the answer command, then list what it wrote and hash every stream’s packets in the old and the new file. -f streamhash prints one hash per stream of exactly the packets ffmpeg would write, and -c copy hands it the packets as they are stored, so two equal video hashes mean the picture is the same bytes, frame for frame.

# The streams the answer command wrote, then a hash of each stream's packets, before and after
ffprobe -v error -of csv=p=0 -show_entries stream=index,codec_type,codec_name,duration out.mp4
for f in video.mp4 out.mp4; do
  echo "$f"
  ffmpeg -v error -i "$f" -map 0 -c copy -f streamhash -
done
0,h264,video,2.000000
1,aac,audio,1.962000
video.mp4
0,v,SHA256=d49ed5cb79675d07aac3cab2730cfd890780d77f84374851cc5d841e08bf7885
1,a,SHA256=d8a6349191fd54634163dacc818e6da470cfde837c23e65ef74a55fecdc0cfcc
out.mp4
0,v,SHA256=d49ed5cb79675d07aac3cab2730cfd890780d77f84374851cc5d841e08bf7885
1,a,SHA256=20c70a7351b942d4186182fb8eb28023e6490763993aa6b5ba643ad46c3dcd6b

The video hash is d49ed5cb… in both files: -c:v copy moved the compressed picture across without decoding or re-encoding it, so the picture lost no quality. The audio hash changed, as it should, because stream 1 is now the encoded 440 Hz tone and not the clip’s 220 Hz one. Stream 1 is 1.962000 s long against the picture’s 2.000000 s; the section on -shortest below is about that.

Why the -map options are needed

Leave them out and ffmpeg chooses the streams itself. The ffmpeg documentation describes the rule: “For each acceptable stream type, ffmpeg will pick one stream, when available, from among all the inputs.” For audio it picks “the stream with the most channels”, and when streams tie, “the stream with the lowest index is chosen.”

# No -map at all: which sound does ffmpeg keep? Hash it against the clip's own sound
ffmpeg -v error -i video.mp4 -i audio.wav -c copy nomap.mp4
ffprobe -v error -of csv=p=0 -show_entries stream=index,codec_type,codec_name,duration nomap.mp4
ffmpeg -v error -i nomap.mp4 -map 0:a -c copy -f streamhash -
0,h264,video,2.000000
1,aac,audio,2.000000
0,a,SHA256=d8a6349191fd54634163dacc818e6da470cfde837c23e65ef74a55fecdc0cfcc

Both sounds are mono, so they tie, and the clip’s own sound wins because input 0 comes first. The audio in nomap.mp4 hashes to d8a63491…, the same as video.mp4’s own sound: the command ran without an error and changed nothing you can hear. -map 0:v -map 1:a is what makes the swap happen. The docs put -map this way: “Create one or more streams in the output file.”

What -shortest did to the lengths

The docs give -shortest one sentence: “Finish encoding when the shortest output stream ends.” Here is out.mp4 next to the same command without -shortest:

# The same replacement without -shortest, then each file's streams and its overall length
ffmpeg -v error -i video.mp4 -i audio.wav -map 0:v -map 1:a -c:v copy -c:a aac long.mp4
for f in out.mp4 long.mp4; do
  echo "$f"
  ffprobe -v error -of csv=p=0 -show_entries stream=codec_type,duration,nb_frames "$f"
  ffprobe -v error -of csv=p=0 -show_entries format=duration "$f"
done
out.mp4
video,2.000000,60
audio,1.962000,93
2.000000
long.mp4
video,2.000000,60
audio,3.000000,142
3.000000

With -shortest, the file is 2.000000 s long, the length of the picture, but its audio stream is 1.962000 s, 0.038 s shorter than the video. Without it, the audio runs its full 3.000000 s, the file is 3.000000 s long, and the last second has sound but no new pictures. What a player shows for that second is up to the player.

So -shortest did what the docs say, it stopped the encode when the shortest stream (the picture) ended, but the sound did not end on the same instant: it ended first. The docs do not promise that both streams end together, and on this run they did not. A 0.038 s gap at the end of a clip is too short to notice on its own. Where it can matter is when you join many clips one after another, because the gaps add up.

When the new sound is shorter than the clip

The more common case for a Short is a voice-over that ends before the footage does. -shortest then cuts the picture instead. If you want to keep all of the picture, pad the sound with silence using apad, whose docs say: “This can be used together with ffmpeg -shortest to extend audio streams to the same length as the video stream.”

# A 1.5 s sound on the 2 s clip: -shortest now cuts the picture; apad pads the sound instead
ffmpeg -v error -f lavfi -i "sine=frequency=440:sample_rate=48000:duration=1.5" short.wav
ffmpeg -v error -i video.mp4 -i short.wav -map 0:v -map 1:a -c:v copy -c:a aac -shortest cut.mp4
ffmpeg -v error -i video.mp4 -i short.wav -map 0:v -map 1:a -c:v copy -af apad -c:a aac \
  -shortest padded.mp4
for f in cut.mp4 padded.mp4; do
  echo "$f"
  ffprobe -v error -of csv=p=0 -show_entries stream=codec_type,duration,nb_frames "$f"
done
cut.mp4
video,1.566667,47
audio,1.500000,72
padded.mp4
video,2.000000,60
audio,1.962000,93

With a 1.5 s sound, -shortest cut the picture from 60 frames to 47, a 1.566667 s video. At 30 frames per second, 1.5 s is 45 frames, so the picture ran 2 frames past the end of the sound. A copied picture can only stop between whole frames, and here it stopped a little late rather than early. With -af apad the picture keeps all 60 frames and 2.000000 s, and the sound is padded with silence to 1.962000 s, the same length it had in out.mp4. Without -shortest, apad would keep adding silence forever, so the two go together.

Add a second audio track and keep the original

To keep the clip’s own sound and add the new one as another track, map everything from input 0 and the audio from input 1. -c copy -c:a:1 aac copies all streams except the second audio stream of the output, which is encoded to AAC (the docs: “For each stream, the last matching c option is applied”). -metadata:s:a:1 title= names the new track and -disposition:a:0 default marks the original as the one to play by default.

# Keep the clip's own sound as track 1 and add the tone as track 2, named "Music",
# once as MP4 and once as MKV; then the MP4 again with no -disposition at all
ffmpeg -v error -i video.mp4 -i audio.wav -map 0 -map 1:a -c copy -c:a:1 aac \
  -metadata:s:a:1 title="Music" -disposition:a:0 default two.mp4
ffmpeg -v error -i video.mp4 -i audio.wav -map 0 -map 1:a -c copy -c:a:1 aac \
  -metadata:s:a:1 title="Music" -disposition:a:0 default two.mkv
ffmpeg -v error -i video.mp4 -i audio.wav -map 0 -map 1:a -c copy -c:a:1 aac nodisp.mp4
for f in two.mp4 two.mkv nodisp.mp4; do
  echo "$f"
  ffprobe -v error -of compact=p=0 -show_entries \
    stream=index,codec_type,codec_name:stream_tags=title,handler_name:stream_disposition=default "$f"
done
two.mp4
index=0|codec_name=h264|codec_type=video|disposition:default=1|tag:handler_name=VideoHandler
index=1|codec_name=aac|codec_type=audio|disposition:default=1|tag:handler_name=SoundHandler
index=2|codec_name=aac|codec_type=audio|disposition:default=0|tag:handler_name=SoundHandler
two.mkv
index=0|codec_name=h264|codec_type=video|disposition:default=1|tag:HANDLER_NAME=VideoHandler
index=1|codec_name=aac|codec_type=audio|disposition:default=1|tag:HANDLER_NAME=SoundHandler
index=2|codec_name=aac|codec_type=audio|disposition:default=0|tag:title=Music
nodisp.mp4
index=0|codec_name=h264|codec_type=video|disposition:default=1|tag:handler_name=VideoHandler
index=1|codec_name=aac|codec_type=audio|disposition:default=1|tag:handler_name=SoundHandler
index=2|codec_name=aac|codec_type=audio|disposition:default=0|tag:handler_name=SoundHandler

Three things came out of that run.

  1. Both tracks are there. Stream 1 is the clip’s AAC sound and stream 2 is the new tone, in MP4 and in MKV.
  2. ffmpeg 4.4.2 did not store the title in the MP4. ffprobe finds no title tag on any stream of two.mp4, and stream 2 is still called SoundHandler. The MKV kept title=Music. The command printed no warning, so you would not know unless you checked.
  3. -disposition:a:0 default changed nothing here. nodisp.mp4, made without any -disposition, has the same flags: track 1 default, track 2 not. The docs explain why: when no -disposition option is given, ffmpeg marks the first stream of each type as the default “when there are multiple streams of this type in the output file and no stream of that type is already marked as default.” The option matters when you want a different track to be the default, for example -disposition:a:0 0 -disposition:a:1 default.

In an MP4, ffmpeg 4.4.2 puts the track name in the handler_name tag, so set that instead. The hashes also check that the picture and the original sound were copied, not re-encoded:

# MP4 keeps the name in handler_name; and the first two streams are the input's, packet for packet
ffmpeg -v error -i video.mp4 -i audio.wav -map 0 -map 1:a -c copy -c:a:1 aac \
  -metadata:s:a:1 handler_name="Music" named.mp4
ffprobe -v error -of compact=p=0 -show_entries \
  stream=index,codec_type:stream_tags=handler_name:stream_disposition=default named.mp4
for f in video.mp4 named.mp4; do
  echo "$f"
  ffmpeg -v error -i "$f" -map 0 -c copy -f streamhash -
done
index=0|codec_type=video|disposition:default=1|tag:handler_name=VideoHandler
index=1|codec_type=audio|disposition:default=1|tag:handler_name=SoundHandler
index=2|codec_type=audio|disposition:default=0|tag:handler_name=Music
video.mp4
0,v,SHA256=d49ed5cb79675d07aac3cab2730cfd890780d77f84374851cc5d841e08bf7885
1,a,SHA256=d8a6349191fd54634163dacc818e6da470cfde837c23e65ef74a55fecdc0cfcc
named.mp4
0,v,SHA256=d49ed5cb79675d07aac3cab2730cfd890780d77f84374851cc5d841e08bf7885
1,a,SHA256=d8a6349191fd54634163dacc818e6da470cfde837c23e65ef74a55fecdc0cfcc
2,a,SHA256=1968293e41516a089ab2d41d7f65f16e16453de8407dee6ac3e3e7c8f5941ed7

Stream 2 is now called Music. In named.mp4, streams 0 and 1 have exactly the hashes of video.mp4: picture and original sound copied unchanged, and only the new track was encoded. Which name a given player or editor shows for a track, and whether it lets the viewer switch tracks, I did not test. I make no claim about what any upload site does with a second audio track, either.

Add a silent audio track to a video with no sound

Some tools further down a pipeline expect every clip to have a sound track, even an empty one. anullsrc is ffmpeg’s source of silence. mute.mp4 below is the test clip with its sound removed.

# A copy of the clip with no sound at all, then anullsrc as its sound track
ffmpeg -v error -i video.mp4 -map 0:v -c copy mute.mp4
ffprobe -v error -of csv=p=0 -show_entries stream=index,codec_type,codec_name,duration mute.mp4
ffmpeg -v error -i mute.mp4 -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \
  -map 0:v -map 1:a -c:v copy -c:a aac -shortest silent.mp4
ffmpeg -v error -i mute.mp4 -f lavfi -i anullsrc \
  -map 0:v -map 1:a -c:v copy -c:a aac -shortest silent-default.mp4
for f in silent.mp4 silent-default.mp4; do
  echo "$f"
  ffprobe -v error -of csv=p=0 \
    -show_entries stream=index,codec_type,codec_name,sample_rate,channels,duration "$f"
  ffmpeg -i "$f" -map 0:a -af volumedetect -f null - 2>&1 | grep -o 'max_volume: .*'
done
0,h264,video,2.000000
silent.mp4
0,h264,video,2.000000
1,aac,audio,48000,2,1.941000
max_volume: -91.0 dB
silent-default.mp4
0,h264,video,2.000000
1,aac,audio,44100,2,1.927007
max_volume: -91.0 dB

mute.mp4 has only the video stream. After the command, silent.mp4 has a stereo AAC stream at 48000 Hz whose loudest moment is -91.0 dB, which is silence. -shortest is needed because anullsrc never ends on its own, and the silent track again stops a little before the picture, at 1.941000 s.

Set the sample rate yourself. Plain anullsrc gave 44100 Hz, and the docs agree: the sample rate “defaults to 44100.” If the rest of your audio is 48000 Hz, a 44100 Hz silent track is one more conversion later.

Delay the new audio by 500 ms: -itsoffset or adelay

There are two ways to start the new sound half a second late, and they do different things to the file. -itsoffset 0.5, placed before the input it applies to, shifts that input’s timestamps. The docs: “The offset is added to the timestamps of the input files.” The adelay filter adds silence to the start of the sound: “Samples in delayed channel are filled with silence.” adelay=500 gives the delay in milliseconds, and all=1 applies it to every channel (“Use last set delay for all remaining channels”).

To see where the sound actually starts, the commands below ask ffprobe for each stream’s start_time, then decode the audio and use silencedetect, which “logs a message when it detects that the input audio volume is less or equal to a noise tolerance value for a duration greater or equal to the minimum detected noise duration.” The first silence_end is where the tone starts.

# Two ways to start the new sound 500 ms late, then where each file says the sound starts
# and where silencedetect hears it start
ffmpeg -v error -i video.mp4 -itsoffset 0.5 -i audio.wav -map 0:v -map 1:a \
  -c:v copy -c:a aac late-itsoffset.mp4
ffmpeg -v error -i video.mp4 -i audio.wav -map 0:v -map 1:a \
  -c:v copy -af adelay=500:all=1 -c:a aac late-adelay.mp4
for f in late-itsoffset.mp4 late-adelay.mp4; do
  echo "$f"
  ffprobe -v error -of csv=p=0 -show_entries stream=codec_type,start_time,duration "$f"
  ffmpeg -i "$f" -map 0:a -af silencedetect=n=-50dB:d=0.1 -f null - 2>&1 \
    | grep -m1 -o 'silence_end: [0-9.]*' || echo "no silence found"
done
late-itsoffset.mp4
video,0.000000,2.000000
audio,0.478000,3.021333
no silence found
late-adelay.mp4
video,0.000000,2.000000
audio,0.000000,3.500000
silence_end: 0.500021

With adelay, the sound starts at 0.500021 s, 500 ms after the picture as asked: the audio stream starts at 0.000000 and is 3.500000 s long, 3 s of tone after 0.5 s of real silence written into the file.

With -itsoffset, the file has no silence in it at all. silencedetect found none, because the delay is only in the timestamps: the audio stream’s start_time is 0.478000. Whether a player waits that long before playing the sound depends on whether it honours the stream’s start time. The stream is also 3.021333 s long instead of 3.000000, and starts 0.022 s earlier than the 0.5 s asked for. I did not trace where that extra 0.021333 s of audio at the front comes from, so I only report it.

If the delay has to survive editing, uploading or being joined with other clips, adelay is the safer choice: the silence is in the samples, so any tool that plays or decodes the file hears it. Use -itsoffset when you are copying the audio and cannot re-encode it, since adelay is a filter and needs a re-encode.

The error: copying a WAV into an MP4

The first thing people try is -c copy for everything. With a WAV as the new sound, that fails:

# Copy PCM WAV into MP4. The hex address in ffmpeg's log prefix changes on every
# run, so sed masks it; every other character is ffmpeg's own stderr.
{ ffmpeg -v error -i video.mp4 -i audio.wav -map 0:v -map 1:a -c copy bad.mp4 2>&1 \
  || echo "exit status $?"; } | sed 's/0x[0-9a-f]*/0x.../'
wc -c bad.mp4
[mp4 @ 0x...] Could not find tag for codec pcm_s16le in stream #1, codec not currently supported in container
Could not write header for output file #0 (incorrect codec parameters ?): Invalid argument
Error initializing output stream 0:1 -- 

exit status 1
0 bad.mp4

The line that matters is “Could not find tag for codec pcm_s16le in stream #1, codec not currently supported in container”. The WAV’s audio is uncompressed pcm_s16le, and ffmpeg’s MP4 muxer has no way to store it. ffmpeg exited with status 1, and it left behind an empty bad.mp4 (0 bytes), so a second run without -y will first ask whether to overwrite it.

There are two fixes. Encode the sound to a codec MP4 accepts, which is the -c:a aac in the answer command, or keep the samples as they are and use a container that accepts PCM, such as MKV:

# The fix: encode the sound to AAC. Or keep the PCM and change the container to MKV.
ffmpeg -v error -i video.mp4 -i audio.wav -map 0:v -map 1:a -c:v copy -c:a aac fixed.mp4
ffmpeg -v error -i video.mp4 -i audio.wav -map 0:v -map 1:a -c copy kept-pcm.mkv
for f in fixed.mp4 kept-pcm.mkv; do
  echo "$f"
  ffprobe -v error -of csv=p=0 -show_entries stream=codec_type,codec_name "$f"
done
fixed.mp4
h264,video
aac,audio
kept-pcm.mkv
h264,video
pcm_s16le,audio

Both worked, and both still copy the picture. For a file you are going to upload or share, the AAC-in-MP4 version is the usual choice. The MKV is handy as an intermediate file when you do not want to encode the sound twice.

Where this fits in a Shorts pipeline

This page covers the step where finished narration goes onto a finished clip, in the YouTube Shorts automation pipeline on a Raspberry Pi. Mixing music under the voice is a different job, with its own settings: see ducking music under a voice-over with sidechaincompress. And if your clip is still landscape, the Shorts ffmpeg command builder writes the command that turns it into a 1080x1920 H.264 file first.

What this test does not tell you

  • Every number here comes from one synthetic 2 s clip and pure tones, on ffmpeg 4.4.2. The lengths at the end of a stream (the 0.038 s gap, the 0.022 s at the start of the -itsoffset file) can differ with other sample rates, frame rates, encoders or ffmpeg versions. Run the ffprobe lines on your own file rather than trusting mine.
  • I checked the files with ffprobe and ffmpeg only. I did not play them in a phone app, a browser or an editor, so how those handle the -itsoffset start time, the extra track or its name is untested here.
  • The docs quoted on this page are the current ones on ffmpeg.org. Where 4.4.2 behaves differently, the page says so; everything it claims was run on 4.4.2.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top