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:
Deluan Quintão
2026-03-11 09:26:32 -04:00
committed by GitHub
parent 51c48bcacd
commit d8bc41fbb1
8 changed files with 111 additions and 17 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ var (
Name: "aac audio",
TargetFormat: "aac",
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",
+1 -6
View File
@@ -283,7 +283,7 @@ var formatCodecMap = map[string]string{
var formatOutputMap = map[string]string{
"mp3": "mp3",
"opus": "opus",
"aac": "ipod",
"aac": "adts",
"flac": "flac",
}
@@ -339,11 +339,6 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
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, "-")
return args
}
+3 -4
View File
@@ -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())
})
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() {
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{
Format: "aac",
FilePath: "/music/file.flac",
@@ -186,8 +186,7 @@ var _ = Describe("ffmpeg", func() {
"-c:a", "aac",
"-b:a", "256k",
"-v", "0",
"-f", "ipod",
"-movflags", "frag_keyframe+empty_moov",
"-f", "adts",
"-",
}))
})
+5
View File
@@ -80,6 +80,11 @@ func matchesCodec(codec string, codecs []string) bool {
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 {
return slices.ContainsFunc(slice, func(item string) bool {
return strings.EqualFold(item, s)
+30
View File
@@ -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
}
+10
View File
@@ -268,6 +268,16 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
}
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
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
if err != nil {
+31 -6
View File
@@ -180,6 +180,29 @@ var _ = Describe("Transcode endpoints", func() {
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() {
mockMFRepo.SetData(model.MediaFiles{
{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
type mockTranscodeDecision struct {
decision *stream.TranscodeDecision
token string
tokenErr error
resolvedReq stream.Request
resolveErr error
decision *stream.TranscodeDecision
token string
tokenErr error
resolvedReq stream.Request
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 {
return m.decision, nil
}