Skip to content
mp3fy

← Blog

The click every thirty minutes: anatomy of a gapless MP3 join

Convert a long audiobook with most tools that split the work into chunks and the output carries a defect you can set a watch by: a soft click, or a breath of dead air, every thirty minutes or so, one at every point where two chunks were glued back together. The cause is not a bug in any single component. It is the MP3 format doing exactly what it was designed to do, in a situation it was never designed for: LAME pads every independent encode with silence, and a chunked pipeline runs many independent encodes.

This is the story of that click: where it comes from, why it survives most attempts to remove it, and the three moves it took to make a twenty-segment parallel conversion match a single-pass encode to within one frame.

A click you can set your watch by

The symptom first. We convert very long files in parallel: a ten-hour recording is split into roughly thirty-minute segments, each segment is converted on a separate machine, and the results are assembled into one MP3. Done naively, the assembled file plays fine. For thirty minutes. Then a click. Thirty minutes later, another. In an audiobook, mid-sentence, it sounds like the narrator swallowed. In a DJ mix the beat stumbles.

Open one of these joins in an audio editor and the problem is right there on the screen: a flat line. Somewhere between 50 and 100 milliseconds of pure digital silence, planted in the middle of continuous audio, at every single join.

Nobody put it there deliberately. Everybody put it there.

What a frame owes you

MP3 audio is a chain of frames, and every frame carries exactly 1152 samples. At a 44.1 kHz sample rate that is about 26.12 ms of audio per frame. There is no such thing as an MP3 frame holding 1000 samples, or 1152.5. The format quantizes time the way film quantizes motion into pictures.

That rounding has a consequence no encoder can escape: an encode cannot end mid-frame. If a segment’s audio is 79,380,000 samples long (thirty minutes exactly, at 44.1 kHz), that is 68,906.25 frames, and a quarter of a frame is not a thing anyone can write. So the encoder writes 68,907 frames and fills the remainder with silence.

There is a matching problem at the start. The filter bank at the heart of MP3 has to be primed before it produces valid output, so LAME feeds it roughly 576 samples of nothing ahead of your first real sample. That is the encoder delay. LAME records it in the file’s metadata so a player can trim the edges, which is why gapless playback of a single, intact file works on players that read the tag.

Now encode ten segments independently. You get ten encoder delays, ten padded tails, and one metadata tag, because a concatenated stream has one header, not ten. The nine interior delays and tails have nowhere to hide. They are simply silence, in the middle of your book. Add the padding at one segment’s tail to the delay at the next segment’s head and you get the 50–100 ms pocket from the editor screenshot.

And that is the well-behaved failure mode. The uglier one happens upstream: if the splitter cut the source on timestamps (“segment two starts at 1800.0 seconds”), the cuts land wherever the source container’s packets happen to fall. Two adjacent workers can each claim the same packet, or neither can. The joined file then repeats a syllable, or eats one.

Silence you notice. A missing syllable you rewind for.

Move one: boundaries are sample counts

The fix starts before any audio is decoded, in the arithmetic that assigns the work.

Segment boundaries are computed in samples, never in seconds, and every boundary is an exact multiple of 1152 at the output sample rate. “About thirty minutes” is not a boundary. At 44.1 kHz, thirty minutes is 79,380,000 samples, which is not divisible by 1152; the nearest multiple below it is 79,379,712, or exactly 68,906 whole frames. So that is the segment length, 288 samples (about 6.5 ms) shy of the round number. Nobody notices, because segments are an internal unit. What matters is that every interior boundary lands on a frame edge in the final output.

Once each segment’s payload is a whole number of frames, the end-of-encode padding problem disappears for every segment except the last. There is no fractional frame to fill. The final segment keeps its padding (that is the file’s true tail, and the format requires it), but a tail is exactly where padding belongs.

Move two: cut by decoding, not by seeking

Knowing the right boundary in samples is worthless if you cannot cut there, and compressed sources will not let you. Seeking lands on packet boundaries, keyframes, whatever the container indexes: close to the target, never reliably on it.

So each worker does the honest thing. It seeks near its boundary, which is fast, then decodes forward and discards samples until it reaches the exact sample index it was assigned. Encoding begins on that sample. The same discipline holds at the far end: decode to exactly the assigned count, then stop.

This costs a few seconds of throwaway decoding per worker. In exchange, segment N’s last sample and segment N+1’s first sample are adjacent samples of the source, nothing duplicated, nothing dropped, even though the two machines producing them never exchange a byte. The sample arithmetic is the only coordination there is.

Move three: assembly that counts frames

The segments come back as twenty valid MP3 files. Assembly’s job is to produce one stream that behaves as if they had never been separate.

It walks each segment frame by frame. Every segment owes a known, exact number of frames (its assigned sample count divided by 1152, fixed since move one), and assembly keeps precisely that many from each segment, no more. LAME’s trailing padding frames do not survive the walk: they are identified and dropped, never decoded, simply not copied. What remains is one continuous chain of frames, with padding only at the true end of the file, where a partial final frame is legitimate.

One correct chain of frames, though, is still not a finished file.

The header ffmpeg won’t write

Players do not count a million frames to learn a file’s duration. That would mean reading the whole file up front. They read the Xing/Info frame: a metadata frame at the front of the stream declaring the total frame count and byte count, from which duration and seek positions are derived. Keep segment one’s header on a twenty-segment assembly and your ten-hour audiobook claims to be thirty minutes long. The seek bar lies. “Time remaining” lies.

So the header must be rewritten with the true totals, which runs into a mundane, stubborn obstacle: the totals are only known at the end, the header lives at the beginning, and assembly writes a stream. It does not buffer ten hours of MP3 in memory to fix up afterwards, as a matter of policy. ffmpeg faces the same constraint and resolves it by giving up: on a non-seekable output it simply will not write a Xing frame.

We do not buffer, and we do not give up. Assembly injects a placeholder Xing/Info frame of exactly the right size at the front of the stream, streams everything after it, and once the last frame of the last segment has been counted, patches the placeholder in place: same size, same offset, now carrying the real frame count and byte count. One small write at a known position, and players report the exact duration of a file that was never held in memory.

Ten hours, two ways

The acceptance test for all of this is a subtraction.

Take one ten-hour file. Convert it twice: once as a single sequential encode, and once split into twenty parallel segments on twenty machines and assembled. Compare the outputs.

Both report 36000.03 seconds: identical duration to within one frame. The parallel version has 19 joins in it and zero added padding at any of them; the frame counts match because move one forced them to, before a single sample was encoded. The only difference left between the two files is that one of them existed about twenty times sooner.

That tolerance, one frame across ten hours, is the whole argument. Had every join leaked even a single frame of padding, the totals would have drifted apart by roughly half a second. They do not.

None of this asks anything of the person converting. Drop an audiobook on the M4B converter, or a long recording on any of the others, and the parallel path, the frame arithmetic and the header patch all engage because the file is long, not because you asked.

What gapless honestly means

A closing note on the word “gapless”, because it invites overclaiming. MP3 frames are not fully independent: the bit reservoir lets a frame stash some of its data in the spare space of earlier frames, and adjacent frames’ transform windows overlap by design. Segments encoded on separate machines cannot share a reservoir or an overlap across a join, so the first frames after a boundary are not bit-identical to what a single-pass encode would have produced there. What this pipeline guarantees is narrower, and it is the part that matters: zero added padding and sample-exact content, every sample of the source present exactly once, and no silence that was not in the original. At a join inside continuous material, that is the difference an ear can detect, and it is the one we can prove with a subtraction.

Convert a file now →