mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 05:47:13 +02:00
The mp3 muxer writes the Xing frame that carries the frame count only after rewinding to the start of the stream, which it cannot do on a pipe (-f mp3 -). An uncached transcode therefore declares its length nowhere: no Xing frame, and no Content-Length unless the client asks for the estimate. A decoder has to guess the duration from the bytes it has so far, and an offline copy stays unseekable, the same defect #6105 fixed for FLAC. Transcode now inserts a Xing frame ahead of the first audio frame, carrying the frame count derived from the track duration. Only the frame count is declared: a piped encode cannot know its own byte size, and a wrong size is worse than an absent one. Decoded audio is unchanged, since decoders skip the tag frame. Measured on a 20s 128kbps transcode: ffprobe reports 20.010s instead of 20.036s, and Firefox reports 19.998s at loadedmetadata instead of 2.04s climbing over nine durationchange events. This does not make an uncached transcode resumable; that needs range support, which only the cache path has. Reported in #6170.
157 lines
4.8 KiB
Go
157 lines
4.8 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())
|
|
})
|
|
|
|
readAll := func(in []byte, duration float32) []byte {
|
|
out, err := io.ReadAll(patchMP3Duration(io.NopCloser(bytes.NewReader(in)), duration))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
return out
|
|
}
|
|
|
|
// Reads the tag, flags and frame count of the frame starting at frameStart.
|
|
readXing := func(b []byte, frameStart, tagOffset int) (string, uint32, uint32) {
|
|
at := frameStart + tagOffset
|
|
return string(b[at : at+4]),
|
|
binary.BigEndian.Uint32(b[at+4:]),
|
|
binary.BigEndian.Uint32(b[at+8:])
|
|
}
|
|
|
|
It("inserts a Xing frame declaring the duration in frames", func() {
|
|
out := readAll(pipedMP3, 1.0)
|
|
|
|
tag, flags, frames := readXing(out, fixtureID3Len, stereoTagOffset)
|
|
Expect(tag).To(Equal("Xing"))
|
|
Expect(flags).To(Equal(uint32(1)), "only the frame count is present")
|
|
Expect(frames).To(Equal(uint32(38)), "1s at 44100Hz is 38 frames of 1152 samples")
|
|
})
|
|
|
|
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("Xing"))
|
|
Expect(frames).To(Equal(uint32(38)))
|
|
})
|
|
|
|
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("Xing"))
|
|
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 := patchMP3Duration(io.NopCloser(iotest.ErrReader(errors.New("ffmpeg died"))), 1.0)
|
|
_, err := io.ReadAll(r)
|
|
Expect(err).To(MatchError(ContainSubstring("ffmpeg died")))
|
|
})
|
|
})
|
|
|
|
// monoMP3 is two silent MPEG1 Layer III frames, 128kbps, 44100Hz, mono.
|
|
func monoMP3() []byte {
|
|
b := make([]byte, 2*417)
|
|
copy(b, []byte{0xFF, 0xFB, 0x90, 0xC0})
|
|
copy(b[417:], []byte{0xFF, 0xFB, 0x90, 0xC0})
|
|
return b
|
|
}
|