playback: apply the gain only from the item flush

Review follow-ups for the apply slice.

- Drop the media item transition application. It never supplied a gain
  the flush had not already supplied, and as a second writer it raced the
  playback thread: it applied the incoming item's gain while the outgoing
  item's buffers were still queued, scaling the previous track's tail by
  the next track's gain (reproduced at +12 dB). The flush is the only
  writer now.

  This reverses the earlier decision to keep it as a fallback. That was
  made on the premise that it was insurance against a flush that could
  not be resolved. The flush covers every transition, and a period-less
  flush only happens while the sink is being reset, so the premise does
  not hold.

- Ignore a flush that carries no period rather than reading it as "this
  item has no gain". Resetting to unity there would have been wrong if a
  bare flush ever arrived mid-playback; the test that pinned that
  behaviour now pins the opposite.

- Guard the pass-through branch against an empty input, which is the
  pipeline's own shared buffer and cannot be put into itself.

- The controller is written from the playback thread now, so its state
  updates and the recompute that follows them are synchronized rather
  than two independent volatile writes.

- Add ReplayGainSinkTest, which drives the real DefaultAudioSink and
  asserts it hands the processor the item being configured. That is the
  media3 behaviour this whole feature rests on, and it was previously
  asserted only by comments.
This commit is contained in:
austinried
2026-10-16 13:07:07 +00:00
parent c92d28c9dd
commit cf272c4edc
7 changed files with 100 additions and 45 deletions
@@ -266,10 +266,6 @@ private fun Bundle.putGain(
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
internal fun ReplayGainController.applyItem(mediaItem: MediaItem?) {
setTrack(mediaItem.replayGain())
}
internal fun queueMediaItem(
item: QueueItem,
local: String?,
@@ -140,7 +140,6 @@ class PlaybackService : MediaSessionService() {
reason: Int,
) {
observeCurrentSong(mediaItem?.mediaId)
replayGain.applyItem(mediaItem)
}
},
)
@@ -1,23 +1,23 @@
package com.subtracks.playback
import androidx.media3.common.MediaItem
import com.subtracks.data.prefs.ReplayGainMode
internal class ReplayGainController(
private val apply: (Float) -> Unit,
) {
// Written from the playback thread by ReplayGainProcessor.onFlush and from the app thread by
// the media item transition fallback.
@Volatile
private var track = ReplayGain()
@Volatile
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
fun setTrack(value: ReplayGain?) {
track = value ?: ReplayGain()
// Called from the playback thread, by ReplayGainProcessor.onFlush.
@Synchronized
fun applyItem(item: MediaItem) {
track = item.replayGain()
applyGain()
}
// Called from the app thread, when the preferences change.
@Synchronized
fun setSettings(value: ReplayGainSettings) {
settings = value
applyGain()
@@ -13,7 +13,7 @@ import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
internal class ReplayGainProcessor(
private val onItem: (MediaItem?) -> Unit = {},
private val onItem: (MediaItem) -> Unit = {},
) : BaseAudioProcessor() {
@Volatile
private var linearGain = 1f
@@ -30,7 +30,9 @@ internal class ReplayGainProcessor(
// Player.Listener.onMediaItemTransition, which the app thread receives afterwards.
// See androidx/media#418.
override fun onFlush(streamMetadata: StreamMetadata) {
onItem(streamMetadata.currentItem())
// A flush with no period comes from a sink reset rather than an item boundary, so leave the
// gain alone rather than resetting it to unity.
streamMetadata.currentItem()?.let(onItem)
}
override fun queueInput(inputBuffer: ByteBuffer) {
@@ -48,7 +50,9 @@ internal class ReplayGainProcessor(
}
else -> {
output.put(inputBuffer)
// An empty input would be the pipeline's own shared buffer, which cannot be put into
// itself.
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
}
}
inputBuffer.position(inputBuffer.limit())