mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-03 00:47:48 +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.
143 lines
4.3 KiB
Go
143 lines
4.3 KiB
Go
package ffmpeg
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"math"
|
|
"slices"
|
|
|
|
"github.com/navidrome/navidrome/utils/gg"
|
|
)
|
|
|
|
const (
|
|
mp3HeaderLen = 4
|
|
mp3ID3Len = 10
|
|
mp3InfoTagLen = 12 // tag, flags and frame count
|
|
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 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) {
|
|
buf, err := h.fill(buf, mp3ID3Len)
|
|
if err != nil || len(buf) < mp3ID3Len {
|
|
return buf, err
|
|
}
|
|
start := 0
|
|
if string(buf[:3]) == "ID3" {
|
|
if start = id3TagLen(buf); start > mp3MaxPrefix {
|
|
return buf, nil
|
|
}
|
|
}
|
|
if buf, err = h.fill(buf, start+mp3HeaderLen); err != nil || len(buf) < start+mp3HeaderLen {
|
|
return buf, err
|
|
}
|
|
frame, ok := parseMP3Header(buf[start:])
|
|
if !ok {
|
|
return buf, nil
|
|
}
|
|
if buf, err = h.fill(buf, start+frame.size); err != nil || len(buf) < start+frame.size {
|
|
return buf, err
|
|
}
|
|
if isXingFrame(buf[start:], frame.tagOffset) {
|
|
return buf, nil
|
|
}
|
|
xing, ok := xingFrame(buf[start:], h.duration)
|
|
if !ok {
|
|
return buf, nil
|
|
}
|
|
return slices.Insert(buf, start, xing...), nil
|
|
}
|
|
|
|
func id3TagLen(header []byte) int {
|
|
size := int(header[6]&0x7F)<<21 | int(header[7]&0x7F)<<14 | int(header[8]&0x7F)<<7 | int(header[9]&0x7F)
|
|
if header[5]&0x10 != 0 { // footer present
|
|
size += mp3ID3Len
|
|
}
|
|
return mp3ID3Len + size
|
|
}
|
|
|
|
type mp3Frame struct {
|
|
sampleRate int
|
|
samples int // per frame
|
|
size int // bytes, including the header
|
|
tagOffset int // where a Xing tag sits in the incoming frame, which may carry a CRC
|
|
}
|
|
|
|
var (
|
|
mp3BitRates = [2][16]int{
|
|
{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}, // MPEG 1
|
|
{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}, // MPEG 2 and 2.5
|
|
}
|
|
mp3SampleRates = [4][4]int{
|
|
{11025, 12000, 8000, 0}, // MPEG 2.5
|
|
{}, // reserved
|
|
{22050, 24000, 16000, 0}, // MPEG 2
|
|
{44100, 48000, 32000, 0}, // MPEG 1
|
|
}
|
|
)
|
|
|
|
func parseMP3Header(h []byte) (mp3Frame, bool) {
|
|
version, layer := (h[1]>>3)&0x03, (h[1]>>1)&0x03
|
|
if h[0] != 0xFF || h[1]&0xE0 != 0xE0 || version == 1 || layer != 1 {
|
|
return mp3Frame{}, false // not the sync word of an MPEG Layer III frame
|
|
}
|
|
mpeg1 := version == 3
|
|
sampleRate := mp3SampleRates[version][(h[2]>>2)&0x03]
|
|
bitRate := 1000 * mp3BitRates[gg.If(mpeg1, 0, 1)][h[2]>>4]
|
|
if sampleRate == 0 || bitRate == 0 {
|
|
return mp3Frame{}, false
|
|
}
|
|
f := mp3Frame{sampleRate: sampleRate, samples: 576}
|
|
var sideInfo int
|
|
mono := (h[3]>>6)&0x03 == 3
|
|
switch {
|
|
case mpeg1 && mono:
|
|
f.samples, sideInfo = 1152, 17
|
|
case mpeg1:
|
|
f.samples, sideInfo = 1152, 32
|
|
case mono:
|
|
sideInfo = 9
|
|
default:
|
|
sideInfo = 17
|
|
}
|
|
f.size = f.samples/8*bitRate/sampleRate + int((h[2]>>1)&0x01)
|
|
f.tagOffset = mp3HeaderLen + sideInfo
|
|
if h[1]&0x01 == 0 { // CRC follows the header
|
|
f.tagOffset += 2
|
|
}
|
|
return f, true
|
|
}
|
|
|
|
func isXingFrame(frame []byte, tagOffset int) bool {
|
|
if len(frame) < tagOffset+4 {
|
|
return false
|
|
}
|
|
tag := string(frame[tagOffset : tagOffset+4])
|
|
return tag == "Xing" || tag == "Info"
|
|
}
|
|
|
|
// xingFrame builds a silent Info frame declaring how many frames follow it. Its header is
|
|
// the first frame's minus the CRC, with the bitrate raised until the frame fits the tag.
|
|
func xingFrame(first []byte, duration float32) ([]byte, bool) {
|
|
header := [mp3HeaderLen]byte(first)
|
|
header[1] |= 0x01
|
|
f, ok := parseMP3Header(header[:])
|
|
for ok && f.size < f.tagOffset+mp3InfoTagLen {
|
|
header[2] += 0x10 // next bitrate index; 15 is invalid, which ends the loop
|
|
f, ok = parseMP3Header(header[:])
|
|
}
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
frames := math.Round(float64(duration) * float64(f.sampleRate) / float64(f.samples))
|
|
if frames < 1 || frames > math.MaxUint32 {
|
|
return nil, false
|
|
}
|
|
frame := make([]byte, f.size)
|
|
copy(frame, header[:])
|
|
copy(frame[f.tagOffset:], "Info") // a Xing tag without a seek table marks the stream unseekable to some decoders
|
|
binary.BigEndian.PutUint32(frame[f.tagOffset+4:], 1) // only the frame count is present
|
|
binary.BigEndian.PutUint32(frame[f.tagOffset+8:], uint32(frames))
|
|
return frame, true
|
|
}
|