playback: add replaygain support

Add ReplayGain support leveraging ExoPlayer. This was a widely
requested feature, but since I thought I needed MediaStore to
expose the fields, I never considered it. Turns out ExoPlayer
automatically exposes metadata for ID3v2 and Xiph tags, so we
can implement it just fine.

Resolves #7.
This commit is contained in:
OxygenCobalt 2022-01-06 10:47:06 -07:00
parent 43e01e839d
commit d419a4230d
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 137 additions and 4 deletions

View file

@ -44,8 +44,6 @@ import kotlin.math.min
* of state and view magic. I tried my best to document it, but it's probably not the most friendly
* or extendable. You have been warned.
*
* TODO: Add the queue view into this layout.
*
* @author OxygenCobalt (With help from Umano and Hai Zhang)
*/
class PlaybackLayout @JvmOverloads constructor(

View file

@ -36,6 +36,7 @@ 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.TracksInfo
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
@ -234,6 +235,29 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
}
}
override fun onTracksInfoChanged(tracksInfo: TracksInfo) {
super.onTracksInfoChanged(tracksInfo)
for (info in tracksInfo.trackGroupInfos) {
if (info.isSelected) {
for (i in 0 until info.trackGroup.length) {
if (info.isTrackSelected(i)) {
val metadata = info.trackGroup.getFormat(i).metadata
if (metadata != null) {
player.volume = calculateReplayGain(metadata)
logD("Applied ReplayGain adjustment: ${player.volume}")
}
break
}
}
break
}
}
}
// --- PLAYBACK STATE CALLBACK OVERRIDES ---
override fun onSongUpdate(song: Song?) {
@ -501,7 +525,6 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
companion object {
private const val DISCONNECTED = 0
private const val CONNECTED = 1
private const val WAKELOCK_TIME = 25000L
private const val POS_POLL_INTERVAL = 500L
const val ACTION_LOOP = BuildConfig.APPLICATION_ID + ".action.LOOP"
@ -510,5 +533,10 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE"
const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT"
const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT"
const val RG_TRACK = "REPLAYGAIN_TRACK_GAIN"
const val RG_ALBUM = "REPLAYGAIN_ALBUM_GAIN"
const val R128_TRACK = "R128_TRACK_GAIN"
const val R128_ALBUM = "R128_ALBUM_GAIN"
}
}

View file

@ -0,0 +1,107 @@
package org.oxycblt.auxio.playback.system
import androidx.core.math.MathUtils
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.flac.VorbisComment
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import kotlin.math.pow
const val RG_TRACK = "REPLAYGAIN_TRACK_GAIN"
const val RG_ALBUM = "REPLAYGAIN_ALBUM_GAIN"
const val R128_TRACK = "R128_TRACK_GAIN"
const val R128_ALBUM = "R128_ALBUM_GAIN"
val replayGainTags = arrayOf(
RG_TRACK,
RG_ALBUM,
R128_ALBUM,
R128_TRACK
)
data class Gain(val track: Float, val album: Float)
/**
* Calculates the rough volume adjustment for [Metadata] with ReplayGain tags.
* This is based off Vanilla Music's implementation.
*/
fun calculateReplayGain(metadata: Metadata): Float {
val gain = parseReplayGain(metadata)
// Currently we consider both the album and the track gain. One might want to add
// configuration to handle more cases.
var adjust = 0f
if (gain != null) {
adjust = if (gain.album != 0f) {
gain.album
} else {
gain.track
}
}
// Final adjustment along the volume curve.
// Ensure this is clamped to 0 or 1 so that it can be used as a volume.
return MathUtils.clamp((10f.pow((adjust / 20f))), 0f, 1f)
}
private fun parseReplayGain(metadata: Metadata): Gain? {
data class GainTag(val key: String, val value: Float)
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)
// Sometimes the ReplayGain keys will be lowercase, so make them uppercase.
if (entry is TextInformationFrame && entry.description?.uppercase() in replayGainTags) {
tags.add(GainTag(entry.description!!.uppercase(), parseReplayGainFloat(entry.value)))
continue
}
if (entry is VorbisComment && entry.key.uppercase() in replayGainTags) {
tags.add(GainTag(entry.key.uppercase(), parseReplayGainFloat(entry.value)))
}
}
// Case 1: Normal ReplayGain, most commonly found on MPEG files.
tags.findLast { tag -> tag.key == RG_TRACK }?.let { tag ->
trackGain = tag.value
found = true
}
tags.findLast { tag -> tag.key == RG_ALBUM }?.let { tag ->
albumGain = tag.value
found = true
}
// Case 2: R128 ReplayGain, most commonly found on FLAC files.
// While technically there is the R128 base gain in Opus files, ExoPlayer doesn't
// have metadata parsing functionality for those, so we just ignore it.
tags.findLast { tag -> tag.key == R128_TRACK }?.let { tag ->
trackGain += tag.value / 256f
found = true
}
tags.findLast { tag -> tag.key == R128_ALBUM }?.let { tag ->
albumGain += tag.value / 256f
found = true
}
return if (found) {
Gain(trackGain, albumGain)
} else {
null
}
}
private fun parseReplayGainFloat(raw: String): Float {
return try {
raw.replace(Regex("[^0-9.-]"), "").toFloat()
} catch (e: Exception) {
0f
}
}

View file

@ -59,7 +59,7 @@ private fun Any.getName(): String = "Auxio.${this::class.simpleName ?: "Anonymou
* I know that this will not stop you, but consider what you are doing with your life, copycats.
* Do you want to live a fulfilling existence on this planet? Or do you want to spend your life
* taking work others did and making it objectively worse so you could arbitrage a fraction of a
* penny on every AdMob impression you get. You could do so many great things if you simply had
* penny on every AdMob impression you get? You could do so many great things if you simply had
* the courage to come up with an idea of your own. Be better.
*/
private fun basedCopyleftNotice() {