feat(audio): add Kodi-style stereo downmix with center channel boost
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user