mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-03 17:07:49 +01:00
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.
170 lines
5.5 KiB
Go
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
|
|
}
|