diff --git a/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt b/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt index b03ccfc31..940e9e801 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt @@ -96,7 +96,6 @@ class PlaybackManager( appCoroutineScope.launch { playback.load(current, next, seekPosition) { result -> result.onSuccess { - playback.setReplayGain(trackGain = current.replayGainTrack, albumGain = current.replayGainAlbum) completion(Result.success(attempt == 1)) } result.onFailure { error -> diff --git a/android/playback/src/main/java/com/simplecityapps/playback/dsp/replaygain/ReplayGainAudioProcessor.kt b/android/playback/src/main/java/com/simplecityapps/playback/dsp/replaygain/ReplayGainAudioProcessor.kt index 5d1ebb331..7db671f2e 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/dsp/replaygain/ReplayGainAudioProcessor.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/dsp/replaygain/ReplayGainAudioProcessor.kt @@ -12,19 +12,36 @@ import com.simplecityapps.playback.exoplayer.ByteUtils.getInt24 import com.simplecityapps.playback.exoplayer.ByteUtils.putInt24 import java.nio.ByteBuffer +/** + * Audio processor that applies ReplayGain volume normalization. + * + * Thread Safety: + * - trackGain and albumGain are updated from the main thread (via setReplayGain) + * - gain getter is accessed from the audio rendering thread (in queueInput) + * - @Synchronized ensures thread-safe access + * + * Note on Timing: + * During gapless transitions, there may be a brief period (a few audio buffers, ~10-50ms) + * where new track samples are processed with the previous track's gain before the update + * takes effect. This is a fundamental limitation of the async nature of Player.Listener + * callbacks vs continuous audio rendering. In practice, this is imperceptible compared + * to the original issue where the delay was ~500ms. + */ class ReplayGainAudioProcessor(var mode: ReplayGainMode, var preAmpGain: Double = 0.0) : BaseAudioProcessor() { + @Volatile var trackGain: Double? = null @Synchronized get @Synchronized set + @Volatile var albumGain: Double? = null @Synchronized get @Synchronized set private val gain: Double - get() = + @Synchronized get() = preAmpGain + when (mode) { ReplayGainMode.Track -> trackGain ?: albumGain ?: 0.0 diff --git a/android/playback/src/main/java/com/simplecityapps/playback/exoplayer/ExoPlayerPlayback.kt b/android/playback/src/main/java/com/simplecityapps/playback/exoplayer/ExoPlayerPlayback.kt index f19935a6e..bd5acf2e5 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/exoplayer/ExoPlayerPlayback.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/exoplayer/ExoPlayerPlayback.kt @@ -87,6 +87,15 @@ class ExoPlayerPlayback( val transitionReason = reason.toTransitionReason() Timber.v("onMediaItemTransition(reason: ${reason.toTransitionReason()})") + // Update ReplayGain immediately from the MediaItem's tag to ensure correct gain for this track + mediaItem?.localConfiguration?.tag?.let { tag -> + if (tag is ReplayGainTag) { + Timber.v("Updating ReplayGain from MediaItem tag: track=${tag.trackGain}, album=${tag.albumGain}") + replayGainAudioProcessor.trackGain = tag.trackGain + replayGainAudioProcessor.albumGain = tag.albumGain + } + } + when (transitionReason) { TransitionReason.Repeat -> callback?.onTrackEnded(true) TransitionReason.Auto -> callback?.onTrackEnded(true) @@ -150,7 +159,11 @@ class ExoPlayerPlayback( val mediaInfo = mediaInfoProvider.getMediaInfo(current) player.addListener(eventListener) - player.setMediaItem(getMediaItem(mediaInfo)) + val mediaItem = getMediaItem(mediaInfo, current.replayGainTrack, current.replayGainAlbum) + player.setMediaItem(mediaItem) + // Set ReplayGain immediately for the current track in case onMediaItemTransition doesn't fire + replayGainAudioProcessor.trackGain = current.replayGainTrack + replayGainAudioProcessor.albumGain = current.replayGainAlbum player.seekTo(seekPosition.toLong()) player.prepare() @@ -174,7 +187,7 @@ class ExoPlayerPlayback( val nextMediaItem: MediaItem? = song?.let { - getMediaItem(mediaInfoProvider.getMediaInfo(song)) + getMediaItem(mediaInfoProvider.getMediaInfo(song), song.replayGainTrack, song.replayGainAlbum) } val count = player.mediaItemCount @@ -293,9 +306,16 @@ class ExoPlayerPlayback( else -> TransitionReason.Unknown } + data class ReplayGainTag(val trackGain: Double?, val albumGain: Double?) + @Throws(IllegalStateException::class) - fun getMediaItem(mediaInfo: MediaInfo): MediaItem = MediaItem.Builder() + fun getMediaItem( + mediaInfo: MediaInfo, + trackGain: Double? = null, + albumGain: Double? = null + ): MediaItem = MediaItem.Builder() .setMimeType(mediaInfo.mimeType) .setUri(mediaInfo.path) + .setTag(ReplayGainTag(trackGain, albumGain)) .build() }