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 407aa0b7..87c00126 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -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 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 = + 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 = + 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 = store.data.map { prefs -> prefs[offlineModeKey] ?: false } suspend fun setOfflineMode(enabled: Boolean) { diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index 8ecdfcc8..1a48ad77 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -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 = sources library = GlobalContext.get().get() + val preferences = GlobalContext.get().get() 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()) } } } diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt new file mode 100644 index 00000000..b6cf628a --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt @@ -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) diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt new file mode 100644 index 00000000..8e8f185a --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGainController.kt @@ -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)) + } +} diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt new file mode 100644 index 00000000..e8f75fb0 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGainProcessor.kt @@ -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 + } +} 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 941163ee..e21aa1f6 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -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() } } diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt new file mode 100644 index 00000000..aee366b8 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt @@ -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() + 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 + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt new file mode 100644 index 00000000..a75d069b --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt @@ -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(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 = + buildList { + while (buffer.remaining() >= 2) add(buffer.short.toInt()) + } + + private fun readFloats(buffer: ByteBuffer): List = + buildList { + while (buffer.remaining() >= 4) add(buffer.float) + } + + private fun readBytes(buffer: ByteBuffer): List = + buildList { + while (buffer.hasRemaining()) add(buffer.get()) + } + + private companion object { + const val SAMPLE_RATE = 44_100 + const val CHANNELS = 2 + } +} diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt new file mode 100644 index 00000000..9d1abe89 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt @@ -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 + } +}