Add Mix Tightener effect, multi-column preset menus, louder master (v0.7.0)

Mix Tightener — a preset-driven "channel strip in a box" (Neutron-style):
parametric EQ → 3-band multiband compressor → peak limiter, all behind ONE
visible control, Strength, which morphs the whole chain from transparent (0)
to the full preset (1). Every per-band value is a hidden slot param the
presets set; the editor shows only a vertical Strength fader.
 * DSP (Effects.kt): reusable RBJ biquad; LR-style 3-way crossover; per-band
   peak-follower compressors; fast peak limiter + output make-up. Performance:
   the expensive per-band gain computation (log/pow) runs at control rate while
   the envelope follower runs per sample; Strength≈0 bypasses the whole effect,
   and flat EQ bands / ratio≈1 comp bands are skipped. Output is clamped and
   NaN-guarded so a channel FX can never poison the master.
 * 30 factory presets (MixTightenerPresets) across drums (808 Crisp, Industrial
   Drums, Trap Hats, Techno Kick, DnB Breaks, …), bass (EBM Bass, Reese, Acid
   303, Sub Bass Tame, Goth Bass, Synthwave Bass, …), synths/leads/pads
   (Synthwave Lead, Cutting Lead, Darkwave Pad, Arp Sparkle, …) and bus glue
   (Mix Glue, Loudness Max), seeded via PresetLibrary.

Global: RetroDropdown now lays long option lists (>12) into balanced columns
so preset menus grow sideways instead of into a tall vertical scroll — applies
to every dropdown in the app.

Master loudness: raise MASTER_GAIN 0.35 → 0.5 (~+3 dB) so playback sits closer
to other media apps; the soft-knee limiter still catches peaks.

