Mix Tightener: per-component Strength faders (EQ / Comp / Limiter) (v0.7.1)

The single Strength control becomes four faders: the master Strength plus a
per-stage trim for EQ, Compressor and Limiter. Each stage's effective amount
is master × its own fader, so you can keep the tone shaping but ease off the
compression, push only the limiter for loudness, etc. Backward compatible —
presets still set the master `strength`; the new eqAmt/compAmt/limAmt default
to 1 (full).

 * DSP (MixTightener): EQ gains scale by strength×eqAmt; compressor gain
   reduction + makeup by strength×compAmt (compActive skips the whole comp
   stage at compAmt 0); limiter ceiling + output make-up by strength×limAmt.
 * Editor: four labelled vertical faders (Strength / EQ / Comp / Limit). Fixed
   a refresh bug where a fader child composable didn't observe vm.revision, so
   its value didn't visibly update after a drag (the DSP did update); it now
   subscribes like ParamControl.

Verified on-device: dragging EQ to 19% left Strength/Comp/Limit at 100%
(independent control, UI refreshes). Test instance added on a spare slot for
verification, then cleared — project untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 18:35:58 +02:00
parent 58639d07cf
commit c4f4cf34c0
3 changed files with 99 additions and 53 deletions

View File

@@ -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 = 19 versionCode = 20
versionName = "0.7.0" versionName = "0.7.1"
// 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"

View File

