Compare commits

..

2 Commits

Author SHA1 Message Date
Reactorcoremeltdown
c4f4cf34c0 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>
2026-07-18 18:35:58 +02:00
Reactorcoremeltdown
58639d07cf 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>
2026-07-18 18:11:56 +02:00
9 changed files with 658 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 = 20
versionName = "0.7.1"
// 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,197 @@ 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 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 {
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)
// 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 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()
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 = compAmt > 0.001f && (ratio.any { it > 1.01f } || makeDb.any { abs(it) > 0.1f })
// 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) * limS
}
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]) * compScale).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,16 +182,49 @@ fun <T> RetroDropdown(
)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
options.forEach { opt ->
DropdownMenuItem(
text = { Text(optionLabel(opt), fontFamily = FontFamily.Monospace) },
onClick = { onSelect(opt); 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
fun SectionLabel(text: String, modifier: Modifier = Modifier) {

View File

@@ -0,0 +1,138 @@
// 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.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.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. 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 faders refresh
val mono = FontFamily.Monospace
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, then set the " +
"master Strength and trim each stage:",
color = c.textDim, fontFamily = mono, fontSize = 11.sp,
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
)
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
.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()
}

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)