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 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user