diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ddd708a..df5d95e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt index 575b5f9..925b574 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/Effects.kt @@ -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-- diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/MixTightenerEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/MixTightenerEditor.kt index 34aa440..48788eb 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/MixTightenerEditor.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/MixTightenerEditor.kt @@ -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() }