Skip to content

Trim the encoder delay and padding of MP3 streams without gapless info - #150

Draft
chrizzchrizz wants to merge 4 commits into
dimitris-c:mainfrom
chrizzchrizz:fix/mp3-gapless-without-header
Draft

chrizzchrizz wants to merge 4 commits into
dimitris-c:mainfrom
chrizzchrizz:fix/mp3-gapless-without-header

Conversation

@chrizzchrizz

Copy link
Copy Markdown

Trim the encoder delay and padding of MP3 streams without gapless info

Based on #144, which adds the packet table based trimming. Until #144 is merged this PR also shows its commits; the new change is the last commit only.

Problem

An MP3 without a Xing/Info header reports no packet table info. That is the case for a transcode of a server (live, or from the server's transcoding cache) and for files of simple encoders. The trimming from #144 does not apply to them, so the decoder outputs the encoder delay at the start and the padding at the end as audio. Two such entries are separated by a gap of a few dozen milliseconds, which is an audible tick in a continuous mix (a DJ mix split into tracks, a live album).

Navidrome is an easy way to see it: its transcoding cache is on by default. The first request of a song is a live stream (chunked, no length), every later request is a cached file (with length, Accept-Ranges: bytes), and neither has a header.

Change

  • For an MP3 that reports no packet table info by the time it is ready to produce packets, assume the encoder delay of LAME (576 frames; the decoder delay of 529 is already compensated by AudioToolbox). The packet table info of a file with header is reported earlier and takes precedence.
  • The padding is not known. When the stream ends, drop the frames at the end of the renderer buffer that are below 20 % of the peak level of the music before them. The limit is one MP3 frame plus the encoder delay, and only frames that were not played yet are dropped. A fixed silence threshold is not enough: the codec rings for a few dozen frames after music that ends abruptly (about 10 % of its level) and has a noise floor around -45 dBFS after that.

Measurement

I played the end of one track and the start of the next one of a DJ mix back to back (M4A transcoded to MP3 192 kbit/s by Navidrome) and compared the captured signal sample by sample with the lossless source (gap = offset after the transition - offset before it):

before after
cached transcode (file, no header) 1535 frames 0
live transcode (chunked) 659 frames 0 (once 59, the ramp-up of the MP3 filter at a hard start)

Same result on macOS and on an iPhone 15 Pro (iOS 27.2). A sine cut in two parts (MP3 files with and without Xing header, live transcode) and RAW: no gap and no phase jump. The captured signal comes from frameFiltering; I can share the harness.

Notes

  • The assumption of 576 frames is a heuristic for an MP3 without header. An encoder with another delay leaves a small rest or drops a few milliseconds of the start. Files with a Xing/Info header are not affected.
  • The new unit tests cover the assumed priming, the level based padding and the cases that must not be trimmed (quiet music, signal at the end).
  • swift test --parallel: 80 tests, 0 failures.

Decoders output the encoder delay (priming) and padding (remainder) of
formats like AAC and MP3 as audio. The packet table info was only used
for the duration, so consecutive entries were separated by a short
silence (2112 + remainder frames, about 50 ms, for AAC).

- Keep only the decoded frames in [priming, priming + valid) while the
  decoded position is exact (decoding from the start of the stream)
- After a seek, drop the remainder at the end of the stream instead
- Frame counts are converted to the output sample rate
- Mark the decoded position as inexact when re-reading from a seek offset
  (reset(decodedPositionExact:)), so mid-stream data isn't trimmed as the start
- Fall back to trailing-only trimming when packet table info arrives late
- Account for frames after a fully filled decode so they aren't overwritten
- Pass the EOF entry to trimTrailingGaplessPadding instead of looking it up
- Extract the trim math into GaplessTrim and add unit tests
- Keep the decoded position exact when seeking to the start of the data
- Ignore repeated packet table info once the gapless values are set
- Round the end of the kept range once to avoid an off-by-one with resampling
An MP3 without Xing/Info header reports no packet table info: a transcode of a server (live or from its cache), or a file of a simple encoder. The decoder then outputs the encoder delay at the start and the padding at the end as audio, so two entries are separated by a gap of about 35 ms, which is a tick in a continuous mix. The trimming added for AAC and tagged MP3 does not apply.

- Assume the encoder delay of LAME (576 frames, the decoder delay is already compensated by AudioToolbox) for an MP3 that reports no packet table info when it is ready to produce packets. The packet table info of a file with header is reported before and takes precedence.
- The padding is unknown. At the end of the stream drop the frames at the end of the renderer buffer that are below 20 percent of the peak level of the music before them (at most one frame plus the encoder delay, and only frames that are not played yet). The codec rings for a few dozen frames after music that ends abruptly, so a fixed silence threshold is not enough.

Measured with the end of one track and the start of the next one of a DJ mix (M4A transcoded to MP3 by Navidrome, with and without its transcoding cache), played back to back and compared sample by sample with the lossless source: gap at the transition 1535 frames (cached) / 659 frames (live) -> 0 frames, on macOS and on an iPhone 15 Pro. A sine cut in two parts, MP3 files with and without Xing header, and RAW: no gap, no phase jump.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant