diff --git a/CHANGELOG.md b/CHANGELOG.md index b26ff8ce4..3c5844921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Migrated constants to centralized table - Introduced new RecyclerView framework - Use native ExoPlayer AudioFocus implementation +- Make ReplayGain functionality use AudioProcessor instead of volume - Removed databinding [Greatly reduces compile times] - A bunch of internal view implementation improvements diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 2abe00108..0521103fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -37,6 +37,7 @@ 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.AudioCapabilities import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer import com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory @@ -82,13 +83,13 @@ class PlaybackService : private lateinit var player: ExoPlayer private lateinit var mediaSession: MediaSessionCompat private lateinit var connector: PlaybackSessionConnector + private val audioProcessor = ReplayGainAudioProcessor() // Notification components private lateinit var notification: PlaybackNotification private lateinit var notificationManager: NotificationManager // System backend components - private lateinit var audioReactor: VolumeReactor private lateinit var widgets: WidgetController private val systemReceiver = PlaybackReceiver() @@ -132,12 +133,6 @@ class PlaybackService : .build(), true) - audioReactor = - VolumeReactor { volume -> - logD("Updating player volume to $volume") - player.volume = volume - } - // --- SYSTEM SETUP --- widgets = WidgetController(this) @@ -252,7 +247,7 @@ class PlaybackService : if (info.isSelected) { for (i in 0 until info.trackGroup.length) { if (info.isTrackSelected(i)) { - audioReactor.applyReplayGain(info.trackGroup.getFormat(i).metadata) + audioProcessor.applyReplayGain(info.trackGroup.getFormat(i).metadata) break } } @@ -358,8 +353,14 @@ class PlaybackService : // battery/apk size/cache size val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( - MediaCodecAudioRenderer(this, MediaCodecSelector.DEFAULT, handler, audioListener), - LibflacAudioRenderer(handler, audioListener)) + MediaCodecAudioRenderer( + this, + MediaCodecSelector.DEFAULT, + handler, + audioListener, + AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, + audioProcessor), + LibflacAudioRenderer(handler, audioListener, audioProcessor)) } // Enable constant bitrate seeking so that certain MP3s/AACs are seekable diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/ReplayGainAudioProcessor.kt new file mode 100644 index 000000000..0a9e222de --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/ReplayGainAudioProcessor.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2022 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 . + */ + +package org.oxycblt.auxio.playback.system + +import androidx.core.math.MathUtils +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.Format +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.TextInformationFrame +import com.google.android.exoplayer2.metadata.vorbis.VorbisComment +import java.nio.ByteBuffer +import kotlin.math.pow +import okhttp3.internal.and +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.settings.SettingsManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * An [AudioProcessor] that automatically 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. + * + * TODO: Positive ReplayGain values (implementation is not good enough yet, results in popping) + * + * TODO: Pre-amp values + * + * @author OxygenCobalt + */ +class ReplayGainAudioProcessor : BaseAudioProcessor() { + private data class Gain(val track: Float, val album: Float) + private data class GainTag(val key: String, val value: Float) + + private val playbackManager = PlaybackStateManager.getInstance() + private val settingsManager = SettingsManager.getInstance() + + private var volume = 1f + + /// --- REPLAYGAIN PARSING --- + + /** + * Updates the rough volume adjustment for [Metadata] with ReplayGain tags. This is based off + * Vanilla Music's implementation. + */ + fun applyReplayGain(metadata: Metadata?) { + if (metadata == null) { + logW("No metadata could be extracted from this track") + volume = 1f + return + } + + // ReplayGain is configurable, so determine what to do based off of the mode. + val useAlbumGain: (Gain) -> Boolean = + when (settingsManager.replayGainMode) { + ReplayGainMode.OFF -> { + logD("ReplayGain is off") + volume = 1f + return + } + + // User wants track gain to be preferred. Default to album gain only if there + // is no track gain. + ReplayGainMode.TRACK -> { gain -> gain.track == 0f } + + // User wants album gain to be preferred. Default to track gain only if there + // is no album gain. + ReplayGainMode.ALBUM -> { gain -> gain.album != 0f } + + // User wants album gain to be used when in an album, track gain otherwise. + ReplayGainMode.DYNAMIC -> { _ -> + playbackManager.parent is Album && + playbackManager.song?.album == playbackManager.parent + } + } + + val gain = parseReplayGain(metadata) + + val adjust = + if (gain != null) { + if (useAlbumGain(gain)) { + logD("Using album gain") + gain.album + } else { + logD("Using track gain") + gain.track + } + } else { + // No gain tags were present + 0f + } + + // Final adjustment along the volume curve. + // Currently, we clamp it to a fixed value as 0f + volume = MathUtils.clamp(10f.pow(adjust / 20f), 0f, 1f) + flush() + } + + private fun parseReplayGain(metadata: Metadata): Gain? { + var trackGain = 0f + var albumGain = 0f + var found = false + + val tags = mutableListOf() + + for (i in 0 until metadata.length()) { + val entry = metadata.get(i) + + val key: String? + val value: String + + when (entry) { + is TextInformationFrame -> { + key = entry.description?.uppercase() + value = entry.value + } + is VorbisComment -> { + key = entry.key + value = entry.value + } + else -> continue + } + + if (key in REPLAY_GAIN_TAGS) { + tags.add(GainTag(unlikelyToBeNull(key), parseReplayGainFloat(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, 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 == 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 + } + } + + // --- AUDIO PROCESSOR IMPLEMENTATION --- + + override fun onConfigure( + inputAudioFormat: AudioProcessor.AudioFormat + ): AudioProcessor.AudioFormat { + // TODO: Determine if we really need all of these encodings + val encoding = inputAudioFormat.encoding + if (encoding != C.ENCODING_PCM_8BIT && + encoding != C.ENCODING_PCM_16BIT && + encoding != C.ENCODING_PCM_16BIT_BIG_ENDIAN && + encoding != C.ENCODING_PCM_24BIT && + encoding != C.ENCODING_PCM_32BIT && + encoding != C.ENCODING_PCM_FLOAT) { + throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) + } + + return inputAudioFormat + } + + override fun queueInput(inputBuffer: ByteBuffer) { + val position = inputBuffer.position() + val limit = inputBuffer.limit() + val size = limit - position + val buffer = replaceOutputBuffer(size) + + if (volume == 1f) { + // Nothing to do, just copy the bytes normally so that we're more efficient. + for (i in position until limit) { + buffer.put(inputBuffer[i]) + } + } else { + // Note: If an encoding value exceeds the actual data capacity of the encoding, + // it is truncated. This is not ideal, but since many of these formats are bitwise + // (and the jvm cannot into unsigned types), we can't do smarter clamping with them. + when (inputAudioFormat.encoding) { + C.ENCODING_PCM_8BIT -> { + // 8-bit PCM, decode a single byte and multiply it + for (i in position until limit) { + val sample = inputBuffer.get(i).toInt().and(0xFF) + val targetSample = (sample * volume).toInt().toByte() + buffer.put(targetSample) + } + } + C.ENCODING_PCM_16BIT -> { + // 16-bit PCM (little endian). + for (i in position until limit step 2) { + val sample = inputBuffer.getLeShort(i) + val targetSample = (sample * volume).toInt().toShort() + buffer.putLeShort(targetSample) + } + } + C.ENCODING_PCM_16BIT_BIG_ENDIAN -> { + // 16-bit PCM (big endian) + for (i in position until limit step 2) { + val sample = inputBuffer.getBeShort(i) + val targetSample = (sample * volume).toInt().toShort() + buffer.putBeSort(targetSample) + } + } + C.ENCODING_PCM_24BIT -> { + // 24-bit PCM (little endian), decode the data three bytes at a time. + for (i in position until limit step 3) { + val sample = inputBuffer.getLeInt24(i) + val targetSample = (sample * volume).toInt() + buffer.putLeInt24(targetSample) + } + } + C.ENCODING_PCM_32BIT -> { + // 32-bit PCM (little endian) + for (i in position until limit step 4) { + var sample = inputBuffer.getLeLong32(i) + sample = (sample * volume).toLong() + buffer.putLeLong32(sample) + } + } + C.ENCODING_PCM_FLOAT -> { + // PCM float. Here we can actually clamp values since the value isn't + // bitwise. + for (i in position until limit step 4) { + var sample = inputBuffer.getFloat(i) + sample = MathUtils.clamp((sample * volume), 0f, 1f) + buffer.putFloat(sample) + } + } + C.ENCODING_INVALID, Format.NO_VALUE -> {} + } + } + + inputBuffer.position(limit) + buffer.flip() + } + + private fun ByteBuffer.getLeShort(at: Int): Short { + return get(at + 1).toInt().shl(8).or(get(at).toInt().and(0xFF)).toShort() + } + + private fun ByteBuffer.getBeShort(at: Int): Short { + return get(at).toInt().shl(8).or(get(at + 1).toInt().and(0xFF)).toShort() + } + + private fun ByteBuffer.putLeShort(short: Short) { + put(short.toByte()) + put(short.toInt().shr(8).toByte()) + } + + private fun ByteBuffer.putBeSort(short: Short) { + put(short.toInt().shr(8).toByte()) + put(short.toByte()) + } + + 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)) + } + + private fun ByteBuffer.putLeInt24(int: Int) { + put(int.toByte()) + put(int.shr(8).toByte()) + put(int.shr(16).toByte()) + } + + private fun ByteBuffer.getLeLong32(at: Int): Long { + return get(at + 3) + .toLong() + .shl(24) + .or(get(at + 2).toLong().shl(16)) + .or(get(at + 1).toLong().shl(8)) + .or(get(at).toLong().and(0xFF)) + } + + private fun ByteBuffer.putLeLong32(long: Long) { + put(long.toByte()) + put(long.shr(8).toByte()) + put(long.shr(16).toByte()) + put(long.shr(24).toByte()) + } + + companion object { + private const val RG_TRACK = "REPLAYGAIN_TRACK_GAIN" + private const val RG_ALBUM = "REPLAYGAIN_ALBUM_GAIN" + private const val R128_TRACK = "R128_TRACK_GAIN" + private const val R128_ALBUM = "R128_ALBUM_GAIN" + + private val REPLAY_GAIN_TAGS = arrayOf(RG_TRACK, RG_ALBUM, R128_ALBUM, R128_TRACK) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/VolumeReactor.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/VolumeReactor.kt deleted file mode 100644 index 4e1b911c9..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/VolumeReactor.kt +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright (c) 2021 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 . - */ - -package org.oxycblt.auxio.playback.system - -import androidx.core.math.MathUtils -import com.google.android.exoplayer2.metadata.Metadata -import com.google.android.exoplayer2.metadata.id3.TextInformationFrame -import com.google.android.exoplayer2.metadata.vorbis.VorbisComment -import kotlin.math.pow -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.settings.SettingsManager -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW -import org.oxycblt.auxio.util.unlikelyToBeNull - -/** - * Manages the current volume across ReplayGain and AudioFocus events. - * - * TODO: Add ReplayGain pre-amp - * - * TODO: Add positive ReplayGain - * @author OxygenCobalt - */ -class VolumeReactor(private val callback: (Float) -> Unit) { - private data class Gain(val track: Float, val album: Float) - private data class GainTag(val key: String, val value: Float) - - private val playbackManager = PlaybackStateManager.getInstance() - private val settingsManager = SettingsManager.getInstance() - - // It's good to keep the volume and the ducking multiplier separate so that we don't - // lose information - private var multiplier = 1f - set(value) { - field = value - callback(volume) - } - - private var volume = 0f - get() = field * multiplier - set(value) { - field = value - callback(volume) - } - - /** - * Updates the rough volume adjustment for [Metadata] with ReplayGain tags. This is based off - * Vanilla Music's implementation. - */ - fun applyReplayGain(metadata: Metadata?) { - if (metadata == null) { - logW("No metadata could be extracted from this track") - volume = 1f - return - } - - // ReplayGain is configurable, so determine what to do based off of the mode. - val useAlbumGain: (Gain) -> Boolean = - when (settingsManager.replayGainMode) { - ReplayGainMode.OFF -> { - logD("ReplayGain is off") - volume = 1f - return - } - - // User wants track gain to be preferred. Default to album gain only if there - // is no track gain. - ReplayGainMode.TRACK -> { gain -> gain.track == 0f } - - // User wants album gain to be preferred. Default to track gain only if there - // is no album gain. - ReplayGainMode.ALBUM -> { gain -> gain.album != 0f } - - // User wants album gain to be used when in an album, track gain otherwise. - ReplayGainMode.DYNAMIC -> { _ -> - playbackManager.parent is Album && - playbackManager.song?.album == playbackManager.parent - } - } - - val gain = parseReplayGain(metadata) - - val adjust = - if (gain != null) { - if (useAlbumGain(gain)) { - logD("Using album gain") - gain.album - } else { - logD("Using track gain") - gain.track - } - } else { - // No gain tags were present - 0f - } - - // Final adjustment along the volume curve. - // Ensure this is clamped to 0 or 1 so that it can be used as a volume. - volume = MathUtils.clamp((10f.pow((adjust / 20f))), 0f, 1f) - } - - private fun parseReplayGain(metadata: Metadata): Gain? { - var trackGain = 0f - var albumGain = 0f - var found = false - - val tags = mutableListOf() - - for (i in 0 until metadata.length()) { - val entry = metadata.get(i) - - val key: String? - val value: String - - when (entry) { - is TextInformationFrame -> { - key = entry.description?.uppercase() - value = entry.value - } - is VorbisComment -> { - key = entry.key - value = entry.value - } - else -> continue - } - - if (key in REPLAY_GAIN_TAGS) { - tags.add(GainTag(unlikelyToBeNull(key), parseReplayGainFloat(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, 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 == 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 - } - } - - // --- SETTINGS MANAGEMENT --- - - companion object { - 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 REPLAY_GAIN_TAGS = arrayOf(RG_TRACK, RG_ALBUM, R128_ALBUM, R128_TRACK) - } -}