feat(android): loudness normalization for exoplayer via audiofx
close #1289
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user