mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user