feat(audio): add Kodi-style stereo downmix with center channel boost

This commit is contained in:
edde746
2026-07-05 14:29:57 +02:00
parent 97901cba87
commit 90c4fe5458
46 changed files with 955 additions and 85 deletions
@@ -0,0 +1,74 @@
package com.edde746.plezy.exoplayer
import kotlin.math.pow
/**
* Stereo downmix coefficient builder (Kodi-style center boost).
*
* Channel-order assumptions per input count follow the Android channel-mask
* bit order that MediaCodec / the FFmpeg extension emit:
* 3 = FL FR FC, 4 = FL FR BL BR, 5 = FL FR FC BL BR, 6 = FL FR FC LFE BL BR,
* 7 = 6.1 (5.1 + BC), 8 = 7.1 (5.1 + SL SR). LFE is dropped, matching
* ffmpeg's default lfe_mix_level=0 (and what the mpv backends produce).
*
* All coefficients must stay >= 0: ChannelMixingMatrix throws on negatives.
*/
object DownmixMatrices {
const val MIN_DOWNMIX_INPUT_CHANNELS = 3
const val MAX_DOWNMIX_INPUT_CHANNELS = 8
const val MAX_CENTER_BOOST_DB = 12
const val SURROUND_GAIN = 0.70710678f // -3 dB
private const val BACK_CENTER_GAIN = 0.5f // SURROUND_GAIN split across both outputs
/** Kodi's mechanism: center coefficient = 10^((-3 + boostDb) / 20); boost 0 is the standard -3 dB. */
fun centerGain(centerBoostDb: Int): Float = 10f.pow((-3f + centerBoostDb.coerceIn(0, MAX_CENTER_BOOST_DB)) / 20f)
/**
* Row-major stereo coefficients ([inputChannel * 2 + outputChannel]), or null
* when [inputChannels] is not downmixed (mono/stereo pass through; >8ch
* unsupported, the caller keeps an identity matrix).
*
* [normalize] scales the matrix so the loudest output sum is <= 1 (cannot
* clip); disabled keeps the original level like Kodi's "maintain original
* volume" (the 16-bit mix saturates on clip).
*/
fun stereoCoefficients(inputChannels: Int, centerBoostDb: Int, normalize: Boolean): FloatArray? {
val c = centerGain(centerBoostDb)
val s = SURROUND_GAIN
val rows: List<FloatArray> = when (inputChannels) {
3 -> listOf(fl(), fr(), both(c))
4 -> listOf(fl(), fr(), left(s), right(s))
5 -> listOf(fl(), fr(), both(c), left(s), right(s))
6 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s))
7 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s), both(BACK_CENTER_GAIN))
8 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s), left(s), right(s))
else -> return null
}
val flat = FloatArray(rows.size * 2)
rows.forEachIndexed { i, row ->
flat[i * 2] = row[0]
flat[i * 2 + 1] = row[1]
}
if (normalize) {
var sumL = 0f
var sumR = 0f
for (i in rows.indices) {
sumL += flat[i * 2]
sumR += flat[i * 2 + 1]
}
val peak = maxOf(sumL, sumR, 1f)
if (peak > 1f) {
for (i in flat.indices) flat[i] /= peak
}
}
return flat
}
private fun fl() = floatArrayOf(1f, 0f)
private fun fr() = floatArrayOf(0f, 1f)
private fun left(gain: Float) = floatArrayOf(gain, 0f)
private fun right(gain: Float) = floatArrayOf(0f, gain)
private fun both(gain: Float) = floatArrayOf(gain, gain)
private fun lfe() = floatArrayOf(0f, 0f)
}
@@ -31,6 +31,7 @@ import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
@@ -220,6 +221,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// disable tunneling.
private var audioPassthroughEnabled: Boolean = false
private var audioNormalizationEnabled: Boolean = false
// Stereo downmix: runs as a ChannelMixingAudioProcessor inside the sink's
// decoded-PCM pipeline, so encoded audio is force-decoded while enabled.
private var audioDownmixEnabled: Boolean = false
private var audioDownmixCenterBoostDb: Int = 0
private var audioDownmixNormalize: Boolean = true
private val audioNormalization = AudioNormalizationEffect(::emitLog)
private var pendingAudioRendererBounce: Boolean = false
private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio renderer bounce timeout") }
@@ -1997,6 +2004,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val mimeType = format.sampleMimeType ?: return false
// Loudness normalization needs decoded PCM for the audiofx chain to act on.
if (audioNormalizationEnabled && isEncodedAudioMimeType(mimeType)) return true
// Stereo downmix runs in the sink's PCM pipeline; bitstream output would
// bypass it, so encoded audio is force-decoded while downmix is on
// (overrides the passthrough preference — Android TV defaults it on).
if (audioDownmixEnabled && isEncodedAudioMimeType(mimeType)) return true
if (shouldBlockDirectOutputForPassthrough(mimeType, audioPassthroughEnabled)) return true
if (directAudioOutputBlockedAfterFailure.contains(mimeType)) {
if (loggedDirectAudioRecoveryBlocks.add("$mimeType|$reason")) {
@@ -2997,7 +3008,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val tunnelingWillFlip = calculateTunnelingEnabled() != currentTunneledPlayback
val outputEncoding = lastAudioTrackConfig?.encoding
val selectedMime = selectedAudioFormat()?.sampleMimeType
// While downmix holds the output on decoded PCM the verdict cannot flip.
val needsBounce = !tunnelingWillFlip &&
!audioDownmixEnabled &&
outputEncoding != null &&
selectedMime != null &&
isEncodedAudioMimeType(selectedMime) &&
@@ -3017,7 +3030,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (exoPlayer == null) return
val outputEncoding = lastAudioTrackConfig?.encoding
val selectedMime = selectedAudioFormat()?.sampleMimeType
val needsBounce = outputEncoding != null &&
// While downmix holds the output on decoded PCM the verdict cannot flip.
val needsBounce = !audioDownmixEnabled &&
outputEncoding != null &&
selectedMime != null &&
isPassthroughAudioMimeType(selectedMime) &&
(if (enabled) isPcmEncoding(outputEncoding) else !isPcmEncoding(outputEncoding))
@@ -3028,6 +3043,48 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
}
fun setAudioDownmix(enabled: Boolean, centerBoostDb: Int, normalize: Boolean) {
val boost = centerBoostDb.coerceIn(0, DownmixMatrices.MAX_CENTER_BOOST_DB)
val enabledChanged = audioDownmixEnabled != enabled
if (!enabledChanged && audioDownmixCenterBoostDb == boost && audioDownmixNormalize == normalize) return
audioDownmixEnabled = enabled
audioDownmixCenterBoostDb = boost
audioDownmixNormalize = normalize
emitLog(
"info",
"audio-downmix",
if (enabled) "Stereo downmix enabled (centerBoost=${boost}dB, normalize=$normalize)" else "Stereo downmix disabled"
)
applyDownmixMatrices()
if (exoPlayer == null || !enabledChanged) return
// Before the first audio track is up (apply-at-open) the initial sink
// configure picks the matrices up on its own; no bounce needed.
if (lastAudioTrackConfig == null) return
// The processor's active/inactive state (identity vs downmix matrix) and
// the sink's direct-output verdict are both latched at configure time —
// bounce the renderer to re-evaluate. Coefficient-only changes are picked
// up live by queueInput without a bounce.
startAudioRendererBounce("audio-downmix")
}
private fun applyDownmixMatrices() {
val processor = renderersFactory?.channelMixProcessor ?: return
for (count in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
val coefficients = if (audioDownmixEnabled) {
DownmixMatrices.stereoCoefficients(count, audioDownmixCenterBoostDb, audioDownmixNormalize)
} else {
null
}
processor.putChannelMixingMatrix(
if (coefficients != null) {
ChannelMixingMatrix(count, 2, coefficients)
} else {
ChannelMixingMatrix.create(count, count)
}
)
}
}
private fun attachNormalizationEffect() {
val sessionId = exoPlayer?.audioSessionId ?: C.AUDIO_SESSION_ID_UNSET
if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
@@ -169,6 +169,7 @@ class ExoPlayerPlugin :
"setDvConversionMode" -> handleSetDvConversionMode(call, result)
"setAudioNormalization" -> handleSetAudioNormalization(call, result)
"setAudioPassthrough" -> handleSetAudioPassthrough(call, result)
"setAudioDownmix" -> handleSetAudioDownmix(call, result)
"observeProperty" -> handleObserveProperty(call, result)
"setMpvProperty" -> handleSetMpvProperty(call, result)
"setLogLevel" -> {
@@ -659,6 +660,26 @@ class ExoPlayerPlugin :
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
private fun handleSetAudioDownmix(call: MethodCall, result: MethodChannel.Result) {
val enabled = call.argument<Boolean>("enabled")
if (enabled == null) {
result.error("INVALID_ARGS", "Missing 'enabled'", null)
return
}
val centerBoostDb = call.argument<Int>("centerBoostDb") ?: 0
val normalize = call.argument<Boolean>("normalize") ?: true
if (usingMpvFallback) {
// mpv applies downmix via the audio-channels/audio-swresample-o
// properties the Dart layer also sends through setMpvProperty.
result.success(true)
return
}
activity?.runOnUiThread {
playerCore?.setAudioDownmix(enabled, centerBoostDb, normalize)
result.success(true)
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
}
private fun handleSetAudioPassthrough(call: MethodCall, result: MethodChannel.Result) {
val enabled = call.argument<Boolean>("enabled")
if (enabled == null) {
@@ -8,6 +8,8 @@ import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.audio.ChannelMixingAudioProcessor
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.decoder.DecoderInputBuffer
@@ -43,6 +45,16 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
val audioDelayUs = AtomicLong(0L)
/**
* Stereo-downmix processor; inactive while every registered matrix is identity.
* Pre-populated for counts 1..12 so sink configure can never hit
* "No mixing matrix for input channel count". ExoPlayerCore swaps in
* downmix matrices via [DownmixMatrices] when the setting is enabled.
*/
val channelMixProcessor = ChannelMixingAudioProcessor().apply {
for (count in 1..12) putChannelMixingMatrix(ChannelMixingMatrix.create(count, count))
}
/** Returns whether direct encoded output should be hidden so decoded PCM output can be selected. */
var shouldBlockDirectAudioOutput: ((Format) -> Boolean)? = null
@@ -116,6 +128,9 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
val defaultSink = DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
// Wraps in DefaultAudioProcessorChain, keeping stock silence-skip +
// Sonic; the downmix runs first and only in the decoded-PCM path.
.setAudioProcessors(arrayOf(channelMixProcessor))
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
.build()
@@ -0,0 +1,117 @@
package com.edde746.plezy.exoplayer
import kotlin.math.pow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DownmixMatricesTest {
private fun coeff(matrix: FloatArray, input: Int, output: Int) = matrix[input * 2 + output]
private fun columnSum(matrix: FloatArray, output: Int): Float {
var sum = 0f
for (input in 0 until matrix.size / 2) sum += coeff(matrix, input, output)
return sum
}
@Test
fun centerGainMatchesKodiFormula() {
// Kodi: center_mix_level = 10^((-3 + boost) / 20)
assertEquals(0.70795f, DownmixMatrices.centerGain(0), 1e-3f)
assertEquals(1.0f, DownmixMatrices.centerGain(3), 1e-3f)
assertEquals(2.81838f, DownmixMatrices.centerGain(12), 1e-3f)
}
@Test
fun centerGainClampsBoost() {
assertEquals(DownmixMatrices.centerGain(12), DownmixMatrices.centerGain(20), 0f)
assertEquals(DownmixMatrices.centerGain(0), DownmixMatrices.centerGain(-5), 0f)
}
@Test
fun fivePointOneLayoutUnnormalized() {
val m = DownmixMatrices.stereoCoefficients(6, centerBoostDb = 0, normalize = false)!!
val c = DownmixMatrices.centerGain(0)
val s = DownmixMatrices.SURROUND_GAIN
// FL FR FC LFE BL BR
assertEquals(1f, coeff(m, 0, 0), 0f)
assertEquals(0f, coeff(m, 0, 1), 0f)
assertEquals(0f, coeff(m, 1, 0), 0f)
assertEquals(1f, coeff(m, 1, 1), 0f)
assertEquals(c, coeff(m, 2, 0), 0f)
assertEquals(c, coeff(m, 2, 1), 0f)
assertEquals(0f, coeff(m, 3, 0), 0f) // LFE dropped
assertEquals(0f, coeff(m, 3, 1), 0f)
assertEquals(s, coeff(m, 4, 0), 0f)
assertEquals(0f, coeff(m, 4, 1), 0f)
assertEquals(0f, coeff(m, 5, 0), 0f)
assertEquals(s, coeff(m, 5, 1), 0f)
}
@Test
fun sixPointOneBackCenterMixesHalfToEachSide() {
val m = DownmixMatrices.stereoCoefficients(7, centerBoostDb = 0, normalize = false)!!
assertEquals(0.5f, coeff(m, 6, 0), 0f)
assertEquals(0.5f, coeff(m, 6, 1), 0f)
}
@Test
fun normalizeScalesMaxColumnSumToOne() {
for (channels in intArrayOf(6, 8)) {
val m = DownmixMatrices.stereoCoefficients(channels, centerBoostDb = 6, normalize = true)!!
assertEquals("channels=$channels L", 1f, columnSum(m, 0), 1e-4f)
assertEquals("channels=$channels R", 1f, columnSum(m, 1), 1e-4f)
// Relative balance is preserved: FC/FL ratio equals the raw center gain.
assertEquals(DownmixMatrices.centerGain(6), coeff(m, 2, 0) / coeff(m, 0, 0), 1e-4f)
}
}
@Test
fun normalizeNeverAmplifies() {
for (channels in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
val raw = DownmixMatrices.stereoCoefficients(channels, centerBoostDb = 6, normalize = false)!!
val normalized = DownmixMatrices.stereoCoefficients(channels, centerBoostDb = 6, normalize = true)!!
for (i in raw.indices) {
assertTrue("channels=$channels i=$i", normalized[i] <= raw[i] + 1e-6f)
}
}
}
@Test
fun boostRaisesOnlyTheCenterChannel() {
val base = DownmixMatrices.stereoCoefficients(6, centerBoostDb = 0, normalize = false)!!
val boosted = DownmixMatrices.stereoCoefficients(6, centerBoostDb = 6, normalize = false)!!
val expectedRatio = 10f.pow(6f / 20f)
assertEquals(expectedRatio, coeff(boosted, 2, 0) / coeff(base, 2, 0), 1e-4f)
for (input in intArrayOf(0, 1, 3, 4, 5)) {
assertEquals("input=$input L", coeff(base, input, 0), coeff(boosted, input, 0), 0f)
assertEquals("input=$input R", coeff(base, input, 1), coeff(boosted, input, 1), 0f)
}
}
@Test
fun passThroughCountsReturnNull() {
for (channels in intArrayOf(1, 2, 9, 12)) {
assertNull("channels=$channels", DownmixMatrices.stereoCoefficients(channels, 0, true))
}
}
@Test
fun allCoefficientsNonNegative() {
// ChannelMixingMatrix throws on negative coefficients; guard every combination.
for (channels in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
for (boost in intArrayOf(0, 6, 12)) {
for (normalize in booleanArrayOf(true, false)) {
val m = DownmixMatrices.stereoCoefficients(channels, boost, normalize)
assertNotNull(m)
for (value in m!!) {
assertTrue("channels=$channels boost=$boost normalize=$normalize", value >= 0f)
}
}
}
}
}
}