feat: apply replay gain during playback

Second slice: read the gain synced in the first one and scale the decoded
PCM by it.

- `ReplayGainProcessor` is a `BaseAudioProcessor` that scales 16-bit and
  float samples, saturating on overflow rather than wrapping. It is
  installed through `DefaultAudioSink`'s audio processor chain, so it runs
  ahead of the default silence-skip and speed processors.
- `replayGainLinearGain` picks the track or album gain according to the
  mode, falls back to the other gain when one is missing, adds the preamp,
  and caps the result at the peak's headroom so a boost cannot clip. A
  non-positive peak means the server has no peak data and is ignored.
- `ReplayGainMode` and the preamp live in `UserPreferences`, defaulting to
  track mode and 0 dB. The settings UI is still to come.
- `PlaybackService` resolves the current song's gain alongside its star
  state and applies it on every media item transition.

The processor only runs while the audio processor chain does, which
`DefaultAudioSink` skips when float output is enabled. That stays off, as
it is by default.
This commit is contained in:
austinried
2026-10-16 13:07:07 +00:00
parent 81e6189ebe
commit e33784ddd3
9 changed files with 517 additions and 5 deletions
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -51,8 +52,13 @@ private val Context.preferences: DataStore<Preferences> by preferencesDataStore(
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
enum class SyncMode { Off, WifiOnly, Always }
enum class ReplayGainMode { Track, Album, Off }
data class ArtworkSeedValue(
val cacheKey: String,
val primary: Int,
@@ -146,6 +152,26 @@ class UserPreferences(
private val scrobblingKey = booleanPreferencesKey("scrobbling")
fun replayGainMode(): Flow<ReplayGainMode> =
store.data.map { prefs ->
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
}
suspend fun setReplayGainMode(mode: ReplayGainMode) {
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
}
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
fun replayGainPreampDb(): Flow<Float> =
store.data.map { prefs -> (prefs[replayGainPreampKey] ?: 0f).coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) }
suspend fun setReplayGainPreampDb(value: Float) {
store.edit { prefs -> prefs[replayGainPreampKey] = value.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) }
}
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
suspend fun setOfflineMode(enabled: Boolean) {
@@ -20,7 +20,10 @@ import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
@@ -34,6 +37,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.subtracks.MainActivity
import com.subtracks.R
import com.subtracks.UserAgent
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
@@ -43,6 +47,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext
@@ -58,7 +63,10 @@ class PlaybackService : MediaSessionService() {
private var library: LibraryRepository? = null
private var currentSongId: String? = null
private var starred = false
private var starJob: Job? = null
private var songJob: Job? = null
private val gainProcessor = ReplayGainProcessor()
private val replayGain = ReplayGainController { gainProcessor.setLinearGain(it) }
private val starCallback =
object : MediaSession.Callback {
@@ -97,10 +105,32 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
val preferences = GlobalContext.get().get<UserPreferences>()
val cache = StreamCache.open(this)
scope.launch {
preferences
.replayGainMode()
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
.collect(replayGain::setSettings)
}
val renderersFactory =
object : DefaultRenderersFactory(this) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParameters: Boolean,
): AudioSink =
DefaultAudioSink
.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
.setAudioProcessors(arrayOf(gainProcessor))
.build()
}
val exoPlayer =
ExoPlayer
.Builder(this)
.setRenderersFactory(renderersFactory)
.setAudioAttributes(
AudioAttributes
.Builder()
@@ -126,7 +156,7 @@ class PlaybackService : MediaSessionService() {
mediaItem: MediaItem?,
reason: Int,
) {
observeStar(mediaItem?.mediaId)
observeCurrentSong(mediaItem?.mediaId)
}
},
)
@@ -161,23 +191,25 @@ class PlaybackService : MediaSessionService() {
pauseAllPlayersAndStopSelf()
}
private fun observeStar(songId: String?) {
starJob?.cancel()
private fun observeCurrentSong(songId: String?) {
songJob?.cancel()
currentSongId = songId
starred = false
val library = library ?: return
if (songId == null) {
session?.setMediaButtonPreferences(emptyList())
replayGain.setTrack(null)
return
}
session?.setMediaButtonPreferences(listOf(starButton(false)))
starJob =
songJob =
scope.launch {
val sourceId = sourceRepository?.activeSourceIdOnce() ?: return@launch
library.song(sourceId, songId).collect { song ->
if (currentSongId != songId) return@collect
starred = song?.starred != null
session?.setMediaButtonPreferences(listOf(starButton(starred)))
replayGain.setTrack(song?.replayGain())
}
}
}
@@ -0,0 +1,48 @@
package com.subtracks.playback
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.ReplayGainMode
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.pow
internal data class ReplayGain(
val trackGain: Float? = null,
val albumGain: Float? = null,
val trackPeak: Float? = null,
val albumPeak: Float? = null,
)
internal data class ReplayGainSettings(
val mode: ReplayGainMode,
val preampDb: Float,
)
internal fun Song.replayGain(): ReplayGain =
ReplayGain(
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
)
internal fun replayGainLinearGain(
gain: ReplayGain,
settings: ReplayGainSettings,
): Float {
val (requestedDb, peak) =
when (settings.mode) {
ReplayGainMode.Off -> return 1f
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (gain.trackPeak ?: gain.albumPeak)
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (gain.albumPeak ?: gain.trackPeak)
}
val gainDb = requestedDb ?: return 1f
val requested = gainDb + settings.preampDb
val applied = if (peak != null && peak > 0f) min(requested, maxSafeGainDb(peak)) else requested
return 10f.pow(applied / 20f)
}
// A peak of 1.0 is full scale, so anything above it leaves no headroom and this goes negative. A
// peak of zero or less carries no peak data (gonic sends 0 when only album gain is known), which
// is why the caller ignores it rather than treating it as silence.
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
@@ -0,0 +1,24 @@
package com.subtracks.playback
import com.subtracks.data.prefs.ReplayGainMode
internal class ReplayGainController(
private val apply: (Float) -> Unit,
) {
private var track = ReplayGain()
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
fun setTrack(value: ReplayGain?) {
track = value ?: ReplayGain()
applyGain()
}
fun setSettings(value: ReplayGainSettings) {
settings = value
applyGain()
}
private fun applyGain() {
apply(replayGainLinearGain(track, settings))
}
}
@@ -0,0 +1,48 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.BaseAudioProcessor
import androidx.media3.common.util.UnstableApi
import java.nio.ByteBuffer
import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
internal class ReplayGainProcessor : BaseAudioProcessor() {
@Volatile
private var linearGain = 1f
fun setLinearGain(value: Float) {
linearGain = value
}
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
override fun queueInput(inputBuffer: ByteBuffer) {
val output = replaceOutputBuffer(inputBuffer.remaining())
val gain = linearGain
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_16BIT -> {
while (inputBuffer.remaining() >= 2) {
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
}
}
C.ENCODING_PCM_FLOAT -> {
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
}
else -> {
output.put(inputBuffer)
}
}
inputBuffer.position(inputBuffer.limit())
output.flip()
}
private companion object {
const val MIN_SAMPLE = -32768
const val MAX_SAMPLE = 32767
}
}
@@ -188,6 +188,31 @@ class UserPreferencesTest {
prefs.setVerboseLogging(true)
assertEquals(true, prefs.verboseLogging().first())
file.delete()
}
@Test
fun replayGainDefaultsToTrackModeAndRoundTrips() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainMode(ReplayGainMode.Album)
prefs.setReplayGainPreampDb(3.5f)
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(99f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(-99f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
file.delete()
}
}
@@ -0,0 +1,49 @@
package com.subtracks.playback
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
class ReplayGainControllerTest {
private val applied = mutableListOf<Float>()
private val controller = ReplayGainController { applied += it }
@Test
fun appliesTheGainForTheCurrentTrack() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.setTrack(ReplayGain(trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
@Test
fun recomputesWhenTheSettingsChange() {
controller.setTrack(ReplayGain(trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 6.0206f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun switchingOffRestoresUnityGain() {
controller.setTrack(ReplayGain(trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.setSettings(ReplayGainSettings(ReplayGainMode.Off, 0f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun aTrackWithoutGainPlaysAtUnity() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.setTrack(null)
assertEquals(1f, applied.last(), TOLERANCE)
}
private companion object {
const val TOLERANCE = 0.001f
}
}
@@ -0,0 +1,111 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
@OptIn(UnstableApi::class)
class ReplayGainProcessorTest {
@Test
fun scalesSixteenBitSamples() {
val processor = processor(gain = 0.5f)
processor.queueInput(sixteenBit(100, -200, 1000, -1000))
assertEquals(listOf(50, -100, 500, -500), readShorts(processor.getOutput()))
}
@Test
fun passesSamplesThroughAtUnityGain() {
val processor = processor(gain = 1f)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun saturatesInsteadOfWrappingWhenTheGainOverflows() {
val processor = processor(gain = 4f)
processor.queueInput(sixteenBit(20000, -20000))
assertEquals(listOf(32767, -32768), readShorts(processor.getOutput()))
}
@Test
fun scalesFloatSamples() {
val processor = processor(gain = 0.5f, encoding = C.ENCODING_PCM_FLOAT)
processor.queueInput(floatBuffer(1f, -0.5f))
assertEquals(listOf(0.5f, -0.25f), readFloats(processor.getOutput()))
}
@Test
fun passesAnUnsupportedEncodingThroughUnchanged() {
val processor = processor(gain = 2f, encoding = C.ENCODING_PCM_24BIT)
processor.queueInput(ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
assertEquals(listOf<Byte>(1, 2, 3), readBytes(processor.getOutput()))
}
@Test
fun keepsTheInputFormatSoTheSinkKeepsItInThePipeline() {
val processor = ReplayGainProcessor()
val configured = processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
assertEquals(C.ENCODING_PCM_16BIT, configured.encoding)
assertEquals(true, processor.isActive())
}
private fun processor(
gain: Float,
encoding: Int = C.ENCODING_PCM_16BIT,
): ReplayGainProcessor =
ReplayGainProcessor().apply {
setLinearGain(gain)
configure(AudioFormat(SAMPLE_RATE, CHANNELS, encoding))
flush(StreamMetadata.DEFAULT)
}
private fun sixteenBit(vararg samples: Int): ByteBuffer =
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putShort(it.toShort()) }
flip()
}
private fun floatBuffer(vararg samples: Float): ByteBuffer =
ByteBuffer.allocate(samples.size * 4).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putFloat(it) }
flip()
}
private fun readShorts(buffer: ByteBuffer): List<Int> =
buildList {
while (buffer.remaining() >= 2) add(buffer.short.toInt())
}
private fun readFloats(buffer: ByteBuffer): List<Float> =
buildList {
while (buffer.remaining() >= 4) add(buffer.float)
}
private fun readBytes(buffer: ByteBuffer): List<Byte> =
buildList {
while (buffer.hasRemaining()) add(buffer.get())
}
private companion object {
const val SAMPLE_RATE = 44_100
const val CHANNELS = 2
}
}
@@ -0,0 +1,149 @@
package com.subtracks.playback
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
class ReplayGainTest {
@Test
fun trackModeAppliesTheTrackGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun trackModeFallsBackToTheAlbumGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(albumGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun albumModeAppliesTheAlbumGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Album, 0f),
),
TOLERANCE,
)
}
@Test
fun albumModeFallsBackToTheTrackGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Album, 0f),
),
TOLERANCE,
)
}
@Test
fun offAppliesUnityGain() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Off, 0f),
),
TOLERANCE,
)
}
@Test
fun aSongWithoutGainPlaysAtUnity() {
assertEquals(
1f,
replayGainLinearGain(ReplayGain(), ReplayGainSettings(ReplayGainMode.Track, 3f)),
TOLERANCE,
)
}
@Test
fun thePreampShiftsTheAppliedGain() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
),
TOLERANCE,
)
}
@Test
fun thePeakCapsAPositiveGain() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakCapsUsingTheAlbumPeakWhenTheTrackPeakIsMissing() {
assertEquals(
2f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, albumPeak = 0.5f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun aMissingOrZeroPeakDoesNotCap() {
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
val expected = 3.9810717f
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
}
@Test
fun aTruePeakAboveFullScaleCapsBelowUnity() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = 6f, trackPeak = 2f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
@Test
fun thePeakNeverLowersAnAttenuation() {
assertEquals(
0.31622776f,
replayGainLinearGain(
ReplayGain(trackGain = -10f, trackPeak = 0.9f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
}
private companion object {
const val TOLERANCE = 0.001f
}
}