FFmpeg concat audio gap isn't your cut. It's AAC priming.

You cut a long recording into parts, join them back with the concat demuxer, and every joint has a click or a beat of dead air. So you re-cut on keyframes, normalize the sample rate, switch from the demuxer to the filter, and the gap is still there, the same size at every join. The cut was never the problem.
Quick answer: An FFmpeg concat audio gap is caused by AAC encoder delay, not by a bad cut: every encoded AAC segment carries about 2112 silent priming samples at its head (roughly 44 ms at 48 kHz, per Apple's TN2258), and joining segments with-c copylays each one down with its own priming intact, so you hear 20-80 ms of silence or a click per joint. The fix is encoding the audio once across the whole join instead of once per segment, for exampleffmpeg -f concat -safe 0 -i list.txt -c:v copy -c:a aac -b:a 192k -af aresample=async=1:first_pts=0 out.mp4, which keeps the video lossless. If you'd rather not maintain a splice script, FFmpeg Micro takes the parts as one normalize-then-concat job.
The gap is encoder delay, not your cut point
AAC encoders don't start at sample zero. Every encoded AAC track begins with silent priming samples the encoder needs to prime its filter bank, conventionally 2112 of them (1024 + 1024 + 64). Apple documents the mechanism and that number in Technical Note TN2258. Those samples contain none of your source audio, so a decoder is supposed to discard them, and it learns how many from the container: an MP4 edit list, an iTunSMPB tag, or the ISO gapless boxes. The same 2112-sample delay shows up in third-party decoders like AAC.js, so it's a format-level convention, not an Apple quirk.
In wall-clock terms, 2112 samples is 44 ms at 48 kHz and 47.9 ms at 44.1 kHz. One AAC frame of 1024 samples is 21.3 ms at 48 kHz, which is why these gaps land in the 20-80 ms band.
Now take a 40-minute recording, cut it into twelve parts, and encode each part. Each part gets its own priming at the head and its own padding at the tail. When you join them with the concat demuxer and -c copy, FFmpeg copies the encoded frames untouched and lays each segment down at its own timestamps, so the per-file trim information doesn't survive into one continuous audio track. Segment two's priming is now sitting in the middle of your timeline as real, audible silence. A video-use bug report describes exactly this: -c copy concat leaves each segment carrying its own priming and padding, and the working fix is re-encoding only the audio into one continuous stream.
Re-cutting never removes the gap, because the priming samples live inside the encoded audio, not at the cut point. Any cut of an AAC track starts with a fresh set. Keyframe alignment fixes video seams and does nothing to audio padding.
Prove the gap is in the file before you change the command
Run two checks on the joined file. The first tells you whether real silence exists in the decoded audio, which rules out a player bug. The second shows the priming bookkeeping on the parts.
# 1. Is there actual silence in the output, and where?
ffmpeg -i joined.mp4 -af silencedetect=noise=-50dB:d=0.01 -f null - 2>&1 | grep silence_
silencedetect prints a silence_start and silence_duration for every stretch it finds. If those timestamps land on your join points and the durations cluster around 0.02-0.08 seconds, the dead air is in the file and no player setting will save you.
# 2. What does each part declare at its head?
ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name,sample_rate,start_time,start_pts,time_base,duration \
-of default=noprint_wrappers=1 part02.mp4
ffprobe -v error -select_streams a:0 -show_packets -read_intervals "%+#4" part02.mp4
The second command prints the first four audio packets. ffprobe exposes the AV_PKT_DATA_SKIP_SAMPLES side data as skip_samples, discard_padding, skip_reason and discard_reason in a SIDE_DATA block, which is the trim the decoder is being told to apply. Compare that against the stream's start_time. A part with a nonzero start_time of about 0.044 and no skip_samples side data will turn its priming into audible silence the moment you copy it into a join.
The fixes that work, and the one stream copy structurally cannot do
Three approaches remove the gap, and all three decode the audio and rebuild it as one continuous stream instead of twelve stitched ones. The concat demuxer with -c copy cannot do that by definition: stream copy means no decode, no resample, no re-encode.
Re-encode audio once across the whole join
Re-encoding the audio once across the whole join is the first option to reach for, because it keeps your video bit-for-bit and only pays the encode cost on the cheap stream.
ffmpeg -f concat -safe 0 -i list.txt \
-c:v copy \
-af aresample=async=1:first_pts=0 \
-c:a aac -b:a 192k -ar 48000 -ac 2 \
joined.mp4
aresample=async=1 fills timestamp gaps with silence rather than pulling later samples earlier, which keeps the audio aligned to the video instead of sliding it forward. first_pts=0 anchors the output stream at zero so the head priming doesn't become a leading offset. The filter belongs on the output audio chain, after the join, not per input.
Use the concat filter when you're re-encoding anyway
If the parts have different resolutions or frame rates, you're already re-encoding video, so build one continuous stream for both:
ffmpeg -i part01.mp4 -i part02.mp4 -i part03.mp4 \
-filter_complex "[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]" \
-map "[v]" -map "[a]" \
-c:v libx264 -crf 18 -preset medium -c:a aac -b:a 192k \
joined.mp4
The concat filter outputs continuous PTS regardless of what each input declared, so the priming never reaches the output. The cost is real: video goes through the filter graph, so -c:v copy is off the table. Mixed-source parts need this path anyway, the same reason repurposing long-form into Shorts usually means normalize-then-join rather than a copy-only merge.
Go through PCM when you want it sample-exact
Decode each part's audio to WAV, concatenate the PCM, then mux it back against the copied video. PCM has no priming and no padding, so the joins are sample-exact. That's why lossless formats are inherently gapless while lossy ones pad both ends of every track. The mux step is the easy half, as covered in replacing audio in video by API.
Pick a lossless intermediate, not a lossy one. A Remotion report measured rendered MP4 audio landing 2048 samples late, 42.67 ms at 48 kHz, because libfdk_aac priming information was lost through an ADTS intermediate. Raw AAC and ADTS have nowhere to carry the trim metadata, so they discard the one thing you needed.
How the options compare:
| Method | Removes the per-join gap | Video re-encoded |
|---|---|---|
| concat demuxer, `-c copy` | No, structurally cannot | No |
| concat demuxer + `aresample` + AAC re-encode | Yes | No |
| `filter_complex concat` | Yes | Yes |
| WAV/PCM intermediate, then mux | Yes, sample-exact | No |
| `-itsoffset` nudge | No, it's a global shift | No |
| `-ignore_editlist 1` | No, it throws out the valid trim too | No |
Both the detection step and the fix are single calls on a hosted API. FFmpeg Micro's /v1/ffprobe endpoint returns the stream metadata you'd otherwise shell out for, and /v1/transcodes accepts an inputs array, so the parts go in as one normalize-then-concat job where the audio is encoded once across the whole join. Billing is per second of input video with no rounding up to the minute, and failed jobs are never billed, which matters when you're iterating on a join that keeps clicking. The docs have the full upload, submit, poll, download sequence.
{
"inputs": [
{ "url": "gs://<YOUR_BUCKET>/part01.mp4" },
{ "url": "gs://<YOUR_BUCKET>/part02.mp4" },
{ "url": "gs://<YOUR_BUCKET>/part03.mp4" }
],
"outputFormat": "mp4",
"preset": { "quality": "medium", "resolution": "1080p" }
}
Why Premiere and After Effects exports sound worse at the joins
Clips exported from Adobe tools often come in with a head gap before you even cut them, which then multiplies across your joins. Adobe's own tracker carries bug 1216927: After Effects' H.264 MP4 output module doesn't declare AAC priming in the expected ISO form (elst plus sbgp plus sgpd) or as iTunSMPB. The report names FFmpeg as the most important application that reads the result incorrectly, and the outcome is roughly 20 ms of silence at the start plus an over-long declared duration. Adobe has separately reproduced a half-frame mute and delay at the head of H.264 and H.265 MP4 exports from Premiere Pro and Media Encoder, visible in other editors but not in Premiere itself.
Editor exports deserve a normalization pass on ingest rather than trust, and a file that sounds clean in one player is not evidence: gapless metadata handling in MP4 and AAC varies by decoder, which is why mpv issue 2284 and Mozilla's open platform bug 1321249, "Properly handle AAC/MP4 encoder delay," both exist. Check with silencedetect, not with your ears in QuickTime.
Common pitfalls
Most of the time lost on this bug goes into fixes that address a different cause.
-itsoffsetshifts an entire input by a fixed amount. It cannot fix a gap that recurs at every joint inside one output, and the guides that recommend it are solving capture-side drift.-ignore_editlist 1looks like it targets the right metadata, and MP4 edit lists are also how legitimate priming trim gets signalled. Dropping them leaves you offset instead of fixed.-ac 2and-ar 48000normalization is necessary when parts disagree on layout or rate, and it is not sufficient: parts with identical codec parameters still produce a gap per join.anullsrcadds silence on purpose. Half the top results for "silence between merged videos" teach it, because the same wording serves people trying to insert silence rather than remove it.- Fixing the joined file after the fact with a trim does nothing, because the gaps are interior. Re-run the join rather than patch it, and check format and filter mismatches if the re-run errors out.
FAQ
Why does the gap come back when I re-cut the clips on keyframes?
Re-cutting on keyframes aligns the video seam and leaves the audio padding untouched, because the priming samples are part of the encoded AAC stream and every new cut produces a fresh set of them. The gap is a property of the encoder, not of where you cut.
Can I keep -c:v copy and only re-encode the audio?
Yes. Re-encoding only the audio is the preferred fix: use the concat demuxer with -c:v copy -c:a aac -af aresample=async=1:first_pts=0, which decodes, aligns, and re-encodes the audio while the video frames are copied bit-for-bit. The concat filter is the exception: anything going through filter_complex on the video path has to be re-encoded.
Does aresample=async=1:first_pts=0 go before or after the concat?
Put aresample=async=1:first_pts=0 on the output audio chain after the join, not on each input. Applied per input it only corrects that input's head; the audible discontinuities are at the joints, which exist only in the joined stream.
Will 40 joins put me two seconds out of sync?
Forty joins can accumulate close to two seconds of audio offset, since roughly 44 ms of untrimmed priming per segment times 40 segments is about 1.76 seconds. The per-join gap is constant rather than drifting, so the arithmetic is predictable: count your segments and multiply by 2112 samples for your worst case.
If you'd rather stop maintaining a splice script that encodes audio at exactly the right point in the chain, send the parts to FFmpeg Micro as one normalize-then-concat job: the API docs walk the upload, submit, poll, download sequence, and the free tier is 200 tokens, about 33 minutes of video, with no card.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

Run an ONNX Upscaler in FFmpeg 9's DNN Filter Graph
FFmpeg onnx dnn filter in 9.0: the real dnn_processing chain, NCHW float32 model limits, silent CUDA-to-CPU fallback, and what hosting it actually costs.

FFmpeg "Buffer Queue Overflow, Dropping" Is a Timestamp Bug
FFmpeg's "buffer queue overflow, dropping" warning means one input in your filter graph outruns another. Find the starving input and fix PTS and durations.

FFmpeg "Impossible to Convert": Fix the Format, Not the Filter
FFmpeg impossible to convert between the formats supported by the filter? Two branches of your graph disagree on pixel format. Fix it before the overlay.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free