playback: add positive replaygain values

Implement support for positive ReplayGain values.

Turns out the blocker for this with the new AudioProcessor was that
I did not properly clamp PCM data when I manipulated the data,
resulting in target samples like 75491 being truncated to lower
values like 9955, which resulted in popping. This is a niche addition,
but also puts Auxio in a category that no other (FOSS) android music
player currently occupies. Yay.

Resolves #115.
This commit is contained in:
OxygenCobalt 2022-03-28 19:58:25 -06:00
parent b4abad26cd
commit 59a56090e8
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 75 additions and 37 deletions

View file

@ -2,6 +2,9 @@
## dev [v2.2.3, v2.3.0, or v3.0.0] ## dev [v2.2.3, v2.3.0, or v3.0.0]
#### What's New
- Added ReplayGain support for below-reference volume tracks [i.e positive ReplayGain values]
#### What's Fixed #### What's Fixed
- Fixed incorrect ellipsizing on song items - Fixed incorrect ellipsizing on song items

View file

@ -42,7 +42,7 @@ I primarily built Auxio for myself, but you can use it too, I guess.
- Customizable UI & Behavior - Customizable UI & Behavior
- Advanced media indexer that prioritizes correct metadata - Advanced media indexer that prioritizes correct metadata
- Reliable playback state persistence - Reliable playback state persistence
- ReplayGain support (On MP3, MP4, FLAC, OGG, and OPUS) - Full ReplayGain support (On MP3, MP4, FLAC, OGG, and OPUS)
- Material You (Android 12+ only) - Material You (Android 12+ only)
- Edge-to-edge - Edge-to-edge
- Embedded covers support - Embedded covers support

View file

