Generic synth name, calmer knobs, darkwave/industrial presets (v0.5.0)

- Rename the 3-osc synth's display name "NES Synth" → "Synth" (the enum
  NES_SYNTH stays as the serialized id, so saved projects/presets are
  unaffected).
- Knobs: replace absolute finger-position control (full range mapped onto
  the 44dp knob → very touchy) with relative drag over ~280dp of travel,
  so fine tweaks are easy and the control isn't sensitive.
- Add a darkwave/industrial preset family: Pluck Bass, Acid Pluck
  (punchy plucky basses), Razor Lead, Cold Lead (cutting leads), and
  Grit Sub, Detune Wall (harsh, detuned deep basses for layering under
  bass pads).
- Preset seeding now records seeded names in the per-type marker instead
  of a single flag, so factory presets added in a later version appear on
  upgrade without resurrecting presets the user deleted (legacy "1"
  markers upgrade cleanly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 10:56:52 +02:00
parent 12c2092843
commit cf306d25e0
5 changed files with 118 additions and 28 deletions

View File

@@ -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"

View File

@@ -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<String>, 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. */

View File

@@ -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",
)),
)
}

View File

@@ -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))

View File

@@ -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) {