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