diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b084637..5f191f7 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 = 14 - versionName = "0.4.7" + versionCode = 15 + versionName = "0.5.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/io/PresetLibrary.kt b/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt index 2397e88..4caee42 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 @@ -53,22 +53,36 @@ class PresetLibrary(private val baseDir: File) { } } - /** Seed one device [type]'s factory presets (guarded by a per-type marker). */ + /** + * Seed one device [type]'s factory presets. The marker records the NAMES already + * seeded (one per line), so a factory preset added in a later app version is seeded + * on the next launch while a preset the user deleted is NOT resurrected (its name is + * still in the marker). Legacy markers held only "1"; on that first upgrade any + * factory preset not currently on disk is (re)written once, then names are tracked + * from then on. + */ private fun seedType(type: ToolboxType, names: List, apply: (ToolboxSlot, String) -> Unit) { val dir = typeDir(type) val marker = File(dir, SEEDED_MARKER) - if (marker.exists()) return + val seeded = if (marker.exists()) + marker.readLines().map { it.trim() }.filterTo(HashSet()) { it.isNotEmpty() && it != "1" } + else HashSet() + var changed = false names.forEach { name -> + if (name in seeded) return@forEach // seeded before; respect a later user delete val file = presetFile(type, name) - if (file.exists()) return@forEach - val slot = ToolboxSlot(0).apply { - fill(type) - apply(this, name) - this.name = name + if (!file.exists()) { + val slot = ToolboxSlot(0).apply { + fill(type) + apply(this, name) + this.name = name + } + file.writeText(PresetIo.export(slot)) } - file.writeText(PresetIo.export(slot)) + seeded.add(name) + changed = true } - marker.writeText("1") + if (changed || !marker.exists()) marker.writeText(seeded.joinToString("\n")) } /** Names (without extension) of the presets stored for [type], alphabetically. */ diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt b/app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt index b3fd4c0..66026da 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt @@ -4,10 +4,11 @@ package space.rcmd.android.sizzle.model /** - * Factory presets for the subtractive synth ([ToolboxType.NES_SYNTH]) — chiptune / - * NES-flavoured starting points (pulse leads, square bass, triangle sub, noise - * percussion) built on the Minimoog-style voice. Each preset is a sparse map of - * overrides applied on top of the type's defaults, so only the interesting params + * Factory presets for the subtractive synth ([ToolboxType.NES_SYNTH]). Two families: + * chiptune-flavoured starting points (pulse leads, square bass, triangle sub, noise + * percussion) and a darkwave / industrial set (plucky basses, cutting leads, and + * harsh detuned deep basses for layering under bass pads). Each preset is a sparse map + * of overrides applied on top of the type's defaults, so only the interesting params * are listed; the rest fall back to the init patch. */ object NesSynthPresets { @@ -72,5 +73,67 @@ object NesSynthPresets { "lfowave" to "Tri", "lfodiv" to "3", "m0src" to "LFO", "m0dst" to "Cutoff", "m0amt" to "0.5", )), + + // ---- Darkwave / industrial ---- + // Punchy plucky bass: a snappy filter contour that slams shut, amp decaying to + // silence so each note is a tight, percussive stab. A second saw a few cents up + // thickens the body. + Preset("Pluck Bass", mapOf( + "osc0wave" to "Square", "osc0oct" to "-1", "osc0level" to "0.9", + "osc1wave" to "Saw", "osc1oct" to "-1", "osc1fine" to "6", "osc1level" to "0.45", + "cutoff" to "0.42", "reso" to "0.38", "keytrack" to "0.4", "fenvamt" to "0.6", + "fatk" to "0.001", "fdec" to "0.11", "fsus" to "0.0", "frel" to "0.10", + "attack" to "0.002", "decay" to "0.13", "sustain" to "0.0", "release" to "0.09", + )), + // Acid-flavoured plucky bass: single saw, high resonance and a strong filter + // envelope for a squelchy 303-ish stab. Amp decays to nothing (plucky). + Preset("Acid Pluck", mapOf( + "osc0wave" to "Saw", "osc0oct" to "-1", "osc0level" to "0.95", + "cutoff" to "0.38", "reso" to "0.70", "keytrack" to "0.5", "fenvamt" to "0.70", + "fatk" to "0.001", "fdec" to "0.14", "fsus" to "0.05", "frel" to "0.10", + "attack" to "0.002", "decay" to "0.18", "sustain" to "0.0", "release" to "0.10", + )), + // Cutting lead: saw + detuned pulse + an octave-up saw for presence, bright + // cutoff with resonant edge, sustained so it sits on top of a mix. + Preset("Razor Lead", mapOf( + "osc0wave" to "Saw", "osc0level" to "0.8", + "osc1wave" to "Pulse25", "osc1fine" to "-7", "osc1level" to "0.55", + "osc2wave" to "Saw", "osc2oct" to "1", "osc2level" to "0.3", + "cutoff" to "0.72", "reso" to "0.40", "keytrack" to "0.5", "fenvamt" to "0.25", + "fatk" to "0.004", "fdec" to "0.25", "fsus" to "0.5", "frel" to "0.2", + "attack" to "0.006", "decay" to "0.2", "sustain" to "0.85", "release" to "0.18", + )), + // Cold darkwave lead: hollow pulse pair with slow tempo-synced PWM movement for + // an eerie, shifting timbre; resonant but not shrill. + Preset("Cold Lead", mapOf( + "osc0wave" to "Pulse25", "osc0level" to "0.9", + "osc1wave" to "Square", "osc1fine" to "8", "osc1level" to "0.5", + "cutoff" to "0.66", "reso" to "0.45", "keytrack" to "0.5", "fenvamt" to "0.3", + "attack" to "0.01", "decay" to "0.25", "sustain" to "0.8", "release" to "0.2", + "lfowave" to "Tri", "lfodiv" to "4", + "m0src" to "LFO", "m0dst" to "PWM", "m0amt" to "0.4", + )), + // Harsh gritty deep bass for LAYERING under a bass pad: a detuned saw pair + // (beating for width) over a square sub, a whisper of noise for grit, sustained. + Preset("Grit Sub", mapOf( + "osc0wave" to "Saw", "osc0oct" to "-1", "osc0level" to "0.75", + "osc1wave" to "Saw", "osc1oct" to "-1", "osc1fine" to "-14", "osc1level" to "0.75", + "osc2wave" to "Square", "osc2oct" to "-2", "osc2level" to "0.5", + "noise" to "0.06", + "cutoff" to "0.48", "reso" to "0.50", "keytrack" to "0.3", "fenvamt" to "0.3", + "fatk" to "0.008", "fdec" to "0.45", "fsus" to "0.55", "frel" to "0.3", + "attack" to "0.008", "decay" to "0.35", "sustain" to "0.85", "release" to "0.28", + )), + // Wide detuned bass wall for layering: two saws pulled hard apart (±20 cents) + // plus a pulse and more noise — a thick, harsh bed to sit beneath a cleaner + // sub. Slow attack so it swells in under the pad. + Preset("Detune Wall", mapOf( + "osc0wave" to "Saw", "osc0oct" to "-1", "osc0fine" to "-20", "osc0level" to "0.7", + "osc1wave" to "Saw", "osc1oct" to "-1", "osc1fine" to "20", "osc1level" to "0.7", + "osc2wave" to "Pulse25", "osc2oct" to "-1", "osc2level" to "0.4", + "noise" to "0.10", + "cutoff" to "0.44", "reso" to "0.55", "keytrack" to "0.25", "fenvamt" to "0.25", + "attack" to "0.02", "decay" to "0.4", "sustain" to "0.9", "release" to "0.35", + )), ) } 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 cc8da3b..442ab20 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 @@ -46,8 +46,11 @@ enum class ToolboxType( // A classic 3-oscillator subtractive synth (Minimoog-style): oscillators → // mixer → resonant filter (own contour) → amp (own envelope), with a tempo-synced // LFO and a small mod matrix. Rendered by [SynthVoice]; edited by NesSynthEditor. + // NOTE: the enum name NES_SYNTH is the on-disk/serialized identifier (preset dirs, + // .sng type) and must stay stable for backward compatibility; only the user-facing + // displayName is generic. NES_SYNTH( - ToolboxKind.INSTRUMENT, "NES Synth", + ToolboxKind.INSTRUMENT, "Synth", buildList { for (i in 0 until SynthDefs.OSC_COUNT) { add(ParamSpec("osc${i}wave", "Osc${i + 1} Wave", choices = SynthDefs.WAVES)) diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt index 6992740..4971777 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -25,13 +27,17 @@ import space.rcmd.android.sizzle.ui.theme.LocalRetro import kotlin.math.cos import kotlin.math.sin +/** Vertical finger travel (dp) for a full min→max knob sweep. Larger = less sensitive. */ +private const val KNOB_TRAVEL_DP = 280f + /** * A rotary gauge (knob). Shows a 270° arc from bottom-left to bottom-right with a * filled portion + pointer for the current value, the formatted value above and a - * short label below. Control is ABSOLUTE and fader-like: the finger's vertical - * position on the knob sets the value directly — press/slide toward the bottom for - * empty (min), toward the top for full (max). Double-tap resets to [default]. Values - * are reported in the caller's [min]..[max] range. + * short label below. Control is RELATIVE and fader-like: sliding the finger up raises + * the value and down lowers it, with a full [min]..[max] sweep spanning [KNOB_TRAVEL_DP] + * of travel — decoupled from the knob's small size, so fine adjustments are easy and + * the control isn't touchy. Double-tap resets to [default]. Values are reported in the + * caller's [min]..[max] range. * * Rotary controls are the tracker's preferred way to show a continuous parameter * (see the synth editor); pair with [RetroDropdown] for enumerated params. @@ -50,6 +56,9 @@ fun Knob( val c = LocalRetro.current val span = if (max > min) max - min else 1f val frac = ((value - min) / span).coerceIn(0f, 1f) + // Latest value seen without restarting the drag gesture, so a relative drag starts + // from wherever the knob currently sits. + val currentValue by rememberUpdatedState(value) Column( modifier, @@ -64,15 +73,16 @@ fun Knob( Modifier .size(44.dp) .pointerInput(min, max) { - // Absolute: value = finger's vertical position (top = full, bottom - // = empty). Set on press and tracked through the drag. - fun setFromY(y: Float) { - val f = (1f - y / size.height.toFloat()).coerceIn(0f, 1f) - onChange(min + f * span) - } - detectDragGestures(onDragStart = { setFromY(it.y) }) { change, _ -> + // Relative: slide up to raise, down to lower. A full min..max sweep + // takes KNOB_TRAVEL_DP of vertical travel (not the 44dp knob height), + // so the control is calm and fine tweaks are easy. Accumulate from + // the current value at drag start (via [currentValue]). + val travelPx = KNOB_TRAVEL_DP.dp.toPx() + var acc = 0f + detectDragGestures(onDragStart = { acc = currentValue }) { change, dragAmount -> change.consume() - setFromY(change.position.y) + acc = (acc - dragAmount.y / travelPx * span).coerceIn(min, max) + onChange(acc) } } .pointerInput(min, max) {