@@ -425,6 +425,7 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
private var limCeil = 1f; private var limRelCoef = 0f; private var limEnv = 0f private var limCeil = 1f; private var limRelCoef = 0f; private var limEnv = 0f
private var outGain = 1f private var outGain = 1f
private var strength = 1f private var strength = 1f
private var compScale = 1f // master × per-component "Comp" amount, applied to the gain reduction
private fun dbToLin(db: Float): Float = 10.0.pow(db / 20.0).toFloat() private fun dbToLin(db: Float): Float = 10.0.pow(db / 20.0).toFloat()
private fun coefFromMs(ms: Float): Float { private fun coefFromMs(ms: Float): Float {
@@ -434,11 +435,17 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
override fun update(slot: ToolboxSlot, tempoBpm: Float) { override fun update(slot: ToolboxSlot, tempoBpm: Float) {
strength = slot.float("strength", 1f).coerceIn(0f, 1f) strength = slot.float("strength", 1f).coerceIn(0f, 1f)
// Per-component amounts (default 1) let each stage be trimmed independently;
// the effective scale for a stage is master strength × that stage's amount.
val eqS = strength * slot.float("eqAmt", 1f).coerceIn(0f, 1f)
val compAmt = slot.float("compAmt", 1f).coerceIn(0f, 1f)
compScale = strength * compAmt
val limS = strength * slot.float("limAmt", 1f).coerceIn(0f, 1f)
// EQ — gains scaled by strength so 0 is flat. // EQ — gains scaled by the EQ amount so 0 is flat.
val loG = slot.float("eqLoGain", 0f).coerceIn(-18f, 18f) * strength val loG = slot.float("eqLoGain", 0f).coerceIn(-18f, 18f) * eqS
val midG = slot.float("eqMidGain", 0f).coerceIn(-18f, 18f) * strength val midG = slot.float("eqMidGain", 0f).coerceIn(-18f, 18f) * eqS
val hiG = slot.float("eqHiGain", 0f).coerceIn(-18f, 18f) * strength val hiG = slot.float("eqHiGain", 0f).coerceIn(-18f, 18f) * eqS
if (abs(loG) > 0.1f) eqLo.lowShelf(sampleRate, slot.float("eqLoFreq", 120f), loG) else eqLo.identity() if (abs(loG) > 0.1f) eqLo.lowShelf(sampleRate, slot.float("eqLoFreq", 120f), loG) else eqLo.identity()
if (abs(midG) > 0.1f) eqMid.peak(sampleRate, slot.float("eqMidFreq", 1000f), midG, if (abs(midG) > 0.1f) eqMid.peak(sampleRate, slot.float("eqMidFreq", 1000f), midG,
slot.float("eqMidQ", 1.0f).coerceIn(0.2f, 8f)) else eqMid.identity() slot.float("eqMidQ", 1.0f).coerceIn(0.2f, 8f)) else eqMid.identity()
@@ -458,12 +465,13 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
makeDb[1] = slot.float("cMidMk", 0f); atkCoef[1] = coefFromMs(slot.float("cMidAtk", 15f)); relCoef[1] = coefFromMs(slot.float("cMidRel", 140f)) makeDb[1] = slot.float("cMidMk", 0f); atkCoef[1] = coefFromMs(slot.float("cMidAtk", 15f)); relCoef[1] = coefFromMs(slot.float("cMidRel", 140f))
thrDb[2] = slot.float("cHiThr", -24f); ratio[2] = slot.float("cHiRatio", 2f).coerceIn(1f, 20f) thrDb[2] = slot.float("cHiThr", -24f); ratio[2] = slot.float("cHiRatio", 2f).coerceIn(1f, 20f)
makeDb[2] = slot.float("cHiMk", 0f); atkCoef[2] = coefFromMs(slot.float("cHiAtk", 5f)); relCoef[2] = coefFromMs(slot.float("cHiRel", 90f)) makeDb[2] = slot.float("cHiMk", 0f); atkCoef[2] = coefFromMs(slot.float("cHiAtk", 5f)); relCoef[2] = coefFromMs(slot.float("cHiRel", 90f))
compActive = ratio.any { it > 1.01f } || makeDb.any { abs(it) > 0.1f } compActive = compAmt > 0.001f && (ratio.any { it > 1.01f } || makeDb.any { abs(it) > 0.1f })
// Limiter: at strength 0 the ceiling is 1.0 (no limiting), at 1 it's the preset. // Limiter: at amount 0 the ceiling is 1.0 (no limiting) and output gain is unity,
limCeil = 1f + (dbToLin(slot.float("limCeil", -0.5f).coerceIn(-24f, 0f)) - 1f) * strength // at 1 they're the preset values.
limCeil = 1f + (dbToLin(slot.float("limCeil", -0.5f).coerceIn(-24f, 0f)) - 1f) * limS
limRelCoef = coefFromMs(slot.float("limRel", 100f)) limRelCoef = coefFromMs(slot.float("limRel", 100f))
outGain = 1f + (dbToLin(slot.float("outGain", 0f).coerceIn(-12f, 18f)) - 1f) * strength outGain = 1f + (dbToLin(slot.float("outGain", 0f).coerceIn(-12f, 18f)) - 1f) * limS
} }
private fun compBand(i: Int, band: Float): Float { private fun compBand(i: Int, band: Float): Float {
@@ -490,7 +498,7 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
val envDb = 20f * log10(env[i] + 1e-9f) val envDb = 20f * log10(env[i] + 1e-9f)
val over = envDb - thrDb[i] val over = envDb - thrDb[i]
val grDb = if (over > 0f) -over * (1f - 1f / ratio[i]) else 0f val grDb = if (over > 0f) -over * (1f - 1f / ratio[i]) else 0f
target[i] = dbToLin(((grDb + makeDb[i]) * strength).coerceIn(-40f, 24f)) target[i] = dbToLin(((grDb + makeDb[i]) * compScale).coerceIn(-40f, 24f))
} }
} }
ctrl-- ctrl--

View File

