From 87dbabb496ce3c2084b673585c941746da6340c5 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:35:27 +0000 Subject: [PATCH] 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. --- .../com/subtracks/playback/ReplayGain.kt | 6 ++- .../playback/ReplayGainControllerTest.kt | 7 +++- .../playback/ReplayGainProcessorTest.kt | 5 ++- .../com/subtracks/playback/ReplayGainTest.kt | 37 +++++++++++++++---- docs/architecture.md | 2 +- 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt index f3157e5b..b0287fa7 100644 --- a/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt +++ b/app/src/main/kotlin/com/subtracks/playback/ReplayGain.kt @@ -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 } diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt index 817bea3b..9db9e099 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainControllerTest.kt @@ -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", diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt index f5b761e4..56c41c1f 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainProcessorTest.kt @@ -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", diff --git a/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt index 4f5fb900..79290d48 100644 --- a/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/ReplayGainTest.kt @@ -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), diff --git a/docs/architecture.md b/docs/architecture.md index f888d21a..7fcf11f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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