From ac72c137b50048a1c7a3d3a77cfa6a4c71b0df3e Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Mon, 25 May 2026 22:16:33 +0200 Subject: [PATCH 1/7] fix(audio): replace LoudnessEnhancer with custom volume normalization processor to fix muffled audio --- .../metrolist/music/playback/MusicService.kt | 154 ++++-------------- .../VolumeNormalizationAudioProcessor.kt | 137 ++++++++++++++++ 2 files changed, 167 insertions(+), 124 deletions(-) create mode 100644 app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt diff --git a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt index 7ecd7cde24..0080a30b96 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt @@ -24,7 +24,7 @@ import android.media.AudioDeviceInfo import android.media.AudioFocusRequest import android.media.AudioManager import android.media.audiofx.AudioEffect -import android.media.audiofx.LoudnessEnhancer +import com.metrolist.music.playback.audio.VolumeNormalizationAudioProcessor import android.net.ConnectivityManager import android.os.Binder import android.os.Build @@ -388,7 +388,7 @@ class MusicService : private var isAudioEffectSessionOpened = false private var openedAudioEffectSessionId: Int = C.AUDIO_SESSION_ID_UNSET - private var loudnessEnhancer: LoudnessEnhancer? = null + private val volumeNormalizationProcessor = VolumeNormalizationAudioProcessor() private var loudnessSetupJob: Job? = null private var loudnessSetupGeneration: Long = 0L @@ -857,7 +857,7 @@ class MusicService : }.collectLatest(scope) { (format, normalizeAudio, loudnessLevel) -> normalizationEnabledCached = normalizeAudio loudnessLevelCached = loudnessLevel - setupLoudnessEnhancer() + setupAudioNormalization() } combine( @@ -2049,85 +2049,48 @@ class MusicService : ) } - private fun applyCachedLoudnessEnhancerNow() { - val enhancer = loudnessEnhancer ?: return - + private fun applyCachedAudioNormalizationNow() { try { val gain = cachedNormalizationGainMb - if (cachedNormalizationEnabled && gain != null) { - enhancer.setTargetGain(gain) - enhancer.enabled = true + volumeNormalizationProcessor.setTargetGain(gain) + volumeNormalizationProcessor.enabled = true } else { - enhancer.enabled = false + volumeNormalizationProcessor.enabled = false } } catch (e: Exception) { reportException(e) - releaseLoudnessEnhancer() - } - } - - private fun createLoudnessEnhancerForSessionId(audioSessionId: Int): Boolean { - try { - loudnessEnhancer = LoudnessEnhancer(audioSessionId) - Timber.tag(TAG).d("LoudnessEnhancer created for sessionId=$audioSessionId") - - return true - } catch (e: Exception) { - reportException(e) - loudnessEnhancer = null - - return false + volumeNormalizationProcessor.enabled = false } } - private fun setupLoudnessEnhancer() { - val audioSessionId = player.audioSessionId - - if (audioSessionId == C.AUDIO_SESSION_ID_UNSET || audioSessionId <= 0) { - Timber - .tag(TAG) - .w("setupLoudnessEnhancer: invalid audioSessionId ($audioSessionId), cannot create effect yet") - return - } - - // Create or recreate enhancer if needed - if (loudnessEnhancer == null && !createLoudnessEnhancerForSessionId(audioSessionId)) { - return - } - + private fun setupAudioNormalization() { val requestGeneration = ++loudnessSetupGeneration loudnessSetupJob?.cancel() loudnessSetupJob = scope.launch { try { - val currentMediaId = - withContext(Dispatchers.Main) { - player.currentMediaItem?.mediaId - } + val currentMediaId = withContext(Dispatchers.Main) { + player.currentMediaItem?.mediaId + } val normalizeAudio = normalizationEnabledCached if (normalizeAudio && currentMediaId != null) { - val format = - withContext(Dispatchers.IO) { - database.format(currentMediaId).first() - } + val format = withContext(Dispatchers.IO) { + database.format(currentMediaId).first() + } val targetLufs = loudnessLevelCached.targetLufs Timber.tag(TAG).d("Audio normalization enabled: $normalizeAudio") - Timber - .tag(TAG) - .d("Format loudnessDb: ${format?.loudnessDb}, perceptualLoudnessDb: ${format?.perceptualLoudnessDb}") - - // Use perceptualLoudnessDb if available, otherwise fall back to loudnessDb + offset + val measuredLufs: Double? = format?.perceptualLoudnessDb ?: format?.loudnessDb?.let { it + LoudnessLevel.AGGRESSIVE.targetLufs } withContext(Dispatchers.Main) { if (!isActive || requestGeneration != loudnessSetupGeneration) return@withContext - if (player.audioSessionId != audioSessionId || player.currentMediaItem?.mediaId != currentMediaId) return@withContext + if (player.currentMediaItem?.mediaId != currentMediaId) return@withContext when { measuredLufs != null -> { @@ -2135,28 +2098,18 @@ class MusicService : val targetGain = (-loudnessDb * 100.0).toInt() val clampedGain = targetGain.coerceIn(MIN_GAIN_MB, MAX_GAIN_MB) - Timber.tag(TAG) - .d("Normalization Target LUFS: $targetLufs, Measured LUFS: $measuredLufs, Calculated gain: $targetGain mB, Clamped gain: $clampedGain mB") - - Timber.tag(TAG) - .d("Calculated raw normalization gain: $targetGain mB (from loudness: $loudnessDb)") - cachedNormalizationGainMb = clampedGain cachedNormalizationEnabled = true - loudnessEnhancer?.setTargetGain(clampedGain) - loudnessEnhancer?.enabled = true + volumeNormalizationProcessor.setTargetGain(clampedGain) + volumeNormalizationProcessor.enabled = true } - format == null -> { - // Row not available yet for new track: keep carry-over gain to avoid a jump. Timber.tag(TAG).d("Loudness row not ready yet; keeping cached normalization state") } - else -> { cachedNormalizationGainMb = null cachedNormalizationEnabled = false - loudnessEnhancer?.enabled = false - Timber.tag(TAG).w("No loudness data available for track - normalization disabled") + volumeNormalizationProcessor.enabled = false } } } @@ -2165,85 +2118,40 @@ class MusicService : if (!isActive || requestGeneration != loudnessSetupGeneration) return@withContext cachedNormalizationGainMb = null cachedNormalizationEnabled = false - loudnessEnhancer?.enabled = false - Timber.tag(TAG).d("setupLoudnessEnhancer: normalization disabled or mediaId unavailable") + volumeNormalizationProcessor.enabled = false } } } catch (e: CancellationException) { - Timber.tag(TAG).d("setupLoudnessEnhancer: job cancelled, likely due to new setup request or session change") throw e } catch (e: Exception) { reportException(e) - releaseLoudnessEnhancer() - } - } - } - - private fun releaseLoudnessEnhancer(clearNormalizationCache: Boolean = true) { - try { - loudnessEnhancer?.release() - Timber.tag(TAG).d("LoudnessEnhancer released") - } catch (e: Exception) { - reportException(e) - Timber.tag(TAG).e(e, "Error releasing LoudnessEnhancer: ${e.message}") - } finally { - if (clearNormalizationCache) { - cachedNormalizationGainMb = null - cachedNormalizationEnabled = false + volumeNormalizationProcessor.enabled = false } - loudnessEnhancer = null } } private fun openAudioEffectSession() { val audioSessionId = player.audioSessionId if (audioSessionId == C.AUDIO_SESSION_ID_UNSET || audioSessionId <= 0) { - Timber.tag(TAG).w("openAudioEffectSession: invalid audioSessionId=$audioSessionId") return } - if (isAudioEffectSessionOpened && - openedAudioEffectSessionId == audioSessionId && - loudnessEnhancer != null - ) { - applyCachedLoudnessEnhancerNow() - - if (!cachedNormalizationEnabled || cachedNormalizationGainMb == null) { - setupLoudnessEnhancer() - } - + if (isAudioEffectSessionOpened && openedAudioEffectSessionId == audioSessionId) { return } if (isAudioEffectSessionOpened && openedAudioEffectSessionId > 0) { closeAudioEffectSession(sessionIdOverride = openedAudioEffectSessionId, clearNormalizationCache = false) - } else { - releaseLoudnessEnhancer(clearNormalizationCache = false) - } - - val enhancerReady = loudnessEnhancer != null || createLoudnessEnhancerForSessionId(audioSessionId) - - if (!enhancerReady) { - isAudioEffectSessionOpened = false - openedAudioEffectSessionId = C.AUDIO_SESSION_ID_UNSET - Timber.tag(TAG).w("openAudioEffectSession: failed to create LoudnessEnhancer for sessionId=$audioSessionId, audio effects will be unavailable") - return } isAudioEffectSessionOpened = true openedAudioEffectSessionId = audioSessionId - applyCachedLoudnessEnhancerNow() - - if (!cachedNormalizationEnabled || cachedNormalizationGainMb == null) { - setupLoudnessEnhancer() - } - sendBroadcast( - Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + Intent(android.media.audiofx.AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(android.media.audiofx.AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId) + putExtra(android.media.audiofx.AudioEffect.EXTRA_PACKAGE_NAME, packageName) + putExtra(android.media.audiofx.AudioEffect.EXTRA_CONTENT_TYPE, android.media.audiofx.AudioEffect.CONTENT_TYPE_MUSIC) }, ) } @@ -2262,9 +2170,6 @@ class MusicService : sessionIdToClose == openedAudioEffectSessionId if (isClosingCurrentSession) { - if (loudnessEnhancer != null) { - releaseLoudnessEnhancer(clearNormalizationCache = clearNormalizationCache) - } isAudioEffectSessionOpened = false openedAudioEffectSessionId = C.AUDIO_SESSION_ID_UNSET @@ -2356,7 +2261,7 @@ class MusicService : lastPlaybackSpeed = -1.0f // force update song - setupLoudnessEnhancer() + setupAudioNormalization() discordUpdateJob?.cancel() @@ -2505,7 +2410,7 @@ class MusicService : } if (playWhenReady) { - applyCachedLoudnessEnhancerNow() + applyCachedAudioNormalizationNow() } } @@ -3563,6 +3468,7 @@ class MusicService : DefaultAudioSink.DefaultAudioProcessorChain( // 2. Inject processor into audio pipeline arrayOf( + volumeNormalizationProcessor, eqProcessor, silenceProcessor, ), diff --git a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt new file mode 100644 index 0000000000..7f04e083ba --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -0,0 +1,137 @@ +package com.metrolist.music.playback.audio + +import androidx.media3.common.C +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.util.UnstableApi +import timber.log.Timber +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.pow + + +@UnstableApi +@SuppressWarnings("Deprecated") +class VolumeNormalizationAudioProcessor : AudioProcessor { + + private var sampleRate = 0 + private var channelCount = 0 + private var encoding = C.ENCODING_INVALID + private var isActive = false + + var enabled = false + set(value) { + if (field != value) { + field = value + Timber.tag(TAG).d("Normalization processor enabled: $value") + } + } + + private var inputBuffer: ByteBuffer = EMPTY_BUFFER + private var outputBuffer: ByteBuffer = EMPTY_BUFFER + private var inputEnded = false + + private var targetGainMb: Int = 0 + private var linearGain: Double = 1.0 + + companion object { + private const val TAG = "VolumeNormalizationProcessor" + private val EMPTY_BUFFER: ByteBuffer = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder()) + } + + @Synchronized + fun setTargetGain(gainMb: Int) { + if (targetGainMb != gainMb) { + targetGainMb = gainMb + linearGain = 10.0.pow(gainMb / 2000.0) + Timber.tag(TAG).d("Target gain set to $gainMb mB (Linear multiplier: $linearGain)") + } + } + + override fun configure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { + sampleRate = inputAudioFormat.sampleRate + channelCount = inputAudioFormat.channelCount + encoding = inputAudioFormat.encoding + + Timber.tag(TAG).d("Configured: sampleRate=$sampleRate, channels=$channelCount, encoding=$encoding") + + if (encoding != C.ENCODING_PCM_16BIT) { + val exception = AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) + throw exception + } + + isActive = true + return inputAudioFormat + } + + override fun isActive(): Boolean = isActive + + override fun queueInput(inputBuffer: ByteBuffer) { + if (!enabled || targetGainMb == 0) { + val remaining = inputBuffer.remaining() + if (remaining == 0) return + + if (outputBuffer.capacity() < remaining) { + outputBuffer = ByteBuffer.allocateDirect(remaining).order(ByteOrder.nativeOrder()) + } else { + outputBuffer.clear() + } + outputBuffer.put(inputBuffer) + outputBuffer.flip() + return + } + + val inputSize = inputBuffer.remaining() + if (inputSize == 0) return + + if (outputBuffer === EMPTY_BUFFER || outputBuffer === inputBuffer) { + outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder()) + } else if (outputBuffer.capacity() < inputSize) { + outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder()) + } else { + outputBuffer.clear() + } + + val sampleCount = inputSize / 2 + + repeat(sampleCount) { + val sample = inputBuffer.getShort() + val processed = (sample * linearGain).coerceIn(-32768.0, 32767.0).toInt().toShort() + outputBuffer.putShort(processed) + } + + outputBuffer.flip() + } + + override fun queueEndOfStream() { + inputEnded = true + } + + override fun getOutput(): ByteBuffer { + val buffer = outputBuffer + outputBuffer = EMPTY_BUFFER + return buffer + } + + override fun isEnded(): Boolean { + return inputEnded && outputBuffer.remaining() == 0 + } + + @Deprecated("Deprecated in Java") + override fun flush() { + outputBuffer = EMPTY_BUFFER + inputEnded = false + } + + override fun reset() { + @Suppress("DEPRECATION") + flush() + inputBuffer = EMPTY_BUFFER + sampleRate = 0 + channelCount = 0 + encoding = C.ENCODING_INVALID + isActive = false + targetGainMb = 0 + linearGain = 1.0 + enabled = false + } +} From c06829306acca645d7355d19032608f04eaad74f Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Tue, 26 May 2026 20:23:51 +0200 Subject: [PATCH 2/7] fix(audio): add @Volatile to shared fields, remove unused inputBuffer field --- .../playback/audio/VolumeNormalizationAudioProcessor.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt index 7f04e083ba..76f495b80e 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -17,7 +17,8 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { private var channelCount = 0 private var encoding = C.ENCODING_INVALID private var isActive = false - + + @Volatile var enabled = false set(value) { if (field != value) { @@ -26,11 +27,13 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { } } - private var inputBuffer: ByteBuffer = EMPTY_BUFFER private var outputBuffer: ByteBuffer = EMPTY_BUFFER private var inputEnded = false + @Volatile private var targetGainMb: Int = 0 + + @Volatile private var linearGain: Double = 1.0 companion object { @@ -125,7 +128,6 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { override fun reset() { @Suppress("DEPRECATION") flush() - inputBuffer = EMPTY_BUFFER sampleRate = 0 channelCount = 0 encoding = C.ENCODING_INVALID From e736eee6a7954f1fb57431d6b95475fec0056cb5 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Tue, 26 May 2026 20:45:45 +0200 Subject: [PATCH 3/7] fix(audio): atomically snapshot gain state in VolumeNormalizationAudioProcessor --- .../VolumeNormalizationAudioProcessor.kt | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt index 76f495b80e..b03cbd3a3e 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -30,11 +30,10 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { private var outputBuffer: ByteBuffer = EMPTY_BUFFER private var inputEnded = false - @Volatile - private var targetGainMb: Int = 0 + private data class GainState(val targetGainMb: Int, val linearGain: Double) @Volatile - private var linearGain: Double = 1.0 + private var currentGain: GainState = GainState(0, 1.0) companion object { private const val TAG = "VolumeNormalizationProcessor" @@ -43,9 +42,9 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { @Synchronized fun setTargetGain(gainMb: Int) { - if (targetGainMb != gainMb) { - targetGainMb = gainMb - linearGain = 10.0.pow(gainMb / 2000.0) + if (currentGain.targetGainMb != gainMb) { + val linearGain = 10.0.pow(gainMb / 2000.0) + currentGain = GainState(gainMb, linearGain) Timber.tag(TAG).d("Target gain set to $gainMb mB (Linear multiplier: $linearGain)") } } @@ -69,7 +68,8 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { override fun isActive(): Boolean = isActive override fun queueInput(inputBuffer: ByteBuffer) { - if (!enabled || targetGainMb == 0) { + val gain = currentGain + if (!enabled || gain.targetGainMb == 0) { val remaining = inputBuffer.remaining() if (remaining == 0) return @@ -98,7 +98,7 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { repeat(sampleCount) { val sample = inputBuffer.getShort() - val processed = (sample * linearGain).coerceIn(-32768.0, 32767.0).toInt().toShort() + val processed = (sample * gain.linearGain).coerceIn(-32768.0, 32767.0).toInt().toShort() outputBuffer.putShort(processed) } @@ -132,8 +132,7 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { channelCount = 0 encoding = C.ENCODING_INVALID isActive = false - targetGainMb = 0 - linearGain = 1.0 + currentGain = GainState(0, 1.0) enabled = false } } From 9ee3be4122589965c262e286074743008a7e3a03 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Tue, 26 May 2026 21:06:34 +0200 Subject: [PATCH 4/7] refactor(audio): support multiple PCM encodings, remove dead code, use replaceOutputBuffer helper --- .../VolumeNormalizationAudioProcessor.kt | 114 +++++++++++++----- 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt index b03cbd3a3e..7025e47fbc 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -8,14 +8,14 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.pow - @UnstableApi -@SuppressWarnings("Deprecated") +@Suppress("DEPRECATION") class VolumeNormalizationAudioProcessor : AudioProcessor { private var sampleRate = 0 private var channelCount = 0 private var encoding = C.ENCODING_INVALID + private var bytesPerSample = 0 private var isActive = false @Volatile @@ -54,13 +54,16 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { channelCount = inputAudioFormat.channelCount encoding = inputAudioFormat.encoding - Timber.tag(TAG).d("Configured: sampleRate=$sampleRate, channels=$channelCount, encoding=$encoding") - - if (encoding != C.ENCODING_PCM_16BIT) { - val exception = AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) - throw exception + bytesPerSample = when (encoding) { + C.ENCODING_PCM_16BIT -> 2 + C.ENCODING_PCM_24BIT -> 3 + C.ENCODING_PCM_32BIT -> 4 + C.ENCODING_PCM_FLOAT -> 4 + else -> throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) } + Timber.tag(TAG).d("Configured: sampleRate=$sampleRate, channels=$channelCount, encoding=$encoding") + isActive = true return inputAudioFormat } @@ -72,37 +75,63 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { if (!enabled || gain.targetGainMb == 0) { val remaining = inputBuffer.remaining() if (remaining == 0) return - - if (outputBuffer.capacity() < remaining) { - outputBuffer = ByteBuffer.allocateDirect(remaining).order(ByteOrder.nativeOrder()) - } else { - outputBuffer.clear() - } - outputBuffer.put(inputBuffer) - outputBuffer.flip() + val out = replaceOutputBuffer(remaining) + out.put(inputBuffer) + out.flip() return } val inputSize = inputBuffer.remaining() if (inputSize == 0) return - if (outputBuffer === EMPTY_BUFFER || outputBuffer === inputBuffer) { - outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder()) - } else if (outputBuffer.capacity() < inputSize) { - outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder()) - } else { - outputBuffer.clear() - } + val sampleCount = inputSize / bytesPerSample + val out = replaceOutputBuffer(inputSize) + + inputBuffer.order(ByteOrder.LITTLE_ENDIAN) + out.order(ByteOrder.LITTLE_ENDIAN) + + when (encoding) { + C.ENCODING_PCM_16BIT -> { + repeat(sampleCount) { + val sample = inputBuffer.getShort() + val processed = (sample * gain.linearGain) + .coerceIn(-32768.0, 32767.0) + .toInt() + .toShort() + out.putShort(processed) + } + } + + C.ENCODING_PCM_24BIT -> { + repeat(sampleCount) { + val sample = read24Bit(inputBuffer) + val processed = (sample * gain.linearGain) + .coerceIn(-8388608.0, 8388607.0) + .toInt() + write24Bit(out, processed) + } + } - val sampleCount = inputSize / 2 + C.ENCODING_PCM_32BIT -> { + repeat(sampleCount) { + val sample = inputBuffer.getInt() + val processed = (sample * gain.linearGain) + .coerceIn(Int.MIN_VALUE.toDouble(), Int.MAX_VALUE.toDouble()) + .toInt() + out.putInt(processed) + } + } - repeat(sampleCount) { - val sample = inputBuffer.getShort() - val processed = (sample * gain.linearGain).coerceIn(-32768.0, 32767.0).toInt().toShort() - outputBuffer.putShort(processed) + C.ENCODING_PCM_FLOAT -> { + repeat(sampleCount) { + val sample = inputBuffer.getFloat() + val processed = (sample * gain.linearGain.toFloat()).coerceIn(-1.0f, 1.0f) + out.putFloat(processed) + } + } } - outputBuffer.flip() + out.flip() } override fun queueEndOfStream() { @@ -116,23 +145,46 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { } override fun isEnded(): Boolean { - return inputEnded && outputBuffer.remaining() == 0 + return inputEnded && outputBuffer === EMPTY_BUFFER } - @Deprecated("Deprecated in Java") + @Deprecated("Deprecated in AudioProcessor") override fun flush() { outputBuffer = EMPTY_BUFFER inputEnded = false } + @Deprecated("Deprecated in AudioProcessor") override fun reset() { - @Suppress("DEPRECATION") flush() sampleRate = 0 channelCount = 0 encoding = C.ENCODING_INVALID + bytesPerSample = 0 isActive = false currentGain = GainState(0, 1.0) enabled = false } + + private fun replaceOutputBuffer(size: Int): ByteBuffer { + if (outputBuffer.capacity() < size) { + outputBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder()) + } else { + outputBuffer.clear() + } + return outputBuffer + } + + private fun read24Bit(buffer: ByteBuffer): Int { + val b0 = buffer.get().toInt() and 0xFF + val b1 = buffer.get().toInt() and 0xFF + val b2 = buffer.get().toInt() + return (b2 shl 16) or (b1 shl 8) or b0 + } + + private fun write24Bit(buffer: ByteBuffer, value: Int) { + buffer.put((value and 0xFF).toByte()) + buffer.put(((value shr 8) and 0xFF).toByte()) + buffer.put((value shr 16).toByte()) + } } From 58660f526c1155065472ea01fd42b90eb34723eb Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Tue, 26 May 2026 21:44:31 +0200 Subject: [PATCH 5/7] fix(audio): always output PCM_16BIT from VolumeNormalizationAudioProcessor --- .../VolumeNormalizationAudioProcessor.kt | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt index 7025e47fbc..e74ea038f7 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -65,27 +65,20 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { Timber.tag(TAG).d("Configured: sampleRate=$sampleRate, channels=$channelCount, encoding=$encoding") isActive = true - return inputAudioFormat + return AudioProcessor.AudioFormat(sampleRate, channelCount, C.ENCODING_PCM_16BIT) } override fun isActive(): Boolean = isActive override fun queueInput(inputBuffer: ByteBuffer) { val gain = currentGain - if (!enabled || gain.targetGainMb == 0) { - val remaining = inputBuffer.remaining() - if (remaining == 0) return - val out = replaceOutputBuffer(remaining) - out.put(inputBuffer) - out.flip() - return - } + val applyGain = enabled && gain.targetGainMb != 0 val inputSize = inputBuffer.remaining() if (inputSize == 0) return val sampleCount = inputSize / bytesPerSample - val out = replaceOutputBuffer(inputSize) + val out = replaceOutputBuffer(sampleCount * 2) inputBuffer.order(ByteOrder.LITTLE_ENDIAN) out.order(ByteOrder.LITTLE_ENDIAN) @@ -94,10 +87,14 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { C.ENCODING_PCM_16BIT -> { repeat(sampleCount) { val sample = inputBuffer.getShort() - val processed = (sample * gain.linearGain) - .coerceIn(-32768.0, 32767.0) - .toInt() - .toShort() + val processed = if (applyGain) { + (sample * gain.linearGain) + .coerceIn(-32768.0, 32767.0) + .toInt() + .toShort() + } else { + sample + } out.putShort(processed) } } @@ -105,28 +102,42 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { C.ENCODING_PCM_24BIT -> { repeat(sampleCount) { val sample = read24Bit(inputBuffer) - val processed = (sample * gain.linearGain) - .coerceIn(-8388608.0, 8388607.0) - .toInt() - write24Bit(out, processed) + val processed = if (applyGain) { + (sample * gain.linearGain) + .coerceIn(-32768.0, 32767.0) + .toInt() + } else { + sample shr 8 + }.toShort() + out.putShort(processed) } } C.ENCODING_PCM_32BIT -> { repeat(sampleCount) { val sample = inputBuffer.getInt() - val processed = (sample * gain.linearGain) - .coerceIn(Int.MIN_VALUE.toDouble(), Int.MAX_VALUE.toDouble()) - .toInt() - out.putInt(processed) + val processed = if (applyGain) { + (sample * gain.linearGain) + .coerceIn(-32768.0, 32767.0) + .toInt() + } else { + sample shr 16 + }.toShort() + out.putShort(processed) } } C.ENCODING_PCM_FLOAT -> { repeat(sampleCount) { val sample = inputBuffer.getFloat() - val processed = (sample * gain.linearGain.toFloat()).coerceIn(-1.0f, 1.0f) - out.putFloat(processed) + val processed = if (applyGain) { + (sample * gain.linearGain.toFloat()).coerceIn(-1.0f, 1.0f) + } else { + sample + } + out.putShort( + (processed * 32767f).toInt().coerceIn(-32768, 32767).toShort() + ) } } } @@ -182,9 +193,5 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { return (b2 shl 16) or (b1 shl 8) or b0 } - private fun write24Bit(buffer: ByteBuffer, value: Int) { - buffer.put((value and 0xFF).toByte()) - buffer.put(((value shr 8) and 0xFF).toByte()) - buffer.put((value shr 16).toByte()) - } + } From 75d1d36f9b1df9c2134edcf9a8c57b43638ab7c1 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Wed, 27 May 2026 23:28:44 +0200 Subject: [PATCH 6/7] fix(audio): apply gain to downshifted sample instead of full-width to avoid clipping --- .../audio/VolumeNormalizationAudioProcessor.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt index e74ea038f7..b65a914902 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -101,28 +101,30 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { C.ENCODING_PCM_24BIT -> { repeat(sampleCount) { - val sample = read24Bit(inputBuffer) + val sample = read24Bit(inputBuffer) shr 8 val processed = if (applyGain) { (sample * gain.linearGain) .coerceIn(-32768.0, 32767.0) .toInt() + .toShort() } else { - sample shr 8 - }.toShort() + sample.toShort() + } out.putShort(processed) } } C.ENCODING_PCM_32BIT -> { repeat(sampleCount) { - val sample = inputBuffer.getInt() + val sample = inputBuffer.getInt() shr 16 val processed = if (applyGain) { (sample * gain.linearGain) .coerceIn(-32768.0, 32767.0) .toInt() + .toShort() } else { - sample shr 16 - }.toShort() + sample.toShort() + } out.putShort(processed) } } From d027f73f126585860def6c338b2f443d56428b03 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Mon, 1 Jun 2026 19:59:42 +0200 Subject: [PATCH 7/7] fix(audio): fix audio normalization bug and preserve encoding across resets (#3833) --- .../VolumeNormalizationAudioProcessor.kt | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt index b65a914902..19aa832f2b 100644 --- a/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -65,7 +65,7 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { Timber.tag(TAG).d("Configured: sampleRate=$sampleRate, channels=$channelCount, encoding=$encoding") isActive = true - return AudioProcessor.AudioFormat(sampleRate, channelCount, C.ENCODING_PCM_16BIT) + return AudioProcessor.AudioFormat(sampleRate, channelCount, encoding) } override fun isActive(): Boolean = isActive @@ -78,7 +78,7 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { if (inputSize == 0) return val sampleCount = inputSize / bytesPerSample - val out = replaceOutputBuffer(sampleCount * 2) + val out = replaceOutputBuffer(sampleCount * bytesPerSample) inputBuffer.order(ByteOrder.LITTLE_ENDIAN) out.order(ByteOrder.LITTLE_ENDIAN) @@ -101,31 +101,36 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { C.ENCODING_PCM_24BIT -> { repeat(sampleCount) { - val sample = read24Bit(inputBuffer) shr 8 + val b0 = inputBuffer.get().toInt() and 0xFF + val b1 = inputBuffer.get().toInt() and 0xFF + val b2 = inputBuffer.get().toInt() + val sample = (b2 shl 16) or (b1 shl 8) or b0 + val processed = if (applyGain) { (sample * gain.linearGain) - .coerceIn(-32768.0, 32767.0) + .coerceIn(-8388608.0, 8388607.0) .toInt() - .toShort() } else { - sample.toShort() + sample } - out.putShort(processed) + out.put((processed and 0xFF).toByte()) + out.put(((processed shr 8) and 0xFF).toByte()) + out.put(((processed shr 16) and 0xFF).toByte()) } } C.ENCODING_PCM_32BIT -> { repeat(sampleCount) { - val sample = inputBuffer.getInt() shr 16 + val sample = inputBuffer.getInt() val processed = if (applyGain) { (sample * gain.linearGain) - .coerceIn(-32768.0, 32767.0) + .coerceIn(-2147483648.0, 2147483647.0) + .toLong() .toInt() - .toShort() } else { - sample.toShort() + sample } - out.putShort(processed) + out.putInt(processed) } } @@ -137,9 +142,7 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { } else { sample } - out.putShort( - (processed * 32767f).toInt().coerceIn(-32768, 32767).toShort() - ) + out.putFloat(processed) } } } @@ -175,8 +178,7 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { encoding = C.ENCODING_INVALID bytesPerSample = 0 isActive = false - currentGain = GainState(0, 1.0) - enabled = false + // DO NOT reset enabled or currentGain, as they are controlled by the service } private fun replaceOutputBuffer(size: Int): ByteBuffer { @@ -194,6 +196,4 @@ class VolumeNormalizationAudioProcessor : AudioProcessor { val b2 = buffer.get().toInt() return (b2 shl 16) or (b1 shl 8) or b0 } - - }