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.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
/**

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.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<CachedSong>
@Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs()
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
@Query("DELETE FROM CachedSong") suspend fun nukeSongs()
@Insert suspend fun insertSongs(songs: List<CachedSong>)
}
@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<String> = 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,

View file

@ -43,7 +43,6 @@ class CacheRoomModule {
Room.databaseBuilder(
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
.fallbackToDestructiveMigration()
.fallbackToDestructiveMigrationFrom(0)
.fallbackToDestructiveMigrationOnDowngrade()
.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.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

View file

@ -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 */

View file

@ -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<String, List<String>>): 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<String>.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.-]") }
}
}

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.
*

View file

@ -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.-]") }
}
}

View file

@ -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.

View file

@ -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.
*