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.
This commit is contained in:
Alexander Capehart 2023-06-08 09:47:47 -06:00
parent 5ab46ba5d1
commit bce03a5833
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 113 additions and 125 deletions

View file

@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType 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.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.toUuidOrNull
@ -255,6 +256,8 @@ interface Song : Music {
val size: Long val size: Long
/** The duration of the audio file, in milliseconds. */ /** The duration of the audio file, in milliseconds. */
val durationMs: Long 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. */ /** The date the audio file was added to the device, as a unix epoch timestamp. */
val dateAdded: Long val dateAdded: Long
/** /**

View file

@ -32,19 +32,19 @@ import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped 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 class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): CachedSongsDao
} }
@Dao @Dao
interface CachedSongsDao { interface CachedSongsDao {
@Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List<CachedSong> @Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
@Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs() @Query("DELETE FROM CachedSong") suspend fun nukeSongs()
@Insert suspend fun insertSongs(songs: List<CachedSong>) @Insert suspend fun insertSongs(songs: List<CachedSong>)
} }
@Entity(tableName = CachedSong.TABLE_NAME) @Entity
@TypeConverters(CachedSong.Converters::class) @TypeConverters(CachedSong.Converters::class)
data class CachedSong( data class CachedSong(
/** /**
@ -60,6 +60,10 @@ data class CachedSong(
var size: Long? = null, var size: Long? = null,
/** @see RawSong */ /** @see RawSong */
var durationMs: Long, var durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float?,
/** @see RawSong.replayGainAlbumAdjustment */
val replayGainAlbumAdjustment: Float?,
/** @see RawSong.musicBrainzId */ /** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null, var musicBrainzId: String? = null,
/** @see RawSong.name */ /** @see RawSong.name */
@ -97,7 +101,7 @@ data class CachedSong(
/** @see RawSong.genreNames */ /** @see RawSong.genreNames */
var genreNames: List<String> = listOf() var genreNames: List<String> = listOf()
) { ) {
fun copyToRaw(rawSong: RawSong): CachedSong { fun copyToRaw(rawSong: RawSong) {
rawSong.musicBrainzId = musicBrainzId rawSong.musicBrainzId = musicBrainzId
rawSong.name = name rawSong.name = name
rawSong.sortName = sortName rawSong.sortName = sortName
@ -105,6 +109,9 @@ data class CachedSong(
rawSong.size = size rawSong.size = size
rawSong.durationMs = durationMs rawSong.durationMs = durationMs
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment
rawSong.track = track rawSong.track = track
rawSong.disc = disc rawSong.disc = disc
rawSong.subtitle = subtitle rawSong.subtitle = subtitle
@ -124,7 +131,6 @@ data class CachedSong(
rawSong.albumArtistSortNames = albumArtistSortNames rawSong.albumArtistSortNames = albumArtistSortNames
rawSong.genreNames = genreNames rawSong.genreNames = genreNames
return this
} }
object Converters { object Converters {
@ -141,8 +147,6 @@ data class CachedSong(
} }
companion object { companion object {
const val TABLE_NAME = "cached_songs"
fun fromRaw(rawSong: RawSong) = fun fromRaw(rawSong: RawSong) =
CachedSong( CachedSong(
mediaStoreId = mediaStoreId =
@ -155,6 +159,8 @@ data class CachedSong(
sortName = rawSong.sortName, sortName = rawSong.sortName,
size = rawSong.size, size = rawSong.size,
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }, durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,
track = rawSong.track, track = rawSong.track,
disc = rawSong.disc, disc = rawSong.disc,
subtitle = rawSong.subtitle, subtitle = rawSong.subtitle,

View file

@ -43,7 +43,6 @@ class CacheRoomModule {
Room.databaseBuilder( Room.databaseBuilder(
context.applicationContext, CacheDatabase::class.java, "music_cache.db") context.applicationContext, CacheDatabase::class.java, "music_cache.db")
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.fallbackToDestructiveMigrationFrom(0)
.fallbackToDestructiveMigrationOnDowngrade() .fallbackToDestructiveMigrationOnDowngrade()
.build() .build()

View file

@ -37,6 +37,8 @@ import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.parseId3GenreNames import org.oxycblt.auxio.music.metadata.parseId3GenreNames
import org.oxycblt.auxio.music.metadata.parseMultiValue 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.nonZeroOrNull
import org.oxycblt.auxio.util.toUuidOrNull import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -88,6 +90,18 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
fromFormat = null) fromFormat = null)
override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" } override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" }
override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" } 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" } override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" }
private var _album: AlbumImpl? = null private var _album: AlbumImpl? = null
override val album: Album override val album: Album

View file

@ -51,6 +51,10 @@ data class RawSong(
var durationMs: Long? = null, var durationMs: Long? = null,
/** @see Song.mimeType */ /** @see Song.mimeType */
var extensionMimeType: String? = null, var extensionMimeType: String? = null,
/** @see Song.replayGainAdjustment */
var replayGainTrackAdjustment: Float? = null,
/** @see Song.replayGainAdjustment */
var replayGainAlbumAdjustment: Float? = null,
/** @see Music.UID */ /** @see Music.UID */
var musicBrainzId: String? = null, var musicBrainzId: String? = null,
/** @see Music.name */ /** @see Music.name */

View file

@ -174,6 +174,13 @@ private class TagWorkerImpl(
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } 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<String, List<String>>): Date? { private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
@ -271,10 +278,38 @@ private class TagWorkerImpl(
rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS } rawSong.albumArtistNames.ifEmpty { COMPILATION_ALBUM_ARTISTS }
rawSong.releaseTypes = rawSong.releaseTypes.ifEmpty { COMPILATION_RELEASE_TYPES } 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<String>.parseReplayGainAdjustment() =
first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()
private companion object { private companion object {
val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists") val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists")
val COMPILATION_RELEASE_TYPES = listOf("compilation") 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.-]") }
} }
} }

View file

@ -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. * The current ReplayGain pre-amp configuration.
* *

View file

@ -21,15 +21,16 @@ package org.oxycblt.auxio.playback.replaygain
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.audio.AudioProcessor import androidx.media3.common.audio.AudioProcessor
import androidx.media3.exoplayer.audio.BaseAudioProcessor import androidx.media3.exoplayer.audio.BaseAudioProcessor
import java.nio.ByteBuffer import java.nio.ByteBuffer
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.pow import kotlin.math.pow
import org.oxycblt.auxio.music.Album 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.PlaybackSettings
import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -48,9 +49,7 @@ class ReplayGainAudioProcessor
constructor( constructor(
private val playbackManager: PlaybackStateManager, private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings private val playbackSettings: PlaybackSettings
) : BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener {
private var lastFormat: Format? = null
private var volume = 1f private var volume = 1f
set(value) { set(value) {
field = value field = value
@ -58,51 +57,38 @@ constructor(
flush() flush()
} }
/** init {
* Add this instance to the components required for it to function correctly. playbackManager.addListener(this)
*
* @param player The [Player] to attach to. Should already have this instance as an audio
* processor.
*/
fun addToListeners(player: Player) {
player.addListener(this)
playbackSettings.registerListener(this) playbackSettings.registerListener(this)
} }
/** /** Remove this instance from the components required for it to function correctly. */
* Remove this instance from the components required for it to function correctly. fun release() {
* playbackManager.removeListener(this)
* @param player The [Player] to detach from. Should already have this instance as an audio
* processor.
*/
fun releaseFromListeners(player: Player) {
player.removeListener(this)
playbackSettings.unregisterListener(this) playbackSettings.unregisterListener(this)
} }
// --- OVERRIDES --- // --- OVERRIDES ---
override fun onTracksChanged(tracks: Tracks) { override fun onIndexMoved(queue: Queue) {
super.onTracksChanged(tracks) logD("Index moved, updating current song")
// Try to find the currently playing track so we can update the ReplayGain adjustment applyReplayGain(queue.currentSong)
// based on it. }
for (group in tracks.groups) {
if (group.isSelected) { override fun onQueueChanged(queue: Queue, change: Queue.Change) {
for (i in 0 until group.length) { // Other types of queue changes preserve the current song.
if (group.isTrackSelected(i)) { if (change.type == Queue.Change.Type.SONG) {
applyReplayGain(group.getTrackFormat(i)) applyReplayGain(queue.currentSong)
return
} }
} }
} override fun onNewPlayback(queue: Queue, parent: MusicParent?) {
} logD("New playback started, updating playback information")
// Nothing selected, apply nothing applyReplayGain(queue.currentSong)
applyReplayGain(null)
} }
override fun onReplayGainSettingsChanged() { override fun onReplayGainSettingsChanged() {
// ReplayGain config changed, we need to set it up again. // ReplayGain config changed, we need to set it up again.
applyReplayGain(lastFormat) applyReplayGain(playbackManager.queue.currentSong)
} }
// --- REPLAYGAIN PARSING --- // --- REPLAYGAIN PARSING ---
@ -110,11 +96,11 @@ constructor(
/** /**
* Updates the volume adjustment based on the given [Format]. * 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?) { private fun applyReplayGain(song: Song?) {
lastFormat = format logD("Applying ReplayGain adjustment for $song")
val gain = parseReplayGain(format ?: return) val gain = song?.replayGainAdjustment
val preAmp = playbackSettings.replayGainPreAmp val preAmp = playbackSettings.replayGainPreAmp
val adjust = val adjust =
@ -167,58 +153,6 @@ constructor(
volume = 10f.pow(adjust / 20f) 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 --- // --- AUDIO PROCESSOR IMPLEMENTATION ---
override fun onConfigure( override fun onConfigure(
@ -284,25 +218,4 @@ constructor(
put(short.toByte()) put(short.toByte())
put(short.toInt().shr(8).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.-]") }
}
} }

View file

@ -144,7 +144,6 @@ class PlaybackService :
true) true)
.build() .build()
.also { it.addListener(this) } .also { it.addListener(this) }
replayGainProcessor.addToListeners(player)
foregroundManager = ForegroundManager(this) foregroundManager = ForegroundManager(this)
// Initialize any listener-dependent components last as we wouldn't want a listener race // 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. // condition to cause us to load music before we were fully initialize.
@ -196,7 +195,7 @@ class PlaybackService :
widgetComponent.release() widgetComponent.release()
mediaSessionComponent.release() mediaSessionComponent.release()
replayGainProcessor.releaseFromListeners(player) replayGainProcessor.release()
player.release() player.release()
if (openAudioEffectSession) { if (openAudioEffectSession) {
// Make sure to close the audio session when we release the player. // Make sure to close the audio session when we release the player.

View file

@ -50,6 +50,13 @@ fun Int.nonZeroOrNull() = if (this > 0) this else null
*/ */
fun Long.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. * Aliases a check to ensure a given value is in a specified range.
* *