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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user