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.
|
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 22
|
versionCode = 23
|
||||||
versionName = "0.7.3"
|
versionName = "0.8.0"
|
||||||
|
|
||||||
// We provide our own instrumentation runner if/when tests are added.
|
// We provide our own instrumentation runner if/when tests are added.
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
import kotlin.math.tanh
|
import kotlin.math.tanh
|
||||||
|
|
||||||
@@ -153,6 +154,24 @@ class AudioEngine(
|
|||||||
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
/** Scratch buffer: per-channel sum for one sample (audio-thread only). */
|
||||||
private val channelSum = FloatArray(Pattern.TRACK_COUNT)
|
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:
|
// 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
|
// 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
|
// 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 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 }
|
for (k in 0 until actASmpN) { val v = auditionSampleVoices[actASmp[k]].render() * LIVE_GAIN; mixL += v; mixR += v }
|
||||||
val oL = softClip(mixL * MASTER_GAIN)
|
// Makeup gain (lifts instruments to media loudness) into a look-ahead
|
||||||
val oR = softClip(mixR * MASTER_GAIN)
|
// 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] = oL
|
||||||
out[2 * i + 1] = oR
|
out[2 * i + 1] = oR
|
||||||
if (recCapturing) recordSample(proj, oL, 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
|
/** Push slot parameters (+ tempo) into every live effect processor, but only when
|
||||||
* they actually changed — re-parsing unchanged params every block allocates and
|
* they actually changed — re-parsing unchanged params every block allocates and
|
||||||
* caused GC-pause dropouts when several effects were stacked. */
|
* caused GC-pause dropouts when several effects were stacked. */
|
||||||
@@ -1225,10 +1284,20 @@ class AudioEngine(
|
|||||||
private const val LIVE_POLYPHONY = 8
|
private const val LIVE_POLYPHONY = 8
|
||||||
private const val AUDITION_POLYPHONY = 8
|
private const val AUDITION_POLYPHONY = 8
|
||||||
private const val BUS_POLYPHONY = 8 // per-mixer-channel live audition
|
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)
|
// Master make-up gain into the look-ahead brickwall limiter ([masterLimit]).
|
||||||
// so playback sits closer to other media apps (it had noticeably more headroom
|
// The old path was a flat 0.5× attenuation into a static soft-clip, which left
|
||||||
// than typical music playback); the soft-knee limiter below still catches peaks.
|
// ~9 dB of unused headroom: even the loudest single preset with its mix bus
|
||||||
private const val MASTER_GAIN = 0.5f
|
// 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 LIVE_GAIN = 0.5f
|
||||||
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
|
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user