diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85e3169..ddd708a 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 = 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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index 4809b7d..fb54284 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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 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 37170ac..575b5f9 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 @@ -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 + } +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt b/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt index 4caee42..c0a7f07 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt @@ -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 }) + } } /** diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/MixTightenerPresets.kt b/app/src/main/java/space/rcmd/android/sizzle/model/MixTightenerPresets.kt new file mode 100644 index 0000000..9a75263 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/model/MixTightenerPresets.kt @@ -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) + + /** 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 = 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", + )), + ) +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt index 442ab20..a853f2a 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt @@ -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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt index f20479e..9023993 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Widgets.kt @@ -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 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) { 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 new file mode 100644 index 0000000..34aa440 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/MixTightenerEditor.kt @@ -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() +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt index d357345..1762c09 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt @@ -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)