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
@@ -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
}
}