From 85a19fdd7175374a473aca704101741b4f292bfd Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 14 Apr 2026 20:04:39 -0500 Subject: [PATCH] Fix mid-stream audio loudness jumps Root cause: the quality upgrade path called replaceCurrentItem mid-stream, which re-loaded the HLS master manifest and re-picked an audio rendition, producing a perceived loudness jump 10-30s into playback. .moviePlayback mode amplified this by re-initializing cinematic audio processing on each variant change. - Start streams directly at user's desiredResolution; remove scheduleQualityUpgrade, qualityUpgradeTask, and the 504p->best swap. - Switch AVAudioSession mode from .moviePlayback to .default in both MultiStreamView and SingleStreamPlayerView. - Pin the HLS audio rendition by selecting the default audible MediaSelectionGroup option on every new AVPlayerItem, preventing ABR from swapping channel layouts mid-stream. Co-Authored-By: Claude Opus 4.6 (1M context) --- mlbTVOS/Views/MultiStreamView.swift | 115 +++++---------------- mlbTVOS/Views/SingleStreamPlayerView.swift | 41 +++++--- 2 files changed, 54 insertions(+), 102 deletions(-) diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index 422dfc2..ebdb916 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -352,7 +352,6 @@ private struct MultiStreamTile: View { @State private var player: AVPlayer? @State private var hasError = false @State private var startupPlaybackTask: Task? - @State private var qualityUpgradeTask: Task? @State private var werkoutMonitorTask: Task? @State private var clipTimeLimitObserver: Any? @State private var isAdvancingClip = false @@ -446,8 +445,6 @@ private struct MultiStreamTile: View { logMultiView("tile disappeared id=\(stream.id) label=\(stream.label)") startupPlaybackTask?.cancel() startupPlaybackTask = nil - qualityUpgradeTask?.cancel() - qualityUpgradeTask = nil werkoutMonitorTask?.cancel() werkoutMonitorTask = nil if let player { @@ -536,11 +533,8 @@ private struct MultiStreamTile: View { .clipShape(Capsule()) } - private var multiViewStartupResolution: String { "504p" } - - private var multiViewUpgradeTargetResolution: String? { - let desiredResolution = viewModel.defaultResolution - return desiredResolution == multiViewStartupResolution ? nil : desiredResolution + private var multiViewStartupResolution: String { + viewModel.defaultResolution } private func startStream() async { @@ -556,7 +550,6 @@ private struct MultiStreamTile: View { onPlaybackEnded: playbackEndedHandler(for: player) ) scheduleStartupPlaybackRecovery(for: player) - scheduleQualityUpgrade(for: player) installClipTimeLimit(on: player) logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)") return @@ -564,10 +557,10 @@ private struct MultiStreamTile: View { if !Self.audioSessionConfigured { do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) Self.audioSessionConfigured = true - logMultiView("startStream audio session configured id=\(stream.id)") + logMultiView("startStream audio session configured id=\(stream.id) mode=default") } catch { logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)") } @@ -583,7 +576,6 @@ private struct MultiStreamTile: View { onPlaybackEnded: playbackEndedHandler(for: existingPlayer) ) scheduleStartupPlaybackRecovery(for: existingPlayer) - scheduleQualityUpgrade(for: existingPlayer) installClipTimeLimit(on: existingPlayer) logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)") return @@ -620,7 +612,6 @@ private struct MultiStreamTile: View { onPlaybackEnded: playbackEndedHandler(for: avPlayer) ) scheduleStartupPlaybackRecovery(for: avPlayer) - scheduleQualityUpgrade(for: avPlayer) logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)") avPlayer.playImmediately(atRate: 1.0) installClipTimeLimit(on: avPlayer) @@ -637,15 +628,32 @@ private struct MultiStreamTile: View { } private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem { + let item: AVPlayerItem if headers.isEmpty { - return AVPlayerItem(url: url) + item = AVPlayerItem(url: url) + } else { + let assetOptions: [String: Any] = [ + "AVURLAssetHTTPHeaderFieldsKey": headers, + ] + let asset = AVURLAsset(url: url, options: assetOptions) + item = AVPlayerItem(asset: asset) } + pinAudioSelection(on: item) + return item + } - let assetOptions: [String: Any] = [ - "AVURLAssetHTTPHeaderFieldsKey": headers, - ] - let asset = AVURLAsset(url: url, options: assetOptions) - return AVPlayerItem(asset: asset) + /// Lock the HLS audio rendition to the default option so ABR can't swap + /// to a different channel layout / loudness mid-stream. + private func pinAudioSelection(on item: AVPlayerItem) { + let streamID = stream.id + let asset = item.asset + Task { @MainActor [weak item] in + guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), + let option = group.defaultOption ?? group.options.first, + let item else { return } + item.select(option, in: group) + logMultiView("pinAudioSelection id=\(streamID) selected=\(option.displayName) options=\(group.options.count)") + } } private func scheduleStartupPlaybackRecovery(for player: AVPlayer) { @@ -686,75 +694,6 @@ private struct MultiStreamTile: View { } } - private func scheduleQualityUpgrade(for player: AVPlayer) { - qualityUpgradeTask?.cancel() - - guard stream.overrideURL == nil else { - logMultiView("qualityUpgrade skip id=\(stream.id) reason=override-url") - return - } - - guard let targetResolution = multiViewUpgradeTargetResolution else { - logMultiView("qualityUpgrade skip id=\(stream.id) reason=target-already-\(multiViewStartupResolution)") - return - } - - let streamID = stream.id - let label = stream.label - qualityUpgradeTask = Task { @MainActor in - let checkDelays: [Double] = [2.0, 4.0, 7.0, 15.0, 30.0] - - for delay in checkDelays { - try? await Task.sleep(for: .seconds(delay)) - guard !Task.isCancelled else { return } - guard let currentPlayer = self.player, currentPlayer === player else { - logMultiView("qualityUpgrade abort id=\(streamID) label=\(label) reason=player-changed") - return - } - - let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown) - let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false - let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false - let indicatedBitrate = player.currentItem?.accessLog()?.events.last?.indicatedBitrate ?? 0 - let stable = (itemStatus == "readyToPlay" || likelyToKeepUp) && !bufferEmpty - - logMultiView( - "qualityUpgrade check id=\(streamID) delay=\(delay)s targetResolution=\(targetResolution) stable=\(stable) rate=\(player.rate) indicatedBitrate=\(Int(indicatedBitrate))" - ) - - guard stable else { continue } - - guard let upgradedURL = await viewModel.resolveStreamURL( - for: stream, - resolutionOverride: targetResolution, - preserveServerResolutionWhenBest: false - ) else { - logMultiView("qualityUpgrade failed id=\(streamID) targetResolution=\(targetResolution) reason=resolve-nil") - return - } - - if let currentURL = currentStreamURL(for: player), currentURL == upgradedURL { - logMultiView("qualityUpgrade skip id=\(streamID) reason=same-url targetResolution=\(targetResolution)") - return - } - - logMultiView("qualityUpgrade begin id=\(streamID) targetResolution=\(targetResolution) url=\(upgradedURL.absoluteString)") - let upgradedItem = AVPlayerItem(url: upgradedURL) - upgradedItem.preferredForwardBufferDuration = 4 - player.replaceCurrentItem(with: upgradedItem) - player.automaticallyWaitsToMinimizeStalling = false - playbackDiagnostics.attach(to: player, streamID: streamID, label: label) - viewModel.attachPlayer(player, to: streamID) - scheduleStartupPlaybackRecovery(for: player) - logMultiView("qualityUpgrade replay id=\(streamID) targetResolution=\(targetResolution)") - player.playImmediately(atRate: 1.0) - return - } - - logMultiView("qualityUpgrade timeout id=\(streamID) targetResolution=\(targetResolution)") - } - } - private func currentStreamURL(for player: AVPlayer) -> URL? { (player.currentItem?.asset as? AVURLAsset)?.url } diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift index 785c464..b981875 100644 --- a/mlbTVOS/Views/SingleStreamPlayerView.swift +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -57,24 +57,37 @@ private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlSt } private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem { + let item: AVPlayerItem if source.httpHeaders.isEmpty { - let item = AVPlayerItem(url: source.url) - item.preferredForwardBufferDuration = 8 - return item + item = AVPlayerItem(url: source.url) + } else { + let assetOptions: [String: Any] = [ + "AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders, + ] + let asset = AVURLAsset(url: source.url, options: assetOptions) + item = AVPlayerItem(asset: asset) + logSingleStream( + "Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))" + ) } - - let assetOptions: [String: Any] = [ - "AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders, - ] - let asset = AVURLAsset(url: source.url, options: assetOptions) - let item = AVPlayerItem(asset: asset) item.preferredForwardBufferDuration = 8 - logSingleStream( - "Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))" - ) + pinSingleStreamAudioSelection(on: item) return item } +/// Lock the HLS audio rendition to the default option so ABR can't swap +/// to a different channel layout / loudness mid-stream. +private func pinSingleStreamAudioSelection(on item: AVPlayerItem) { + let asset = item.asset + Task { @MainActor [weak item] in + guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), + let option = group.defaultOption ?? group.options.first, + let item else { return } + item.select(option, in: group) + logSingleStream("pinAudioSelection selected=\(option.displayName) options=\(group.options.count)") + } +} + struct SingleStreamPlaybackScreen: View { @Environment(\.dismiss) private var dismiss let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? @@ -544,9 +557,9 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { ) do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setActive(true) - logSingleStream("AVAudioSession configured for playback") + logSingleStream("AVAudioSession configured for playback mode=default") } catch { logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)") }