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:
@@ -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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user