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.
minSdk = 26
targetSdk = 34
versionCode = 19
versionName = "0.7.0"
versionCode = 20
versionName = "0.7.1"
// We provide our own instrumentation runner if/when tests are added.
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 outGain = 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 coefFromMs(ms: Float): Float {
@@ -434,11 +435,17 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
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.
val loG = slot.float("eqLoGain", 0f).coerceIn(-18f, 18f) * strength
val midG = slot.float("eqMidGain", 0f).coerceIn(-18f, 18f) * strength
val hiG = slot.float("eqHiGain", 0f).coerceIn(-18f, 18f) * strength
// EQ — gains scaled by the EQ amount so 0 is flat.
val loG = slot.float("eqLoGain", 0f).coerceIn(-18f, 18f) * eqS
val midG = slot.float("eqMidGain", 0f).coerceIn(-18f, 18f) * eqS
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(midG) > 0.1f) eqMid.peak(sampleRate, slot.float("eqMidFreq", 1000f), midG,
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))
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))
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.
limCeil = 1f + (dbToLin(slot.float("limCeil", -0.5f).coerceIn(-24f, 0f)) - 1f) * strength
// Limiter: at amount 0 the ceiling is 1.0 (no limiting) and output gain is unity,
// 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))
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 {
@@ -490,7 +498,7 @@ private class MixTightener(private val sampleRate: Int) : AudioEffect {
val envDb = 20f * log10(env[i] + 1e-9f)
val over = envDb - thrDb[i]
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--

View File

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