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:
@@ -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"
|
||||
|
||||
@@ -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--
|
||||
|
||||
@@ -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)
|
||||
Row(
|
||||
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(
|
||||
Modifier
|
||||
.height(260.dp)
|
||||
.width(96.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.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, key) {
|
||||
detectTapGestures { off -> setAmt(vm, slot, key, 1f - off.y / size.height) }
|
||||
}
|
||||
.pointerInput(slot.index) {
|
||||
.pointerInput(slot.index, key) {
|
||||
detectDragGestures { change, _ ->
|
||||
change.consume()
|
||||
setStrength(vm, slot, 1f - change.position.y / size.height)
|
||||
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(strength.coerceIn(0.001f, 1f))
|
||||
.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
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Bottom = transparent · Top = full preset",
|
||||
color = c.textDim, fontFamily = mono, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setStrength(vm: AppViewModel, slot: ToolboxSlot, fraction: Float) {
|
||||
slot.set("strength", fraction.coerceIn(0f, 1f))
|
||||
private fun setAmt(vm: AppViewModel, slot: ToolboxSlot, key: String, fraction: Float) {
|
||||
slot.set(key, fraction.coerceIn(0f, 1f))
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user