From 5943040a2b0995e6672a0439f24662f7c3998ccb Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Sat, 18 Jul 2026 21:50:39 +0200 Subject: [PATCH] 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 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/audio/AudioEngine.kt | 81 +++++++++++++++++-- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2d46949..0ad7f09 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index fb54284..5bad9db 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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