diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 87c00126..d12c7771 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -163,13 +163,15 @@ class UserPreferences( private val replayGainModeKey = stringPreferencesKey("replay_gain_mode") - fun replayGainPreampDb(): Flow = - store.data.map { prefs -> (prefs[replayGainPreampKey] ?: 0f).coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) } + fun replayGainPreampDb(): Flow = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() } suspend fun setReplayGainPreampDb(value: Float) { - store.edit { prefs -> prefs[replayGainPreampKey] = value.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) } + store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() } } + private fun Float?.sanitizedPreampDb(): Float = + this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f + private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db") fun offlineMode(): Flow = store.data.map { prefs -> prefs[offlineModeKey] ?: false } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 1a48ad77..ab0f6f00 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -20,10 +20,7 @@ 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 @@ -113,24 +110,10 @@ class PlaybackService : MediaSessionService() { .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) + .setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor)) .setAudioAttributes( AudioAttributes .Builder() @@ -204,7 +187,11 @@ class PlaybackService : MediaSessionService() { session?.setMediaButtonPreferences(listOf(starButton(false))) songJob = scope.launch { - val sourceId = sourceRepository?.activeSourceIdOnce() ?: return@launch + val sourceId = sourceRepository?.activeSourceIdOnce() + if (sourceId == null) { + replayGain.setTrack(null) + return@launch + } library.song(sourceId, songId).collect { song -> if (currentSongId != songId) return@collect starred = song?.starred != null diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt index b6cf628a..c8139f5b 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt @@ -38,11 +38,11 @@ internal fun replayGainLinearGain( } 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) + val cap = peak?.takeIf { it.isFinite() && it > 0f }?.let(::maxSafeGainDb) + val applied = if (cap != null) min(requested, cap) else requested + return 10f.pow(applied / 20f).takeIf { it.isFinite() } ?: 1f } -// 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. +// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means +// "no peak data" and is ignored rather than treated as silence. private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak) diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt index e8f75fb0..6ac8e415 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt @@ -21,7 +21,7 @@ internal class ReplayGainProcessor : BaseAudioProcessor() { override fun queueInput(inputBuffer: ByteBuffer) { val output = replaceOutputBuffer(inputBuffer.remaining()) - val gain = linearGain + val gain = linearGain.takeIf { it.isFinite() } ?: 1f when (inputAudioFormat.encoding) { C.ENCODING_PCM_16BIT -> { while (inputBuffer.remaining() >= 2) { diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGainRenderersFactory.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGainRenderersFactory.kt new file mode 100644 index 00000000..628a9486 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGainRenderersFactory.kt @@ -0,0 +1,29 @@ +package com.subtracks.playback + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink + +@OptIn(UnstableApi::class) +internal class ReplayGainRenderersFactory( + context: Context, + private val processor: ReplayGainProcessor, +) : DefaultRenderersFactory(context) { + // Mirrors the stock DefaultRenderersFactory.buildAudioSink, plus the processor. setAudioProcessors + // keeps the default chain and prepends ours, but DefaultAudioSink drops the whole chain when + // float output is enabled, so enabling that would silently disable replay gain. + public override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioOutputPlaybackParameters: Boolean, + ): AudioSink = + DefaultAudioSink + .Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters) + .setAudioProcessors(arrayOf(processor)) + .build() +} diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index e21aa1f6..58aa5252 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -213,6 +213,9 @@ class UserPreferencesTest { prefs.setReplayGainPreampDb(-99f) assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f) + prefs.setReplayGainPreampDb(Float.NaN) + assertEquals(0f, prefs.replayGainPreampDb().first(), 0f) + file.delete() } } diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt index a75d069b..39703c58 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt @@ -57,6 +57,24 @@ class ReplayGainProcessorTest { assertEquals(listOf(1, 2, 3), readBytes(processor.getOutput())) } + @Test + fun passesSamplesThroughWhenTheGainIsNaN() { + val processor = processor(gain = Float.NaN) + + processor.queueInput(sixteenBit(1234, -4321)) + + assertEquals(listOf(1234, -4321), readShorts(processor.getOutput())) + } + + @Test + fun passesSamplesThroughWhenTheGainIsInfinite() { + val processor = processor(gain = Float.POSITIVE_INFINITY) + + processor.queueInput(sixteenBit(1234, -4321)) + + assertEquals(listOf(1234, -4321), readShorts(processor.getOutput())) + } + @Test fun keepsTheInputFormatSoTheSinkKeepsItInThePipeline() { val processor = ReplayGainProcessor() diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainRenderersFactoryTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainRenderersFactoryTest.kt new file mode 100644 index 00000000..36287b96 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainRenderersFactoryTest.kt @@ -0,0 +1,34 @@ +package com.subtracks.playback + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.audio.AudioProcessorChain +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(UnstableApi::class) +@RunWith(AndroidJUnit4::class) +class ReplayGainRenderersFactoryTest { + @Test + fun installsTheGainProcessorInTheAudioSink() { + val context = ApplicationProvider.getApplicationContext() + val processor = ReplayGainProcessor() + + val sink = ReplayGainRenderersFactory(context, processor).buildAudioSink(context, false, false) + + // DefaultAudioSink exposes no accessor for the chain it was built with, so this is the only + // way to assert the processor is actually installed rather than merely constructed. + val chain = + DefaultAudioSink::class.java + .getDeclaredField("audioProcessorChain") + .apply { isAccessible = true } + .get(sink) as AudioProcessorChain + + assertEquals(true, chain.audioProcessors.any { it === processor }) + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt index 9d1abe89..8617f0af 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt @@ -143,6 +143,26 @@ class ReplayGainTest { ) } + @Test + fun aNonFinitePeakDoesNotCap() { + assertEquals( + 3.9810717f, + replayGainLinearGain( + ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY), + ReplayGainSettings(ReplayGainMode.Track, 0f), + ), + TOLERANCE, + ) + } + + @Test + fun playsAtUnityWhenTheGainIsNotFinite() { + val settings = ReplayGainSettings(ReplayGainMode.Track, 0f) + + assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.POSITIVE_INFINITY), settings), TOLERANCE) + assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = Float.NaN), settings), TOLERANCE) + } + private companion object { const val TOLERANCE = 0.001f }