From d8bc41fbb19179da9d9321b3f0ae3e7f539432eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 11 Mar 2026 09:26:32 -0400 Subject: [PATCH] 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 --------- Signed-off-by: Deluan --- consts/consts.go | 2 +- core/ffmpeg/ffmpeg.go | 7 +--- core/ffmpeg/ffmpeg_test.go | 7 ++-- core/stream/aliases.go | 5 +++ core/stream/aliases_test.go | 30 +++++++++++++++ ...0260310113858_fix_aac_transcode_command.go | 30 +++++++++++++++ server/subsonic/transcode.go | 10 +++++ server/subsonic/transcode_test.go | 37 ++++++++++++++++--- 8 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 core/stream/aliases_test.go create mode 100644 db/migrations/20260310113858_fix_aac_transcode_command.go diff --git a/consts/consts.go b/consts/consts.go index 2a5fdd94..061aebd7 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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", diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index d0cf0755..9c4f9936 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -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 } diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index eebeefe3..23e41921 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -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", "-", })) }) diff --git a/core/stream/aliases.go b/core/stream/aliases.go index 6a4c8386..af42ac07 100644 --- a/core/stream/aliases.go +++ b/core/stream/aliases.go @@ -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) diff --git a/core/stream/aliases_test.go b/core/stream/aliases_test.go new file mode 100644 index 00000000..72f06181 --- /dev/null +++ b/core/stream/aliases_test.go @@ -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()) + }) + }) +}) diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go new file mode 100644 index 00000000..58813738 --- /dev/null +++ b/db/migrations/20260310113858_fix_aac_transcode_command.go @@ -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 +} diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 79250487..64e74d46 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -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 { diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 8db729c6..15ba168d 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -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 }