Builds clean (staging + release). On-device performance profiling and install
are pending — the device dropped off wireless adb mid-session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 18:11:56 +02:00
parent 3a180eb983
commit 58639d07cf
9 changed files with 612 additions and 8 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 18
versionName = "0.6.0"
versionCode = 19
versionName = "0.7.0"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -1225,7 +1225,10 @@ class AudioEngine(
private const val LIVE_POLYPHONY = 8
private const val AUDITION_POLYPHONY = 8
private const val BUS_POLYPHONY = 8 // per-mixer-channel live audition
private const val MASTER_GAIN = 0.35f
// Master make-up before the soft-clip limiter. Raised from 0.35 → 0.5 (~+3 dB)
// so playback sits closer to other media apps (it had noticeably more headroom
// than typical music playback); the soft-knee limiter below still catches peaks.
private const val MASTER_GAIN = 0.5f
private const val LIVE_GAIN = 0.5f
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above

View File

@@ -10,10 +10,12 @@ import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tanh
/**
@@ -39,6 +41,7 @@ interface AudioEffect {
ToolboxType.FILTER -> BiquadFilterEffect(sampleRate)
ToolboxType.BITCRUSHER -> Bitcrusher()
ToolboxType.EQ10 -> GraphicEq(sampleRate)
ToolboxType.MIX_TIGHTENER -> MixTightener(sampleRate)
else -> null // arpeggiator / transposer / LFO are handled by the sequencer
}
}
@@ -322,3 +325,189 @@ private class GraphicEq(private val sampleRate: Int) : AudioEffect {
}
}
}
// ---------------------------------------------------------------------------
// Reusable RBJ biquad (transposed direct form II, double state) for the Mix
// Tightener. Coefficients are set at block rate; process() runs per sample and
// self-recovers to silence if state ever goes non-finite.
// ---------------------------------------------------------------------------
private class Bq {
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0
private var a1 = 0.0; private var a2 = 0.0
private var z1 = 0.0; private var z2 = 0.0
fun identity() { b0 = 1.0; b1 = 0.0; b2 = 0.0; a1 = 0.0; a2 = 0.0 }
private fun setRaw(nb0: Double, nb1: Double, nb2: Double, a0: Double, na1: Double, na2: Double) {
b0 = nb0 / a0; b1 = nb1 / a0; b2 = nb2 / a0; a1 = na1 / a0; a2 = na2 / a0
}
fun peak(sr: Int, freq: Float, gainDb: Float, q: Float) {
val f = freq.coerceIn(20f, sr * 0.45f)
val a = 10.0.pow(gainDb / 40.0)
val w0 = 2.0 * PI * f / sr; val cw = cos(w0); val alpha = sin(w0) / (2.0 * q.coerceAtLeast(0.1f))
setRaw(1 + alpha * a, -2 * cw, 1 - alpha * a, 1 + alpha / a, -2 * cw, 1 - alpha / a)
}
fun lowShelf(sr: Int, freq: Float, gainDb: Float) {
val f = freq.coerceIn(20f, sr * 0.45f)
val a = 10.0.pow(gainDb / 40.0); val w0 = 2.0 * PI * f / sr; val cw = cos(w0)
val alpha = sin(w0) / 2.0 * sqrt(2.0); val ta = 2.0 * sqrt(a) * alpha
setRaw(
a * ((a + 1) - (a - 1) * cw + ta), 2 * a * ((a - 1) - (a + 1) * cw),
a * ((a + 1) - (a - 1) * cw - ta), (a + 1) + (a - 1) * cw + ta,
-2 * ((a - 1) + (a + 1) * cw), (a + 1) + (a - 1) * cw - ta,
)
}
fun highShelf(sr: Int, freq: Float, gainDb: Float) {
val f = freq.coerceIn(20f, sr * 0.45f)
val a = 10.0.pow(gainDb / 40.0); val w0 = 2.0 * PI * f / sr; val cw = cos(w0)
val alpha = sin(w0) / 2.0 * sqrt(2.0); val ta = 2.0 * sqrt(a) * alpha
setRaw(
a * ((a + 1) + (a - 1) * cw + ta), -2 * a * ((a - 1) + (a + 1) * cw),
a * ((a + 1) + (a - 1) * cw - ta), (a + 1) - (a - 1) * cw + ta,
2 * ((a - 1) - (a + 1) * cw), (a + 1) - (a - 1) * cw - ta,
)
}
fun lowPass(sr: Int, freq: Float, q: Float) {
val f = freq.coerceIn(20f, sr * 0.45f)
val w0 = 2.0 * PI * f / sr; val cw = cos(w0); val alpha = sin(w0) / (2.0 * q)
setRaw((1 - cw) / 2, 1 - cw, (1 - cw) / 2, 1 + alpha, -2 * cw, 1 - alpha)
}
fun highPass(sr: Int, freq: Float, q: Float) {
val f = freq.coerceIn(20f, sr * 0.45f)
val w0 = 2.0 * PI * f / sr; val cw = cos(w0); val alpha = sin(w0) / (2.0 * q)
setRaw((1 + cw) / 2, -(1 + cw), (1 + cw) / 2, 1 + alpha, -2 * cw, 1 - alpha)
}
fun reset() { z1 = 0.0; z2 = 0.0 }
fun process(x: Float): Float {
val inp = x.toDouble()
var out = b0 * inp + z1
z1 = b1 * inp - a1 * out + z2
z2 = b2 * inp - a2 * out
if (!out.isFinite()) { z1 = 0.0; z2 = 0.0; out = 0.0 }
return out.toFloat()
}
}
// ---------------------------------------------------------------------------
// Mix Tightener: a preset-driven "channel strip in a box" (parametric EQ →
// 3-band multiband compressor → peak limiter) behind ONE user control, Strength,
// which morphs the whole chain from transparent (0) to the full preset (1). Every
// per-band value is a hidden slot param the genre/instrument presets set; only
// Strength is exposed in the editor.
//
// Performance: crossover/EQ are biquads; the expensive per-band gain computation
// (log/pow) runs at control rate (every CTRL samples) while the envelope follower
// runs per sample. Idle/gentle settings short-circuit: Strength≈0 bypasses the
// whole effect, flat EQ bands and ratio≈1 comp bands are skipped.
// ---------------------------------------------------------------------------
private class MixTightener(private val sampleRate: Int) : AudioEffect {
// Parametric EQ: low shelf, mid peak, high shelf.
private val eqLo = Bq(); private val eqMid = Bq(); private val eqHi = Bq()
private var eqActive = false
// Linkwitz-Riley-ish 3-way crossover (Q≈0.5) into low / mid / high.
private val xLoLP = Bq(); private val xLoHP = Bq(); private val xHiLP = Bq(); private val xHiHP = Bq()
private var compActive = false
// Per-band compressor state (0=low,1=mid,2=high).
private val env = FloatArray(3)
private val gain = FloatArray(3) { 1f } // per-sample smoothed applied gain
private val target = FloatArray(3) { 1f } // control-rate gain target
private val atkCoef = FloatArray(3); private val relCoef = FloatArray(3)
private val thrDb = FloatArray(3); private val ratio = FloatArray(3); private val makeDb = FloatArray(3)
private var ctrl = 0
// Limiter + output.
private var limCeil = 1f; private var limRelCoef = 0f; private var limEnv = 0f
private var outGain = 1f
private var strength = 1f
private fun dbToLin(db: Float): Float = 10.0.pow(db / 20.0).toFloat()
private fun coefFromMs(ms: Float): Float {
val t = (ms.coerceAtLeast(0.1f)) * 0.001f * sampleRate
return (1.0 - exp(-1.0 / t)).toFloat()
}
override fun update(slot: ToolboxSlot, tempoBpm: Float) {
strength = slot.float("strength", 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
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()
if (abs(hiG) > 0.1f) eqHi.highShelf(sampleRate, slot.float("eqHiFreq", 8000f), hiG) else eqHi.identity()
eqActive = abs(loG) > 0.1f || abs(midG) > 0.1f || abs(hiG) > 0.1f
// Crossovers.
val xl = slot.float("xLo", 200f).coerceIn(40f, sampleRate * 0.4f)
val xh = slot.float("xHi", 2500f).coerceIn(xl + 50f, sampleRate * 0.45f)
xLoLP.lowPass(sampleRate, xl, 0.5f); xLoHP.highPass(sampleRate, xl, 0.5f)
xHiLP.lowPass(sampleRate, xh, 0.5f); xHiHP.highPass(sampleRate, xh, 0.5f)
// Compressor bands.
thrDb[0] = slot.float("cLoThr", -24f); ratio[0] = slot.float("cLoRatio", 2f).coerceIn(1f, 20f)
makeDb[0] = slot.float("cLoMk", 0f); atkCoef[0] = coefFromMs(slot.float("cLoAtk", 10f)); relCoef[0] = coefFromMs(slot.float("cLoRel", 120f))
thrDb[1] = slot.float("cMidThr", -22f); ratio[1] = slot.float("cMidRatio", 2f).coerceIn(1f, 20f)
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 }
// 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
limRelCoef = coefFromMs(slot.float("limRel", 100f))
outGain = 1f + (dbToLin(slot.float("outGain", 0f).coerceIn(-12f, 18f)) - 1f) * strength
}
private fun compBand(i: Int, band: Float): Float {
val r = abs(band)
val e = env[i]
env[i] = if (r > e) e + atkCoef[i] * (r - e) else e + relCoef[i] * (r - e)
gain[i] += GAIN_SMOOTH * (target[i] - gain[i]) // click-free gain ramp
return band * gain[i]
}
override fun process(x: Float): Float {
if (strength < 0.001f) return x
var s = x
if (eqActive) { s = eqLo.process(s); s = eqMid.process(s); s = eqHi.process(s) }
if (compActive) {
val low = xLoLP.process(s)
val hp = xLoHP.process(s)
val mid = xHiLP.process(hp)
val high = xHiHP.process(hp)
if (ctrl <= 0) {
ctrl = CTRL
for (i in 0 until 3) {
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))
}
}
ctrl--
s = compBand(0, low) + compBand(1, mid) + compBand(2, high)
}
// Peak limiter: instant attack, smoothed release; a plain divide (no log/pow).
val r = abs(s)
limEnv = if (r > limEnv) r else limEnv + limRelCoef * (r - limEnv)
if (limEnv > limCeil && limEnv > 1e-6f) s *= limCeil / limEnv
s *= outGain
// Safety: never leave the effect hotter than full scale.
return if (s > 1f) 1f else if (s < -1f) -1f else if (!s.isFinite()) 0f else s
}
private companion object {
const val CTRL = 16 // samples between (expensive) gain recomputes
const val GAIN_SMOOTH = 0.05f // per-sample gain ramp toward the control-rate target
}
}

View File

@@ -5,6 +5,7 @@ package space.rcmd.android.sizzle.io
import space.rcmd.android.sizzle.audio.SampleStore
import space.rcmd.android.sizzle.model.AmbiencePresets
import space.rcmd.android.sizzle.model.MixTightenerPresets
import space.rcmd.android.sizzle.model.NesSynthPresets
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.SamplerPads
@@ -51,6 +52,9 @@ class PresetLibrary(private val baseDir: File) {
seedType(ToolboxType.NES_SYNTH, NesSynthPresets.FACTORY.map { it.name }) { slot, name ->
NesSynthPresets.apply(slot, NesSynthPresets.FACTORY.first { it.name == name })
}
seedType(ToolboxType.MIX_TIGHTENER, MixTightenerPresets.FACTORY.map { it.name }) { slot, name ->
MixTightenerPresets.apply(slot, MixTightenerPresets.FACTORY.first { it.name == name })
}
}
/**

View File

@@ -0,0 +1,262 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.model
/**
* Factory presets for the [ToolboxType.MIX_TIGHTENER] effect — genre- and
* instrument-targeted "channel strip in a box" settings. Each preset is a sparse
* map of the effect's hidden per-band parameters (EQ / crossover / 3-band
* compressor / limiter / output); anything omitted falls back to the DSP's
* defaults in `MixTightener`. Only the single **Strength** control is exposed in
* the editor — these presets are the "under the hood" tuning it morphs toward.
*
* Parameter keys (all optional, dB / Hz / ms):
* strength 0..1 morph amount (default 1)
* eqLoFreq eqLoGain low shelf
* eqMidFreq eqMidGain eqMidQ mid peak
* eqHiFreq eqHiGain high shelf
* xLo xHi band crossover frequencies
* cLoThr cLoRatio cLoAtk cLoRel cLoMk low-band compressor
* cMidThr cMidRatio cMidAtk cMidRel cMidMk mid-band compressor
* cHiThr cHiRatio cHiAtk cHiRel cHiMk high-band compressor
* limCeil limRel peak limiter
* outGain make-up at the output
*/
object MixTightenerPresets {
class Preset(val name: String, val params: Map<String, String>)
/** Apply [preset]'s overrides onto an already-[ToolboxSlot.fill]ed slot. */
fun apply(slot: ToolboxSlot, preset: Preset) {
preset.params.forEach { (k, v) -> slot.set(k, v) }
}
val FACTORY: List<Preset> = listOf(
// ---------------- Drums & percussion ----------------
Preset("808 Crisp", mapOf(
"eqLoFreq" to "70", "eqLoGain" to "4", "eqMidFreq" to "450", "eqMidGain" to "-2",
"eqHiFreq" to "9000", "eqHiGain" to "4", "xLo" to "120", "xHi" to "3000",
"cLoThr" to "-22", "cLoRatio" to "4", "cLoAtk" to "8", "cLoRel" to "140", "cLoMk" to "3",
"cMidThr" to "-20", "cMidRatio" to "2.5", "cMidMk" to "1",
"cHiThr" to "-24", "cHiRatio" to "3", "cHiAtk" to "3", "cHiRel" to "80", "cHiMk" to "2",
"limCeil" to "-0.3", "limRel" to "80", "outGain" to "2",
)),
Preset("Boom-Bap Kit", mapOf(
"eqLoFreq" to "90", "eqLoGain" to "2", "eqHiFreq" to "10000", "eqHiGain" to "1.5",
"xLo" to "160", "xHi" to "2500",
"cLoThr" to "-20", "cLoRatio" to "3", "cLoAtk" to "12", "cLoRel" to "160", "cLoMk" to "2",
"cMidThr" to "-18", "cMidRatio" to "2.5", "cMidMk" to "1",
"limCeil" to "-0.5", "outGain" to "1.5",
)),
Preset("Trap Hats", mapOf(
"eqMidFreq" to "4000", "eqMidGain" to "1", "eqHiFreq" to "11000", "eqHiGain" to "5", "xHi" to "4000",
"cLoRatio" to "1.5", "cMidRatio" to "2",
"cHiThr" to "-26", "cHiRatio" to "4", "cHiAtk" to "2", "cHiRel" to "60", "cHiMk" to "2",
"limCeil" to "-0.3", "outGain" to "2",
)),
Preset("Industrial Drums", mapOf(
"eqLoFreq" to "80", "eqLoGain" to "3", "eqMidFreq" to "2200", "eqMidGain" to "3", "eqMidQ" to "1.2",
"eqHiFreq" to "8000", "eqHiGain" to "2", "xLo" to "150", "xHi" to "2000",
"cLoThr" to "-18", "cLoRatio" to "5", "cLoAtk" to "6", "cLoRel" to "120", "cLoMk" to "3",
"cMidThr" to "-16", "cMidRatio" to "4", "cMidAtk" to "8", "cMidRel" to "120", "cMidMk" to "3",
"cHiThr" to "-20", "cHiRatio" to "3", "cHiMk" to "2",
"limCeil" to "-0.2", "limRel" to "60", "outGain" to "3",
)),
Preset("Techno Kick", mapOf(
"eqLoFreq" to "60", "eqLoGain" to "3", "eqMidFreq" to "700", "eqMidGain" to "-2",
"xLo" to "120", "xHi" to "3000",
"cLoThr" to "-16", "cLoRatio" to "6", "cLoAtk" to "4", "cLoRel" to "100", "cLoMk" to "3",
"cMidRatio" to "2", "cHiRatio" to "1.5",
"limCeil" to "-0.2", "outGain" to "2.5",
)),
Preset("House Groove", mapOf(
"strength" to "0.85",
"eqLoFreq" to "90", "eqLoGain" to "1.5", "eqHiFreq" to "9000", "eqHiGain" to "1.5",
"xLo" to "180", "xHi" to "2600",
"cLoThr" to "-22", "cLoRatio" to "2.5", "cLoMk" to "1.5",
"cMidThr" to "-20", "cMidRatio" to "2", "cMidMk" to "1",
"cHiThr" to "-22", "cHiRatio" to "2", "cHiMk" to "1",
"limCeil" to "-0.5", "outGain" to "1.5",
)),
Preset("DnB Breaks", mapOf(
"eqLoFreq" to "80", "eqLoGain" to "2", "eqMidFreq" to "2500", "eqMidGain" to "1.5",
"eqHiFreq" to "9000", "eqHiGain" to "2", "xLo" to "150", "xHi" to "2500",
"cLoThr" to "-18", "cLoRatio" to "4", "cLoAtk" to "3", "cLoRel" to "90", "cLoMk" to "2",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidAtk" to "4", "cMidRel" to "90", "cMidMk" to "2",
"cHiThr" to "-22", "cHiRatio" to "3", "cHiAtk" to "2", "cHiRel" to "60", "cHiMk" to "2",
"limCeil" to "-0.3", "limRel" to "50", "outGain" to "2",
)),
Preset("Lo-Fi Kit", mapOf(
"strength" to "0.8",
"eqLoFreq" to "100", "eqLoGain" to "2", "eqMidFreq" to "500", "eqMidGain" to "1",
"eqHiFreq" to "6000", "eqHiGain" to "-4", "xLo" to "200", "xHi" to "2200",
"cLoThr" to "-22", "cLoRatio" to "2.5", "cLoMk" to "1", "cMidRatio" to "2", "cHiRatio" to "1.5",
"limCeil" to "-1", "outGain" to "1",
)),
Preset("Snare Crack", mapOf(
"eqLoFreq" to "200", "eqLoGain" to "-2", "eqMidFreq" to "2000", "eqMidGain" to "3", "eqMidQ" to "1.5",
"eqHiFreq" to "8000", "eqHiGain" to "3", "xLo" to "250", "xHi" to "1800",
"cLoRatio" to "1.5",
"cMidThr" to "-18", "cMidRatio" to "4", "cMidAtk" to "3", "cMidRel" to "100", "cMidMk" to "2",
"cHiThr" to "-22", "cHiRatio" to "3", "cHiAtk" to "2", "cHiRel" to "70", "cHiMk" to "2",
"limCeil" to "-0.3", "outGain" to "2",
)),
Preset("Clap Snap", mapOf(
"eqMidFreq" to "1500", "eqMidGain" to "2", "eqHiFreq" to "9000", "eqHiGain" to "4", "xHi" to "3000",
"cLoRatio" to "1.5",
"cMidRatio" to "3", "cMidAtk" to "3", "cMidMk" to "1",
"cHiRatio" to "3", "cHiAtk" to "2", "cHiMk" to "2", "outGain" to "2",
)),
Preset("Cymbal Sheen", mapOf(
"strength" to "0.75",
"eqMidFreq" to "3000", "eqMidGain" to "-1", "eqHiFreq" to "12000", "eqHiGain" to "4", "xHi" to "5000",
"cHiThr" to "-26", "cHiRatio" to "2.5", "cHiAtk" to "5", "cHiRel" to "120", "cHiMk" to "1",
"limCeil" to "-0.5", "outGain" to "1.5",
)),
Preset("Percussion Tight", mapOf(
"eqMidFreq" to "2500", "eqMidGain" to "1.5", "eqHiFreq" to "8000", "eqHiGain" to "2",
"xLo" to "200", "xHi" to "2500",
"cLoRatio" to "2", "cLoMk" to "1",
"cMidRatio" to "3", "cMidAtk" to "4", "cMidMk" to "1.5",
"cHiRatio" to "2.5", "cHiMk" to "1", "outGain" to "1.5",
)),
// ---------------- Bass ----------------
Preset("EBM Bass", mapOf(
"eqLoFreq" to "80", "eqLoGain" to "3", "eqMidFreq" to "800", "eqMidGain" to "2", "eqMidQ" to "1.2",
"eqHiFreq" to "9000", "eqHiGain" to "-2", "xLo" to "150", "xHi" to "1500",
"cLoThr" to "-18", "cLoRatio" to "4", "cLoAtk" to "10", "cLoRel" to "130", "cLoMk" to "3",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidAtk" to "12", "cMidRel" to "130", "cMidMk" to "2",
"cHiRatio" to "1.5", "limCeil" to "-0.3", "outGain" to "2.5",
)),
Preset("Reese Bass", mapOf(
"eqLoFreq" to "70", "eqLoGain" to "2", "eqMidFreq" to "500", "eqMidGain" to "2",
"xLo" to "140", "xHi" to "1200",
"cLoThr" to "-16", "cLoRatio" to "5", "cLoAtk" to "8", "cLoRel" to "120", "cLoMk" to "3",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidMk" to "2", "cHiRatio" to "1.5",
"limCeil" to "-0.3", "outGain" to "2",
)),
Preset("Acid 303", mapOf(
"eqLoFreq" to "90", "eqLoGain" to "1", "eqMidFreq" to "1200", "eqMidGain" to "3", "eqMidQ" to "1.5",
"xLo" to "160", "xHi" to "1800",
"cLoRatio" to "3", "cLoMk" to "2",
"cMidThr" to "-18", "cMidRatio" to "4", "cMidAtk" to "6", "cMidRel" to "110", "cMidMk" to "2",
"cHiRatio" to "2", "limCeil" to "-0.4", "outGain" to "2",
)),
Preset("Sub Bass Tame", mapOf(
"strength" to "0.9",
"eqLoFreq" to "55", "eqLoGain" to "2", "eqMidFreq" to "900", "eqMidGain" to "-2",
"xLo" to "120", "xHi" to "2000",
"cLoThr" to "-14", "cLoRatio" to "6", "cLoAtk" to "12", "cLoRel" to "150", "cLoMk" to "3",
"cMidRatio" to "2", "cHiRatio" to "1.2", "limCeil" to "-0.5", "outGain" to "2",
)),
Preset("FM Bass Punch", mapOf(
"eqLoFreq" to "90", "eqLoGain" to "2", "eqMidFreq" to "1000", "eqMidGain" to "1.5",
"xLo" to "160", "xHi" to "2200",
"cLoThr" to "-18", "cLoRatio" to "4", "cLoAtk" to "6", "cLoRel" to "110", "cLoMk" to "2",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidMk" to "1.5", "cHiRatio" to "2",
"limCeil" to "-0.3", "outGain" to "2",
)),
Preset("Goth Bass", mapOf(
"strength" to "0.9",
"eqLoFreq" to "75", "eqLoGain" to "3", "eqMidFreq" to "600", "eqMidGain" to "-1",
"eqHiFreq" to "8000", "eqHiGain" to "-2", "xLo" to "140", "xHi" to "1600",
"cLoThr" to "-18", "cLoRatio" to "4", "cLoAtk" to "10", "cLoRel" to "140", "cLoMk" to "3",
"cMidRatio" to "2.5", "cMidMk" to "1", "cHiRatio" to "1.5", "limCeil" to "-0.5", "outGain" to "2",
)),
Preset("Synthwave Bass", mapOf(
"eqLoFreq" to "80", "eqLoGain" to "2.5", "eqMidFreq" to "900", "eqMidGain" to "1",
"eqHiFreq" to "7000", "eqHiGain" to "1", "xLo" to "150", "xHi" to "2000",
"cLoThr" to "-20", "cLoRatio" to "3", "cLoAtk" to "10", "cLoRel" to "130", "cLoMk" to "2",
"cMidRatio" to "2.5", "cMidMk" to "1", "cHiRatio" to "2",
"limCeil" to "-0.5", "outGain" to "1.8",
)),
// ---------------- Synths, leads & pads ----------------
Preset("Synthwave Lead", mapOf(
"strength" to "0.85",
"eqLoFreq" to "200", "eqLoGain" to "-1", "eqMidFreq" to "2000", "eqMidGain" to "1.5",
"eqHiFreq" to "9000", "eqHiGain" to "4", "xLo" to "300", "xHi" to "3000",
"cLoRatio" to "1.5",
"cMidThr" to "-20", "cMidRatio" to "2.5", "cMidAtk" to "12", "cMidRel" to "150", "cMidMk" to "1.5",
"cHiThr" to "-22", "cHiRatio" to "2", "cHiMk" to "1.5", "limCeil" to "-0.5", "outGain" to "2",
)),
Preset("Cutting Lead", mapOf(
"eqLoFreq" to "200", "eqLoGain" to "-2", "eqMidFreq" to "3000", "eqMidGain" to "3", "eqMidQ" to "1.2",
"eqHiFreq" to "8000", "eqHiGain" to "2", "xLo" to "300", "xHi" to "3500",
"cLoRatio" to "1.5",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidAtk" to "8", "cMidRel" to "130", "cMidMk" to "2",
"cHiRatio" to "2", "cHiMk" to "1", "limCeil" to "-0.4", "outGain" to "2",
)),
Preset("Darkwave Pad", mapOf(
"strength" to "0.7",
"eqLoFreq" to "150", "eqLoGain" to "1", "eqMidFreq" to "1000", "eqMidGain" to "0.5",
"eqHiFreq" to "8000", "eqHiGain" to "-2", "xLo" to "250", "xHi" to "2500",
"cLoRatio" to "2", "cLoMk" to "1",
"cMidThr" to "-24", "cMidRatio" to "2", "cMidAtk" to "20", "cMidRel" to "200", "cMidMk" to "1",
"cHiRatio" to "2", "limCeil" to "-1", "outGain" to "1",
)),
Preset("Ambient Wash", mapOf(
"strength" to "0.6",
"eqLoFreq" to "120", "eqLoGain" to "1", "eqHiFreq" to "10000", "eqHiGain" to "1",
"xLo" to "300", "xHi" to "3000",
"cMidThr" to "-26", "cMidRatio" to "1.8", "cMidAtk" to "30", "cMidRel" to "250", "cMidMk" to "0.5",
"limCeil" to "-1.5", "outGain" to "0.5",
)),
Preset("Pluck Tight", mapOf(
"eqMidFreq" to "2500", "eqMidGain" to "2", "eqHiFreq" to "9000", "eqHiGain" to "3",
"xLo" to "300", "xHi" to "3000", "cLoRatio" to "1.5",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidAtk" to "3", "cMidRel" to "100", "cMidMk" to "1.5",
"cHiThr" to "-22", "cHiRatio" to "2.5", "cHiAtk" to "2", "cHiRel" to "70", "cHiMk" to "1.5",
"outGain" to "1.5",
)),
Preset("Arp Sparkle", mapOf(
"strength" to "0.8",
"eqMidFreq" to "3000", "eqMidGain" to "1", "eqHiFreq" to "11000", "eqHiGain" to "4", "xHi" to "4000",
"cMidRatio" to "2", "cMidMk" to "1",
"cHiThr" to "-24", "cHiRatio" to "2.5", "cHiAtk" to "4", "cHiMk" to "1.5",
"limCeil" to "-0.5", "outGain" to "1.5",
)),
Preset("Chip Lead", mapOf(
"eqLoFreq" to "150", "eqLoGain" to "-3", "eqMidFreq" to "2500", "eqMidGain" to "2.5", "eqMidQ" to "1.2",
"eqHiFreq" to "8000", "eqHiGain" to "2", "xLo" to "350", "xHi" to "3000",
"cLoRatio" to "1.2",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidMk" to "2", "cHiRatio" to "2", "cHiMk" to "1",
"limCeil" to "-0.4", "outGain" to "2",
)),
Preset("Bell Clarity", mapOf(
"strength" to "0.75",
"eqMidFreq" to "1800", "eqMidGain" to "1.5", "eqHiFreq" to "10000", "eqHiGain" to "3", "xHi" to "4000",
"cMidRatio" to "2", "cMidAtk" to "6", "cMidMk" to "1",
"cHiRatio" to "2", "cHiAtk" to "4", "cHiMk" to "1", "limCeil" to "-0.5", "outGain" to "1",
)),
Preset("Keys Clarity", mapOf(
"strength" to "0.8",
"eqLoFreq" to "200", "eqLoGain" to "-1", "eqMidFreq" to "2500", "eqMidGain" to "1.5",
"eqHiFreq" to "9000", "eqHiGain" to "2", "xLo" to "250", "xHi" to "3000",
"cLoRatio" to "1.8",
"cMidThr" to "-22", "cMidRatio" to "2.5", "cMidAtk" to "10", "cMidRel" to "150", "cMidMk" to "1",
"cHiRatio" to "2", "cHiMk" to "1", "limCeil" to "-0.5", "outGain" to "1.5",
)),
// ---------------- Bus / master ----------------
Preset("Mix Glue", mapOf(
"strength" to "0.7",
"eqLoFreq" to "100", "eqLoGain" to "0.5", "eqHiFreq" to "10000", "eqHiGain" to "1",
"xLo" to "200", "xHi" to "2500",
"cLoThr" to "-22", "cLoRatio" to "2", "cLoAtk" to "20", "cLoRel" to "200", "cLoMk" to "1",
"cMidThr" to "-22", "cMidRatio" to "2", "cMidAtk" to "25", "cMidRel" to "200", "cMidMk" to "1",
"cHiThr" to "-22", "cHiRatio" to "2", "cHiAtk" to "15", "cHiRel" to "180", "cHiMk" to "1",
"limCeil" to "-0.8", "outGain" to "1",
)),
Preset("Loudness Max", mapOf(
"eqLoFreq" to "90", "eqLoGain" to "1", "eqHiFreq" to "9000", "eqHiGain" to "2",
"xLo" to "180", "xHi" to "2800",
"cLoThr" to "-20", "cLoRatio" to "3", "cLoMk" to "2",
"cMidThr" to "-18", "cMidRatio" to "3", "cMidMk" to "2",
"cHiThr" to "-20", "cHiRatio" to "3", "cHiMk" to "2",
"limCeil" to "-0.1", "limRel" to "40", "outGain" to "4",
)),
)
}

View File

@@ -216,6 +216,16 @@ enum class ToolboxType(
ParamSpec("duckattack", "Duck Attack (ms)", 10f, 0.5f, 100f),
ParamSpec("duckrelease", "Duck Release (ms)", 200f, 10f, 2000f),
),
),
MIX_TIGHTENER(
ToolboxKind.EFFECT, "Mix Tightener",
// A preset-driven "channel strip in a box": parametric EQ → 3-band multiband
// compressor → peak limiter, all behind ONE visible control — Strength — which
// morphs the whole chain from transparent (0) to the full preset (1). Every
// per-band value is a hidden slot param set by the genre/instrument presets
// (see MixTightenerPresets); only Strength is user-facing. Rendered by
// MixTightener in Effects.kt, edited by ui/toolbox/MixTightenerEditor.
listOf(ParamSpec("strength", "Strength", 1f, 0f, 1f)),
);
val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT

