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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-14 20:04:39 -05:00
parent ba24c767a0
commit 85a19fdd71
2 changed files with 54 additions and 102 deletions

View File

@@ -352,7 +352,6 @@ private struct MultiStreamTile: View {
@State private var player: AVPlayer? @State private var player: AVPlayer?
@State private var hasError = false @State private var hasError = false
@State private var startupPlaybackTask: Task<Void, Never>? @State private var startupPlaybackTask: Task<Void, Never>?
@State private var qualityUpgradeTask: Task<Void, Never>?
@State private var werkoutMonitorTask: Task<Void, Never>? @State private var werkoutMonitorTask: Task<Void, Never>?
@State private var clipTimeLimitObserver: Any? @State private var clipTimeLimitObserver: Any?
@State private var isAdvancingClip = false @State private var isAdvancingClip = false
@@ -446,8 +445,6 @@ private struct MultiStreamTile: View {
logMultiView("tile disappeared id=\(stream.id) label=\(stream.label)") logMultiView("tile disappeared id=\(stream.id) label=\(stream.label)")
startupPlaybackTask?.cancel() startupPlaybackTask?.cancel()
startupPlaybackTask = nil startupPlaybackTask = nil
qualityUpgradeTask?.cancel()
qualityUpgradeTask = nil
werkoutMonitorTask?.cancel() werkoutMonitorTask?.cancel()
werkoutMonitorTask = nil werkoutMonitorTask = nil
if let player { if let player {
@@ -536,11 +533,8 @@ private struct MultiStreamTile: View {
.clipShape(Capsule()) .clipShape(Capsule())
} }
private var multiViewStartupResolution: String { "504p" } private var multiViewStartupResolution: String {
viewModel.defaultResolution
private var multiViewUpgradeTargetResolution: String? {
let desiredResolution = viewModel.defaultResolution
return desiredResolution == multiViewStartupResolution ? nil : desiredResolution
} }
private func startStream() async { private func startStream() async {
@@ -556,7 +550,6 @@ private struct MultiStreamTile: View {
onPlaybackEnded: playbackEndedHandler(for: player) onPlaybackEnded: playbackEndedHandler(for: player)
) )
scheduleStartupPlaybackRecovery(for: player) scheduleStartupPlaybackRecovery(for: player)
scheduleQualityUpgrade(for: player)
installClipTimeLimit(on: player) installClipTimeLimit(on: player)
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)") logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
return return
@@ -564,10 +557,10 @@ private struct MultiStreamTile: View {
if !Self.audioSessionConfigured { if !Self.audioSessionConfigured {
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
Self.audioSessionConfigured = true Self.audioSessionConfigured = true
logMultiView("startStream audio session configured id=\(stream.id)") logMultiView("startStream audio session configured id=\(stream.id) mode=default")
} catch { } catch {
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)") logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
} }
@@ -583,7 +576,6 @@ private struct MultiStreamTile: View {
onPlaybackEnded: playbackEndedHandler(for: existingPlayer) onPlaybackEnded: playbackEndedHandler(for: existingPlayer)
) )
scheduleStartupPlaybackRecovery(for: existingPlayer) scheduleStartupPlaybackRecovery(for: existingPlayer)
scheduleQualityUpgrade(for: existingPlayer)
installClipTimeLimit(on: existingPlayer) installClipTimeLimit(on: existingPlayer)
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)") logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
return return
@@ -620,7 +612,6 @@ private struct MultiStreamTile: View {
onPlaybackEnded: playbackEndedHandler(for: avPlayer) onPlaybackEnded: playbackEndedHandler(for: avPlayer)
) )
scheduleStartupPlaybackRecovery(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)") logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)")
avPlayer.playImmediately(atRate: 1.0) avPlayer.playImmediately(atRate: 1.0)
installClipTimeLimit(on: avPlayer) installClipTimeLimit(on: avPlayer)
@@ -637,15 +628,32 @@ private struct MultiStreamTile: View {
} }
private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem { private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem {
let item: AVPlayerItem
if headers.isEmpty { 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] = [ /// Lock the HLS audio rendition to the default option so ABR can't swap
"AVURLAssetHTTPHeaderFieldsKey": headers, /// to a different channel layout / loudness mid-stream.
] private func pinAudioSelection(on item: AVPlayerItem) {
let asset = AVURLAsset(url: url, options: assetOptions) let streamID = stream.id
return AVPlayerItem(asset: asset) 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) { 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? { private func currentStreamURL(for player: AVPlayer) -> URL? {
(player.currentItem?.asset as? AVURLAsset)?.url (player.currentItem?.asset as? AVURLAsset)?.url
} }

View File

@@ -57,24 +57,37 @@ private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlSt
} }
private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem { private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem {
let item: AVPlayerItem
if source.httpHeaders.isEmpty { if source.httpHeaders.isEmpty {
let item = AVPlayerItem(url: source.url) item = AVPlayerItem(url: source.url)
item.preferredForwardBufferDuration = 8 } else {
return item 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 item.preferredForwardBufferDuration = 8
logSingleStream( pinSingleStreamAudioSelection(on: item)
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
)
return 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 { struct SingleStreamPlaybackScreen: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
@@ -544,9 +557,9 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
) )
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
logSingleStream("AVAudioSession configured for playback") logSingleStream("AVAudioSession configured for playback mode=default")
} catch { } catch {
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)") logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
} }