fix: use ADTS for AAC transcoding, temporarily exclude AAC from transcode decisions (#5167)
* fix: use ADTS format for AAC transcoding to avoid silent output on ffmpeg 8.0+ The fragmented MP4 muxer (`-f ipod -movflags frag_keyframe+empty_moov`) produces corrupt/silent audio when ffmpeg pipes to stdout, confirmed on ffmpeg 8.0+. The moof atom offset values are zeroed out in pipe mode, causing AAC decoder errors. Switch to `-f adts` (raw AAC framing) which works reliably via pipe and is widely supported by clients including UPnP/Sonos devices. * fix: exclude AAC from transcode decision, as it is not working for Sonos. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
+1
-1
@@ -153,7 +153,7 @@ var (
|
|||||||
Name: "aac audio",
|
Name: "aac audio",
|
||||||
TargetFormat: "aac",
|
TargetFormat: "aac",
|
||||||
DefaultBitRate: 256,
|
DefaultBitRate: 256,
|
||||||
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
|
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "flac audio",
|
Name: "flac audio",
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ var formatCodecMap = map[string]string{
|
|||||||
var formatOutputMap = map[string]string{
|
var formatOutputMap = map[string]string{
|
||||||
"mp3": "mp3",
|
"mp3": "mp3",
|
||||||
"opus": "opus",
|
"opus": "opus",
|
||||||
"aac": "ipod",
|
"aac": "adts",
|
||||||
"flac": "flac",
|
"flac": "flac",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,11 +339,6 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
|||||||
args = append(args, "-f", outputFmt)
|
args = append(args, "-f", outputFmt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming
|
|
||||||
if opts.Format == "aac" {
|
|
||||||
args = append(args, "-movflags", "frag_keyframe+empty_moov")
|
|
||||||
}
|
|
||||||
|
|
||||||
args = append(args, "-")
|
args = append(args, "-")
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||||
})
|
})
|
||||||
It("returns true for known default aac command", func() {
|
It("returns true for known default aac command", func() {
|
||||||
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue())
|
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||||
})
|
})
|
||||||
It("returns true for known default flac command", func() {
|
It("returns true for known default flac command", func() {
|
||||||
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||||
@@ -174,7 +174,7 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("builds aac args with fragmented MP4 container", func() {
|
It("builds aac args with ADTS output", func() {
|
||||||
args := buildDynamicArgs(TranscodeOptions{
|
args := buildDynamicArgs(TranscodeOptions{
|
||||||
Format: "aac",
|
Format: "aac",
|
||||||
FilePath: "/music/file.flac",
|
FilePath: "/music/file.flac",
|
||||||
@@ -186,8 +186,7 @@ var _ = Describe("ffmpeg", func() {
|
|||||||
"-c:a", "aac",
|
"-c:a", "aac",
|
||||||
"-b:a", "256k",
|
"-b:a", "256k",
|
||||||
"-v", "0",
|
"-v", "0",
|
||||||
"-f", "ipod",
|
"-f", "adts",
|
||||||
"-movflags", "frag_keyframe+empty_moov",
|
|
||||||
"-",
|
"-",
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ func matchesCodec(codec string, codecs []string) bool {
|
|||||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAACCodec returns true if the given codec or container name resolves to AAC.
|
||||||
|
func IsAACCodec(name string) bool {
|
||||||
|
return matchesCodec(name, []string{"aac"}) || matchesContainer(name, []string{"aac"})
|
||||||
|
}
|
||||||
|
|
||||||
func containsIgnoreCase(slice []string, s string) bool {
|
func containsIgnoreCase(slice []string, s string) bool {
|
||||||
return slices.ContainsFunc(slice, func(item string) bool {
|
return slices.ContainsFunc(slice, func(item string) bool {
|
||||||
return strings.EqualFold(item, s)
|
return strings.EqualFold(item, s)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package stream
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Aliases", func() {
|
||||||
|
Describe("IsAACCodec", func() {
|
||||||
|
It("returns true for AAC and its aliases", func() {
|
||||||
|
Expect(IsAACCodec("aac")).To(BeTrue())
|
||||||
|
Expect(IsAACCodec("AAC")).To(BeTrue())
|
||||||
|
Expect(IsAACCodec("adts")).To(BeTrue())
|
||||||
|
Expect(IsAACCodec("m4a")).To(BeTrue())
|
||||||
|
Expect(IsAACCodec("mp4")).To(BeTrue())
|
||||||
|
Expect(IsAACCodec("m4b")).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false for non-AAC formats", func() {
|
||||||
|
Expect(IsAACCodec("mp3")).To(BeFalse())
|
||||||
|
Expect(IsAACCodec("opus")).To(BeFalse())
|
||||||
|
Expect(IsAACCodec("flac")).To(BeFalse())
|
||||||
|
Expect(IsAACCodec("ogg")).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false for empty string", func() {
|
||||||
|
Expect(IsAACCodec("")).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
|
||||||
|
// The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces
|
||||||
|
// corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+).
|
||||||
|
// Switch to `-f adts` (raw AAC framing) which works reliably via pipe.
|
||||||
|
// Only update rows that still have the old default command.
|
||||||
|
const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"
|
||||||
|
const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -"
|
||||||
|
_, err := tx.Exec(
|
||||||
|
"UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?",
|
||||||
|
newCommand, oldCommand,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -268,6 +268,16 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
clientInfo := clientInfoReq.toCoreClientInfo()
|
clientInfo := clientInfoReq.toCoreClientInfo()
|
||||||
|
|
||||||
|
// TODO: Remove this filter once AAC transcoding works reliably
|
||||||
|
// with streaming clients (Sonos, etc).
|
||||||
|
// See https://github.com/navidrome/navidrome/discussions/4832#discussioncomment-16068231
|
||||||
|
clientInfo.TranscodingProfiles = slices.DeleteFunc(clientInfo.TranscodingProfiles, func(p stream.Profile) bool {
|
||||||
|
if p.AudioCodec != "" {
|
||||||
|
return stream.IsAACCodec(p.AudioCodec)
|
||||||
|
}
|
||||||
|
return stream.IsAACCodec(p.Container)
|
||||||
|
})
|
||||||
|
|
||||||
// Get media file
|
// Get media file
|
||||||
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
|
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -180,6 +180,29 @@ var _ = Describe("Transcode endpoints", func() {
|
|||||||
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
|
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("filters AAC from transcoding profiles", func() {
|
||||||
|
mockMFRepo.SetData(model.MediaFiles{
|
||||||
|
{ID: "song-1", Suffix: "opus", Codec: "opus", BitRate: 128, Channels: 2, SampleRate: 48000},
|
||||||
|
})
|
||||||
|
mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true}
|
||||||
|
mockTD.token = "token"
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"transcodingProfiles": [
|
||||||
|
{"container": "aac", "audioCodec": "aac", "protocol": "http"},
|
||||||
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"},
|
||||||
|
{"container": "m4a", "audioCodec": "aac", "protocol": "http"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||||
|
_, err := router.GetTranscodeDecision(w, r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mockTD.capturedClient).ToNot(BeNil())
|
||||||
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
||||||
|
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
|
||||||
|
})
|
||||||
|
|
||||||
It("includes transcode stream when transcoding", func() {
|
It("includes transcode stream when transcoding", func() {
|
||||||
mockMFRepo.SetData(model.MediaFiles{
|
mockMFRepo.SetData(model.MediaFiles{
|
||||||
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
|
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
|
||||||
@@ -354,14 +377,16 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
|
|||||||
|
|
||||||
// mockTranscodeDecision is a test double for stream.TranscodeDecider
|
// mockTranscodeDecision is a test double for stream.TranscodeDecider
|
||||||
type mockTranscodeDecision struct {
|
type mockTranscodeDecision struct {
|
||||||
decision *stream.TranscodeDecision
|
decision *stream.TranscodeDecision
|
||||||
token string
|
token string
|
||||||
tokenErr error
|
tokenErr error
|
||||||
resolvedReq stream.Request
|
resolvedReq stream.Request
|
||||||
resolveErr error
|
resolveErr error
|
||||||
|
capturedClient *stream.ClientInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) {
|
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) {
|
||||||
|
m.capturedClient = ci
|
||||||
if m.decision != nil {
|
if m.decision != nil {
|
||||||
return m.decision, nil
|
return m.decision, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user