@@ -10,11 +10,12 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -32,19 +33,19 @@ import kotlin.math.roundToInt
/** /**
* Editor for the [space.rcmd.android.sizzle.model.ToolboxType.MIX_TIGHTENER] * Editor for the [space.rcmd.android.sizzle.model.ToolboxType.MIX_TIGHTENER]
* effect. Deliberately minimal: the preset bar above (rendered by the parent * effect. The preset bar above (rendered by [ToolboxScreen]) chooses a genre/
* [ToolboxScreen]) chooses a genre/instrument voicing, and this screen exposes the * instrument voicing; this screen exposes four faders:
* single **Strength** fader that morphs the whole EQ → multiband-compressor → * - **Strength** — the master amount for the whole EQ → compressor → limiter chain.
* limiter chain from transparent (bottom) to the full preset (top). All the * - **EQ / Comp / Limit** — per-stage trims. Each stage's effective amount is
* per-band tuning lives in the preset — there's intentionally nothing else to * master Strength × that stage's fader, so you can (say) keep the tone shaping
* fiddle with here. * but back off the compression, or push the limiter for more loudness.
* At the bottom every fader is transparent; at the top it's the full preset.
*/ */
@Composable @Composable
fun MixTightenerEditor(vm: AppViewModel, slot: ToolboxSlot) { fun MixTightenerEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so the fader refreshes @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so the faders refresh
val mono = FontFamily.Monospace val mono = FontFamily.Monospace
val strength = slot.float("strength", 1f).coerceIn(0f, 1f)
Column( Column(
Modifier.fillMaxWidth().padding(top = 4.dp), Modifier.fillMaxWidth().padding(top = 4.dp),
@@ -52,49 +53,86 @@ fun MixTightenerEditor(vm: AppViewModel, slot: ToolboxSlot) {
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
"Preset-driven EQ → 3-band compressor → limiter. Pick a preset for the " + "Preset-driven EQ → 3-band compressor → limiter. Pick a preset, then set the " +
"instrument on this channel, then set how hard it works:", "master Strength and trim each stage:",
color = c.textDim, fontFamily = mono, fontSize = 11.sp, color = c.textDim, fontFamily = mono, fontSize = 11.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
) )
Text("STRENGTH", color = c.accent, fontFamily = mono, fontSize = 13.sp) Row(
Text("${(strength * 100).roundToInt()}%", color = c.text, fontFamily = mono, fontSize = 22.sp) Modifier.fillMaxWidth().height(268.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Fader(vm, slot, "strength", "STRENGTH", 1f, Modifier.weight(1f))
Fader(vm, slot, "eqAmt", "EQ", 1f, Modifier.weight(1f))
Fader(vm, slot, "compAmt", "COMP", 1f, Modifier.weight(1f))
Fader(vm, slot, "limAmt", "LIMIT", 1f, Modifier.weight(1f))
}
Text(
"Bottom = off · Top = full. Each stage = Strength × its fader.",
color = c.textDim, fontFamily = mono, fontSize = 10.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
}
}
/** One labelled vertical fader writing [key] on the slot. */
@Composable
private fun RowScope.Fader(
vm: AppViewModel,
slot: ToolboxSlot,
key: String,
label: String,
default: Float,
modifier: Modifier,
) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so this fader refreshes on write
val mono = FontFamily.Monospace
val value = slot.float(key, default).coerceIn(0f, 1f)
Column(
modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(label, color = c.accent, fontFamily = mono, fontSize = 11.sp, maxLines = 1)
Text("${(value * 100).roundToInt()}%", color = c.text, fontFamily = mono, fontSize = 12.sp)
Box( Box(
Modifier Modifier
.height(260.dp) .fillMaxWidth()
.width(96.dp) .weight(1f)
.border(1.dp, c.grid, RectangleShape) .border(1.dp, c.grid, RectangleShape)
.background(c.background) .background(c.background)
.pointerInput(slot.index) { .pointerInput(slot.index, key) {
detectTapGestures { off -> setStrength(vm, slot, 1f - off.y / size.height) } detectTapGestures { off -> setAmt(vm, slot, key, 1f - off.y / size.height) }
} }
.pointerInput(slot.index) { .pointerInput(slot.index, key) {
detectDragGestures { change, _ -> detectDragGestures { change, _ ->
change.consume() change.consume()
setStrength(vm, slot, 1f - change.position.y / size.height) setAmt(vm, slot, key, 1f - change.position.y / size.height)
} }
}, },
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter,
) { ) {
FaderFill(value)
}
}
}
@Composable
private fun FaderFill(value: Float) {
val c = LocalRetro.current
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(strength.coerceIn(0.001f, 1f)) .fillMaxHeight(value.coerceIn(0.001f, 1f))
.background(c.accent.copy(alpha = 0.30f)), .background(c.accent.copy(alpha = 0.30f)),
contentAlignment = Alignment.TopCenter, contentAlignment = Alignment.TopCenter,
) { ) {
Box(Modifier.fillMaxWidth().height(3.dp).background(c.accent)) // thumb Box(Modifier.fillMaxWidth().height(3.dp).background(c.accent)) // thumb
} }
}
Text(
"Bottom = transparent · Top = full preset",
color = c.textDim, fontFamily = mono, fontSize = 10.sp,
)
}
} }
private fun setStrength(vm: AppViewModel, slot: ToolboxSlot, fraction: Float) { private fun setAmt(vm: AppViewModel, slot: ToolboxSlot, key: String, fraction: Float) {
slot.set("strength", fraction.coerceIn(0f, 1f)) slot.set(key, fraction.coerceIn(0f, 1f))
vm.bumpForToolbar() vm.bumpForToolbar()
} }