Louder master via makeup gain + look-ahead limiter (v0.8.0)

The master stage was a flat 0.5x attenuation into a static tanh soft-clip,
leaving ~9 dB of unused headroom: even the loudest single preset with its mix
bus maxed peaked around -7 dBFS, well below other media playback.

Replace it with a generous makeup gain (1.5x, ~+9.5 dB) feeding a feed-forward,
look-ahead brickwall limiter. A short delay line lets the peak detector pull the
gain down before a loud sample reaches the output and hold it across the
look-ahead window, so the output is mathematically guaranteed to stay under the
ceiling (0.97) - loudness without clipping, and none of the tanh grit a static
clipper adds to a hot single voice. Allocation-free (results go to scratch
fields, not a Pair) and NaN-guarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 21:50:39 +02:00
parent b7ae8cfbff
commit 5943040a2b
2 changed files with 77 additions and 8 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 22
versionName = "0.7.3"
versionCode = 23
versionName = "0.8.0"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.concurrent.thread
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.sin
import kotlin.math.tanh
@@ -153,6 +154,24 @@ class AudioEngine(
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
// ---- master brickwall limiter (feed-forward, look-ahead) ----
// A short delay line lets the peak detector "see" a loud sample coming and pull
// the gain down BEFORE it reaches the output, so the makeup gain below can be
// pushed hard (bringing quiet instruments up to media loudness) while the output
// is mathematically guaranteed to stay under LIM_CEILING — no clipping, and none
// of the tanh saturation a static soft-clipper would add to a hot single voice.
// Gain has instant attack (drops the moment a peak enters the look-ahead window),
// holds for the window length, then releases smoothly toward unity. Audio-thread
// state only.
private val limDelayL = FloatArray(LIM_LOOKAHEAD)
private val limDelayR = FloatArray(LIM_LOOKAHEAD)
private var limPos = 0
private var limGain = 1f
private var limHold = 0
// Limiter output for the current sample (avoids a per-sample Pair allocation).
private var limOutL = 0f
private var limOutR = 0f
// Per-block compact active-voice lists (audio-thread only). Rebuilt once per block:
// the per-sample mix loop iterates ONLY the voices that are actually sounding, so it
// never calls render() on idle pool slots (the whole POLYPHONY*TRACK_COUNT pool was
@@ -594,14 +613,54 @@ class AudioEngine(
}
for (k in 0 until actLiveN) { val v = liveVoices[actLive[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
for (k in 0 until actASmpN) { val v = auditionSampleVoices[actASmp[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
val oL = softClip(mixL * MASTER_GAIN)
val oR = softClip(mixR * MASTER_GAIN)
// Makeup gain (lifts instruments to media loudness) into a look-ahead
// brickwall limiter that guarantees the output stays under the ceiling.
masterLimit(mixL * MASTER_MAKEUP, mixR * MASTER_MAKEUP)
val oL = limOutL
val oR = limOutR
out[2 * i] = oL
out[2 * i + 1] = oR
if (recCapturing) recordSample(proj, oL, oR)
}
}
/**
* Feed-forward, look-ahead brickwall limiter for the master bus. Results land in
* [limOutL]/[limOutR] (no per-sample allocation). Runs on the audio thread.
*
* How it guarantees "no clipping": the incoming (post-makeup) sample is pushed
* into a [LIM_LOOKAHEAD]-sample delay line, and the gain is driven by the *newest*
* sample while it is applied to the *oldest* (delayed) one. When a sample louder
* than [LIM_CEILING] enters, the gain drops instantly to exactly the amount needed
* to tame it and is *held* for the whole look-ahead window (plus a small margin),
* so by the time that loud sample reaches the output the gain is already pulled
* down — the delayed peak can never exceed the ceiling. Between peaks the gain
* releases smoothly back toward unity, so quiet passages keep the full makeup.
* A final hard clamp is a cheap belt-and-braces guard (it should never engage).
*/
private fun masterLimit(inL: Float, inR: Float) {
val dL = limDelayL[limPos]
val dR = limDelayR[limPos]
limDelayL[limPos] = inL
limDelayR[limPos] = inR
limPos++; if (limPos >= LIM_LOOKAHEAD) limPos = 0
val peak = max(abs(inL), abs(inR))
val target = if (peak > LIM_CEILING) LIM_CEILING / peak else 1f
when {
target < limGain -> { limGain = target; limHold = LIM_LOOKAHEAD + LIM_HOLD_EXTRA }
limHold > 0 -> limHold--
else -> limGain += (1f - limGain) * LIM_RELEASE
}
var oL = dL * limGain
var oR = dR * limGain
if (oL > LIM_CEILING) oL = LIM_CEILING else if (oL < -LIM_CEILING) oL = -LIM_CEILING
if (oR > LIM_CEILING) oR = LIM_CEILING else if (oR < -LIM_CEILING) oR = -LIM_CEILING
limOutL = if (oL.isFinite()) oL else 0f
limOutR = if (oR.isFinite()) oR else 0f
}
/** Push slot parameters (+ tempo) into every live effect processor, but only when
* they actually changed — re-parsing unchanged params every block allocates and
* caused GC-pause dropouts when several effects were stacked. */
@@ -1225,10 +1284,20 @@ class AudioEngine(
private const val LIVE_POLYPHONY = 8
private const val AUDITION_POLYPHONY = 8
private const val BUS_POLYPHONY = 8 // per-mixer-channel live audition
// Master make-up before the soft-clip limiter. Raised from 0.35 → 0.5 (~+3 dB)
// so playback sits closer to other media apps (it had noticeably more headroom
// than typical music playback); the soft-knee limiter below still catches peaks.
private const val MASTER_GAIN = 0.5f
// Master make-up gain into the look-ahead brickwall limiter ([masterLimit]).
// The old path was a flat 0.5× attenuation into a static soft-clip, which left
// ~9 dB of unused headroom: even the loudest single preset with its mix bus
// maxed peaked around -7 dBFS, well below other media playback. This pushes the
// signal UP (≈ +9.5 dB vs the old 0.5) so a single instrument reaches close to
// full scale, while the limiter guarantees the ceiling is never exceeded on
// busy passages — loudness without clipping. Tunable: raise for even louder /
// denser output, lower to trade loudness back for dynamics.
private const val MASTER_MAKEUP = 1.5f
// Brickwall limiter tuning (see [masterLimit]).
private const val LIM_CEILING = 0.97f // hard output ceiling (~-0.26 dBFS)
private const val LIM_LOOKAHEAD = 64 // delay/look-ahead in samples (~1.3 ms @ 48 kHz)
private const val LIM_HOLD_EXTRA = 8 // extra hold beyond the window so the peak fully clears
private const val LIM_RELEASE = 0.00025f // per-sample rise toward unity (~80 ms release @ 48 kHz)
private const val LIVE_GAIN = 0.5f
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above