Per-track loudness normalisation
SkillMediaApply per-track loudness normalisation with the platform's loudness enhancer when each track gets its own player — re-creating the effect per track because it stays attached to one audio session, skipping it while a remote-playback session is active, and clamping the gain that comes from metadata. Use when normalisation works on the first track and silently stops afterwards, when enabling it throws while nothing is playing, or when every other track in a queue comes out louder.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Per-track loudness normalisation skill
What this skill tells your AI
The instructions your AI receives, as published by maxrave-dev/kotlin-footguns in skills/per-track-loudness-normalization/SKILL.md and read by ahel’s review.
The platform's loudness enhancer is constructed against an audio session id and stays attached to it for its whole life. That is fine for a single long-lived player. It is a trap the moment a new player is created per track: each new player gets its own session, and the effect you built for the previous one is still perfectly alive, still enabled, and no longer connected to anything you can hear.
The working shape is: on every track change, tear the old one down, build a new one against the current session, then push the gain the track's metadata asks for.
override fun mayBeNormalizeVolume() {
if (!normalizeVolume) { releaseEnhancer(); player.volume = 1f; return }
if (!castState.value.isRemote && player.audioSessionId != AUDIO_SESSION_ID_UNSET) {
try { enhancer?.release() } catch (_: Exception) {}
try { enhancer = LoudnessEnhancer(player.audioSessionId) }
catch (e: Exception) { Logger.e(TAG, "…: ${e.message}") }
}
…observe the track's loudness and setTargetGain…
}
Call it from the track-transition callback, not from setup.
Traps
Re-create it per track — never reuse. The old effect does not report an error and does not stop working; it applies its gain to a session nothing is playing on. From outside, normalisation appears to work for exactly one track and then quietly does nothing, which reads as "the setting is flaky" rather than as a lifecycle bug. To verify, log the session id you construct against on every track and check it actually changes.
Guard the unset session id. Before a player has an output there is no session, and the id is
0. Constructing the effect with 0 throws, and the throw lands in the middle of a track change —
so a case that only happens at cold start, or right after a stop, takes the whole transition with
it. Compare against a named constant rather than a bare literal, since 0 reads like a plausible
id:
const val AUDIO_SESSION_ID_UNSET = 0
Skip it entirely while a remote-playback session is active. When output has been handed to another device there is no local audio session to attach to, and the id is unset — so this is the same guard, but it needs stating separately because the intent differs: not "wait until ready" but "this feature does not apply here". Bundle both into one condition so a future edit cannot satisfy one and drop the other.
Release the old one inside its own try, and do not let it abort the track change. Releasing
an effect whose session no longer exists can throw, and by that point you have nothing to gain from
the failure. Swallow it deliberately, with a comment saying so; wrapping both the release and the
construction in one try means a failed release skips the construction and the new track goes
un-normalised.
Clamp the gain from metadata, and reject rather than clamp. Loudness figures arriving from a source are not validated by anyone. A value outside a sane window is not "a very loud track", it is a bad row — clamping it to the boundary applies a large, wrong correction, while mapping it to zero applies none:
val loudnessMb = format.loudnessDb.toMb().let { if (it !in -2000..2000) 0 else it }
enhancer?.setTargetGain(0f.toMb() - loudnessMb)
Watch the units. The platform's target gain is in millibels; track metadata is in decibels.
The conversion is ×100 into an Int, and losing it is a factor-of-a-hundred error that is
inaudible in one direction and destructive in the other. Keep the conversion in one named helper
rather than writing * 100 at each site.
Turning the feature off must release, not just disable. Setting enabled = false leaves the
object attached; the next track then finds a stale reference and the enable/disable state depends
on which path ran last. Release it, null the field, cancel the observation job, and restore any
volume line the feature touched.
If you run two players, you need two effects — and the second one is easy to leave unwired. A
crossfade has an incoming player with its own session, which needs its own enhancer. A field
declared for it and released on teardown but never actually constructed compiles, runs, and
produces a queue where alternating tracks are normalised and the rest are not. Grep for the
assignment (secondEnhancer = with a constructor on the right) — not for the declaration, which
will be there.
Cancel the observation job before relaunching it. The gain comes from a flow keyed on the current track; re-creating the effect per track means re-subscribing per track, and without a cancel each transition leaves another collector alive, all writing gains for tracks that finished long ago.
Release everything at teardown. Disable, release and null both effects in the service's own
shutdown path, inside a try/catch — effects outlive the players they were attached to, and a
release against a session that is already gone can throw; whatever teardown steps run after yours
should not die for an effect that no longer exists.
Verifying it
Run from the repository root; the implementation is
core/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.kt.
-
The session guard compares against the named constant, not a bare
0:grep -rn "AUDIO_SESSION_ID_UNSET" \ core/domain/src/commonMain/kotlin/com/maxrave/domain/data/player/PlayerConstants.kt \ core/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.ktPass condition: one
const val AUDIO_SESSION_ID_UNSET = 0, and the handler's guard readsplayer.audioSessionId != PlayerConstants.AUDIO_SESSION_ID_UNSET— never a literal0. -
Out-of-range loudness is rejected to zero, never clamped to the boundary:
grep -n -A4 "loudnessDb.toMb()" \ core/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.ktPass condition: the branch is
if (it !in -2000..2000) 0 else it— a reject-to-zero, not.coerceIn(-2000, 2000), which would apply a large wrong correction instead of none. -
The second enhancer must be constructed, not only released — grep for the assignment, not the declaration:
grep -n "secondLoudnessEnhancer *=" \ core/data/src/androidMain/kotlin/com/maxrave/data/mediaservice/MediaServiceHandlerImpl.ktRun against this codebase, the only hit is
secondLoudnessEnhancer = nullat teardown — no constructor call anywhere. That is this exact trap, caught live in the mined source: the second player of a crossfade never gets an enhancer, so every other track plays unnormalised. A codebase without the bug shows at least one hit withLoudnessEnhancer(on the right-hand side too. -
By hand: play three or more tracks back to back with normalisation on. Log the session id each enhancer is constructed against on every track change. Observable outcome: the logged id is different every track, and perceived loudness stays consistent across the whole queue — not loud on track one and unnormalised from track two on, which is what a reused enhancer sounds like.
Signals
- GitHub stars
- 315
- Forks
- 6
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
per-track-loudness-normalization- Source
- github.com/maxrave-dev/kotlin-footguns