@ -17,7 +17,6 @@
package org.oxycblt.auxio.playback.system package org.oxycblt.auxio.playback.system
import androidx.core.math.MathUtils
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.Format import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.audio.AudioProcessor import com.google.android.exoplayer2.audio.AudioProcessor
@ -27,10 +26,10 @@ import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment 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 okhttp3.internal.and
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.clamp
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -41,8 +40,6 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* manipulates the bitstream itself to modify the volume, which allows the use of positive * manipulates the bitstream itself to modify the volume, which allows the use of positive
* ReplayGain values. * ReplayGain values.
* *
* TODO: Positive ReplayGain values (implementation is not good enough yet, results in popping)
*
* TODO: Pre-amp values * TODO: Pre-amp values
* *
* @author OxygenCobalt * @author OxygenCobalt
@ -55,6 +52,11 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private var volume = 1f private var volume = 1f
set(value) {
field = value
// Processed bytes are no longer valid, flush the stream
flush()
}
/// --- REPLAYGAIN PARSING --- /// --- REPLAYGAIN PARSING ---
@ -110,9 +112,7 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
} }
// Final adjustment along the volume curve. // Final adjustment along the volume curve.
// Currently, we clamp it to a fixed value as 0f volume = 10f.pow(adjust / 20f)
volume = MathUtils.clamp(10f.pow(adjust / 20f), 0f, 1f)
flush()
} }
private fun parseReplayGain(metadata: Metadata): Gain? { private fun parseReplayGain(metadata: Metadata): Gain? {
@ -170,7 +170,6 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
albumGain += tag.value / 256f albumGain += tag.value / 256f
found = true found = true
} }
return if (found) { return if (found) {
Gain(trackGain, albumGain) Gain(trackGain, albumGain)
} else { } else {
@ -212,20 +211,27 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
val buffer = replaceOutputBuffer(size) val buffer = replaceOutputBuffer(size)
if (volume == 1f) { if (volume == 1f) {
// Nothing to do, just copy the bytes normally so that we're more efficient. // Nothing to do, just copy the bytes into the output buffer.
for (i in position until limit) { for (i in position until limit) {
buffer.put(inputBuffer[i]) buffer.put(inputBuffer[i])
} }
} else { } else {
// Note: If an encoding value exceeds the actual data capacity of the encoding, // AudioProcessor supplies us with the raw bytes and the encoding. It's our job
// it is truncated. This is not ideal, but since many of these formats are bitwise // to decode and manipulate it. However, the way we muck the bytes into integer
// (and the jvm cannot into unsigned types), we can't do smarter clamping with them. // types (and vice versa) introduces the possibility for bits to be dropped along
// the way. This is very bad and can result in popping, corrupted audio streams.
// Fix this by clamping the values to the possible range of *signed* values, as
// the PCM data is unsigned and still uses the bit that the JVM interprets as a sign.
when (inputAudioFormat.encoding) { when (inputAudioFormat.encoding) {
C.ENCODING_PCM_8BIT -> { C.ENCODING_PCM_8BIT -> {
// 8-bit PCM, decode a single byte and multiply it // 8-bit PCM, decode a single byte and multiply it
for (i in position until limit) { for (i in position until limit) {
val sample = inputBuffer.get(i).toInt().and(0xFF) val sample = inputBuffer.get(i).toInt().and(0xFF)
val targetSample = (sample * volume).toInt().toByte() val targetSample =
(sample * volume)
.toInt()
.clamp(Byte.MIN_VALUE.toInt(), Byte.MAX_VALUE.toInt())
.toByte()
buffer.put(targetSample) buffer.put(targetSample)
} }
} }
@ -233,7 +239,11 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
// 16-bit PCM (little endian). // 16-bit PCM (little endian).
for (i in position until limit step 2) { for (i in position until limit step 2) {
val sample = inputBuffer.getLeShort(i) val sample = inputBuffer.getLeShort(i)
val targetSample = (sample * volume).toInt().toShort() val targetSample =
(sample * volume)
.toInt()
.clamp(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
.toShort()
buffer.putLeShort(targetSample) buffer.putLeShort(targetSample)
} }
} }
@ -241,33 +251,40 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
// 16-bit PCM (big endian) // 16-bit PCM (big endian)
for (i in position until limit step 2) { for (i in position until limit step 2) {
val sample = inputBuffer.getBeShort(i) val sample = inputBuffer.getBeShort(i)
val targetSample = (sample * volume).toInt().toShort() val targetSample =
(sample * volume)
.toInt()
.clamp(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
.toShort()
buffer.putBeSort(targetSample) buffer.putBeSort(targetSample)
} }
} }
C.ENCODING_PCM_24BIT -> { C.ENCODING_PCM_24BIT -> {
// 24-bit PCM (little endian), decode the data three bytes at a time. // 24-bit PCM (little endian), decode the data three bytes at a time.
// I don't know if the clamping we do here is valid or not. Since the bit
// values should not cross over into the sign, we should be able to do a
// simple unsigned clamp, but I'm not sure.
for (i in position until limit step 3) { for (i in position until limit step 3) {
val sample = inputBuffer.getLeInt24(i) val sample = inputBuffer.getLeInt24(i)
val targetSample = (sample * volume).toInt() val targetSample = (sample * volume).toInt().clamp(0, 0xFF_FF_FF)
buffer.putLeInt24(targetSample) buffer.putLeInt24(targetSample)
} }
} }
C.ENCODING_PCM_32BIT -> { C.ENCODING_PCM_32BIT -> {
// 32-bit PCM (little endian) // 32-bit PCM (little endian).
for (i in position until limit step 4) { for (i in position until limit step 4) {
var sample = inputBuffer.getLeLong32(i) var sample = inputBuffer.getLeInt32(i)
sample = (sample * volume).toLong() sample = (sample * volume).toInt().clamp(Int.MIN_VALUE, Int.MAX_VALUE)
buffer.putLeLong32(sample) buffer.putLeInt32(sample)
} }
} }
C.ENCODING_PCM_FLOAT -> { C.ENCODING_PCM_FLOAT -> {
// PCM float. Here we can actually clamp values since the value isn't // PCM float. Here we can actually clamp values since the value isn't
// bitwise. // bitwise.
for (i in position until limit step 4) { for (i in position until limit step 4) {
var sample = inputBuffer.getFloat(i) val sample = inputBuffer.getFloat(i)
sample = MathUtils.clamp((sample * volume), 0f, 1f) val targetSample = (sample * volume).clamp(0f, 1f)
buffer.putFloat(sample) buffer.putFloat(targetSample)
} }
} }
C.ENCODING_INVALID, Format.NO_VALUE -> {} C.ENCODING_INVALID, Format.NO_VALUE -> {}
@ -297,7 +314,11 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
} }
private fun ByteBuffer.getLeInt24(at: Int): Int { private fun ByteBuffer.getLeInt24(at: Int): Int {
return get(at + 2).toInt().shl(16).or(get(at + 1).toInt().shl(8)).or(get(at).and(0xFF)) return get(at + 2)
.toInt()
.shl(16)
.or(get(at + 1).toInt().shl(8))
.or(get(at).toInt().and(0xFF))
} }
private fun ByteBuffer.putLeInt24(int: Int) { private fun ByteBuffer.putLeInt24(int: Int) {
@ -306,20 +327,20 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() {
put(int.shr(16).toByte()) put(int.shr(16).toByte())
} }
private fun ByteBuffer.getLeLong32(at: Int): Long { private fun ByteBuffer.getLeInt32(at: Int): Int {
return get(at + 3) return get(at + 3)
.toLong() .toInt()
.shl(24) .shl(24)
.or(get(at + 2).toLong().shl(16)) .or(get(at + 2).toInt().shl(16))
.or(get(at + 1).toLong().shl(8)) .or(get(at + 1).toInt().shl(8))
.or(get(at).toLong().and(0xFF)) .or(get(at).toInt().and(0xFF))
} }
private fun ByteBuffer.putLeLong32(long: Long) { private fun ByteBuffer.putLeInt32(int: Int) {
put(long.toByte()) put(int.toByte())
put(long.shr(8).toByte()) put(int.shr(8).toByte())
put(long.shr(16).toByte()) put(int.shr(16).toByte())
put(long.shr(24).toByte()) put(int.shr(24).toByte())
} }
companion object { companion object {

View file

@ -78,7 +78,13 @@ class SettingsListFragment : PreferenceFragmentCompat() {
override fun onDisplayPreferenceDialog(preference: Preference) { override fun onDisplayPreferenceDialog(preference: Preference) {
if (preference is IntListPreference) { if (preference is IntListPreference) {
// Creating our own preference dialog is hilariously difficult. For one, we need // Creating our own preference dialog is hilariously difficult. For one, we need
// to override this random method within the class in order to // to override this random method within the class in order to launch the dialog in
// the first (because apparently you can't just implement some interface that
// automatically provides this behavior), then we also need to use a deprecated method
// to adequately supply
// a "target fragment" (otherwise we will crash since the dialog requires one), and then
// we need to actually show the dialog, making sure we use the parent FragmentManager
// as again, it will crash if we don't do it right. Fragments were a mistake.
val dialog = IntListPreferenceDialog.from(preference) val dialog = IntListPreferenceDialog.from(preference)
dialog.setTargetFragment(this, 0) dialog.setTargetFragment(this, 0)
dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG) dialog.show(parentFragmentManager, IntListPreferenceDialog.TAG)

View file

@ -70,6 +70,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
super.onDetachedFromWindow() super.onDetachedFromWindow()
viewTreeObserver.removeOnPreDrawListener(onPreDraw) viewTreeObserver.removeOnPreDrawListener(onPreDraw)
scrollingChild = null
} }
override fun setLiftOnScrollTargetViewId(liftOnScrollTargetViewId: Int) { override fun setLiftOnScrollTargetViewId(liftOnScrollTargetViewId: Int) {
@ -88,7 +89,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (liftOnScrollTargetViewId != ResourcesCompat.ID_NULL) { if (liftOnScrollTargetViewId != ResourcesCompat.ID_NULL) {
scrollingChild = (parent as ViewGroup).findViewById(liftOnScrollTargetViewId) scrollingChild = (parent as ViewGroup).findViewById(liftOnScrollTargetViewId)
} else { } else {
logW("liftOnScrollTargetViewId was not specified. ignoring scroll events") logW("liftOnScrollTargetViewId was not specified, ignoring scroll events")
} }
} }

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.util
import android.database.Cursor import android.database.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.os.Looper import android.os.Looper
import androidx.core.math.MathUtils
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
@ -51,3 +52,9 @@ fun <T> unlikelyToBeNull(value: T?): T {
/** Require the fragment is attached to an activity. */ /** Require the fragment is attached to an activity. */
fun Fragment.requireAttached() = check(!isDetached) { "Fragment is detached from activity" } fun Fragment.requireAttached() = check(!isDetached) { "Fragment is detached from activity" }
fun Int.clamp(min: Int, max: Int): Int = MathUtils.clamp(this, min, max)
fun Long.clamp(min: Long, max: Long): Long = MathUtils.clamp(this, min, max)
fun Float.clamp(min: Float, max: Float): Float = MathUtils.clamp(this, min, max)