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:
@@ -352,7 +352,6 @@ private struct MultiStreamTile: View {
|
||||
@State private var player: AVPlayer?
|
||||
@State private var hasError = false
|
||||
@State private var startupPlaybackTask: Task<Void, Never>?
|
||||
@State private var qualityUpgradeTask: Task<Void, Never>?
|
||||
@State private var werkoutMonitorTask: Task<Void, Never>?
|
||||
@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
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user