feat(android): implement audio settings (EQ, normalization, gapless)
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled

ExoPlayerBackend was the only backend not overriding the PlayerBackend trait's
set_audio_settings/audio_settings defaults, so the Settings > Audio controls
rendered on Android and silently did nothing — the default returns Ok(()) while
applying nothing, so the failure was invisible.

Rust owns what the values are (canonical 10-band ISO layout, preset curves,
normalization presets); Kotlin owns when the AudioEffect objects exist, since
that needs the live audio session id.

- settings.rs: audio_settings_jni_payload() sanitises (crossfade clamped, band
  vector normalised) before serialising, so a malformed vector cannot reach the
  Kotlin parser. JSON rather than a wide JNI signature, matching how load()
  already passes subtitles — adding a field will not change the signature.
- ExoPlayerBackend: set_audio_settings/audio_settings over JNI; ExoPlayerState
  gains the first command-side field (settings are pushed out, never reported).
- JellyTauPlayer.kt: Equalizer, LoudnessEnhancer, and gapless via
  pauseAtEndOfMediaItems.

Three details that are easy to get wrong:
- Effects re-attach on onAudioSessionIdChanged. ExoPlayer rebuilds its audio
  sink on a format change, which invalidates effects bound to the old session;
  without this the EQ silently stops applying mid-queue.
- All effect work is posted to mainHandler rather than run inline. AudioEffect
  construction from a player callback can re-enter the player and deadlock —
  the same shape as the AutoplayDecision lock-scrutinee bug.
- Device equalizers expose a device-dependent band count (commonly 5) at fixed
  centres, so the canonical 10 bands are resampled by nearest centre frequency.
  resampleBands() is a pure @JvmStatic function so that mapping is testable
  without a device.

Normalization is approximate, not parity: LoudnessEnhancer is a gain stage, not
a true EBU R128 normalizer like MPV's dynaudnorm. Recorded as such rather than
claimed as equivalent.

Crossfade is deliberately excluded — unimplemented on every platform and
blocked on mpv, so building it on Android alone would invert the parity gap.

Tests written first and observed failing (cannot find function
audio_settings_jni_payload) before the implementation: the payload contract is
pinned by tests because a serde rename would otherwise silently break the
Kotlin parser.

