playback: only attenuate a gain with no peak

A peak is the only headroom check the gain code has. When the server sends a gain without one (Navidrome does this for Opus R128 gains, which carry no peak tags by design), a positive gain was applied unchecked and the 16-bit sink saturated.

Cap a peakless gain at unity instead: attenuation still applies, but a boost needs a peak to prove it fits. The cap is unchanged when a peak is present.

Update the tests that expected a peakless boost, and cover the positive preamp that can no longer lift a peakless track and the attenuation that still applies.
This commit is contained in:
austinried
2026-10-18 13:35:27 +00:00
parent 746e2319b9
commit 87dbabb496
5 changed files with 43 additions and 14 deletions
@@ -53,7 +53,9 @@ class ReplayGainControllerTest {
fun theLatestItemLeavesItsGainApplied() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = 6.0206f))
controller.applyItem(item("a", trackGain = 6.0206f, trackPeak = 0.5f))
assertEquals(2f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
@@ -62,6 +64,7 @@ class ReplayGainControllerTest {
private fun item(
id: String,
trackGain: Float?,
trackPeak: Float? = null,
) = queueMediaItem(
QueueItem(
id = id,
@@ -69,7 +72,7 @@ class ReplayGainControllerTest {
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
),
local = null,
stream = "http://server/$id",
@@ -128,7 +128,7 @@ class ReplayGainProcessorTest {
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
processor.flush(streamMetadata(item("b", trackGain = 6.0206f)))
processor.flush(streamMetadata(item("b", trackGain = 6.0206f, trackPeak = 0.5f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(2000), readShorts(processor.getOutput()))
}
@@ -146,6 +146,7 @@ class ReplayGainProcessorTest {
private fun item(
id: String,
trackGain: Float,
trackPeak: Float? = null,
): MediaItem =
queueMediaItem(
QueueItem(
@@ -154,7 +155,7 @@ class ReplayGainProcessorTest {
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
),
local = null,
stream = "http://server/$id",
@@ -76,11 +76,35 @@ class ReplayGainTest {
@Test
fun thePreampShiftsTheAppliedGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -12.0412f),
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
),
TOLERANCE,
)
}
@Test
fun aPositivePreampCannotBoostWithoutAPeak() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 12f),
),
TOLERANCE,
)
}
@Test
fun aMissingPeakStillAppliesAnAttenuation() {
assertEquals(
0.31622776f,
replayGainLinearGain(
ReplayGain(trackGain = -10f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
@@ -111,12 +135,11 @@ class ReplayGainTest {
}
@Test
fun aMissingOrZeroPeakDoesNotCap() {
fun aMissingOrZeroPeakAllowsNoBoost() {
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)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
}
@Test
@@ -144,9 +167,9 @@ class ReplayGainTest {
}
@Test
fun aNonFinitePeakDoesNotCap() {
fun aNonFinitePeakAllowsNoBoost() {
assertEquals(
3.9810717f,
1f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
ReplayGainSettings(ReplayGainMode.Track, 0f),