navidrome/core/ffmpeg/mp3_xing_test.go
Deluan ec49ae476f fix(transcoding): size the inserted mp3 frame to fit its tag
The inserted Info frame reused the first audio frame's header, and so its size.
A VBR encode at 22050Hz that starts on silence opens with an 8kbps frame of 26
bytes, while the tag needs 33, so building the frame panicked with an index out
of range. Reproduced with ffmpeg -ar 22050 -q:a 9 on a quiet input.

The frame now keeps the first frame's version, sample rate and channel mode but
raises the bitrate until the frame is big enough for the tag, which is what
ffmpeg's own mp3 muxer does. The frame count is unaffected, and decoded audio of
the reproducing file is byte-identical after the patch.
2026-09-24 17:16:42 -04:00

170 lines
5.5 KiB
Go

package ffmpeg
import (
"bytes"
"encoding/binary"
"errors"
"io"
"os"
"testing/iotest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Offsets of the Xing tag inside the first frame, for the two layouts the specs use.
const (
stereoTagOffset = 36 // 4 header + 32 side info
monoTagOffset = 21 // 4 header + 17 side info
fixtureID3Len = 39581 // where the first frame of tests/fixtures/test.mp3 starts
fixtureFrameLen = 627 // its frame size, at 192kbps and 44100Hz
)
var _ = Describe("patchMP3Duration", func() {
var pipedMP3 []byte
// The fixture is an ffmpeg pipe's output in every way that matters here: ID3 tag,
// frames, no Xing.
BeforeEach(func() {
var err error
pipedMP3, err = os.ReadFile("tests/fixtures/test.mp3")
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 {
out, err := io.ReadAll(patchPipedHeader(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
// Reads the tag and frame count of the frame starting at frameStart.
readXing := func(b []byte, frameStart, tagOffset int) (string, uint32) {
at := frameStart + tagOffset
return string(b[at : at+4]), binary.BigEndian.Uint32(b[at+8:])
}
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("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")
})
It("leaves the ID3 tag and the audio frames untouched", func() {
out := readAll(pipedMP3, 1.0)
start := fixtureID3Len
Expect(out).To(HaveLen(len(pipedMP3) + fixtureFrameLen))
Expect(out[:start]).To(Equal(pipedMP3[:start]))
Expect(out[start+fixtureFrameLen:]).To(Equal(pipedMP3[start:]))
})
It("takes the sample rate from the header, not from the source file", func() {
in := bytes.Clone(pipedMP3)
start := fixtureID3Len
in[start+2] = in[start+2]&^0x0C | 0x04 // sample rate index 1: 48000Hz
out := readAll(in, 1.0)
_, frames := readXing(out, start, stereoTagOffset)
Expect(frames).To(Equal(uint32(42))) // 48000/1152, rounded
})
It("rounds the frame count to the nearest frame", func() {
out := readAll(pipedMP3, 0.99) // 37.9 frames
_, frames := readXing(out, fixtureID3Len, stereoTagOffset)
Expect(frames).To(Equal(uint32(38)))
})
It("places the tag after the shorter side info of a mono stream", func() {
in := monoMP3()
out := readAll(in, 1.0)
tag, frames := readXing(out, 0, monoTagOffset)
Expect(tag).To(Equal("Info"))
Expect(frames).To(Equal(uint32(38)))
})
It("raises the bitrate of the inserted frame when the first frame is too small for the tag", func() {
// MPEG 2, 8kbps, 22050Hz, stereo: 26 bytes, as a VBR encode starting on silence emits.
in := make([]byte, 26)
copy(in, []byte{0xFF, 0xF3, 0x10, 0x00})
out := readAll(in, 1.0)
Expect(out[2]>>4).To(Equal(byte(2)), "16kbps, the first bitrate whose frame fits the tag")
tag, frames := readXing(out, 0, 21) // 4 header + 17 side info
Expect(tag).To(Equal("Info"))
Expect(frames).To(Equal(uint32(38))) // 1s at 22050Hz is 38 frames of 576 samples
Expect(out[52:]).To(Equal(in)) // 52 bytes = frame size at 16kbps, 22050Hz
})
It("inserts the frame at the start of a stream with no ID3 tag", func() {
in := pipedMP3[fixtureID3Len:]
out := readAll(in, 1.0)
tag, _ := readXing(out, 0, stereoTagOffset)
Expect(tag).To(Equal("Info"))
Expect(out[fixtureFrameLen:]).To(Equal(in))
})
It("leaves a stream that already declares its duration alone", func() {
patched := readAll(pipedMP3, 1.0)
Expect(readAll(patched, 99.0)).To(Equal(patched))
})
It("passes through when the duration is zero or negative", func() {
Expect(readAll(pipedMP3, 0)).To(Equal(pipedMP3))
Expect(readAll(pipedMP3, -5)).To(Equal(pipedMP3))
})
It("passes through a stream that is not mp3", func() {
in := []byte("fLaC\x00\x00\x00\x22 and then some bytes that are not frames")
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a frame header with a reserved bitrate or sample rate", func() {
in := monoMP3()
in[2] |= 0xF0 // bitrate index 15 is invalid
Expect(readAll(in, 1.0)).To(Equal(in))
in = monoMP3()
in[2] |= 0x0C // sample rate index 3 is reserved
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a stream too short to hold a frame header", func() {
in := monoMP3()[:3]
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through a stream whose ID3 tag never ends", func() {
in := bytes.Clone(pipedMP3[:200])
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readAll(nil, 1.0)).To(BeEmpty())
})
It("propagates a read error from the underlying stream", func() {
r := patchPipedHeader(io.NopCloser(iotest.ErrReader(errors.New("ffmpeg died"))), 1.0)
_, err := io.ReadAll(r)
Expect(err).To(MatchError(ContainSubstring("ffmpeg died")))
})
})
// monoMP3 is one silent MPEG1 Layer III frame, 128kbps, 44100Hz, mono.
func monoMP3() []byte {
b := make([]byte, 417)
copy(b, []byte{0xFF, 0xFB, 0x90, 0xC0})
return b
}