Not yet verified on a physical device — AudioEffect availability and band
layouts are device-specific. Requirements matrix marks these rows accordingly,
and flipping the trait default to Err(not_implemented()) is deferred until that
verification lands.
This commit is contained in:
2026-07-28 23:03:31 +02:00
parent b11188e9dd
commit cb79a376b3
4 changed files with 1099 additions and 691 deletions
@@ -36,6 +36,53 @@ class JellyTauPlayer(private val appContext: Context) {
/** Position update interval in milliseconds */
private const val POSITION_UPDATE_INTERVAL_MS = 250L
/** AudioEffect priority. Positive = higher priority than the default. */
private const val EFFECT_PRIORITY = 1000
/**
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
* is only the lookup table used to map those gains onto whatever bands
* the device's equalizer actually has.
*/
private val CANONICAL_BAND_CENTRES_HZ =
intArrayOf(31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000)
/**
* Map canonical band gains onto a device's band centres by nearest
* centre frequency.
*
* Pure function so it can be unit-tested without a device — device band
* counts vary (commonly 5) and getting this wrong silently mis-shapes the
* EQ curve rather than failing.
*
* TRACES: UR-027 | DR-030
*/
@JvmStatic
fun resampleBands(
canonicalGains: FloatArray,
canonicalCentresHz: IntArray,
deviceCentresHz: IntArray
): FloatArray {
if (canonicalGains.isEmpty() || deviceCentresHz.isEmpty()) {
return FloatArray(deviceCentresHz.size)
}
val usable = minOf(canonicalGains.size, canonicalCentresHz.size)
return FloatArray(deviceCentresHz.size) { d ->
val target = deviceCentresHz[d]
var nearest = 0
var bestDelta = Int.MAX_VALUE
for (c in 0 until usable) {
val delta = kotlin.math.abs(canonicalCentresHz[c] - target)
if (delta < bestDelta) {
bestDelta = delta
nearest = c
}
}
canonicalGains[nearest]
}
}
/** Singleton instance for JNI access */
@Volatile
private var instance: JellyTauPlayer? = null
@@ -135,6 +182,18 @@ class JellyTauPlayer(private val appContext: Context) {
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var positionUpdateJob: Job? = null
/** Graphic EQ bound to the current audio session, or null if not attached. */
private var equalizer: android.media.audiofx.Equalizer? = null
/** Loudness/normalization effect bound to the current audio session. */
private var loudnessEnhancer: android.media.audiofx.LoudnessEnhancer? = null
/**
* Last settings pushed from Rust, replayed when the audio session is rebuilt.
* Held as the raw payload so re-application needs no second parse contract.
*/
private var lastAudioSettings: org.json.JSONObject? = null
/** Current media ID being played */
private var currentMediaId: String? = null
@@ -334,6 +393,11 @@ class JellyTauPlayer(private val appContext: Context) {
override fun onAudioSessionIdChanged(audioSessionId: Int) {
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
// effects bound to the old session are dead. Re-attach, or the EQ
// silently stops applying mid-queue.
releaseAudioEffects()
lastAudioSettings?.let { applyAudioEffects(it) }
}
})
}
@@ -416,6 +480,133 @@ class JellyTauPlayer(private val appContext: Context) {
}
}
/**
* Apply audio settings pushed from Rust as JSON.
*
* Rust owns *what* the values are (band layout, preset curves, normalization
* presets); this owns *when* the Android AudioEffect objects exist, since
* that needs the live audio session id and must survive a sink rebuild.
*
* Posted to the main handler rather than run inline: AudioEffect construction
* from a player callback can re-enter the player and deadlock.
*
* TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
*/
fun setAudioSettings(json: String) {
mainHandler.post {
try {
val settings = org.json.JSONObject(json)
lastAudioSettings = settings
// Gapless: ExoPlayer is gapless by default for compatible
// formats, so honouring the setting means disabling it when off.
exoPlayer.pauseAtEndOfMediaItems = !settings.optBoolean("gaplessPlayback", true)
applyAudioEffects(settings)
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "Failed to apply audio settings", e)
}
}
}
/** Attach/update the EQ and loudness effects for the current audio session. */
private fun applyAudioEffects(settings: org.json.JSONObject) {
val sessionId = exoPlayer.audioSessionId
if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
// No sink yet; onAudioSessionIdChanged will re-drive this.
return
}
try {
applyEqualizer(sessionId, settings)
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "Equalizer unavailable on this device", e)
}
try {
applyNormalization(sessionId, settings)
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "LoudnessEnhancer unavailable on this device", e)
}
}
private fun applyEqualizer(sessionId: Int, settings: org.json.JSONObject) {
val enabled = settings.optBoolean("equalizerEnabled", false)
if (!enabled) {
equalizer?.enabled = false
return
}
val eq = equalizer ?: android.media.audiofx.Equalizer(EFFECT_PRIORITY, sessionId).also {
equalizer = it
}
val bandsJson = settings.optJSONArray("equalizerBands")
val canonicalGains = FloatArray(bandsJson?.length() ?: 0) { i ->
bandsJson!!.optDouble(i, 0.0).toFloat()
}
if (canonicalGains.isEmpty()) {
eq.enabled = false
return
}
// The device's band count/centres are device-dependent (commonly 5) and
// will not match our canonical 10-band ISO layout, so resample.
val deviceBandCount = eq.numberOfBands.toInt()
val deviceCentresHz = IntArray(deviceBandCount) { i ->
eq.getCenterFreq(i.toShort()) / 1000 // device reports milliHertz
}
val levelRange = eq.bandLevelRange // millibels, [min, max]
val resampled = resampleBands(canonicalGains, CANONICAL_BAND_CENTRES_HZ, deviceCentresHz)
for (i in 0 until deviceBandCount) {
val millibels = (resampled[i] * 100f)
.coerceIn(levelRange[0].toFloat(), levelRange[1].toFloat())
eq.setBandLevel(i.toShort(), millibels.toInt().toShort())
}
eq.enabled = true
}
private fun applyNormalization(sessionId: Int, settings: org.json.JSONObject) {
val enabled = settings.optBoolean("normalizeVolume", false)
if (!enabled) {
loudnessEnhancer?.enabled = false
return
}
val enhancer = loudnessEnhancer
?: android.media.audiofx.LoudnessEnhancer(sessionId).also { loudnessEnhancer = it }
// Approximate parity with the Linux dynaudnorm path: LoudnessEnhancer is
// a gain stage, not a true EBU R128 normalizer, so these are relative
// offsets preserving the Loud > Normal > Quiet ordering.
val targetGainMb = when (settings.optString("volumeLevel", "normal")) {
"loud" -> 600
"quiet" -> -600
else -> 0
}
enhancer.setTargetGain(targetGainMb)
enhancer.enabled = true
}
private fun releaseAudioEffects() {
try {
equalizer?.release()
} catch (e: Exception) {
android.util.Log.w("JellyTauPlayer", "Equalizer release failed", e)
}
try {
loudnessEnhancer?.release()
} catch (e: Exception) {
android.util.Log.w("JellyTauPlayer", "LoudnessEnhancer release failed", e)
}
equalizer = null
loudnessEnhancer = null
}
/**
* Get the current playback position in seconds.
*/
@@ -756,6 +947,7 @@ class JellyTauPlayer(private val appContext: Context) {
mainHandler.post {
stopPositionUpdates()
coroutineScope.cancel()
releaseAudioEffects()
exoPlayer.release()
instance = null
}