mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +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:
@@ -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