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:
parent
7721e64096
commit
1f5594fb33
21 changed files with 292 additions and 271 deletions
|
@ -10,6 +10,9 @@
|
||||||
- Value lists are now properly localized
|
- Value lists are now properly localized
|
||||||
- Queue no longer primarily shows previous songs when opened
|
- 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
|
#### What's Fixed
|
||||||
- Fixed mangled multi-value ID3v2 tags when UTF-16 is used
|
- Fixed mangled multi-value ID3v2 tags when UTF-16 is used
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.music.Song
|
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.
|
* A header variation that displays a button to open a sort menu.
|
||||||
|
|
|
@ -36,8 +36,8 @@ import org.oxycblt.auxio.util.getInteger
|
||||||
import org.oxycblt.auxio.util.lazyReflectedField
|
import org.oxycblt.auxio.util.lazyReflectedField
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
|
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||||
* beyond it's first item.
|
* 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,
|
* 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
|
* and thus scrolling past them should make the toolbar show the name in order to give context on
|
||||||
|
|
|
@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.Sort
|
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.settings.Settings
|
||||||
import org.oxycblt.auxio.util.*
|
import org.oxycblt.auxio.util.*
|
||||||
|
|
||||||
|
|
|
@ -30,9 +30,9 @@ import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Item
|
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.parseId3GenreNames
|
||||||
import org.oxycblt.auxio.music.parsing.parseMultiValue
|
import org.oxycblt.auxio.music.parsing.parseMultiValue
|
||||||
import org.oxycblt.auxio.music.storage.*
|
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
|
@ -20,8 +20,8 @@ package org.oxycblt.auxio.music
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||||
import org.oxycblt.auxio.music.storage.useQuery
|
import org.oxycblt.auxio.music.filesystem.useQuery
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A repository granting access to the music library..
|
* A repository granting access to the music library..
|
||||||
|
|
|
@ -24,10 +24,8 @@ package org.oxycblt.auxio.music.extractor
|
||||||
enum class ExtractionResult {
|
enum class ExtractionResult {
|
||||||
/** A raw song was successfully extracted from the cache. */
|
/** A raw song was successfully extracted from the cache. */
|
||||||
CACHED,
|
CACHED,
|
||||||
|
|
||||||
/** A raw song was successfully extracted from parsing it's file. */
|
/** A raw song was successfully extracted from parsing it's file. */
|
||||||
PARSED,
|
PARSED,
|
||||||
|
|
||||||
/** A raw song could not be parsed. */
|
/** A raw song could not be parsed. */
|
||||||
NONE
|
NONE
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,17 +26,17 @@ import android.provider.MediaStore
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.database.getIntOrNull
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import org.oxycblt.auxio.music.Date
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import org.oxycblt.auxio.music.Date
|
||||||
import org.oxycblt.auxio.music.Song
|
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.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.settings.Settings
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
|
@ -21,14 +21,10 @@ import android.content.Context
|
||||||
import androidx.core.text.isDigitsOnly
|
import androidx.core.text.isDigitsOnly
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
import com.google.android.exoplayer2.MetadataRetriever
|
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.Date
|
||||||
import org.oxycblt.auxio.music.Song
|
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.parsing.parseId3v2Position
|
||||||
import org.oxycblt.auxio.music.storage.toAudioUri
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
@ -164,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
|
|
||||||
val metadata = format.metadata
|
val metadata = format.metadata
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
populateWithMetadata(metadata)
|
val tags = Tags(metadata)
|
||||||
|
populateWithId3v2(tags.id3v2)
|
||||||
|
populateWithVorbis(tags.vorbis)
|
||||||
} else {
|
} else {
|
||||||
logD("No metadata could be extracted for ${raw.name}")
|
logD("No metadata could be extracted for ${raw.name}")
|
||||||
}
|
}
|
||||||
|
@ -172,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
return 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.
|
* 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
|
* @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>>) {
|
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||||
// Song
|
// 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["TIT2"]?.let { raw.name = it[0] }
|
||||||
textFrames["TSOT"]?.let { raw.sortName = it[0] }
|
textFrames["TSOT"]?.let { raw.sortName = it[0] }
|
||||||
|
|
||||||
// Track. Only parse out the track number and ignore the total tracks value.
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
||||||
// 4. ID3v2.3 Original Date, as it is like #1
|
// 4. ID3v2.3 Original Date, as it is like #1
|
||||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
// 5. ID3v2.3 Release Year, as it is the most common date type
|
||||||
(textFrames["TDOR"]?.run { Date.from(get(0)) }
|
(textFrames["TDOR"]?.run { Date.from(first()) }
|
||||||
?: textFrames["TDRC"]?.run { Date.from(get(0)) }
|
?: textFrames["TDRC"]?.run { Date.from(first()) }
|
||||||
?: textFrames["TDRL"]?.run { Date.from(get(0)) }
|
?: textFrames["TDRL"]?.run { Date.from(first()) }
|
||||||
?: parseId3v23Date(textFrames))
|
?: parseId3v23Date(textFrames))
|
||||||
?.let { raw.date = it }
|
?.let { raw.date = it }
|
||||||
|
|
||||||
// Album
|
// 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["TALB"]?.let { raw.albumName = it[0] }
|
||||||
textFrames["TSOA"]?.let { raw.albumSortName = 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
|
raw.albumTypes = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist
|
// 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["TPE1"]?.let { raw.artistNames = it }
|
||||||
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// 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["TPE2"]?.let { raw.albumArtistNames = it }
|
||||||
textFrames["TSO2"]?.let { raw.albumArtistSortNames = 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
|
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||||
// is present.
|
// is present.
|
||||||
val year =
|
val year =
|
||||||
textFrames["TORY"]?.run { get(0).toIntOrNull() }
|
textFrames["TORY"]?.run { first().toIntOrNull() }
|
||||||
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
|
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
|
||||||
|
|
||||||
val tdat = textFrames["TDAT"]
|
val tdat = textFrames["TDAT"]
|
||||||
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
|
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>>) {
|
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||||
// Song
|
// Song
|
||||||
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
|
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
|
||||||
comments["TITLE"]?.let { raw.name = it[0] }
|
comments["title"]?.let { raw.name = it[0] }
|
||||||
comments["TITLESORT"]?.let { raw.sortName = it[0] }
|
comments["titlesort"]?.let { raw.sortName = it[0] }
|
||||||
|
|
||||||
// Track. The total tracks value is in a different comment, so we can just
|
// Track. The total tracks value is in a different comment, so we can just
|
||||||
// convert the entirety of this comment into a number.
|
// 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
|
// Disc. The total discs value is in a different comment, so we can just
|
||||||
// convert the entirety of this comment into a number.
|
// 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
|
// Vorbis dates are less complicated, but there are still several types
|
||||||
// Our hierarchy for dates is as such:
|
// 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
|
// 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
|
// 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!)
|
// date tag that android supports, so it must be 15 years old or more!)
|
||||||
(comments["ORIGINALDATE"]?.run { Date.from(get(0)) }
|
(comments["originaldate"]?.run { Date.from(first()) }
|
||||||
?: comments["DATE"]?.run { Date.from(get(0)) }
|
?: comments["date"]?.run { Date.from(first()) }
|
||||||
?: comments["YEAR"]?.run { get(0).toIntOrNull()?.let(Date::from) })
|
?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
|
||||||
?.let { raw.date = it }
|
?.let { raw.date = it }
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
|
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||||
comments["ALBUM"]?.let { raw.albumName = it[0] }
|
comments["album"]?.let { raw.albumName = it[0] }
|
||||||
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
comments["albumsort"]?.let { raw.albumSortName = it[0] }
|
||||||
comments["RELEASETYPE"]?.let { raw.albumTypes = it }
|
comments["releasetype"]?.let { raw.albumTypes = it }
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
|
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
|
||||||
comments["ARTIST"]?.let { raw.artistNames = it }
|
comments["artist"]?.let { raw.artistNames = it }
|
||||||
comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
|
comments["artistsort"]?.let { raw.artistSortNames = it }
|
||||||
|
|
||||||
// Album artist
|
// Album artist
|
||||||
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
|
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||||
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
|
comments["albumartist"]?.let { raw.albumArtistNames = it }
|
||||||
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
|
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
comments["GENRE"]?.let { raw.genreNames = it }
|
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())
|
|
||||||
}
|
}
|
||||||
|
|
86
app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
Normal file
86
app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
Normal 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())
|
||||||
|
}
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.content.Context
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.annotation.SuppressLint
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
|
@ -15,7 +15,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
@ -31,7 +31,6 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
|
@ -31,7 +31,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
*/
|
*/
|
||||||
fun List<String>.parseMultiValue(settings: Settings) =
|
fun List<String>.parseMultiValue(settings: Settings) =
|
||||||
if (size == 1) {
|
if (size == 1) {
|
||||||
get(0).maybeParseBySeparators(settings)
|
first().maybeParseBySeparators(settings)
|
||||||
} else {
|
} else {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
this
|
this
|
||||||
|
@ -124,7 +124,7 @@ fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZer
|
||||||
*/
|
*/
|
||||||
fun List<String>.parseId3GenreNames(settings: Settings) =
|
fun List<String>.parseId3GenreNames(settings: Settings) =
|
||||||
if (size == 1) {
|
if (size == 1) {
|
||||||
get(0).parseId3MultiValueGenre(settings)
|
first().parseId3MultiValueGenre(settings)
|
||||||
} else {
|
} else {
|
||||||
// Nothing to split, just map any ID3v1 genres to their name counterparts.
|
// Nothing to split, just map any ID3v1 genres to their name counterparts.
|
||||||
map { it.parseId3v1Genre() ?: it }
|
map { it.parseId3v1Genre() ?: it }
|
||||||
|
|
|
@ -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
|
package org.oxycblt.auxio.music.parsing
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
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.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.service.ForegroundManager
|
import org.oxycblt.auxio.service.ForegroundManager
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
|
|
|
@ -18,33 +18,38 @@
|
||||||
package org.oxycblt.auxio.playback.replaygain
|
package org.oxycblt.auxio.playback.replaygain
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import com.google.android.exoplayer2.C
|
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.AudioProcessor
|
||||||
import com.google.android.exoplayer2.audio.BaseAudioProcessor
|
import com.google.android.exoplayer2.audio.BaseAudioProcessor
|
||||||
import com.google.android.exoplayer2.metadata.Metadata
|
import com.google.android.exoplayer2.util.MimeTypes
|
||||||
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 java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.extractor.Tags
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
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.
|
* 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
|
* 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.
|
* 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)
|
* @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 playbackManager = PlaybackStateManager.getInstance()
|
||||||
private val settings = Settings(context)
|
private val settings = Settings(context)
|
||||||
|
private var lastFormat: Format? = null
|
||||||
|
|
||||||
private var volume = 1f
|
private var volume = 1f
|
||||||
set(value) {
|
set(value) {
|
||||||
|
@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||||
flush()
|
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 ---
|
// --- REPLAYGAIN PARSING ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the volume adjustment based on the given [Metadata].
|
* Updates the volume adjustment based on the given [Format].
|
||||||
* @param metadata The [Metadata] of the currently playing track, or null if the track has no
|
* @param format The [Format] of the currently playing track, or null if nothing is playing.
|
||||||
* [Metadata].
|
|
||||||
*/
|
*/
|
||||||
fun applyReplayGain(metadata: Metadata?) {
|
private fun applyReplayGain(format: Format?) {
|
||||||
// TODO: Allow this to automatically obtain it's own [Metadata].
|
lastFormat = format
|
||||||
val gain = metadata?.let(::parseReplayGain)
|
val gain = parseReplayGain(format ?: return)
|
||||||
val preAmp = settings.replayGainPreAmp
|
val preAmp = settings.replayGainPreAmp
|
||||||
|
|
||||||
val adjust =
|
val adjust =
|
||||||
if (gain != null) {
|
if (gain != null) {
|
||||||
|
logD("Found ReplayGain adjustment $gain")
|
||||||
// ReplayGain is configurable, so determine what to do based off of the mode.
|
// ReplayGain is configurable, so determine what to do based off of the mode.
|
||||||
val useAlbumGain =
|
val useAlbumGain =
|
||||||
when (settings.replayGainMode) {
|
when (settings.replayGainMode) {
|
||||||
|
@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse ReplayGain information from the given [Metadata].
|
* Parse ReplayGain information from the given [Format].
|
||||||
* @param metadata The [Metadata] to parse.
|
* @param format The [Format] to parse.
|
||||||
* @return A [Gain] adjustment, or null if there was no adjustments to parse.
|
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
|
||||||
*/
|
*/
|
||||||
private fun parseReplayGain(metadata: Metadata): Gain? {
|
private fun parseReplayGain(format: Format): Adjustment? {
|
||||||
// TODO: Unify this parser with the music parser? They both grok Metadata.
|
val tags = Tags(format.metadata ?: return null)
|
||||||
|
|
||||||
var trackGain = 0f
|
var trackGain = 0f
|
||||||
var albumGain = 0f
|
var albumGain = 0f
|
||||||
var found = false
|
|
||||||
|
|
||||||
val tags = mutableListOf<GainTag>()
|
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
|
||||||
|
// replaygain_*_gain tag.
|
||||||
for (i in 0 until metadata.length()) {
|
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
|
||||||
val entry = metadata.get(i)
|
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
|
||||||
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
val key: String?
|
?.let { trackGain = it }
|
||||||
val value: String
|
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
|
||||||
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
when (entry) {
|
?.let { albumGain = it }
|
||||||
// ID3v2 text information frame, usually these are formatted in lowercase
|
tags.vorbis[TAG_RG_ALBUM_GAIN]
|
||||||
// (like "replaygain_track_gain"), but can also be uppercase. Make sure that
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
// capitalization is consistent before continuing.
|
?.let { trackGain = it }
|
||||||
is TextInformationFrame -> {
|
tags.vorbis[TAG_RG_TRACK_GAIN]
|
||||||
key = entry.description
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
value = entry.values[0]
|
?.let { albumGain = it }
|
||||||
}
|
} else {
|
||||||
// Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2
|
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
|
||||||
// frame by ExoPlayer (presumably to reduce duplication).
|
// adjustment by 256 to get the gain. This is used alongside the base adjustment
|
||||||
is InternalFrame -> {
|
// intrinsic to the format to create the normalized adjustment. That base adjustment
|
||||||
key = entry.description
|
// is already handled by the media framework, so we just need to apply the more
|
||||||
value = entry.text
|
// specific adjustments.
|
||||||
}
|
tags.vorbis[TAG_R128_TRACK_GAIN]
|
||||||
// Vorbis comment. These are nearly always uppercase, so a check for such is
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
// skipped.
|
?.let { trackGain = it / 256f }
|
||||||
is VorbisComment -> {
|
tags.vorbis[TAG_R128_ALBUM_GAIN]
|
||||||
key = entry.key
|
?.run { first().parseReplayGainAdjustment() }
|
||||||
value = entry.value
|
?.let { albumGain = it / 256f }
|
||||||
}
|
|
||||||
else -> continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key in REPLAY_GAIN_TAGS) {
|
return if (trackGain != 0f || albumGain != 0f) {
|
||||||
// Grok a float from a ReplayGain tag by removing everything that is not 0-9, ,
|
Adjustment(trackGain, albumGain)
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
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(
|
||||||
|
@ -271,21 +279,18 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||||
* @param track The track adjustment (in dB), or 0 if it is not present.
|
* @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.
|
* @param album The album adjustment (in dB), or 0 if it is not present.
|
||||||
*/
|
*/
|
||||||
private data class Gain(val track: Float, val album: Float)
|
private data class Adjustment(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 companion object {
|
private companion object {
|
||||||
const val TAG_RG_TRACK = "replaygain_track_gain"
|
const val TAG_RG_TRACK_GAIN = "replaygain_track_gain"
|
||||||
const val TAG_RG_ALBUM = "replaygain_album_gain"
|
const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain"
|
||||||
const val R128_TRACK = "r128_track_gain"
|
const val TAG_R128_TRACK_GAIN = "r128_track_gain"
|
||||||
const val R128_ALBUM = "r128_album_gain"
|
const val TAG_R128_ALBUM_GAIN = "r128_album_gain"
|
||||||
val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
|
|
||||||
|
/**
|
||||||
|
* Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
|
||||||
|
* https://github.com/vanilla-music/vanilla
|
||||||
|
*/
|
||||||
|
val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.audiofx.AudioEffect
|
import android.media.audiofx.AudioEffect
|
||||||
import android.os.IBinder
|
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.PlaybackException
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
import com.google.android.exoplayer2.RenderersFactory
|
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.AudioAttributes
|
||||||
import com.google.android.exoplayer2.audio.AudioCapabilities
|
import com.google.android.exoplayer2.audio.AudioCapabilities
|
||||||
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
|
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
|
||||||
|
@ -45,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.music.MusicStore
|
import org.oxycblt.auxio.music.MusicStore
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||||
|
@ -81,8 +78,7 @@ class PlaybackService :
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
InternalPlayer,
|
InternalPlayer,
|
||||||
MediaSessionComponent.Listener,
|
MediaSessionComponent.Listener,
|
||||||
MusicStore.Listener,
|
MusicStore.Listener {
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
|
||||||
// Player components
|
// Player components
|
||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
|
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
|
||||||
|
@ -144,9 +140,9 @@ class PlaybackService :
|
||||||
true)
|
true)
|
||||||
.build()
|
.build()
|
||||||
.also { it.addListener(this) }
|
.also { it.addListener(this) }
|
||||||
|
replayGainProcessor.addToListeners(player)
|
||||||
// Initialize the core service components
|
// Initialize the core service components
|
||||||
settings = Settings(this)
|
settings = Settings(this)
|
||||||
settings.addListener(this)
|
|
||||||
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.
|
||||||
|
@ -187,7 +183,6 @@ class PlaybackService :
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
foregroundManager.release()
|
foregroundManager.release()
|
||||||
settings.removeListener(this)
|
|
||||||
|
|
||||||
// Pause just in case this destruction was unexpected.
|
// Pause just in case this destruction was unexpected.
|
||||||
playbackManager.setPlaying(false)
|
playbackManager.setPlaying(false)
|
||||||
|
@ -200,6 +195,7 @@ class PlaybackService :
|
||||||
widgetComponent.release()
|
widgetComponent.release()
|
||||||
mediaSessionComponent.release()
|
mediaSessionComponent.release()
|
||||||
|
|
||||||
|
replayGainProcessor.releaseFromListeners(player)
|
||||||
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.
|
||||||
|
@ -304,24 +300,6 @@ class PlaybackService :
|
||||||
playbackManager.next()
|
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 ---
|
// --- MUSICSTORE OVERRIDES ---
|
||||||
|
|
||||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
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 ---
|
// --- OTHER FUNCTIONS ---
|
||||||
|
|
||||||
private fun broadcastAudioEffectAction(event: String) {
|
private fun broadcastAudioEffectAction(event: String) {
|
||||||
|
|
|
@ -31,8 +31,8 @@ import org.oxycblt.auxio.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
import org.oxycblt.auxio.image.CoverMode
|
||||||
import org.oxycblt.auxio.music.MusicMode
|
import org.oxycblt.auxio.music.MusicMode
|
||||||
import org.oxycblt.auxio.music.Sort
|
import org.oxycblt.auxio.music.Sort
|
||||||
import org.oxycblt.auxio.music.storage.Directory
|
import org.oxycblt.auxio.music.filesystem.Directory
|
||||||
import org.oxycblt.auxio.music.storage.MusicDirectories
|
import org.oxycblt.auxio.music.filesystem.MusicDirectories
|
||||||
import org.oxycblt.auxio.playback.ActionMode
|
import org.oxycblt.auxio.playback.ActionMode
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
|
||||||
|
|
|
@ -106,7 +106,7 @@
|
||||||
tools:layout="@layout/dialog_pre_amp" />
|
tools:layout="@layout/dialog_pre_amp" />
|
||||||
<dialog
|
<dialog
|
||||||
android:id="@+id/music_dirs_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"
|
android:label="music_dirs_dialog"
|
||||||
tools:layout="@layout/dialog_music_dirs" />
|
tools:layout="@layout/dialog_music_dirs" />
|
||||||
<dialog
|
<dialog
|
||||||
|
|
Loading…
Reference in a new issue