music: refactor metadata extractor

Refactor the internal tag management portion of MetadataExtractor into a
new "Tags" object that can now be re-used in the ReplayGain system.

This also does a minor rework to the ReplayGain object to make it
totally self-sufficient.
This commit is contained in:
Alexander Capehart 2023-01-01 10:22:30 -07:00
parent 7721e64096
commit 1f5594fb33
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
21 changed files with 292 additions and 271 deletions

View file

@ -10,6 +10,9 @@
- Value lists are now properly localized
- Queue no longer primarily shows previous songs when opened
#### What's Changed
- R128 Gain tags are now only used when playing OPUS files
#### What's Fixed
- Fixed mangled multi-value ID3v2 tags when UTF-16 is used

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.filesystem.MimeType
/**
* A header variation that displays a button to open a sort menu.

View file

@ -36,8 +36,8 @@ import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
/**
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
* beyond it's first item.
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
* view goes beyond it's first item.
*
* This is intended for the detail views, in which the first item is the album/artist/genre header,
* and thus scrolling past them should make the toolbar show the name in order to give context on

View file

@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.filesystem.MimeType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.*

View file

@ -30,9 +30,9 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.filesystem.*
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.nonZeroOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -20,8 +20,8 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.filesystem.useQuery
/**
* A repository granting access to the music library..

View file

@ -24,10 +24,8 @@ package org.oxycblt.auxio.music.extractor
enum class ExtractionResult {
/** A raw song was successfully extracted from the cache. */
CACHED,
/** A raw song was successfully extracted from parsing it's file. */
PARSED,
/** A raw song could not be parsed. */
NONE
}

View file

@ -26,17 +26,17 @@ import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.Date
import java.io.File
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.music.filesystem.directoryCompat
import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.filesystem.safeQuery
import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
import org.oxycblt.auxio.music.filesystem.useQuery
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.directoryCompat
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.storage.safeQuery
import org.oxycblt.auxio.music.storage.storageVolumesCompat
import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD

View file

