From bce03a5833262fbc5bd2e67f01cb6b4cfdc4ece9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 8 Jun 2023 09:47:47 -0600 Subject: [PATCH] playback: do not dynamically extract adjustments Do not extract ReplayGain adjustments on the fly, instead doing them as we go along. This prevents an issue where the ReplayGain information would only be applied a short period after playback start, which is heavily jarring. --- .../java/org/oxycblt/auxio/music/Music.kt | 3 + .../auxio/music/cache/CacheDatabase.kt | 22 ++- .../oxycblt/auxio/music/cache/CacheModule.kt | 1 - .../auxio/music/device/DeviceMusicImpl.kt | 14 ++ .../oxycblt/auxio/music/device/RawMusic.kt | 4 + .../oxycblt/auxio/music/metadata/TagWorker.kt | 35 +++++ .../auxio/playback/replaygain/ReplayGain.kt | 8 + .../replaygain/ReplayGainAudioProcessor.kt | 141 ++++-------------- .../auxio/playback/system/PlaybackService.kt | 3 +- .../java/org/oxycblt/auxio/util/LangUtil.kt | 7 + 10 files changed, 113 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index a9783cfae..edb5a69e1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.ReleaseType +import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.auxio.util.toUuidOrNull @@ -255,6 +256,8 @@ interface Song : Music { val size: Long /** The duration of the audio file, in milliseconds. */ val durationMs: Long + /** The ReplayGain adjustment to apply during playback. */ + val replayGainAdjustment: ReplayGainAdjustment? /** The date the audio file was added to the device, as a unix epoch timestamp. */ val dateAdded: Long /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 2cf6d33c0..7d1ac68d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,19 +32,19 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 27, exportSchema = false) +@Database(entities = [CachedSong::class], version = 32, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } @Dao interface CachedSongsDao { - @Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List - @Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs() + @Query("SELECT * FROM CachedSong") suspend fun readSongs(): List + @Query("DELETE FROM CachedSong") suspend fun nukeSongs() @Insert suspend fun insertSongs(songs: List) } -@Entity(tableName = CachedSong.TABLE_NAME) +@Entity @TypeConverters(CachedSong.Converters::class) data class CachedSong( /** @@ -60,6 +60,10 @@ data class CachedSong( var size: Long? = null, /** @see RawSong */ var durationMs: Long, + /** @see RawSong.replayGainTrackAdjustment */ + val replayGainTrackAdjustment: Float?, + /** @see RawSong.replayGainAlbumAdjustment */ + val replayGainAlbumAdjustment: Float?, /** @see RawSong.musicBrainzId */ var musicBrainzId: String? = null, /** @see RawSong.name */ @@ -97,7 +101,7 @@ data class CachedSong( /** @see RawSong.genreNames */ var genreNames: List = listOf() ) { - fun copyToRaw(rawSong: RawSong): CachedSong { + fun copyToRaw(rawSong: RawSong) { rawSong.musicBrainzId = musicBrainzId rawSong.name = name rawSong.sortName = sortName @@ -105,6 +109,9 @@ data class CachedSong( rawSong.size = size rawSong.durationMs = durationMs + rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment + rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment + rawSong.track = track rawSong.disc = disc rawSong.subtitle = subtitle @@ -124,7 +131,6 @@ data class CachedSong( rawSong.albumArtistSortNames = albumArtistSortNames rawSong.genreNames = genreNames - return this } object Converters { @@ -141,8 +147,6 @@ data class CachedSong( } companion object { - const val TABLE_NAME = "cached_songs" - fun fromRaw(rawSong: RawSong) = CachedSong( mediaStoreId = @@ -155,6 +159,8 @@ data class CachedSong( sortName = rawSong.sortName, size = rawSong.size, durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }, + replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment, + replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment, track = rawSong.track, disc = rawSong.disc, subtitle = rawSong.subtitle, diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt index 82e70f217..281cb6f4a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt @@ -43,7 +43,6 @@ class CacheRoomModule { Room.databaseBuilder( context.applicationContext, CacheDatabase::class.java, "music_cache.db") .fallbackToDestructiveMigration() - .fallbackToDestructiveMigrationFrom(0) .fallbackToDestructiveMigrationOnDowngrade() .build() diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index e3ce99928..022d68c86 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -37,6 +37,8 @@ import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.parseId3GenreNames import org.oxycblt.auxio.music.metadata.parseMultiValue +import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment +import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.unlikelyToBeNull @@ -88,6 +90,18 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son fromFormat = null) override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" } override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" } + override val replayGainAdjustment = + if (rawSong.replayGainTrackAdjustment != null && + rawSong.replayGainAlbumAdjustment != null) { + ReplayGainAdjustment( + track = unlikelyToBeNull(rawSong.replayGainTrackAdjustment), + album = unlikelyToBeNull(rawSong.replayGainAlbumAdjustment)) + } else { + null + } + .also { + logD("${rawSong.replayGainTrackAdjustment} ${rawSong.replayGainAlbumAdjustment}}") + } override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" } private var _album: AlbumImpl? = null override val album: Album diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 46c84fc51..57539ec09 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -51,6 +51,10 @@ data class RawSong( var durationMs: Long? = null, /** @see Song.mimeType */ var extensionMimeType: String? = null, + /** @see Song.replayGainAdjustment */ + var replayGainTrackAdjustment: Float? = null, + /** @see Song.replayGainAdjustment */ + var replayGainAlbumAdjustment: Float? = null, /** @see Music.UID */ var musicBrainzId: String? = null, /** @see Music.name */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index e1501bede..691f8997a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -174,6 +174,13 @@ private class TagWorkerImpl( rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } } + // ReplayGain information + textFrames["TXXX:replaygain_track_gain"]?.parseReplayGainAdjustment()?.let { + rawSong.replayGainTrackAdjustment = it + } + textFrames["TXXX:replaygain_album_gain"]?.parseReplayGainAdjustment()?.let { + rawSong.replayGainAlbumAdjustment = it + } } private fun parseId3v23Date(textFrames: Map>): Date? { @@ -271,10 +278,38 @@ private class TagWorkerImpl( rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } } + + // ReplayGain information + // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom + // replaygain_*_gain tag, but opus has it's own "r128_*_gain" ReplayGain specification, + // which requires dividing the adjustment by 256 to get the gain. This is used alongside + // the base adjustment intrinsic to the format to create the normalized adjustment. This is + // normally the only tag used for opus files, but some software still writes replay gain + // tags anyway. + (comments["r128_track_gain"]?.parseReplayGainAdjustment()?.div(256) + ?: comments["replaygain_track_gain"]?.parseReplayGainAdjustment()) + ?.let { rawSong.replayGainTrackAdjustment = it } + (comments["r128_album_gain"]?.parseReplayGainAdjustment()?.div(256) + ?: comments["replaygain_album_gain"]?.parseReplayGainAdjustment()) + ?.let { rawSong.replayGainAlbumAdjustment = it } } + /** + * Parse a ReplayGain adjustment into a float value. + * + * @return A parsed adjustment float, or null if the adjustment had invalid formatting. + */ + private fun List.parseReplayGainAdjustment() = + first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull() + private companion object { val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists") val COMPILATION_RELEASE_TYPES = listOf("compilation") + + /** + * Matches non-float information from ReplayGain adjustments. Derived from vanilla music: + * https://github.com/vanilla-music/vanilla + */ + val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt index 0b1855a50..8a85b73e2 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGain.kt @@ -50,6 +50,14 @@ enum class ReplayGainMode { } } +/** + * Represents a ReplayGain adjustment to apply during song playback. + * + * @param track The track-specific adjustment that should be applied. + * @param album A more general album-specific adjustment that should be applied. + */ +data class ReplayGainAdjustment(val track: Float, val album: Float) + /** * The current ReplayGain pre-amp configuration. * diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index ab86651e0..d74e45543 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -21,15 +21,16 @@ package org.oxycblt.auxio.playback.replaygain import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Player -import androidx.media3.common.Tracks import androidx.media3.common.audio.AudioProcessor import androidx.media3.exoplayer.audio.BaseAudioProcessor import java.nio.ByteBuffer import javax.inject.Inject import kotlin.math.pow import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.metadata.TextTags +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD @@ -48,9 +49,7 @@ class ReplayGainAudioProcessor constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings -) : BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { - private var lastFormat: Format? = null - +) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { private var volume = 1f set(value) { field = value @@ -58,51 +57,38 @@ constructor( flush() } - /** - * Add this instance to the components required for it to function correctly. - * - * @param player The [Player] to attach to. Should already have this instance as an audio - * processor. - */ - fun addToListeners(player: Player) { - player.addListener(this) + init { + playbackManager.addListener(this) playbackSettings.registerListener(this) } - /** - * Remove this instance from the components required for it to function correctly. - * - * @param player The [Player] to detach from. Should already have this instance as an audio - * processor. - */ - fun releaseFromListeners(player: Player) { - player.removeListener(this) + /** Remove this instance from the components required for it to function correctly. */ + fun release() { + playbackManager.removeListener(this) playbackSettings.unregisterListener(this) } // --- OVERRIDES --- - override fun onTracksChanged(tracks: Tracks) { - super.onTracksChanged(tracks) - // Try to find the currently playing track so we can update the ReplayGain adjustment - // based on it. - for (group in tracks.groups) { - if (group.isSelected) { - for (i in 0 until group.length) { - if (group.isTrackSelected(i)) { - applyReplayGain(group.getTrackFormat(i)) - return - } - } - } + override fun onIndexMoved(queue: Queue) { + logD("Index moved, updating current song") + applyReplayGain(queue.currentSong) + } + + override fun onQueueChanged(queue: Queue, change: Queue.Change) { + // Other types of queue changes preserve the current song. + if (change.type == Queue.Change.Type.SONG) { + applyReplayGain(queue.currentSong) } - // Nothing selected, apply nothing - applyReplayGain(null) + } + override fun onNewPlayback(queue: Queue, parent: MusicParent?) { + logD("New playback started, updating playback information") + applyReplayGain(queue.currentSong) } override fun onReplayGainSettingsChanged() { // ReplayGain config changed, we need to set it up again. - applyReplayGain(lastFormat) + applyReplayGain(playbackManager.queue.currentSong) } // --- REPLAYGAIN PARSING --- @@ -110,11 +96,11 @@ constructor( /** * Updates the volume adjustment based on the given [Format]. * - * @param format The [Format] of the currently playing track, or null if nothing is playing. + * @param song The [Format] of the currently playing track, or null if nothing is playing. */ - private fun applyReplayGain(format: Format?) { - lastFormat = format - val gain = parseReplayGain(format ?: return) + private fun applyReplayGain(song: Song?) { + logD("Applying ReplayGain adjustment for $song") + val gain = song?.replayGainAdjustment val preAmp = playbackSettings.replayGainPreAmp val adjust = @@ -167,58 +153,6 @@ constructor( volume = 10f.pow(adjust / 20f) } - /** - * Parse ReplayGain information from the given [Format]. - * - * @param format The [Format] to parse. - * @return A [Adjustment] adjustment, or null if there were no valid adjustments. - */ - private fun parseReplayGain(format: Format): Adjustment? { - val textTags = TextTags(format.metadata ?: return null) - var trackGain = 0f - var albumGain = 0f - - // Most ReplayGain tags are formatted as a simple decibel adjustment in a custom - // replaygain_*_gain tag. - textTags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"] - ?.run { first().parseReplayGainAdjustment() } - ?.let { trackGain = it } - textTags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"] - ?.run { first().parseReplayGainAdjustment() } - ?.let { albumGain = it } - textTags.vorbis[TAG_RG_ALBUM_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { trackGain = it } - textTags.vorbis[TAG_RG_TRACK_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { albumGain = it } - - // Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the - // adjustment by 256 to get the gain. This is used alongside the base adjustment - // intrinsic to the format to create the normalized adjustment. This is normally the only - // tag used for opus files, but some software still writes replay gain tags anyway. - textTags.vorbis[TAG_R128_TRACK_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { trackGain = it / 256f } - textTags.vorbis[TAG_R128_ALBUM_GAIN] - ?.run { first().parseReplayGainAdjustment() } - ?.let { albumGain = it / 256f } - - return if (trackGain != 0f || albumGain != 0f) { - Adjustment(trackGain, albumGain) - } else { - null - } - } - - /** - * Parse a ReplayGain adjustment into a float value. - * - * @return A parsed adjustment float, or null if the adjustment had invalid formatting. - */ - private fun String.parseReplayGainAdjustment() = - replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull() - // --- AUDIO PROCESSOR IMPLEMENTATION --- override fun onConfigure( @@ -284,25 +218,4 @@ constructor( put(short.toByte()) put(short.toInt().shr(8).toByte()) } - - /** - * The resolved ReplayGain adjustment for a file. - * - * @param track The track adjustment (in dB), or 0 if it is not present. - * @param album The album adjustment (in dB), or 0 if it is not present. - */ - private data class Adjustment(val track: Float, val album: Float) - - private companion object { - const val TAG_RG_TRACK_GAIN = "replaygain_track_gain" - const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain" - const val TAG_R128_TRACK_GAIN = "r128_track_gain" - const val TAG_R128_ALBUM_GAIN = "r128_album_gain" - - /** - * Matches non-float information from ReplayGain adjustments. Derived from vanilla music: - * https://github.com/vanilla-music/vanilla - */ - val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX by lazy { Regex("[^\\d.-]") } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 848d47b4d..26629030b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -144,7 +144,6 @@ class PlaybackService : true) .build() .also { it.addListener(this) } - replayGainProcessor.addToListeners(player) foregroundManager = ForegroundManager(this) // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. @@ -196,7 +195,7 @@ class PlaybackService : widgetComponent.release() mediaSessionComponent.release() - replayGainProcessor.releaseFromListeners(player) + replayGainProcessor.release() player.release() if (openAudioEffectSession) { // Make sure to close the audio session when we release the player. diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index 51835d68c..3ad2f8eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -50,6 +50,13 @@ fun Int.nonZeroOrNull() = if (this > 0) this else null */ fun Long.nonZeroOrNull() = if (this > 0) this else null +/** + * Aliases a check to ensure that the given number is non-zero. + * + * @return The same number if it's non-zero, null otherwise. + */ +fun Float.nonZeroOrNull() = if (this > 0) this else null + /** * Aliases a check to ensure a given value is in a specified range. *