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:
parent
5ab46ba5d1
commit
bce03a5833
10 changed files with 113 additions and 125 deletions
|
@ -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
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -43,7 +43,6 @@ class CacheRoomModule {
|
|||
Room.databaseBuilder(
|
||||
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.fallbackToDestructiveMigrationFrom(0)
|
||||
.fallbackToDestructiveMigrationOnDowngrade()
|
||||
.build()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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.-]") }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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.-]") }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
Loading…
Reference in a new issue