View File

@@ -10,6 +10,8 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.ui.graphics.RectangleShape
@@ -180,15 +182,48 @@ fun <T> RetroDropdown(
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
if (options.size <= DROPDOWN_MAX_ROWS) {
// Short lists: the ordinary one-per-row menu.
options.forEach { opt ->
DropdownMenuItem(
text = { Text(optionLabel(opt), fontFamily = FontFamily.Monospace) },
onClick = { onSelect(opt); open = false },
)
}
} else {
// Long lists (e.g. 30+ presets): lay out in balanced columns so the menu
// grows sideways instead of into a tall vertical scroll. Column count is
// the fewest that keeps each column within DROPDOWN_MAX_ROWS.
val cols = (options.size + DROPDOWN_MAX_ROWS - 1) / DROPDOWN_MAX_ROWS
val perCol = (options.size + cols - 1) / cols
Row(Modifier.padding(horizontal = 4.dp)) {
for (col in 0 until cols) {
Column {
for (r in 0 until perCol) {
val idx = col * perCol + r
if (idx >= options.size) break
val opt = options[idx]
Text(
optionLabel(opt),
color = if (opt == selected) c.accent else c.text,
fontFamily = FontFamily.Monospace, fontSize = 13.sp,
maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clickable { onSelect(opt); open = false }
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
}
}
}
}
/** Above this many options a [RetroDropdown] menu switches to a multi-column layout
* so it never becomes a long vertical scroll. */
private const val DROPDOWN_MAX_ROWS = 12
/** A dim, spaced section header, e.g. "── TRANSPORT ──". */
@Composable

View File

@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.ui.toolbox
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
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.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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.ui.AppViewModel
import space.rcmd.android.sizzle.ui.theme.LocalRetro
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.
*/
@Composable
fun MixTightenerEditor(vm: AppViewModel, slot: ToolboxSlot) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so the fader refreshes
val mono = FontFamily.Monospace
val strength = slot.float("strength", 1f).coerceIn(0f, 1f)
Column(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
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:",
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,
) {
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
}
}
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))
vm.bumpForToolbar()
}

View File

@@ -303,6 +303,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
// auto-generated parameter list, laid out two-per-row to fit more.
when (type) {
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
ToolboxType.MIX_TIGHTENER -> MixTightenerEditor(vm, slot)
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
ToolboxType.DELAY -> DelayEditor(vm, slot)