From 472705614a86c501b809fe57a4d47eecaa8558ed Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sun, 9 Jun 2024 12:20:19 +0200 Subject: [PATCH] Implement normalization gain for music tracks --- .../mediastream/normalizationGainElement.kt | 13 ++++++ .../src/main/kotlin/ExoPlayerAudioPipeline.kt | 37 ++++++++++++++++ .../src/main/kotlin/ExoPlayerBackend.kt | 43 ++++++++++++++----- .../kotlin/queue/createBaseItemQueueEntry.kt | 2 + 4 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 playback/core/src/main/kotlin/mediastream/normalizationGainElement.kt create mode 100644 playback/exoplayer/src/main/kotlin/ExoPlayerAudioPipeline.kt diff --git a/playback/core/src/main/kotlin/mediastream/normalizationGainElement.kt b/playback/core/src/main/kotlin/mediastream/normalizationGainElement.kt new file mode 100644 index 0000000000..aa460ee8fc --- /dev/null +++ b/playback/core/src/main/kotlin/mediastream/normalizationGainElement.kt @@ -0,0 +1,13 @@ +package org.jellyfin.playback.core.mediastream + +import org.jellyfin.playback.core.element.ElementKey +import org.jellyfin.playback.core.element.element +import org.jellyfin.playback.core.queue.QueueEntry + +private val normalizationGainKey = ElementKey("NormalizationGain") + +/** + * Get or set the normalization gain for this [QueueEntry]. A supported backend will use this to + * apply a gain to the audio output. The normalization gain must target a loudness of -23LUFS. + */ +var QueueEntry.normalizationGain by element(normalizationGainKey) diff --git a/playback/exoplayer/src/main/kotlin/ExoPlayerAudioPipeline.kt b/playback/exoplayer/src/main/kotlin/ExoPlayerAudioPipeline.kt new file mode 100644 index 0000000000..c0e0ee020f --- /dev/null +++ b/playback/exoplayer/src/main/kotlin/ExoPlayerAudioPipeline.kt @@ -0,0 +1,37 @@ +package org.jellyfin.playback.exoplayer + +import android.media.audiofx.LoudnessEnhancer +import timber.log.Timber + +class ExoPlayerAudioPipeline { + private var loudnessEnhancer: LoudnessEnhancer? = null + var normalizationGain: Float? = null + set(value) { + Timber.d("Normalization gain changed to $value") + field = value + applyGain() + } + + fun setAudioSessionId(audioSessionId: Int) { + Timber.d("Audio session id changed to $audioSessionId") + + // Re-creare loudness enhancer for normalization gain + loudnessEnhancer?.release() + loudnessEnhancer = LoudnessEnhancer(audioSessionId) + + // Re-apply current normalization gain + applyGain() + } + + private fun applyGain() { + val targetGain = normalizationGain + // Convert to millibels + ?.times(100f) + // Round to integer + ?.toInt() + + Timber.d("Applying gain (targetGain=$targetGain)") + loudnessEnhancer?.setEnabled(targetGain != null) + loudnessEnhancer?.setTargetGain(targetGain ?: 0) + } +} diff --git a/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt b/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt index 015288458e..80fd7b700c 100644 --- a/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt +++ b/playback/exoplayer/src/main/kotlin/ExoPlayerBackend.kt @@ -24,6 +24,7 @@ import org.jellyfin.playback.core.backend.BasePlayerBackend import org.jellyfin.playback.core.mediastream.MediaStream import org.jellyfin.playback.core.mediastream.PlayableMediaStream import org.jellyfin.playback.core.mediastream.mediaStream +import org.jellyfin.playback.core.mediastream.normalizationGain import org.jellyfin.playback.core.model.PlayState import org.jellyfin.playback.core.model.PositionInfo import org.jellyfin.playback.core.queue.QueueEntry @@ -48,6 +49,7 @@ class ExoPlayerBackend( private var currentStream: PlayableMediaStream? = null private var subtitleView: SubtitleView? = null + private var audioPipeline = ExoPlayerAudioPipeline() private val exoPlayer by lazy { ExoPlayer.Builder(context) @@ -58,24 +60,32 @@ class ExoPlayerBackend( .setTrackSelector(DefaultTrackSelector(context).apply { setParameters(buildUponParameters().apply { setTunnelingEnabled(true) - setAudioOffloadPreferences(TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply { - setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) - }.build()) + setAudioOffloadPreferences( + TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply { + setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) + }.build() + ) }) }) .setMediaSourceFactory(DefaultMediaSourceFactory( context, DefaultExtractorsFactory().apply { - val isLowRamDevice = context.getSystemService()?.isLowRamDevice == true - setTsExtractorTimestampSearchBytes(when (isLowRamDevice) { - true -> TS_SEARCH_BYTES_LM - false -> TS_SEARCH_BYTES_HM - }) + val isLowRamDevice = + context.getSystemService()?.isLowRamDevice == true + setTsExtractorTimestampSearchBytes( + when (isLowRamDevice) { + true -> TS_SEARCH_BYTES_LM + false -> TS_SEARCH_BYTES_HM + } + ) } )) .setPauseAtEndOfMediaItems(true) .build() - .also { player -> player.addListener(PlayerListener()) } + .also { player -> + player.addListener(PlayerListener()) + audioPipeline.setAudioSessionId(player.audioSessionId) + } } inner class PlayerListener : Player.Listener { @@ -109,6 +119,15 @@ class ExoPlayerBackend( listener?.onMediaStreamEnd(requireNotNull(currentStream)) } } + + override fun onAudioSessionIdChanged(audioSessionId: Int) { + audioPipeline.setAudioSessionId(audioSessionId) + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + val queueEntry = mediaItem?.localConfiguration?.tag as? QueueEntry + audioPipeline.normalizationGain = queueEntry?.normalizationGain + } } override fun supportsStream( @@ -132,7 +151,7 @@ class ExoPlayerBackend( override fun prepareItem(item: QueueEntry) { val stream = requireNotNull(item.mediaStream) val mediaItem = MediaItem.Builder().apply { - setTag(stream) + setTag(item) setMediaId(stream.hashCode().toString()) setUri(stream.url) }.build() @@ -153,7 +172,9 @@ class ExoPlayerBackend( var streamIsPrepared = false repeat(exoPlayer.mediaItemCount) { index -> - streamIsPrepared = streamIsPrepared || exoPlayer.getMediaItemAt(index).mediaId == stream.hashCode().toString() + streamIsPrepared = + streamIsPrepared || exoPlayer.getMediaItemAt(index).mediaId == stream.hashCode() + .toString() } if (!streamIsPrepared) prepareItem(item) diff --git a/playback/jellyfin/src/main/kotlin/queue/createBaseItemQueueEntry.kt b/playback/jellyfin/src/main/kotlin/queue/createBaseItemQueueEntry.kt index 6be035f662..cc9c70a05e 100644 --- a/playback/jellyfin/src/main/kotlin/queue/createBaseItemQueueEntry.kt +++ b/playback/jellyfin/src/main/kotlin/queue/createBaseItemQueueEntry.kt @@ -1,5 +1,6 @@ package org.jellyfin.playback.jellyfin.queue +import org.jellyfin.playback.core.mediastream.normalizationGain import org.jellyfin.playback.core.queue.QueueEntry import org.jellyfin.playback.core.queue.QueueEntryMetadata import org.jellyfin.playback.core.queue.metadata @@ -44,6 +45,7 @@ fun createBaseItemQueueEntry(api: ApiClient, baseItem: BaseItemDto): QueueEntry genre = baseItem.genres?.joinToString(", "), ) entry.baseItem = baseItem + entry.normalizationGain = baseItem.normalizationGain return entry }