@ -21,14 +21,10 @@ import android.content.Context
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.parsing.correctWhitespace
import org.oxycblt.auxio.music.filesystem.toAudioUri
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -164,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
val metadata = format.metadata
if (metadata != null) {
populateWithMetadata(metadata)
val tags = Tags(metadata)
populateWithId3v2(tags.id3v2)
populateWithVorbis(tags.vorbis)
} else {
logD("No metadata could be extracted for ${raw.name}")
}
@ -172,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
return raw
}
/**
* Complete this instance's [Song.Raw] with the newly extracted [Metadata].
* @param metadata The [Metadata] to complete the [Song.Raw] with.
*/
private fun populateWithMetadata(metadata: Metadata) {
val id3v2Tags = mutableMapOf<String, List<String>>()
val vorbisTags = mutableMapOf<String, MutableList<String>>()
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
// of audio formats. Load both of these types of tags into separate maps, letting the
// "source of truth" be the last of a particular tag in a file.
for (i in 0 until metadata.length()) {
when (val tag = metadata[i]) {
is TextInformationFrame -> {
// Map TXXX frames differently so we can specifically index by their
// descriptions.
val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
val values = tag.values.map { it.sanitize() }.correctWhitespace()
if (values.isNotEmpty()) {
id3v2Tags[id] = values
}
}
is VorbisComment -> {
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
val id = tag.key.sanitize().uppercase()
val value = tag.value.sanitize().correctWhitespace()
if (value != null) {
vorbisTags.getOrPut(id) { mutableListOf() }.add(value)
}
}
}
}
when {
vorbisTags.isEmpty() -> populateWithId3v2(id3v2Tags)
id3v2Tags.isEmpty() -> populateWithVorbis(vorbisTags)
else -> {
// Some formats (like FLAC) can contain both ID3v2 and Vorbis, so we apply
// them both with priority given to vorbis.
populateWithId3v2(id3v2Tags)
populateWithVorbis(vorbisTags)
}
}
}
/**
* Complete this instance's [Song.Raw] with ID3v2 Text Identification Frames.
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
@ -224,15 +177,15 @@ class Task(context: Context, private val raw: Song.Raw) {
*/
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song
textFrames["TXXX:MusicBrainz Release Track Id"]?.let { raw.musicBrainzId = it[0] }
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] }
textFrames["TIT2"]?.let { raw.name = it[0] }
textFrames["TSOT"]?.let { raw.sortName = it[0] }
// Track. Only parse out the track number and ignore the total tracks value.
textFrames["TRCK"]?.run { get(0).parseId3v2Position() }?.let { raw.track = it }
textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it }
// Disc. Only parse out the disc number and ignore the total discs value.
textFrames["TPOS"]?.run { get(0).parseId3v2Position() }?.let { raw.disc = it }
textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it }
// Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -243,27 +196,27 @@ class Task(context: Context, private val raw: Song.Raw) {
// 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type
(textFrames["TDOR"]?.run { Date.from(get(0)) }
?: textFrames["TDRC"]?.run { Date.from(get(0)) }
?: textFrames["TDRL"]?.run { Date.from(get(0)) }
(textFrames["TDOR"]?.run { Date.from(first()) }
?: textFrames["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames))
?.let { raw.date = it }
// Album
textFrames["TXXX:MusicBrainz Album Id"]?.let { raw.albumMusicBrainzId = it[0] }
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] }
textFrames["TALB"]?.let { raw.albumName = it[0] }
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
(textFrames["TXXX:MusicBrainz Album Type"] ?: textFrames["GRP1"])?.let {
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
raw.albumTypes = it
}
// Artist
textFrames["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
textFrames["TPE1"]?.let { raw.artistNames = it }
textFrames["TSOP"]?.let { raw.artistSortNames = it }
// Album artist
textFrames["TXXX:MusicBrainz Album Artist Id"]?.let { raw.albumArtistMusicBrainzIds = it }
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
@ -284,8 +237,8 @@ class Task(context: Context, private val raw: Song.Raw) {
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
// is present.
val year =
textFrames["TORY"]?.run { get(0).toIntOrNull() }
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
textFrames["TORY"]?.run { first().toIntOrNull() }
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"]
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
@ -319,17 +272,17 @@ class Task(context: Context, private val raw: Song.Raw) {
*/
private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
comments["TITLE"]?.let { raw.name = it[0] }
comments["TITLESORT"]?.let { raw.sortName = it[0] }
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
comments["title"]?.let { raw.name = it[0] }
comments["titlesort"]?.let { raw.sortName = it[0] }
// Track. The total tracks value is in a different comment, so we can just
// convert the entirety of this comment into a number.
comments["TRACKNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.track = it }
comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it }
// Disc. The total discs value is in a different comment, so we can just
// convert the entirety of this comment into a number.
comments["DISCNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.disc = it }
comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it }
// Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such:
@ -337,35 +290,28 @@ class Task(context: Context, private val raw: Song.Raw) {
// 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// date tag that android supports, so it must be 15 years old or more!)
(comments["ORIGINALDATE"]?.run { Date.from(get(0)) }
?: comments["DATE"]?.run { Date.from(get(0)) }
?: comments["YEAR"]?.run { get(0).toIntOrNull()?.let(Date::from) })
(comments["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
?.let { raw.date = it }
// Album
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
comments["ALBUM"]?.let { raw.albumName = it[0] }
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
comments["RELEASETYPE"]?.let { raw.albumTypes = it }
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
comments["album"]?.let { raw.albumName = it[0] }
comments["albumsort"]?.let { raw.albumSortName = it[0] }
comments["releasetype"]?.let { raw.albumTypes = it }
// Artist
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
comments["ARTIST"]?.let { raw.artistNames = it }
comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
comments["artist"]?.let { raw.artistNames = it }
comments["artistsort"]?.let { raw.artistSortNames = it }
// Album artist
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
comments["albumartist"]?.let { raw.albumArtistNames = it }
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
// Genre
comments["GENRE"]?.let { raw.genreNames = it }
}
/**
* Copies and sanitizes a possibly native/non-UTF-8 string.
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
* the Unicode replacement byte sequence.
*/
private fun String.sanitize() = String(encodeToByteArray())
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.extractor
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.InternalFrame
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.parsing.correctWhitespace
/**
* Processing wrapper for [Metadata] that allows access to more organized metadata.
* @param metadata The [Metadata] to wrap.
* @author Alexander Capehart (OxygenCobalt)
*/
class Tags(metadata: Metadata) {
private val _id3v2 = mutableMapOf<String, List<String>>()
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
val id3v2: Map<String, List<String>>
get() = _id3v2
private val _vorbis = mutableMapOf<String, MutableList<String>>()
/** The vorbis comments found in the file. Can have more than one value. */
val vorbis: Map<String, List<String>>
get() = _vorbis
init {
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
// of audio formats. Load both of these types of tags into separate maps, letting the
// "source of truth" be the last of a particular tag in a file.
for (i in 0 until metadata.length()) {
when (val tag = metadata[i]) {
is TextInformationFrame -> {
// Map TXXX frames differently so we can specifically index by their
// descriptions.
val id =
tag.description?.let { "TXXX:${it.sanitize().lowercase()}" }
?: tag.id.sanitize()
val values = tag.values.map { it.sanitize() }.correctWhitespace()
if (values.isNotEmpty()) {
_id3v2[id] = values
}
}
is InternalFrame -> {
// Most MP4 metadata atoms map to ID3v2 text frames, except for the ---- atom,
// which has it's own frame. Map this to TXXX, it's rough ID3v2 equivalent.
val id = "TXXX:${tag.description.sanitize().lowercase()}"
val value = tag.text
if (value.isNotEmpty()) {
_id3v2[id] = listOf(value)
}
}
is VorbisComment -> {
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
val id = tag.key.sanitize().lowercase()
val value = tag.value.sanitize().correctWhitespace()
if (value != null) {
_vorbis.getOrPut(id) { mutableListOf() }.add(value)
}
}
}
}
}
/**
* Copies and sanitizes a possibly invalid string outputted from ExoPlayer.
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
* the Unicode replacement byte sequence.
*/
private fun String.sanitize() = String(encodeToByteArray())
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.filesystem
import android.view.View
import android.view.ViewGroup

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.filesystem
import android.content.Context
import android.media.MediaFormat

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.filesystem
import android.annotation.SuppressLint
import android.content.ContentResolver

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.storage
package org.oxycblt.auxio.music.filesystem
import android.net.Uri
import android.os.Bundle
@ -31,7 +31,6 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull
*/
fun List<String>.parseMultiValue(settings: Settings) =
if (size == 1) {
get(0).maybeParseBySeparators(settings)
first().maybeParseBySeparators(settings)
} else {
// Nothing to do.
this
@ -124,7 +124,7 @@ fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZer
*/
fun List<String>.parseId3GenreNames(settings: Settings) =
if (size == 1) {
get(0).parseId3MultiValueGenre(settings)
first().parseId3MultiValueGenre(settings)
} else {
// Nothing to split, just map any ID3v1 genres to their name counterparts.
map { it.parseId3v1Genre() ?: it }
@ -147,8 +147,8 @@ private fun String.parseId3v1Genre(): String? {
// try to index the genre table with such.
val numeric =
toIntOrNull()
// Not a numeric value, try some other fixed values.
?: return when (this) {
// Not a numeric value, try some other fixed values.
?: return when (this) {
// CR and RX are not technically ID3v1, but are formatted similarly to a plain
// number.
"CR" -> "Cover"

View file

@ -1,3 +1,20 @@
/*
* Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.parsing
/**

View file

@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings

View file

@ -18,33 +18,38 @@
package org.oxycblt.auxio.playback.replaygain
import android.content.Context
import android.content.SharedPreferences
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Tracks
import com.google.android.exoplayer2.audio.AudioProcessor
import com.google.android.exoplayer2.audio.BaseAudioProcessor
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.InternalFrame
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import com.google.android.exoplayer2.util.MimeTypes
import java.nio.ByteBuffer
import kotlin.math.pow
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.extractor.Tags
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* An [AudioProcessor] that handles ReplayGain values and their amplification of the audio stream.
* Instead of leveraging the volume attribute like other implementations, this system manipulates
* the bitstream itself to modify the volume, which allows the use of positive ReplayGain values.
*
* Note: This instance must be updated with a new [Metadata] every time the active track chamges.
* Note: This audio processor must be attached to a respective [Player] instance as a
* [Player.Listener] to function properly.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
class ReplayGainAudioProcessor(private val context: Context) :
BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
private val playbackManager = PlaybackStateManager.getInstance()
private val settings = Settings(context)
private var lastFormat: Format? = null
private var volume = 1f
set(value) {
@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
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)
settings.addListener(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)
settings.removeListener(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
}
}
}
}
// Nothing selected, apply nothing
applyReplayGain(null)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == context.getString(R.string.set_key_replay_gain) ||
key == context.getString(R.string.set_key_pre_amp_with) ||
key == context.getString(R.string.set_key_pre_amp_without)) {
// ReplayGain changed, we need to set it up again.
applyReplayGain(lastFormat)
}
}
// --- REPLAYGAIN PARSING ---
/**
* Updates the volume adjustment based on the given [Metadata].
* @param metadata The [Metadata] of the currently playing track, or null if the track has no
* [Metadata].
* Updates the volume adjustment based on the given [Format].
* @param format The [Format] of the currently playing track, or null if nothing is playing.
*/
fun applyReplayGain(metadata: Metadata?) {
// TODO: Allow this to automatically obtain it's own [Metadata].
val gain = metadata?.let(::parseReplayGain)
private fun applyReplayGain(format: Format?) {
lastFormat = format
val gain = parseReplayGain(format ?: return)
val preAmp = settings.replayGainPreAmp
val adjust =
if (gain != null) {
logD("Found ReplayGain adjustment $gain")
// ReplayGain is configurable, so determine what to do based off of the mode.
val useAlbumGain =
when (settings.replayGainMode) {
@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
}
/**
* Parse ReplayGain information from the given [Metadata].
* @param metadata The [Metadata] to parse.
* @return A [Gain] adjustment, or null if there was no adjustments to parse.
* 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(metadata: Metadata): Gain? {
// TODO: Unify this parser with the music parser? They both grok Metadata.
private fun parseReplayGain(format: Format): Adjustment? {
val tags = Tags(format.metadata ?: return null)
var trackGain = 0f
var albumGain = 0f
var found = false
val tags = mutableListOf<GainTag>()
for (i in 0 until metadata.length()) {
val entry = metadata.get(i)
val key: String?
val value: String
when (entry) {
// ID3v2 text information frame, usually these are formatted in lowercase
// (like "replaygain_track_gain"), but can also be uppercase. Make sure that
// capitalization is consistent before continuing.
is TextInformationFrame -> {
key = entry.description
value = entry.values[0]
}
// Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2
// frame by ExoPlayer (presumably to reduce duplication).
is InternalFrame -> {
key = entry.description
value = entry.text
}
// Vorbis comment. These are nearly always uppercase, so a check for such is
// skipped.
is VorbisComment -> {
key = entry.key
value = entry.value
}
else -> continue
}
if (key in REPLAY_GAIN_TAGS) {
// Grok a float from a ReplayGain tag by removing everything that is not 0-9, ,
// or -.
// Derived from vanilla music: https://github.com/vanilla-music/vanilla
val gainValue =
try {
value.replace(Regex("[^\\d.-]"), "").toFloat()
} catch (e: Exception) {
0f
}
tags.add(GainTag(unlikelyToBeNull(key), gainValue))
}
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
// replaygain_*_gain tag.
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it }
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it }
tags.vorbis[TAG_RG_ALBUM_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it }
tags.vorbis[TAG_RG_TRACK_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it }
} else {
// 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. That base adjustment
// is already handled by the media framework, so we just need to apply the more
// specific adjustments.
tags.vorbis[TAG_R128_TRACK_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { trackGain = it / 256f }
tags.vorbis[TAG_R128_ALBUM_GAIN]
?.run { first().parseReplayGainAdjustment() }
?.let { albumGain = it / 256f }
}
// Case 1: Normal ReplayGain, most commonly found on MPEG files.
tags
.findLast { tag -> tag.key.equals(TAG_RG_TRACK, ignoreCase = true) }
?.let { tag ->
trackGain = tag.value
found = true
}
tags
.findLast { tag -> tag.key.equals(TAG_RG_ALBUM, ignoreCase = true) }
?.let { tag ->
albumGain = tag.value
found = true
}
// Case 2: R128 ReplayGain, most commonly found on FLAC files and other lossless
// encodings to increase precision in volume adjustments.
// While technically there is the R128 base gain in Opus files, that is automatically
// applied by the media framework [which ExoPlayer relies on]. The only reason we would
// want to read it is to zero previous ReplayGain values for being invalid, however there
// is no demand to fix that edge case right now.
tags
.findLast { tag -> tag.key.equals(R128_TRACK, ignoreCase = true) }
?.let { tag ->
trackGain += tag.value / 256f
found = true
}
tags
.findLast { tag -> tag.key.equals(R128_ALBUM, ignoreCase = true) }
?.let { tag ->
albumGain += tag.value / 256f
found = true
}
return if (found) {
Gain(trackGain, albumGain)
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(
@ -271,21 +279,18 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
* @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 Gain(val track: Float, val album: Float)
/**
* A raw ReplayGain adjustment.
* @param key The tag's key.
* @param value The tag's adjustment, in dB.
*/
private data class GainTag(val key: String, val value: Float)
// TODO: Try to phase this out
private data class Adjustment(val track: Float, val album: Float)
private companion object {
const val TAG_RG_TRACK = "replaygain_track_gain"
const val TAG_RG_ALBUM = "replaygain_album_gain"
const val R128_TRACK = "r128_track_gain"
const val R128_ALBUM = "r128_album_gain"
val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
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 = Regex("[^\\d.-]")
}
}

View file

@ -22,7 +22,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.media.AudioManager
import android.media.audiofx.AudioEffect
import android.os.IBinder
@ -32,7 +31,6 @@ import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.RenderersFactory
import com.google.android.exoplayer2.Tracks
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.audio.AudioCapabilities
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
@ -45,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
@ -81,8 +78,7 @@ class PlaybackService :
Player.Listener,
InternalPlayer,
MediaSessionComponent.Listener,
MusicStore.Listener,
SharedPreferences.OnSharedPreferenceChangeListener {
MusicStore.Listener {
// Player components
private lateinit var player: ExoPlayer
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
@ -144,9 +140,9 @@ class PlaybackService :
true)
.build()
.also { it.addListener(this) }
replayGainProcessor.addToListeners(player)
// Initialize the core service components
settings = Settings(this)
settings.addListener(this)
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.
@ -187,7 +183,6 @@ class PlaybackService :
super.onDestroy()
foregroundManager.release()
settings.removeListener(this)
// Pause just in case this destruction was unexpected.
playbackManager.setPlaying(false)
@ -200,6 +195,7 @@ class PlaybackService :
widgetComponent.release()
mediaSessionComponent.release()
replayGainProcessor.releaseFromListeners(player)
player.release()
if (openAudioEffectSession) {
// Make sure to close the audio session when we release the player.
@ -304,24 +300,6 @@ class PlaybackService :
playbackManager.next()
}
override fun onTracksChanged(tracks: Tracks) {
super.onTracksChanged(tracks)
// Try to find the currently playing track so we can update ReplayGainAudioProcessor
// with it.
for (group in tracks.groups) {
if (group.isSelected) {
for (i in 0 until group.length) {
if (group.isTrackSelected(i)) {
replayGainProcessor.applyReplayGain(group.getTrackFormat(i).metadata)
break
}
}
break
}
}
}
// --- MUSICSTORE OVERRIDES ---
override fun onLibraryChanged(library: MusicStore.Library?) {
@ -331,17 +309,6 @@ class PlaybackService :
}
}
// --- SETTINGS OVERRIDES ---
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (key == getString(R.string.set_key_replay_gain) ||
key == getString(R.string.set_key_pre_amp_with) ||
key == getString(R.string.set_key_pre_amp_without)) {
// ReplayGain changed, we need to set it up again.
onTracksChanged(player.currentTracks)
}
}
// --- OTHER FUNCTIONS ---
private fun broadcastAudioEffectAction(event: String) {

View file

@ -31,8 +31,8 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.MusicDirectories
import org.oxycblt.auxio.music.filesystem.Directory
import org.oxycblt.auxio.music.filesystem.MusicDirectories
import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp

View file

@ -106,7 +106,7 @@
tools:layout="@layout/dialog_pre_amp" />
<dialog
android:id="@+id/music_dirs_dialog"
android:name="org.oxycblt.auxio.music.storage.MusicDirsDialog"
android:name="org.oxycblt.auxio.music.filesystem.MusicDirsDialog"
android:label="music_dirs_dialog"
tools:layout="@layout/dialog_music_dirs" />
<dialog