feat(android): loudness normalization for exoplayer via audiofx

close #1289
This commit is contained in:
edde746
2026-06-12 12:38:57 +02:00
parent 71b05fd4b2
commit bd0a0c5d6b
8 changed files with 327 additions and 11 deletions
@@ -0,0 +1,173 @@
package com.edde746.plezy.exoplayer
import android.media.audiofx.DynamicsProcessing
import android.media.audiofx.LoudnessEnhancer
import android.os.Build
import androidx.annotation.RequiresApi
/**
* Session-bound loudness normalization approximating mpv's
* `loudnorm=I=-14:TP=-3:LRA=4` filter (#1289).
*
* API 28+: DynamicsProcessing with a single full-range compressor band as a
* slow AGC plus a limiter for the true-peak ceiling. API 25-27, or when the
* device lacks the DynamicsProcessing effect HAL: LoudnessEnhancer (fixed
* makeup gain with built-in limiting).
*
* Effects bind to the audio session, so they survive AudioTrack re-creation
* (seeks, format changes) while the session id is stable. They only process
* PCM mixer streams — the caller must force decoded PCM output and disable
* tunneling while enabled. DynamicsProcessing parameters are per-channel, so
* the effect is re-created when the output channel count changes (dialogue
* lives in the center channel of 5.1 PCM).
*/
class AudioNormalizationEffect(private val log: (String, String, String) -> Unit) {
private companion object {
const val EFFECT_PRIORITY = 0
// DynamicsProcessing — tuned for movie mixes whose dialogue sits around
// -27 LUFS: compress above threshold, make up toward I=-14, limit at TP=-3.
const val FRAME_DURATION_MS = 10f
const val MBC_CUTOFF_HZ = 20_000f
const val MBC_ATTACK_MS = 30f
const val MBC_RELEASE_MS = 300f
const val MBC_RATIO = 4f
const val MBC_THRESHOLD_DB = -34f
const val MBC_KNEE_DB = 6f
const val MBC_NOISE_GATE_DB = -90f // effectively disabled
const val MBC_EXPANDER_RATIO = 1f // no downward expansion
const val MBC_POST_GAIN_DB = 15f
const val LIMITER_ATTACK_MS = 1f
const val LIMITER_RELEASE_MS = 60f
const val LIMITER_RATIO = 10f
const val LIMITER_THRESHOLD_DB = -3f
const val DEFAULT_CHANNEL_COUNT = 2
const val MAX_CHANNEL_COUNT = 8
// LoudnessEnhancer fallback: fixed boost, internally limited.
const val LOUDNESS_ENHANCER_GAIN_MB = 900 // +9 dB
}
private var dynamicsProcessing: DynamicsProcessing? = null
private var loudnessEnhancer: LoudnessEnhancer? = null
private var attachedSessionId = 0
private var attachedChannelCount = 0
val isActive: Boolean get() = dynamicsProcessing != null || loudnessEnhancer != null
/** For stats/QA: which engine is processing. */
val describe: String
get() = when {
dynamicsProcessing != null -> "DynamicsProcessing"
loudnessEnhancer != null -> "LoudnessEnhancer"
else -> "off"
}
/** Attach to [sessionId]; idempotent for an unchanged (session, channels) pair. */
fun attach(sessionId: Int, channelCount: Int?) {
val channels = (channelCount ?: DEFAULT_CHANNEL_COUNT).coerceIn(1, MAX_CHANNEL_COUNT)
if (sessionId == attachedSessionId && channels == attachedChannelCount && isActive) return
release()
if (sessionId == 0) return // AUDIO_SESSION_ID_UNSET — retry on onAudioSessionIdChanged
attachedSessionId = sessionId
attachedChannelCount = channels
if (Build.VERSION.SDK_INT >= 28 && tryDynamicsProcessing(sessionId, channels)) return
tryLoudnessEnhancer(sessionId)
}
fun release() {
dynamicsProcessing?.let { effect ->
runCatching { effect.setEnabled(false) }
runCatching { effect.release() }
}
dynamicsProcessing = null
loudnessEnhancer?.let { effect ->
runCatching { effect.setEnabled(false) }
runCatching { effect.release() }
}
loudnessEnhancer = null
attachedSessionId = 0
attachedChannelCount = 0
}
@RequiresApi(28)
private fun tryDynamicsProcessing(sessionId: Int, channelCount: Int): Boolean = try {
val band = DynamicsProcessing.MbcBand(
/* inUse = */ true,
MBC_CUTOFF_HZ,
MBC_ATTACK_MS,
MBC_RELEASE_MS,
MBC_RATIO,
MBC_THRESHOLD_DB,
MBC_KNEE_DB,
MBC_NOISE_GATE_DB,
MBC_EXPANDER_RATIO,
/* preGain = */ 0f,
MBC_POST_GAIN_DB
)
val mbc = DynamicsProcessing.Mbc(/* inUse = */ true, /* enabled = */ true, /* bandCount = */ 1)
.apply { setBand(0, band) }
val limiter = DynamicsProcessing.Limiter(
/* inUse = */ true,
/* enabled = */ true,
/* linkGroup = */ 0,
LIMITER_ATTACK_MS,
LIMITER_RELEASE_MS,
LIMITER_RATIO,
LIMITER_THRESHOLD_DB,
/* postGain = */ 0f
)
val channel = DynamicsProcessing.Channel(
/* inputGain = */ 0f,
/* preEqInUse = */ false, /* preEqBandCount = */ 0,
/* mbcInUse = */ true, /* mbcBandCount = */ 1,
/* postEqInUse = */ false, /* postEqBandCount = */ 0,
/* limiterInUse = */ true
).apply {
setMbc(mbc)
setLimiter(limiter)
}
val config = DynamicsProcessing.Config.Builder(
DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
channelCount,
/* preEqInUse = */ false, /* preEqBandCount = */ 0,
/* mbcInUse = */ true, /* mbcBandCount = */ 1,
/* postEqInUse = */ false, /* postEqBandCount = */ 0,
/* limiterInUse = */ true
)
.setPreferredFrameDuration(FRAME_DURATION_MS)
.setAllChannelsTo(channel)
.build()
dynamicsProcessing = DynamicsProcessing(EFFECT_PRIORITY, sessionId, config).apply { setEnabled(true) }
log("info", "audio-normalization", "DynamicsProcessing attached (session=$sessionId, channels=$channelCount)")
true
} catch (e: Exception) {
log(
"warn",
"audio-normalization",
"DynamicsProcessing unavailable (${e.javaClass.simpleName}: ${e.message}); trying LoudnessEnhancer"
)
dynamicsProcessing = null
false
}
private fun tryLoudnessEnhancer(sessionId: Int) {
try {
loudnessEnhancer = LoudnessEnhancer(sessionId).apply {
setTargetGain(LOUDNESS_ENHANCER_GAIN_MB)
setEnabled(true)
}
log("info", "audio-normalization", "LoudnessEnhancer attached (session=$sessionId, gain=${LOUDNESS_ENHANCER_GAIN_MB}mB)")
} catch (e: Exception) {
loudnessEnhancer = null
attachedSessionId = 0
attachedChannelCount = 0
log(
"warn",
"audio-normalization",
"No loudness effect available on this device (${e.javaClass.simpleName}: ${e.message})"
)
}
}
}
@@ -96,6 +96,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private const val DECODER_HANG_TIMEOUT_MS = 5000L
private const val MAX_AUDIO_RECOVERY_ATTEMPTS = 2
private const val FPS_SAMPLE_COUNT = 8
private const val AUDIO_BOUNCE_TIMEOUT_MS = 1000L
/** Per-frame "video is at X" logcat stream (tag AssFrameCb) for diagnosing
* ASS subtitle lag against the libass pipeline's render/swap lines. */
@@ -153,6 +154,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private val tunnelingDisabledForCodec: Boolean
get() = tunnelingDisabledForAudioCodec || tunnelingDisabledForVideoCodec || tunnelingDisabledForDecodedTrueHdPcm || tunnelingDisabledForAudioRecovery
private var currentTunneledPlayback: Boolean = false
// Loudness normalization (#1289): audiofx effects only process non-tunneled
// PCM mixer streams, so while enabled we block direct/bitstream output and
// disable tunneling.
private var audioNormalizationEnabled: Boolean = false
private val audioNormalization = AudioNormalizationEffect(::emitLog)
private var pendingAudioRendererBounce: Boolean = false
private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio-normalization bounce timeout") }
private var lastSeekable: Boolean? = null
@Volatile private var disposing: Boolean = false
@@ -915,6 +924,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
return
}
// Audio renderer bounce (loudness normalization): the playback thread has
// observed the disabled audio renderer — re-enable so selection re-queries
// the sink's direct-output verdict.
if (pendingAudioRendererBounce && tracks.groups.none { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }) {
completeAudioRendererBounce("audio-normalization (audio renderer back on)")
return // skip processing the intermediate no-audio track list
}
if (restorePendingDvTrackSelection(tracks)) return
// Log selected video and audio track details
@@ -1770,6 +1787,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private fun shouldBlockDirectAudioOutput(format: Format, reason: String): Boolean {
val mimeType = format.sampleMimeType ?: return false
// Loudness normalization needs decoded PCM for the audiofx chain to act on.
if (audioNormalizationEnabled && isEncodedAudioMimeType(mimeType)) return true
if (directAudioOutputBlockedAfterFailure.contains(mimeType)) {
if (loggedDirectAudioRecoveryBlocks.add("$mimeType|$reason")) {
emitLog(
@@ -2153,7 +2172,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val player = exoPlayer ?: return null
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
return tunnelingUserEnabled && (player.playbackParameters.speed == 1f) && !tunnelingDisabledForCodec &&
!tunnelingDisabledForAssSubtitles && !audioDelayActive
!tunnelingDisabledForAssSubtitles && !audioDelayActive && !audioNormalizationEnabled
}
private fun updateCurrentTunnelingState(reason: String, shouldTunnel: Boolean): Boolean {
@@ -2161,7 +2180,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
currentTunneledPlayback = shouldTunnel
val speed = exoPlayer?.playbackParameters?.speed ?: 1f
val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L
emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=$speed, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec, decodedTrueHdPcmDisabled=$tunnelingDisabledForDecodedTrueHdPcm, audioRecoveryDisabled=$tunnelingDisabledForAudioRecovery, assSubtitlesDisabled=$tunnelingDisabledForAssSubtitles, audioDelay=$audioDelayActive)")
emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=$speed, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec, decodedTrueHdPcmDisabled=$tunnelingDisabledForDecodedTrueHdPcm, audioRecoveryDisabled=$tunnelingDisabledForAudioRecovery, assSubtitlesDisabled=$tunnelingDisabledForAssSubtitles, audioDelay=$audioDelayActive, audioNormalization=$audioNormalizationEnabled)")
return true
}
@@ -2327,6 +2346,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
updateTunnelingState("encoded TrueHD output initialized", forceSelector = true)
}
}
// Re-key the normalization effect to the actual output channel count
// (DynamicsProcessing parameters are per-channel).
if (audioNormalizationEnabled) attachNormalizationEffect()
}
override fun onAudioSessionIdChanged(eventTime: AnalyticsListener.EventTime, audioSessionId: Int) {
emitLog("debug", "audio", "Audio session id: $audioSessionId")
if (audioNormalizationEnabled) attachNormalizationEffect()
}
override fun onAudioTrackReleased(
@@ -2556,6 +2583,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
tunnelingDisabledForAudioRecovery = false
tunnelingDisabledForAssSubtitles = false
currentTunneledPlayback = false
// audioNormalizationEnabled persists across opens (user-level state, like
// tunnelingUserEnabled); only the in-flight bounce is abandoned.
pendingAudioRendererBounce = false
handler.removeCallbacks(audioBounceTimeout)
pendingStartPositionMs = startPositionMs
pendingPlayWhenReady = autoPlay
applyTrackSelectorPolicy(
@@ -2605,6 +2636,59 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
updateTunnelingState("audio-delay")
}
fun setAudioNormalization(enabled: Boolean) {
if (audioNormalizationEnabled == enabled) return
audioNormalizationEnabled = enabled
emitLog("info", "audio-normalization", "Loudness normalization ${if (enabled) "enabled" else "disabled"}")
if (enabled) attachNormalizationEffect() else audioNormalization.release()
if (exoPlayer == null) return
// A selector-parameter change only re-inits the audio renderer when the
// parameters actually differ (the tunneling flag flipping). Otherwise the
// renderer keeps its bypass/decode path and the sink's new direct-output
// verdict is never consulted — bounce the renderer in that case.
val tunnelingWillFlip = calculateTunnelingEnabled() != currentTunneledPlayback
val outputEncoding = lastAudioTrackConfig?.encoding
val selectedMime = selectedAudioFormat()?.sampleMimeType
val needsBounce = !tunnelingWillFlip && outputEncoding != null && selectedMime != null &&
isEncodedAudioMimeType(selectedMime) &&
(if (enabled) !isPcmEncoding(outputEncoding) else isPcmEncoding(outputEncoding))
if (needsBounce) {
startAudioRendererBounce("audio-normalization")
} else {
updateTunnelingState("audio-normalization")
}
}
private fun attachNormalizationEffect() {
val sessionId = exoPlayer?.audioSessionId ?: C.AUDIO_SESSION_ID_UNSET
if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
emitLog("debug", "audio-normalization", "Audio session id not ready; attach deferred")
return // onAudioSessionIdChanged re-attaches
}
val channels = lastAudioTrackConfig?.channelConfig?.let { Integer.bitCount(it) }
audioNormalization.attach(sessionId, channels)
}
// Two-phase audio renderer bounce: disable, wait for the playback thread to
// observe it (onTracksChanged with no selected audio), then re-enable so track
// selection re-queries the sink's format support. A synchronous flip-back
// would be coalesced: the invalidation message reads the latest parameters.
private fun startAudioRendererBounce(reason: String) {
if (pendingAudioRendererBounce) return
pendingAudioRendererBounce = true
emitLog("info", "audio-normalization", "Bouncing audio renderer to re-evaluate output path (reason=$reason)")
applyTrackSelectorPolicy(reason = "$reason (audio renderer off)", audioDisabled = true)
handler.postDelayed(audioBounceTimeout, AUDIO_BOUNCE_TIMEOUT_MS)
}
private fun completeAudioRendererBounce(reason: String) {
if (!pendingAudioRendererBounce) return
pendingAudioRendererBounce = false
handler.removeCallbacks(audioBounceTimeout)
applyTrackSelectorPolicy(reason = reason, audioDisabled = false)
}
fun setSubtitleDelay(seconds: Double) {
subtitleDelayUs.set((seconds * 1_000_000).toLong())
}
@@ -3072,6 +3156,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"audioOutputTunneling" to audioTrackConfig?.tunneling,
"audioOutputOffload" to audioTrackConfig?.offload,
"audioOutputBufferSize" to audioTrackConfig?.bufferSize,
"audioNormalization" to audioNormalizationEnabled,
"audioNormalizationEffect" to audioNormalization.describe,
"audioLastSinkError" to lastAudioSinkError,
"audioRecoveryAttempts" to audioRecoveryAttempts,
"audioRecoveryLastAction" to lastAudioRecoveryAction,
@@ -3144,6 +3230,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (currentTunneledPlayback) return "Active"
if (!tunnelingUserEnabled) return "Disabled by user"
if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)"
if (audioNormalizationEnabled) return "Off (loudness normalization)"
if (tunnelingDisabledForAudioRecovery) return "Off (audio recovery)"
if (tunnelingDisabledForDecodedTrueHdPcm) return "Off (decoded TrueHD PCM)"
if (tunnelingDisabledForVideoCodec) return "Off (video codec unsupported)"
@@ -3180,6 +3267,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
audioFocusManager?.release()
audioFocusManager = null
audioNormalization.release()
pendingAudioRendererBounce = false
decoderInitName = null
audioDecoderInitName = null
lastAudioTrackConfig = null
@@ -164,6 +164,7 @@ class ExoPlayerPlugin :
"setBoxFitMode" -> handleSetBoxFitMode(call, result)
"setVideoZoom" -> handleSetVideoZoom(call, result)
"setDvConversionMode" -> handleSetDvConversionMode(call, result)
"setAudioNormalization" -> handleSetAudioNormalization(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"setMpvProperty" -> handleSetMpvProperty(call, result)
"setLogLevel" -> {
@@ -608,6 +609,23 @@ class ExoPlayerPlugin :
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
private fun handleSetAudioNormalization(call: MethodCall, result: MethodChannel.Result) {
val enabled = call.argument<Boolean>("enabled")
if (enabled == null) {
result.error("INVALID_ARGS", "Missing 'enabled'", null)
return
}
if (usingMpvFallback) {
// mpv applies loudnorm via the 'af' property the Dart layer also sends.
result.success(true)
return
}
activity?.runOnUiThread {
playerCore?.setAudioNormalization(enabled)
result.success(true)
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) {
val name = call.argument<String>("name")
val value = call.argument<String>("value")