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:
parent
43e01e839d
commit
d419a4230d
4 changed files with 137 additions and 4 deletions
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
Loading…
Reference in a new issue