From e918b1278b810fc175ebaf71ea75dd0f3e807d06 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 24 Sep 2026 17:08:33 -0400 Subject: [PATCH] fix(transcoding): tag the inserted mp3 frame as Info, not Xing The frame inserted into piped mp3 transcodes carried a Xing tag with only a frame count. Media3/ExoPlayer, which most Android clients use, builds a XingSeeker from a Xing tag and reports it seekable only when a seek table is present, so a cached or downloaded transcode became unseekable there. Without any frame it would have used constant-bitrate seeking. An Info tag takes Media3 down its constant-bitrate path, now with the declared duration. Info is also what ffmpeg and LAME write for a CBR file, which is what the default mp3 profile produces. Checked against the decoders on hand: ffprobe reports 20.010s either way, Firefox reports 19.998s at loadedmetadata either way, and decoded audio is unchanged. Apple's AudioToolbox ignores Info frames, including ffmpeg's own, so it sees the same stream it saw before this series. --- core/ffmpeg/ffmpeg_test.go | 4 ++-- core/ffmpeg/mp3_xing.go | 6 +++--- core/ffmpeg/mp3_xing_test.go | 9 +++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 79c46176b..9b0c7f6d4 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -722,7 +722,7 @@ var _ = Describe("ffmpeg", func() { Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100))) }) - It("inserts a Xing frame on a piped mp3 transcode", func() { + It("inserts an Info frame on a piped mp3 transcode", func() { stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ Command: "ffmpeg -i %s -map 0:a:0 -v 0 -b:a 128k -f mp3 -", Format: "mp3", @@ -734,7 +734,7 @@ var _ = Describe("ffmpeg", func() { out, err := io.ReadAll(stream) Expect(err).ToNot(HaveOccurred()) - at := bytes.Index(out, []byte("Xing")) + at := bytes.Index(out, []byte("Info")) Expect(at).To(BeNumerically(">", 0)) Expect(binary.BigEndian.Uint32(out[at+8:])).To(Equal(uint32(38))) }) diff --git a/core/ffmpeg/mp3_xing.go b/core/ffmpeg/mp3_xing.go index fd2c8524b..dcfb08790 100644 --- a/core/ffmpeg/mp3_xing.go +++ b/core/ffmpeg/mp3_xing.go @@ -14,7 +14,7 @@ const ( mp3MaxPrefix = 64 << 10 // ffmpeg cannot write an attached picture to a pipe, so the tag stays small ) -// mp3Prefix returns the head of the stream with a Xing frame inserted before the first +// mp3Prefix returns the head of the stream with an Info frame inserted before the first // audio frame, which ffmpeg omits on a pipe since it only knows the frame count once it // can rewind. It returns the head unchanged when it cannot make sense of what ffmpeg wrote. func (h *headerPatcher) mp3Prefix(buf []byte) ([]byte, error) { @@ -116,7 +116,7 @@ func isXingFrame(frame []byte, tagOffset int) bool { return tag == "Xing" || tag == "Info" } -// xingFrame builds a silent frame declaring how many frames follow it. It reuses the +// xingFrame builds a silent Info frame declaring how many frames follow it. It reuses the // first frame's header, minus its CRC, so the two describe the same stream. func xingFrame(first []byte, f mp3Frame, duration float32) ([]byte, bool) { frames := math.Round(float64(duration) * float64(f.sampleRate) / float64(f.samples)) @@ -127,7 +127,7 @@ func xingFrame(first []byte, f mp3Frame, duration float32) ([]byte, bool) { copy(frame, first[:mp3HeaderLen]) frame[1] |= 0x01 // no CRC, so the tag follows the side info directly at := mp3HeaderLen + f.sideInfo - copy(frame[at:], "Xing") + copy(frame[at:], "Info") // a Xing tag without a seek table marks the stream unseekable to some decoders binary.BigEndian.PutUint32(frame[at+4:], 1) // only the frame count is present binary.BigEndian.PutUint32(frame[at+8:], uint32(frames)) return frame, true diff --git a/core/ffmpeg/mp3_xing_test.go b/core/ffmpeg/mp3_xing_test.go index 2a66454c8..a5fca40b8 100644 --- a/core/ffmpeg/mp3_xing_test.go +++ b/core/ffmpeg/mp3_xing_test.go @@ -31,6 +31,7 @@ var _ = Describe("patchMP3Duration", func() { Expect(err).ToNot(HaveOccurred()) Expect(pipedMP3[fixtureID3Len : fixtureID3Len+2]).To(Equal([]byte{0xFF, 0xFB})) Expect(bytes.Contains(pipedMP3[:4096], []byte("Xing"))).To(BeFalse()) + Expect(bytes.Contains(pipedMP3[:4096], []byte("Info"))).To(BeFalse()) }) readAll := func(in []byte, duration float32) []byte { @@ -45,11 +46,11 @@ var _ = Describe("patchMP3Duration", func() { return string(b[at : at+4]), binary.BigEndian.Uint32(b[at+8:]) } - It("inserts a Xing frame declaring the duration in frames", func() { + It("inserts an Info frame declaring the duration in frames", func() { out := readAll(pipedMP3, 1.0) tag, frames := readXing(out, fixtureID3Len, stereoTagOffset) - Expect(tag).To(Equal("Xing")) + Expect(tag).To(Equal("Info")) Expect(frames).To(Equal(uint32(38)), "1s at 44100Hz is 38 frames of 1152 samples") flags := binary.BigEndian.Uint32(out[fixtureID3Len+stereoTagOffset+4:]) Expect(flags).To(Equal(uint32(1)), "only the frame count is present") @@ -87,7 +88,7 @@ var _ = Describe("patchMP3Duration", func() { out := readAll(in, 1.0) tag, frames := readXing(out, 0, monoTagOffset) - Expect(tag).To(Equal("Xing")) + Expect(tag).To(Equal("Info")) Expect(frames).To(Equal(uint32(38))) }) @@ -97,7 +98,7 @@ var _ = Describe("patchMP3Duration", func() { out := readAll(in, 1.0) tag, _ := readXing(out, 0, stereoTagOffset) - Expect(tag).To(Equal("Xing")) + Expect(tag).To(Equal("Info")) Expect(out[fixtureFrameLen:]).To(Equal(in)) })