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
@@ -31,7 +31,8 @@ internal fun replayGainLinearGain(
settings: ReplayGainSettings,
): Float {
// 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.
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
// headroom without a peak, so a peakless track is only ever attenuated.
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
val (requestedDb, peak) =
@@ -42,7 +43,8 @@ internal fun replayGainLinearGain(
}
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
val requested = gainDb + settings.preampDb
val applied = peak?.let { min(requested, maxSafeGainDb(it)) } ?: requested
val maxGainDb = peak?.let(::maxSafeGainDb) ?: 0f
val applied = min(requested, maxGainDb)
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
}
@@ -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),
+1 -1
View File
@@ -98,7 +98,7 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated.
## Data model