From 8ff6aa1917cb530f3f20b82c47729ab17ddfb783 Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:22:00 +1000 Subject: [PATCH] fix(playback): normalize Firefox remux audio timestamps --- .../fixtures/valid/capability_response.json | 2 +- internal/playback/device_quirks_v3.go | 37 ++++- internal/playback/device_quirks_v3_test.go | 38 ++++++ internal/playback/plan_v3.go | 20 ++- internal/playback/protocol_v3.go | 2 +- internal/playback/protocol_v3_test.go | 127 +++++++++++++++++- internal/playback/remux.go | 4 +- internal/playback/remux_dv_test.go | 12 +- .../testdata/protocol_v3/attempt_keys.json | 6 +- .../protocol_v3/capability_response.json | 2 +- .../protocol_v3/conformance_matrix.json | 36 ++--- internal/playback/transcode.go | 23 +++- internal/playback/transcode_args_test.go | 39 +++--- internal/playback/transformations_v3.go | 2 +- 14 files changed, 284 insertions(+), 66 deletions(-) diff --git a/docs/design/schemas/playback-v3/v3/fixtures/valid/capability_response.json b/docs/design/schemas/playback-v3/v3/fixtures/valid/capability_response.json index 39a3b1850..34f5fb995 100644 --- a/docs/design/schemas/playback-v3/v3/fixtures/valid/capability_response.json +++ b/docs/design/schemas/playback-v3/v3/fixtures/valid/capability_response.json @@ -28,7 +28,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] diff --git a/internal/playback/device_quirks_v3.go b/internal/playback/device_quirks_v3.go index c99810cde..4cf42e4e9 100644 --- a/internal/playback/device_quirks_v3.go +++ b/internal/playback/device_quirks_v3.go @@ -8,6 +8,7 @@ const ( QuirkAndroidMobileEAC3BluetoothV3 = "android.mobile.eac3_bluetooth_hls_audio_adapt_v1" QuirkFireTVDV8HDR10PlusV3 = "android.fire_tv.dv8_hdr10plus_sei_v1" QuirkFirefoxHEVCOpenGOPV3 = "web.firefox.hevc_open_gop_resume_v1" + QuirkFirefoxMatroskaAACTimingV3 = "web.firefox.matroska_aac_timestamps_v1" ) func high10DecodeOverrideV3(source SourceDescriptorV3, request StartRequestV3) (*AppliedQuirkV3, bool) { @@ -92,12 +93,7 @@ func dv8HDR10PlusRuntimeCorrectionV3(source SourceDescriptorV3, request StartReq // for a non-zero seek, but the plan freezes the quirk from the first start so a // later seek reanchor cannot lose the byte recipe. func firefoxHEVCOpenGOPQuirkV3(source SourceDescriptorV3, request StartRequestV3) (*AppliedQuirkV3, bool) { - device := request.ClientPlaybackContext.Device - if !strings.EqualFold(device.Platform, "web") || !strings.EqualFold(source.VideoCodec, "hevc") { - return nil, false - } - userAgent := strings.ToLower(strings.TrimSpace(device.PlatformDetails["user_agent"])) - if !strings.Contains(userAgent, "firefox/") || strings.Contains(userAgent, "seamonkey/") { + if !isFirefoxWebV3(request) || !strings.EqualFold(source.VideoCodec, "hevc") { return nil, false } quirk := AppliedQuirkV3{ @@ -109,6 +105,35 @@ func firefoxHEVCOpenGOPQuirkV3(source SourceDescriptorV3, request StartRequestV3 return &quirk, true } +// firefoxMatroskaAACTimingQuirkV3 prevents millisecond-rounded Matroska AAC +// packet timestamps from being copied into MP4/fMP4. Firefox treats those +// sub-frame gaps as missing audio and inserts silence, which is heard as +// crackling. Other clients keep codec-copy remuxing, and Firefox direct play +// remains available when its native container claim is valid. +func firefoxMatroskaAACTimingQuirkV3(source SourceDescriptorV3, request StartRequestV3) (*AppliedQuirkV3, bool) { + container := strings.ToLower(strings.TrimSpace(source.Container)) + if !isFirefoxWebV3(request) || (container != containerMKVV3 && container != "matroska") || + !strings.EqualFold(strings.TrimSpace(source.AudioCodec), audioCodecAACV3) { + return nil, false + } + quirk := AppliedQuirkV3{ + ID: QuirkFirefoxMatroskaAACTimingV3, + RegistryRevision: DeviceQuirkRegistryRevisionV3, + Action: "audio_only_transcode", + Reason: "Firefox requires Matroska AAC timestamps to be normalized before MP4 or HLS packaging.", + } + return &quirk, true +} + +func isFirefoxWebV3(request StartRequestV3) bool { + device := request.ClientPlaybackContext.Device + if !strings.EqualFold(device.Platform, "web") { + return false + } + userAgent := strings.ToLower(strings.TrimSpace(device.PlatformDetails["user_agent"])) + return strings.Contains(userAgent, "firefox/") && !strings.Contains(userAgent, "seamonkey/") +} + func applyFirefoxHEVCOpenGOPQuirkV3(plan *PlanV3, source SourceDescriptorV3, request StartRequestV3) bool { quirk, ok := firefoxHEVCOpenGOPQuirkV3(source, request) if !ok { diff --git a/internal/playback/device_quirks_v3_test.go b/internal/playback/device_quirks_v3_test.go index 87a85a414..b58c3f5aa 100644 --- a/internal/playback/device_quirks_v3_test.go +++ b/internal/playback/device_quirks_v3_test.go @@ -217,6 +217,44 @@ func TestFirefoxHEVCOpenGOPQuirkIsExact(t *testing.T) { } } +func TestFirefoxMatroskaAACTimingQuirkIsExact(t *testing.T) { + request := validStartRequestV3() + request.ClientPlaybackContext.Device = DeviceContextV3{ + Platform: "web", + PlatformDetails: map[string]string{ + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:154.0) Gecko/20100101 Firefox/154.0", + }, + } + source := SourceDescriptorV3{Container: "mkv", AudioCodec: "aac"} + quirk, ok := firefoxMatroskaAACTimingQuirkV3(source, request) + if !ok || quirk == nil || quirk.ID != QuirkFirefoxMatroskaAACTimingV3 || quirk.Action != "audio_only_transcode" { + t.Fatalf("Firefox Matroska AAC quirk = %#v, ok=%v", quirk, ok) + } + + for _, test := range []struct { + name string + platform string + userAgent string + container string + codec string + }{ + {name: "MP4 AAC", platform: "web", userAgent: "Mozilla/5.0 Firefox/154.0", container: "mp4", codec: "aac"}, + {name: "Matroska Opus", platform: "web", userAgent: "Mozilla/5.0 Firefox/154.0", container: "mkv", codec: "opus"}, + {name: "Chrome", platform: "web", userAgent: "Mozilla/5.0 Chrome/140.0", container: "mkv", codec: "aac"}, + {name: "SeaMonkey", platform: "web", userAgent: "Mozilla/5.0 Firefox/128.0 SeaMonkey/2.53", container: "mkv", codec: "aac"}, + {name: "non-web", platform: "android", userAgent: "Mozilla/5.0 Firefox/154.0", container: "mkv", codec: "aac"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := request + candidate.ClientPlaybackContext.Device.Platform = test.platform + candidate.ClientPlaybackContext.Device.PlatformDetails = map[string]string{"user_agent": test.userAgent} + if got, eligible := firefoxMatroskaAACTimingQuirkV3(SourceDescriptorV3{Container: test.container, AudioCodec: test.codec}, candidate); eligible || got != nil { + t.Fatalf("unexpected quirk = %#v, eligible=%v", got, eligible) + } + }) + } +} + func TestPlanAttemptKeyV3DeviceQuirkIsStable(t *testing.T) { width, height, bitrate := 3840, 2160, 60_000 plan := PlanV3{ diff --git a/internal/playback/plan_v3.go b/internal/playback/plan_v3.go index 9888d96c6..39bc978b8 100644 --- a/internal/playback/plan_v3.go +++ b/internal/playback/plan_v3.go @@ -21,6 +21,7 @@ type PlannerSettingsV3 struct { const ( TerminalMessage4KTranscodeDisabledV3 = "A lower-resolution source is required because 4K transcoding is disabled." containerMP4V3 = "mp4" + containerMKVV3 = "mkv" mimeVideoMP4V3 = "video/mp4" degradationAudioConvertedV3 = "audio_converted" audioCodecAACV3 = "aac" @@ -454,8 +455,15 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { progressiveAudioOK := noAudioTrack || deliverySupportsAudioClaimV3(input.Request, DeliveryClassProgressiveV3, source.AudioCodec, audioClaims, audioOK) hlsAudioOK := noAudioTrack || hlsNativeAudioCodecV3(source.AudioCodec) && deliverySupportsAudioClaimV3(input.Request, DeliveryClassHLSV3, source.AudioCodec, audioClaims, audioOK) - progressiveTranscodeAudio := !progressiveAudioOK - hlsTranscodeAudio := !hlsAudioOK + // AAC frames in Matroska use a millisecond packet clock while each frame + // contains 1024 samples. Copying those rounded timestamps into MP4/fMP4 + // produces real sub-frame gaps and overlaps that Firefox renders as + // crackle. Keep video-copy remuxing, but re-encode the selected AAC track + // through the versioned timestamp-normalization recipe. Native original + // playback above remains byte-for-byte direct play. + firefoxAACTimingQuirk, normalizeMatroskaAAC := firefoxMatroskaAACTimingQuirkV3(source, input.Request) + progressiveTranscodeAudio := !progressiveAudioOK || normalizeMatroskaAAC + hlsTranscodeAudio := !hlsAudioOK || normalizeMatroskaAAC hlsAudioQuirk, hlsAudioQuirkOK := hlsEAC3AudioCorrectionV3(source, input.Request) localAudioConvertOK := input.Registry.Available(TransformationAudioToAACV3) if progressiveTranscodeAudio && hlsTranscodeAudio { @@ -496,6 +504,9 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { progressivePlan.DegradationWarnings = append(progressivePlan.DegradationWarnings, DegradationWarningV3{Code: degradationAudioConvertedV3, Message: fmt.Sprintf("The selected audio track is converted to AAC %s.", audioLayoutForChannelsV3(progressiveAudioChannels))}) progressivePlan.DecisionReason = decisionReasonAudioAdaptationV3 } + if normalizeMatroskaAAC { + appendAppliedQuirkV3(&progressivePlan, *firefoxAACTimingQuirk, "") + } if !dvStrip { applyCopiedVideoQuirksV3(&progressivePlan, source, input.Request, high10Quirk) } @@ -548,6 +559,9 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 { plan.Transformations = append(plan.Transformations, TransformationV3{Name: TransformationAudioToAACV3, Executor: ExecutorServerV3, RecipeVersion: TransformationAudioToAACRecipeVersionV3, ValidatedClaims: []string{ClaimAudioDecodeV3}}) plan.DegradationWarnings = append(plan.DegradationWarnings, DegradationWarningV3{Code: degradationAudioConvertedV3, Message: "The selected audio track is converted to AAC for HLS delivery."}) } + if normalizeMatroskaAAC { + appendAppliedQuirkV3(&plan, *firefoxAACTimingQuirk, "") + } if hlsAudioQuirkOK && !hlsTranscodeAudio { if !input.hlsRegistry().Available(TransformationAudioToAACV3) { return terminalPlannerResultV3(TerminalAudioConversionUnsupportedV3, "The device-specific HLS route requires the validated AAC conversion toolchain.", true) @@ -986,7 +1000,7 @@ func applySubtitleDecisionV3(plan *PlanV3, decision SubtitleDecisionV3) { } func prefersWebHLSForMKVDolbyV3(source SourceDescriptorV3, request StartRequestV3) bool { - if !strings.EqualFold(source.Container, "mkv") && !strings.EqualFold(source.Container, "matroska") { + if !strings.EqualFold(source.Container, containerMKVV3) && !strings.EqualFold(source.Container, "matroska") { return false } if !strings.EqualFold(strings.TrimSpace(request.ClientPlaybackContext.Device.Platform), "web") || source.DVProfile <= 0 { diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go index d4aab65c8..e30fb1ef4 100644 --- a/internal/playback/protocol_v3.go +++ b/internal/playback/protocol_v3.go @@ -188,7 +188,7 @@ const ( TransformationHDRToSDRToneMapV3 = "hdr_to_sdr_tonemap" TransformationVideoToH264RecipeVersionV3 = "2" - TransformationAudioToAACRecipeVersionV3 = "3" + TransformationAudioToAACRecipeVersionV3 = "4" TransformationServerDV7HDR10RecipeVersionV3 = "3" TransformationServerDV8BaseRecipeVersionV3 = "2" TransformationHDRToSDRToneMapRecipeVersionV3 = "1" diff --git a/internal/playback/protocol_v3_test.go b/internal/playback/protocol_v3_test.go index 3755b6b17..3d21f7a3a 100644 --- a/internal/playback/protocol_v3_test.go +++ b/internal/playback/protocol_v3_test.go @@ -1045,8 +1045,8 @@ func TestPlanPlaybackV3WebHLSJSKeepsProgressiveDolbyRouteFirst(t *testing.T) { if result.PlayMethod != PlayRemux || result.Plan.EffectiveRecipe.VideoCodec != "hevc" || result.Plan.EffectiveMediaFileID != file.ID { t.Fatalf("the first browser route did not keep the 4K HEVC remux: %#v", result) } - if !result.DropInitialLeadingPictures || len(result.Plan.AppliedQuirks) != 1 || result.Plan.AppliedQuirks[0].ID != QuirkFirefoxHEVCOpenGOPV3 { - t.Fatalf("Firefox progressive resume recipe was not frozen: %#v", result) + if !result.DropInitialLeadingPictures || len(result.Plan.AppliedQuirks) != 2 || result.Plan.AppliedQuirks[0].ID != QuirkFirefoxMatroskaAACTimingV3 || result.Plan.AppliedQuirks[1].ID != QuirkFirefoxHEVCOpenGOPV3 { + t.Fatalf("Firefox progressive audio and resume recipes were not frozen: %#v", result) } input.AttemptedKeys = []string{PlanAttemptKeyV3(*result.Plan, req.ClientPlaybackContext.Output.OutputContextID, nil)} @@ -1057,8 +1057,8 @@ func TestPlanPlaybackV3WebHLSJSKeepsProgressiveDolbyRouteFirst(t *testing.T) { if fallback.Plan.EffectiveRecipe.VideoSampleEntry != VideoSampleEntryHEV1V3 { t.Fatalf("hls.js recovery sample entry = %q, want hev1", fallback.Plan.EffectiveRecipe.VideoSampleEntry) } - if !fallback.DropInitialLeadingPictures || len(fallback.Plan.AppliedQuirks) != 1 || fallback.Plan.AppliedQuirks[0].ID != QuirkFirefoxHEVCOpenGOPV3 { - t.Fatalf("Firefox HLS resume recipe was not frozen: %#v", fallback) + if !fallback.DropInitialLeadingPictures || len(fallback.Plan.AppliedQuirks) != 2 || fallback.Plan.AppliedQuirks[0].ID != QuirkFirefoxMatroskaAACTimingV3 || fallback.Plan.AppliedQuirks[1].ID != QuirkFirefoxHEVCOpenGOPV3 { + t.Fatalf("Firefox HLS audio and resume recipes were not frozen: %#v", fallback) } }) } @@ -3526,6 +3526,125 @@ func TestPlanPlaybackV3VideoRemuxAdaptsProgressiveWhenHLSVideoUnsupported(t *tes } } +func TestPlanPlaybackV3MatroskaAACRemuxUsesTimestampNormalizedAudio(t *testing.T) { + file := detailedFixtureFileV3() + file.CodecVideo = "h264" + file.Resolution = "1080p" + file.Bitrate = 4_244 + file.VideoTracks[0] = models.VideoTrack{Codec: "h264", Profile: "High", Level: 40, Width: 1920, Height: 804, FrameRate: "25", BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"} + file.AudioTracks[0] = models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", SampleRate: 48_000} + + req := validStartRequestV3() + req.Capabilities.VideoEvidence = EvidenceDeclaredV3 + req.Capabilities.AudioEvidence = EvidenceDeclaredV3 + req.ClientPlaybackContext.FormFactor = "desktop" + req.ClientPlaybackContext.Device = DeviceContextV3{Platform: "web", PlatformDetails: map[string]string{"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:154.0) Gecko/20100101 Firefox/154.0"}} + req.Capabilities.CodecsVideo = []string{"h264"} + req.Capabilities.CodecsAudio = []string{"aac"} + req.Capabilities.Containers = []string{"mp4"} + req.Capabilities.MaxResolution = "1080p" + delete(req.ClientPlaybackContext.Deliveries, DeliveryClassOriginalHTTPV3) + for _, delivery := range []string{DeliveryClassProgressiveV3, DeliveryClassHLSV3} { + capability := req.ClientPlaybackContext.Deliveries[delivery] + if delivery == DeliveryClassProgressiveV3 { + capability.Containers = []string{"mp4"} + } else { + capability.Containers = []string{"hls"} + } + capability.VideoCodecs = []string{"h264"} + capability.AudioDecodeCodecs = []string{"aac"} + req.ClientPlaybackContext.Deliveries[delivery] = capability + } + + result := PlanPlaybackV3(PlannerInputV3{ + Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, + Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: testTransformationRegistryV3(), + }) + if result.Plan == nil || result.Plan.Delivery != DeliveryRemuxProgressiveV3 || result.PlayMethod != PlayRemux || !result.TranscodeAudio || result.TargetAudioCodec != "aac" { + t.Fatalf("result = %s", ExplainPlannerResultV3(result)) + } + if result.Plan.DecisionReason != decisionReasonAudioAdaptationV3 || len(result.Plan.Transformations) != 1 || result.Plan.Transformations[0].Name != TransformationAudioToAACV3 || result.Plan.Transformations[0].RecipeVersion != TransformationAudioToAACRecipeVersionV3 || len(result.Plan.AppliedQuirks) != 1 || result.Plan.AppliedQuirks[0].ID != QuirkFirefoxMatroskaAACTimingV3 { + t.Fatalf("normalized AAC plan = %#v", result.Plan) + } +} + +func TestPlanPlaybackV3NativeMatroskaAACDirectPlayRemainsUnchanged(t *testing.T) { + file := detailedFixtureFileV3() + file.VideoTracks[0].VideoRange = "SDR" + file.VideoTracks[0].VideoRangeType = "SDR" + req := validStartRequestV3() + req.Capabilities.VideoEvidence = EvidenceDeclaredV3 + req.Capabilities.AudioEvidence = EvidenceDeclaredV3 + req.ClientPlaybackContext.FormFactor = "desktop" + req.ClientPlaybackContext.Device = DeviceContextV3{Platform: "web", PlatformDetails: map[string]string{"user_agent": "Mozilla/5.0 Firefox/154.0"}} + original := req.ClientPlaybackContext.Deliveries[DeliveryClassOriginalHTTPV3] + original.Containers = []string{"mkv"} + original.VideoCodecs = []string{"hevc"} + original.AudioDecodeCodecs = []string{"aac"} + req.ClientPlaybackContext.Deliveries[DeliveryClassOriginalHTTPV3] = original + + result := PlanPlaybackV3(PlannerInputV3{ + Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, + Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: testTransformationRegistryV3(), + }) + if result.Plan == nil || result.Plan.Delivery != DeliveryOriginalHTTPV3 || result.PlayMethod != PlayDirect || result.TranscodeAudio || len(result.Plan.Transformations) != 0 { + t.Fatalf("direct play changed = %s", ExplainPlannerResultV3(result)) + } +} + +func TestPlanPlaybackV3FirefoxIncompatibleAudioCodecsUseNormalizedAACRecipe(t *testing.T) { + for _, test := range []struct { + codec string + channels int + }{ + {codec: "dts", channels: 6}, + {codec: "eac3", channels: 6}, + {codec: "ac3", channels: 6}, + {codec: "truehd", channels: 8}, + {codec: "opus", channels: 2}, + {codec: "vorbis", channels: 2}, + {codec: "flac", channels: 2}, + } { + t.Run(test.codec, func(t *testing.T) { + file := detailedFixtureFileV3() + file.CodecVideo = "h264" + file.CodecAudio = test.codec + file.Resolution = "1080p" + file.VideoTracks[0] = models.VideoTrack{Codec: "h264", Profile: "High", Level: 40, Width: 1920, Height: 1080, FrameRate: "24", BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"} + file.AudioTracks[0] = models.AudioTrack{Codec: test.codec, Channels: test.channels} + + req := validStartRequestV3() + req.Capabilities.VideoEvidence = EvidenceDeclaredV3 + req.Capabilities.AudioEvidence = EvidenceDeclaredV3 + req.ClientPlaybackContext.FormFactor = "desktop" + req.ClientPlaybackContext.Device = DeviceContextV3{Platform: "web", PlatformDetails: map[string]string{"user_agent": "Mozilla/5.0 Firefox/154.0"}} + req.Capabilities.CodecsVideo = []string{"h264"} + req.Capabilities.CodecsAudio = []string{"aac"} + req.Capabilities.Containers = []string{"mp4"} + delete(req.ClientPlaybackContext.Deliveries, DeliveryClassOriginalHTTPV3) + for _, delivery := range []string{DeliveryClassProgressiveV3, DeliveryClassHLSV3} { + capability := req.ClientPlaybackContext.Deliveries[delivery] + if delivery == DeliveryClassProgressiveV3 { + capability.Containers = []string{"mp4"} + } else { + capability.Containers = []string{"hls"} + } + capability.VideoCodecs = []string{"h264"} + capability.AudioDecodeCodecs = []string{"aac"} + req.ClientPlaybackContext.Deliveries[delivery] = capability + } + + result := PlanPlaybackV3(PlannerInputV3{ + Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, + Settings: PlannerSettingsV3{TranscodeEnabled: true}, Registry: testTransformationRegistryV3(), + }) + if result.Plan == nil || result.PlayMethod != PlayRemux || !result.TranscodeAudio || result.TargetAudioCodec != "aac" || len(result.Plan.Transformations) != 1 || result.Plan.Transformations[0].RecipeVersion != TransformationAudioToAACRecipeVersionV3 { + t.Fatalf("result = %s", ExplainPlannerResultV3(result)) + } + }) + } +} + func TestPlanPlaybackV3VideoRemuxHonorsProgressiveAudioPassthrough(t *testing.T) { file := detailedFixtureFileV3() file.CodecAudio = "ac3" diff --git a/internal/playback/remux.go b/internal/playback/remux.go index 9245ba0d0..985b44cd1 100644 --- a/internal/playback/remux.go +++ b/internal/playback/remux.go @@ -260,9 +260,7 @@ func buildRemuxArgsWithAudioV3(filePath, outputFormat string, seekSeconds float6 "-ac", strconv.Itoa(channels), "-b:a", strconv.Itoa(bitrateKbps)+"k", ) - if IsAudioToAACStereoDownmixV3(sourceAudioChannels, "aac", targetAudioChannels) { - args = appendStereoDownmixBoostArgs(args, sourceAudioChannels, channels) - } + args = appendAACEncodeFilterArgs(args, sourceAudioChannels, "aac", targetAudioChannels, channels) } else { args = append(args, "-c", "copy") } diff --git a/internal/playback/remux_dv_test.go b/internal/playback/remux_dv_test.go index bf9f87862..cfe5864e6 100644 --- a/internal/playback/remux_dv_test.go +++ b/internal/playback/remux_dv_test.go @@ -68,7 +68,7 @@ func TestBuildRemuxArgsExcludesAttachedPictures(t *testing.T) { func TestBuildRemuxArgsHonorsPlannedAACOutput(t *testing.T) { args := buildRemuxArgsWithAudioV3("/book.m4b", "mp4", 0, true, -1, 0, false, true, 2, 1, 96, false) - if !argsContainPair(args, "-ac", "1") || !argsContainPair(args, "-b:a", "96k") { + if !argsContainPair(args, "-ac", "1") || !argsContainPair(args, "-b:a", "96k") || !argsContainPair(args, "-af", aacTimestampNormalizeFilterV3) { t.Fatalf("planned mono bitrate missing from remux args: %s", strings.Join(args, " ")) } } @@ -100,12 +100,14 @@ func TestBuildRemuxArgsBoostsOnlySurroundToStereoAAC(t *testing.T) { if gotBoost != tt.wantBoost { t.Fatalf("downmix boost present=%t, want %t; args=%s", gotBoost, tt.wantBoost, strings.Join(args, " ")) } + if tt.transcodeAudio && !tt.wantBoost && !argsContainPair(args, "-af", aacTimestampNormalizeFilterV3) { + t.Fatalf("ordinary AAC encode is missing timestamp normalization: %s", strings.Join(args, " ")) + } }) } } func TestBuildRemuxArgsNormalizesAACAcrossSeekAnchors(t *testing.T) { - const wantFilter = "aresample=out_chlayout=stereo:async=1,alimiter=level_in=2:limit=0.794328235:attack=5:release=50:level=false:latency=true" anchors := []struct { name string seek float64 @@ -118,8 +120,8 @@ func TestBuildRemuxArgsNormalizesAACAcrossSeekAnchors(t *testing.T) { for _, anchor := range anchors { t.Run(anchor.name, func(t *testing.T) { - args := buildRemuxArgsWithAudioV3("/movie.mkv", "mp4", anchor.seek, true, 0, 0, false, false, 6, 2, 192, false) - if !argsContainPair(args, "-af", wantFilter) { + args := buildRemuxArgsWithAudioV3("/movie.mkv", "mp4", anchor.seek, true, 0, 0, false, false, 2, 2, 192, false) + if !argsContainPair(args, "-af", aacTimestampNormalizeFilterV3) { t.Fatalf("AAC timestamp normalization missing at seek %.3f: %s", anchor.seek, strings.Join(args, " ")) } if strings.Contains(strings.Join(args, " "), "first_pts") { @@ -131,7 +133,7 @@ func TestBuildRemuxArgsNormalizesAACAcrossSeekAnchors(t *testing.T) { }) } - codecCopy := buildRemuxArgsWithAudioV3("/movie.mkv", "mp4", 600, false, 0, 0, false, false, 6, 2, 192, false) + codecCopy := buildRemuxArgsWithAudioV3("/movie.mkv", "mp4", 600, false, 0, 0, false, false, 2, 2, 192, false) if slices.Contains(codecCopy, "-af") { t.Fatalf("codec-copy remux unexpectedly received an audio filter: %s", strings.Join(codecCopy, " ")) } diff --git a/internal/playback/testdata/protocol_v3/attempt_keys.json b/internal/playback/testdata/protocol_v3/attempt_keys.json index e7bcdf728..83ce0897a 100644 --- a/internal/playback/testdata/protocol_v3/attempt_keys.json +++ b/internal/playback/testdata/protocol_v3/attempt_keys.json @@ -1,10 +1,10 @@ [ { "name": "hls_burn_in_sorted_transformations_and_pcm_mutations", - "server_plan_attempt_key": "v3:e139629390ef6e20", - "replan_echo": "v3:e139629390ef6e20", + "server_plan_attempt_key": "v3:a1531ef7c1bd7f75", + "replan_echo": "v3:a1531ef7c1bd7f75", "attempted_plan_keys": [ - "v3:e139629390ef6e20" + "v3:a1531ef7c1bd7f75" ], "expected_server_action": "reject_already_attempted_plan" }, diff --git a/internal/playback/testdata/protocol_v3/capability_response.json b/internal/playback/testdata/protocol_v3/capability_response.json index 39a3b1850..34f5fb995 100644 --- a/internal/playback/testdata/protocol_v3/capability_response.json +++ b/internal/playback/testdata/protocol_v3/capability_response.json @@ -28,7 +28,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] diff --git a/internal/playback/testdata/protocol_v3/conformance_matrix.json b/internal/playback/testdata/protocol_v3/conformance_matrix.json index 3a0e43b51..6c3c1a59f 100644 --- a/internal/playback/testdata/protocol_v3/conformance_matrix.json +++ b/internal/playback/testdata/protocol_v3/conformance_matrix.json @@ -175,8 +175,8 @@ "outcome": "playable", "delivery": "server_transcode_hls", "decision_reason": "quality_original", - "plan_id": "plan:a8beff7df33c6ca6776a093b840cdea3", - "plan_attempt_key": "v3:2cdebc939dc3cf22", + "plan_id": "plan:176744988a004fd5f2230c221961c41a", + "plan_attempt_key": "v3:d1ecc0d9511b83b8", "selected_tracks": { "audio": { "id": "file:42:audio:0", @@ -218,7 +218,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] @@ -1862,8 +1862,8 @@ "outcome": "playable", "delivery": "server_transcode_hls", "decision_reason": "quality_fixed_rung", - "plan_id": "plan:c2695f5ba02dba71753615a263d2367e", - "plan_attempt_key": "v3:4b72643d8afc4436", + "plan_id": "plan:f12311eb7ac696c8bbeeab6c135b4225", + "plan_attempt_key": "v3:315d40199ceb0333", "selected_tracks": { "audio": { "id": "file:42:audio:0", @@ -1905,7 +1905,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] @@ -2573,8 +2573,8 @@ "outcome": "playable", "delivery": "server_transcode_hls", "decision_reason": "quality_fixed_rung", - "plan_id": "plan:01ff67650a0ef2592ce739121de6d99a", - "plan_attempt_key": "v3:adc66f1d69acda72", + "plan_id": "plan:1aae8ad82720984fbc14c38de3a5485b", + "plan_attempt_key": "v3:78825f13a334a7ed", "selected_tracks": { "audio": { "id": "file:42:audio:0", @@ -2616,7 +2616,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] @@ -3603,8 +3603,8 @@ "outcome": "playable", "delivery": "server_transcode_hls", "decision_reason": "quality_fixed_rung", - "plan_id": "plan:5fb510747d6b4314346fc53132de7fe3", - "plan_attempt_key": "v3:00106a7fb5f5abfd", + "plan_id": "plan:e302dcdd4f94f3c3027c7603de66c0a5", + "plan_attempt_key": "v3:b1ff9bc2e47e341e", "selected_tracks": { "audio": { "id": "file:42:audio:0", @@ -3646,7 +3646,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] @@ -3928,8 +3928,8 @@ "outcome": "playable", "delivery": "server_remux_progressive", "decision_reason": "audio_adaptation", - "plan_id": "plan:95f8d4582b61f1420657f490266f621e", - "plan_attempt_key": "v3:314df86bd43f5f34", + "plan_id": "plan:c82db36f4435045be713ef1a058314f8", + "plan_attempt_key": "v3:796f6a9ae77904e6", "selected_tracks": { "audio": { "id": "file:42:audio:0", @@ -3963,7 +3963,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] @@ -4902,8 +4902,8 @@ "outcome": "playable", "delivery": "server_transcode_hls", "decision_reason": "subtitle_burn_in_required", - "plan_id": "plan:ffc560da5dd89610e4e672299601c0f2", - "plan_attempt_key": "v3:2156e83ea26e40a0", + "plan_id": "plan:c42d17216c7049e49c5453bc2f21cfe5", + "plan_attempt_key": "v3:7ec0846b12cc97a6", "selected_tracks": { "audio": { "id": "file:42:audio:0", @@ -4964,7 +4964,7 @@ { "name": "audio_to_aac", "executor": "server", - "recipe_version": "3", + "recipe_version": "4", "validated_claims": [ "audio_decode" ] diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index b53d87e8b..f1c0bee53 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -1452,7 +1452,10 @@ func IsAudioToAACStereoDownmixV3(sourceChannels int, targetCodecAudio string, ta (targetAudioChannels == 0 || targetAudioChannels == 2) } -const stereoDownmixBoostFilterV3 = "aresample=out_chlayout=stereo:async=1,alimiter=level_in=2:limit=0.794328235:attack=5:release=50:level=false:latency=true" +const ( + aacTimestampNormalizeFilterV3 = "aresample=async=1" + stereoDownmixBoostFilterV3 = "aresample=out_chlayout=stereo:async=1,alimiter=level_in=2:limit=0.794328235:attack=5:release=50:level=false:latency=true" +) // appendStereoDownmixBoostArgs applies the playback downmix policy only after // the source is explicitly rematrixed to stereo. The order matters: limiting @@ -1469,6 +1472,20 @@ func appendStereoDownmixBoostArgs(args []string, sourceChannels, outputChannels return append(args, "-af", stereoDownmixBoostFilterV3) } +// appendAACEncodeFilterArgs gives every AAC encode a continuous sample clock. +// Matroska commonly represents fixed 1024-sample AAC frames on a millisecond +// timebase, so their packet PTS alternate between rounded 21 ms and 22 ms +// steps. Carrying those timestamps into fragmented MP4 leaves sub-frame gaps +// that Firefox renders as audible crackle. The resampler corrects only the +// timestamp jitter; surround-to-stereo conversions retain the existing +// rematrix and limiter policy. +func appendAACEncodeFilterArgs(args []string, sourceChannels int, targetCodec string, targetChannels, outputChannels int) []string { + if IsAudioToAACStereoDownmixV3(sourceChannels, targetCodec, targetChannels) && outputChannels == 2 { + return appendStereoDownmixBoostArgs(args, sourceChannels, outputChannels) + } + return append(args, "-af", aacTimestampNormalizeFilterV3) +} + // appendAudioArgs adds audio codec arguments. Supports "copy" for passthrough, // plus opus / aac / eac3 / ac3 as re-encode targets. EAC3 and AC3 are useful // when we must transcode video but want to preserve surround channels for an @@ -1497,9 +1514,7 @@ func appendAudioArgs(args []string, opts TranscodeOpts) []string { default: channels, bitrateKbps := resolvedAACOutputV3(opts.TargetAudioChannels, opts.TargetAudioBitrateKbps) args = append(args, "-c:a", "aac", "-b:a", strconv.Itoa(bitrateKbps)+"k", "-ac", strconv.Itoa(channels)) - if IsAudioToAACStereoDownmixV3(opts.SourceAudioChannels, opts.TargetCodecAudio, opts.TargetAudioChannels) { - args = appendStereoDownmixBoostArgs(args, opts.SourceAudioChannels, channels) - } + args = appendAACEncodeFilterArgs(args, opts.SourceAudioChannels, opts.TargetCodecAudio, opts.TargetAudioChannels, channels) } return args diff --git a/internal/playback/transcode_args_test.go b/internal/playback/transcode_args_test.go index f1ac43a62..9b440e08f 100644 --- a/internal/playback/transcode_args_test.go +++ b/internal/playback/transcode_args_test.go @@ -1616,26 +1616,27 @@ func TestIsAudioToAACStereoDownmixV3RequiresExactRecipeShape(t *testing.T) { } } -func TestAppendAudioArgsBoostsOnlyEncodedSurroundToStereo(t *testing.T) { - const wantFilter = "aresample=out_chlayout=stereo:async=1,alimiter=level_in=2:limit=0.794328235:attack=5:release=50:level=false:latency=true" +func TestAppendAudioArgsNormalizesEveryAACEncodeAndBoostsOnlySurroundToStereo(t *testing.T) { + const boostFilter = "aresample=out_chlayout=stereo:async=1,alimiter=level_in=2:limit=0.794328235:attack=5:release=50:level=false:latency=true" + const normalizeFilter = "aresample=async=1" tests := []struct { name string codec string sourceChannels int targetChannels int - wantBoost bool + wantFilter string }{ - {name: "aac 5.1 to stereo", codec: "aac", sourceChannels: 6, targetChannels: 2, wantBoost: true}, - {name: "default aac 7.1 to stereo", sourceChannels: 8, targetChannels: 2, wantBoost: true}, - {name: "aac default target is stereo", codec: "aac", sourceChannels: 6, wantBoost: true}, + {name: "DTS 5.1 to AAC stereo", codec: "aac", sourceChannels: 6, targetChannels: 2, wantFilter: boostFilter}, + {name: "TrueHD 7.1 to default AAC stereo", sourceChannels: 8, targetChannels: 2, wantFilter: boostFilter}, + {name: "EAC3 surround to default AAC stereo", codec: "aac", sourceChannels: 6, wantFilter: boostFilter}, {name: "opus has no versioned boost recipe", codec: "opus", sourceChannels: 6}, - {name: "unknown codec fallback has no versioned boost", codec: "unknown", sourceChannels: 6, targetChannels: 2}, - {name: "stereo aac encode", codec: "aac", sourceChannels: 2, targetChannels: 2}, - {name: "unknown source channels", codec: "aac", targetChannels: 2}, - {name: "surround to mono", codec: "aac", sourceChannels: 6, targetChannels: 1}, - {name: "negative target resolves to ordinary stereo", codec: "aac", sourceChannels: 6, targetChannels: -1}, - {name: "noncanonical target resolves to ordinary stereo", codec: "aac", sourceChannels: 6, targetChannels: 3}, - {name: "surround preserved", codec: "aac", sourceChannels: 6, targetChannels: 6}, + {name: "unknown codec AAC fallback", codec: "unknown", sourceChannels: 6, targetChannels: 2, wantFilter: normalizeFilter}, + {name: "stereo AAC encode", codec: "aac", sourceChannels: 2, targetChannels: 2, wantFilter: normalizeFilter}, + {name: "unknown source channels", codec: "aac", targetChannels: 2, wantFilter: normalizeFilter}, + {name: "surround to AAC mono", codec: "aac", sourceChannels: 6, targetChannels: 1, wantFilter: normalizeFilter}, + {name: "negative target resolves to ordinary AAC stereo", codec: "aac", sourceChannels: 6, targetChannels: -1, wantFilter: normalizeFilter}, + {name: "noncanonical target resolves to ordinary AAC stereo", codec: "aac", sourceChannels: 6, targetChannels: 3, wantFilter: normalizeFilter}, + {name: "surround AAC preserved", codec: "aac", sourceChannels: 6, targetChannels: 6, wantFilter: normalizeFilter}, {name: "copy", codec: "copy", sourceChannels: 6, targetChannels: 2}, {name: "ac3 preserves source layout", codec: "ac3", sourceChannels: 6, targetChannels: 2}, {name: "eac3 preserves source layout", codec: "eac3", sourceChannels: 6, targetChannels: 2}, @@ -1649,9 +1650,15 @@ func TestAppendAudioArgsBoostsOnlyEncodedSurroundToStereo(t *testing.T) { SourceAudioChannels: tt.sourceChannels, TargetAudioChannels: tt.targetChannels, }) - gotBoost := argsContainPair(args, "-af", wantFilter) - if gotBoost != tt.wantBoost { - t.Fatalf("downmix boost present=%t, want %t; args=%s", gotBoost, tt.wantBoost, strings.Join(args, " ")) + var gotFilter string + for i := 0; i < len(args)-1; i++ { + if args[i] == "-af" { + gotFilter = args[i+1] + break + } + } + if gotFilter != tt.wantFilter { + t.Fatalf("audio filter = %q, want %q; args=%s", gotFilter, tt.wantFilter, strings.Join(args, " ")) } }) } diff --git a/internal/playback/transformations_v3.go b/internal/playback/transformations_v3.go index bc7c3a602..900dd6980 100644 --- a/internal/playback/transformations_v3.go +++ b/internal/playback/transformations_v3.go @@ -66,7 +66,7 @@ func ProbeTransformationRegistryWithToneMapV3Result(ctx context.Context, ffmpegP registry := NewTransformationRegistryV3([]TransformationSpecV3{ {Name: TransformationServerDV7HDR10V3, RecipeVersion: TransformationServerDV7HDR10RecipeVersionV3, Available: bytes.Contains(bsfs, []byte("dovi_rpu")) && bytes.Contains(bsfs, []byte("filter_units")), RequiredCapability: "ffmpeg_bsf:dovi_rpu+filter_units", PromisedDynamicRange: DynamicRangeHDR10V3, ValidatedClaims: DV7ToHDR10ClaimsV3(), TerminalReason: TerminalDVConversionUnsupportedV3}, {Name: TransformationServerDV8BaseV3, RecipeVersion: TransformationServerDV8BaseRecipeVersionV3, Available: bytes.Contains(bsfs, []byte("dovi_rpu")) && bytes.Contains(bsfs, []byte("filter_units")), RequiredCapability: "ffmpeg_bsf:dovi_rpu+filter_units", ValidatedClaims: DV8ToBaseLayerClaimsV3(""), TerminalReason: TerminalDVConversionUnsupportedV3}, - {Name: TransformationAudioToAACV3, RecipeVersion: TransformationAudioToAACRecipeVersionV3, Available: ffmpegErr == nil && bytes.Contains(encoders, []byte(" aac ")) && audioRecipeErr == nil, RequiredCapability: "ffmpeg_encoder:aac+ffmpeg_filter_smoke:stereo_downmix_limiter_v3", ValidatedClaims: []string{ClaimAudioDecodeV3}, TerminalReason: TerminalAudioConversionUnsupportedV3}, + {Name: TransformationAudioToAACV3, RecipeVersion: TransformationAudioToAACRecipeVersionV3, Available: ffmpegErr == nil && bytes.Contains(encoders, []byte(" aac ")) && audioRecipeErr == nil, RequiredCapability: "ffmpeg_encoder:aac+ffmpeg_filter_smoke:timestamp_normalization_v4", ValidatedClaims: []string{ClaimAudioDecodeV3}, TerminalReason: TerminalAudioConversionUnsupportedV3}, {Name: TransformationVideoToH264V3, RecipeVersion: TransformationVideoToH264RecipeVersionV3, Available: ffmpegErr == nil && h264EncoderAvailableV3(encoders), RequiredCapability: "ffmpeg_encoder:h264", PromisedDynamicRange: DynamicRangeSDRV3, ValidatedClaims: []string{ClaimH264DecodeV3}, TerminalReason: TerminalVideoConversionUnsupportedV3}, {Name: TransformationHDRToSDRToneMapV3, RecipeVersion: TransformationHDRToSDRToneMapRecipeVersionV3, Available: len(toneMapCapabilities) > 0, RequiredCapability: "ffmpeg_filter:hdr_to_sdr_tonemap", PromisedDynamicRange: DynamicRangeSDRV3, ValidatedClaims: []string{ClaimHDRMetadataRemovedV3, ClaimSDRBT709OutputV3}, TerminalReason: TerminalHDRTranscodeUnsupportedV3}, })