Skip to content

Conversation

@timusus
Copy link
Owner

@timusus timusus commented Nov 16, 2025

Description:

Summary

Fixes #121 - ReplayGain volume adjustments are now applied before playback starts instead of after, eliminating the audible volume shift that occurred ~500ms into each track.

Problem

The original issue reported that "ReplayGain volume adjustments are only applied after the track begins playing, which leads to a noticeable change in volume a fraction of a second into the track."

Root Cause:

  • setReplayGain() was called in the success callback AFTER player.prepare() completed
  • This meant the audio processing pipeline started with incorrect gain values
  • The ~500ms delay corresponded to the time it took to load, prepare, and start playback

Solution

The fix uses a proactive update model with ReplayGain values embedded in MediaItems:

1. MediaItem Tagging Architecture

fun getMediaItem(mediaInfo: MediaInfo, trackGain: Double?, albumGain: Double?): MediaItem =
    MediaItem.Builder()
        .setTag(ReplayGainTag(trackGain, albumGain))
        .build()
2. Manual Track Changes
// In load() - BEFORE prepare()
replayGainAudioProcessor.trackGain = current.replayGainTrack
replayGainAudioProcessor.albumGain = current.replayGainAlbum
player.prepare()  // Audio processing starts after gain is set
3. Automatic Track Transitions
// In onMediaItemTransition
mediaItem?.localConfiguration?.tag?.let { tag ->
    if (tag is ReplayGainTag) {
        replayGainAudioProcessor.trackGain = tag.trackGain
        replayGainAudioProcessor.albumGain = tag.albumGain
    }
}
Changes
PlaybackManager.kt: Removed reactive setReplayGain() calls
ExoPlayerPlayback.kt: Added MediaItem tagging, proactive ReplayGain updates
ReplayGainAudioProcessor.kt: Enhanced thread safety with @Volatile and documentation
Known Limitations
During gapless playback, there's a potential ~10-50ms window where a few audio buffers from the new track may be processed with the previous track's gain due to ExoPlayer's async callback model vs continuous audio rendering. This is ~90-95% smaller than the original 500ms issue and is likely imperceptible.

ReplayGain volume adjustments were being applied after playback began,
causing a noticeable volume shift approximately half a second into each
track. This occurred because setReplayGain() was called in the success
callback after player.prepare() had already been executed.

The fix moves setReplayGain() calls to execute before load() in all
playback functions, ensuring the ReplayGainAudioProcessor has the
correct gain values before the player begins preparing and processing
audio samples.

Changes:
- attemptLoad(): Moved setReplayGain() before load() call
- skipToNext(): Added setReplayGain() before load() call
- skipToPrev(): Added setReplayGain() before load() call
- skipTo(): Added setReplayGain() before load() call

This eliminates the race condition and ensures volume is correct from
the first audio sample of each track.
During automatic track transitions (when ExoPlayer moves to the next
track), there was still a potential race condition where audio
processing could start before ReplayGain values were updated.

This adds an explicit setReplayGain() call immediately after
skipToNext() in the onTrackEnded() handler when trackWentToNext is
true. This ensures ReplayGain values are set synchronously as soon
as the queue position changes, minimizing any delay.

This complements the existing onQueuePositionChanged callback and
provides additional protection against timing issues during automatic
transitions.
This commit addresses the fundamental architectural issue with ReplayGain
timing. The previous approach set ReplayGain values reactively after track
changes, which created race conditions during both manual and automatic
transitions.

The new architecture embeds ReplayGain values directly into each MediaItem:

1. MediaItem Creation: ReplayGain values are attached as a tag when creating
   MediaItems in both load() and loadNext(). This ensures each queued track
   carries its own gain information.

2. Manual Track Changes: In ExoPlayerPlayback.load(), ReplayGain is set
   immediately after setMediaItem() and before prepare(), ensuring correct
   gain from the first sample.

3. Automatic Transitions: In onMediaItemTransition(), the ReplayGain values
   are read from the transitioning MediaItem's tag and applied immediately.
   This eliminates the need to look up gain values from the queue manager.

4. Centralized Logic: All ReplayGain management is now in ExoPlayerPlayback,
   removing scattered setReplayGain() calls from PlaybackManager.

This approach ensures ReplayGain values are associated with specific audio
tracks throughout ExoPlayer's pipeline, preventing any samples from being
processed with incorrect gain values.

Fixes #121
This addresses the gapless playback timing issue: when ExoPlayer buffers
the next track during gapless playback, audio samples can flow through
the AudioProcessor before onMediaItemTransition callbacks fire.

The solution makes ReplayGainAudioProcessor directly aware of which
MediaItem is currently playing:

1. The processor now holds a reference to the Player instance
2. On every audio buffer, it queries player.currentMediaItem
3. It reads ReplayGain values from the MediaItem's tag in real-time
4. Falls back to manually-set values if tag is unavailable

This ensures the correct gain is applied based on what's actually
being rendered, not based on callback timing. Even if Track B's samples
are buffered while Track A plays, they'll use Track A's gain until the
player actually transitions to Track B.

Benefits:
- Eliminates race conditions during gapless transitions
- Handles buffering edge cases automatically
- Gain switches at the exact moment playback transitions
- No dependency on callback ordering or timing

This is the proper architectural fix for #121
This commit removes the Player reference from ReplayGainAudioProcessor,
addressing critical architectural and thread-safety issues:

## Issues Fixed

1. **Layering Violation**: AudioProcessor (low-level DSP) was holding a
   reference to Player (high-level controller), creating a circular
   dependency and violating separation of concerns.

2. **Thread Safety Violation**: player.currentMediaItem was being accessed
   from the audio rendering thread, but Player API is explicitly designed
   for main-thread-only access. This violated ExoPlayer's threading model.

3. **Performance**: Querying player.currentMediaItem on every audio buffer
   (thousands of times per second) added unnecessary overhead in the hot path.

4. **Redundancy**: Two update mechanisms existed (proactive updates in
   onMediaItemTransition + reactive queries), adding complexity.

## Solution

The processor now uses a clean proactive update model:
- Manual transitions: ReplayGain set in load() BEFORE prepare()
- Automatic transitions: ReplayGain set in onMediaItemTransition

Thread safety ensured via:
- @volatile for memory visibility across threads
- @synchronized for atomic read/write operations

## Timing Analysis

The original issue (#121) reported ~500ms delay, caused by setting ReplayGain
AFTER prepare() completed. The new approach sets it BEFORE prepare() (manual)
and in onMediaItemTransition (automatic).

During gapless transitions, there may be a brief window (~10-50ms, a few audio
buffers) where samples are processed with the previous track's gain. This is a
fundamental limitation of async Player.Listener callbacks vs continuous audio
rendering, but is imperceptible compared to the original 500ms issue.

MediaItem tagging remains in place as the proper way to associate ReplayGain
metadata with tracks through ExoPlayer's pipeline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ReplayGain adjustments applied after playback begins

3 participants