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..19aa832f2b --- /dev/null +++ b/app/src/main/kotlin/com/metrolist/music/playback/audio/VolumeNormalizationAudioProcessor.kt @@ -0,0 +1,199 @@ +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 +@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 + var enabled = false + set(value) { + if (field != value) { + field = value + Timber.tag(TAG).d("Normalization processor enabled: $value") + } + } + + private var outputBuffer: ByteBuffer = EMPTY_BUFFER + private var inputEnded = false + + private data class GainState(val targetGainMb: Int, val linearGain: Double) + + @Volatile + private var currentGain: GainState = GainState(0, 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 (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)") + } + } + + override fun configure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { + sampleRate = inputAudioFormat.sampleRate + channelCount = inputAudioFormat.channelCount + encoding = inputAudioFormat.encoding + + 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 AudioProcessor.AudioFormat(sampleRate, channelCount, encoding) + } + + override fun isActive(): Boolean = isActive + + override fun queueInput(inputBuffer: ByteBuffer) { + val gain = currentGain + val applyGain = enabled && gain.targetGainMb != 0 + + val inputSize = inputBuffer.remaining() + if (inputSize == 0) return + + val sampleCount = inputSize / bytesPerSample + val out = replaceOutputBuffer(sampleCount * bytesPerSample) + + inputBuffer.order(ByteOrder.LITTLE_ENDIAN) + out.order(ByteOrder.LITTLE_ENDIAN) + + when (encoding) { + C.ENCODING_PCM_16BIT -> { + repeat(sampleCount) { + val sample = inputBuffer.getShort() + val processed = if (applyGain) { + (sample * gain.linearGain) + .coerceIn(-32768.0, 32767.0) + .toInt() + .toShort() + } else { + sample + } + out.putShort(processed) + } + } + + C.ENCODING_PCM_24BIT -> { + repeat(sampleCount) { + 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(-8388608.0, 8388607.0) + .toInt() + } else { + sample + } + 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() + val processed = if (applyGain) { + (sample * gain.linearGain) + .coerceIn(-2147483648.0, 2147483647.0) + .toLong() + .toInt() + } else { + sample + } + out.putInt(processed) + } + } + + C.ENCODING_PCM_FLOAT -> { + repeat(sampleCount) { + val sample = inputBuffer.getFloat() + val processed = if (applyGain) { + (sample * gain.linearGain.toFloat()).coerceIn(-1.0f, 1.0f) + } else { + sample + } + out.putFloat(processed) + } + } + } + + out.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 === EMPTY_BUFFER + } + + @Deprecated("Deprecated in AudioProcessor") + override fun flush() { + outputBuffer = EMPTY_BUFFER + inputEnded = false + } + + @Deprecated("Deprecated in AudioProcessor") + override fun reset() { + flush() + sampleRate = 0 + channelCount = 0 + encoding = C.ENCODING_INVALID + bytesPerSample = 0 + isActive = false + // DO NOT reset enabled or currentGain, as they are controlled by the service + } + + 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 + } +}