Rewrite the NES synth as a 3-oscillator subtractive synth; track-tied note-offs (v0.4.1)
Two changes that landed together (the note-off work was uncommitted when the synth
rewrite began):
Synth rewrite — replace the simple NES tone generator with a classic Minimoog-style
voice: 3 oscillators (+ noise) → mixer → resonant 4-pole ladder low-pass (own
contour envelope + key tracking) → amplifier (own envelope), plus a tempo-synced
LFO and a 4-slot modulation matrix.
- DSP: SynthVoice rewritten (PolyBLEP oscillators, ladder filter, two ADSRs, LFO,
mod matrix); new SynthPatch parses a slot into an immutable snapshot per note.
PolyBLEP + control-rate cutoff + precomputed osc tuning keep the RT cost down.
- Model: SynthDefs (shared option lists/defaults), rewritten NES_SYNTH params,
NesSynthPresets (8 chiptune factory presets) seeded into the preset library.
- Editor: NesSynthEditor — swipeable cards (Oscillators / Filter / Amp+LFO / Mod
Matrix) with dot pagination; new rotary Knob control (absolute, fader-like
vertical drag: bottom = empty, top = full); enums via RetroDropdown. The shared
slot-audition keyboard (renamed SoundFontKeyboard -> SlotAuditionKeyboard) is
pinned at the editor bottom.
- Engine builds a SynthPatch per note. The enum id stays NES_SYNTH so existing
projects still route; the old single `wave` param is dropped (amp envelope keys
carry over).
Track-tied note-offs — a note-off ("===") is now tied to its track, not a channel:
it has no velocity/channel of its own (shown as ".." and non-editable) and simply
releases whatever that track last played, on the bus(es) that note routed to
(AudioEngine.trackLastChannel). `.sng` export writes "OFF .. ..".
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 = 5
|
||||
versionName = "0.3.2"
|
||||
versionCode = 8
|
||||
versionName = "0.4.1"
|
||||
|
||||
// We provide our own instrumentation runner if/when tests are added.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -46,8 +46,8 @@ import kotlin.math.tanh
|
||||
* one. Notes are allocated round-robin, stealing the oldest voice when the pool is
|
||||
* full — the requested "N sounds per instrument" cap.
|
||||
*
|
||||
* INSTRUMENTS: NES synth and Sampler are rendered here; the SoundFont loader
|
||||
* still falls back to the synth. Each bus pool has both [SynthVoice]s and
|
||||
* INSTRUMENTS: the subtractive [SynthVoice] (NES synth), the Sampler and the
|
||||
* SoundFont are all rendered here. Each bus pool has both [SynthVoice]s and
|
||||
* [SampleVoice]s; the channel's instrument type decides which one a note triggers.
|
||||
* Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
|
||||
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this
|
||||
@@ -77,6 +77,10 @@ class AudioEngine(
|
||||
private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
|
||||
// Per-(lane,track) arpeggiator state, driven by advanceArps.
|
||||
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() }
|
||||
// Per-(lane,track) MIDI channel the track last played a note on. A note-off cell
|
||||
// is tied to its track (it has no channel of its own) and releases whatever the
|
||||
// track last started, on the bus(es) that channel routed to. -1 = nothing yet.
|
||||
private val trackLastChannel = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }
|
||||
|
||||
/** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. */
|
||||
private fun allocSynthVoice(bus: Int): SynthVoice {
|
||||
@@ -438,6 +442,7 @@ class AudioEngine(
|
||||
busVoices.forEach { it.kill() }
|
||||
busSampleVoices.forEach { it.kill() }
|
||||
arps.forEach { it.stop() }
|
||||
trackLastChannel.fill(-1)
|
||||
}
|
||||
|
||||
/** Stop playback. If already stopped, rewind to the very beginning. */
|
||||
@@ -457,7 +462,7 @@ class AudioEngine(
|
||||
// ------------------------------------------------------ live note audition
|
||||
fun liveNoteOn(pitch: Int, velocity: Int) {
|
||||
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
|
||||
v.noteOn(pitch, velocity, SynthVoice.Wave.PULSE_50, 0.005, 0.1, 0.7, 0.15)
|
||||
v.noteOn(pitch, velocity, SynthPatch.DEFAULT)
|
||||
}
|
||||
|
||||
fun liveNoteOff(pitch: Int) {
|
||||
@@ -666,62 +671,74 @@ class AudioEngine(
|
||||
// whole bus; without this ordering it would kill a just-triggered voice).
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val cell = pattern.cell(t, line)
|
||||
if (cell.isNoteOff) triggerCell(proj, lane = 0, cell = cell)
|
||||
if (cell.isNoteOff) triggerCell(proj, lane = 0, track = t, cell = cell)
|
||||
}
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val cell = pattern.cell(t, line)
|
||||
if (cell.isPlayable) triggerCell(proj, lane = 0, cell = cell)
|
||||
if (cell.isPlayable) triggerCell(proj, lane = 0, track = t, cell = cell)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger one tracker [cell], routing it to the mixer bus(es) whose MIDI channel
|
||||
* matches the cell's channel — NOT the source track. A note therefore plays
|
||||
* whatever instrument sits on its channel's bus, wherever it is placed in the
|
||||
* grid (this is what decouples tracks from buses). Each matching bus applies
|
||||
* its own MIDI effects (Transposer shifts pitch; an Arpeggiator captures the
|
||||
* note and hands retriggering to [advanceArps]).
|
||||
* Trigger one tracker [cell] from source [track] on [lane].
|
||||
*
|
||||
* A PLAYABLE note is routed to the mixer bus(es) whose MIDI channel matches the
|
||||
* cell's channel — NOT the source track — so a note plays whatever instrument
|
||||
* sits on its channel's bus, wherever it's placed in the grid (this decouples
|
||||
* tracks from buses). Each matching bus applies its own MIDI effects (Transposer
|
||||
* shifts pitch; an Arpeggiator captures the note, retriggered by [advanceArps]).
|
||||
* The channel used is remembered per (lane, track).
|
||||
*
|
||||
* A NOTE-OFF is tied to its TRACK, not to a channel: it has no channel/velocity
|
||||
* of its own and simply releases whatever this track last started — on the same
|
||||
* bus(es) that track's previous note routed to ([trackLastChannel]).
|
||||
*/
|
||||
private fun triggerCell(proj: Project, lane: Int, cell: space.rcmd.android.sizzle.model.Cell) {
|
||||
private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
|
||||
val trackKey = lane * Pattern.TRACK_COUNT + track
|
||||
if (cell.isNoteOff) {
|
||||
val ch = trackLastChannel[trackKey]
|
||||
if (ch < 0) return // this track hasn't played anything yet
|
||||
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||
if (proj.mixer.channels[bus].midiChannel != ch) continue
|
||||
arps[lane * Pattern.TRACK_COUNT + bus].stop()
|
||||
releaseBus(bus)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!cell.isPlayable) return
|
||||
// Remember the channel so a later note-off on this track releases it.
|
||||
trackLastChannel[trackKey] = cell.channel
|
||||
for (bus in 0 until Pattern.TRACK_COUNT) {
|
||||
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
|
||||
val arp = arps[lane * Pattern.TRACK_COUNT + bus]
|
||||
when {
|
||||
cell.isPlayable -> {
|
||||
val velocity = cell.velocity.coerceIn(0, 127)
|
||||
val note = (cell.note + channelTranspose(proj, bus)).coerceIn(0, 127)
|
||||
val arpSlot = channelArpSlot(proj, bus)
|
||||
if (arpSlot != null) {
|
||||
val octaves = arpSlot.float("octaves", 1f).toInt()
|
||||
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
|
||||
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||
val mode = arpSlot.string("mode", "Up")
|
||||
val resetBar = arpSlot.string("resetbar", "Off") == "On"
|
||||
if (arp.active && arp.base == note) {
|
||||
// The same note is already arpeggiating — this trigger is the
|
||||
// pattern looping back over a still-held note. Keep the running
|
||||
// phase (just refresh params) so the pulse stays steady across
|
||||
// the loop point instead of slipping when the loop length isn't
|
||||
// an exact multiple of the arp step (dotted/triplet divisions).
|
||||
arp.refresh(octaves, mode, stepSamples, resetBar)
|
||||
} else {
|
||||
arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||
// The arpeggiator is monophonic (it cycles octaves of ONE note),
|
||||
// so it must not stack notes. Releasing the bus first means a
|
||||
// chord entered on the same line collapses to a single
|
||||
// arpeggiated note (the last one), not all of them at once.
|
||||
releaseBus(bus)
|
||||
playNote(proj, bus, arp.currentNote(), velocity)
|
||||
}
|
||||
} else {
|
||||
arp.stop()
|
||||
playNote(proj, bus, note, velocity)
|
||||
}
|
||||
}
|
||||
cell.isNoteOff -> {
|
||||
arp.stop()
|
||||
val velocity = cell.velocity.coerceIn(0, 127)
|
||||
val note = (cell.note + channelTranspose(proj, bus)).coerceIn(0, 127)
|
||||
val arpSlot = channelArpSlot(proj, bus)
|
||||
if (arpSlot != null) {
|
||||
val octaves = arpSlot.float("octaves", 1f).toInt()
|
||||
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
|
||||
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
|
||||
val mode = arpSlot.string("mode", "Up")
|
||||
val resetBar = arpSlot.string("resetbar", "Off") == "On"
|
||||
if (arp.active && arp.base == note) {
|
||||
// The same note is already arpeggiating — this trigger is the
|
||||
// pattern looping back over a still-held note. Keep the running
|
||||
// phase (just refresh params) so the pulse stays steady across
|
||||
// the loop point instead of slipping when the loop length isn't
|
||||
// an exact multiple of the arp step (dotted/triplet divisions).
|
||||
arp.refresh(octaves, mode, stepSamples, resetBar)
|
||||
} else {
|
||||
arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
|
||||
// The arpeggiator is monophonic (it cycles octaves of ONE note),
|
||||
// so it must not stack notes. Releasing the bus first means a
|
||||
// chord entered on the same line collapses to a single
|
||||
// arpeggiated note (the last one), not all of them at once.
|
||||
releaseBus(bus)
|
||||
playNote(proj, bus, arp.currentNote(), velocity)
|
||||
}
|
||||
} else {
|
||||
arp.stop()
|
||||
playNote(proj, bus, note, velocity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -745,8 +762,7 @@ class AudioEngine(
|
||||
slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
|
||||
// NES synth, or a SoundFont with nothing loaded → the tone generator.
|
||||
else -> {
|
||||
val (wave, a, d, s, r) = synthParamsForTrack(proj, bus)
|
||||
allocSynthVoice(bus).noteOn(note, velocity, wave, a, d, s, r)
|
||||
allocSynthVoice(bus).noteOn(note, velocity, patchForSlot(slot, proj.tempoBpm))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,9 +950,8 @@ class AudioEngine(
|
||||
// note-off, so a sustaining synth voice would ring forever). A SoundFont
|
||||
// with nothing loaded still falls through to the synth preview below.
|
||||
if (slot?.type == ToolboxType.SAMPLER) return
|
||||
val (wave, a, d, s, r) = synthParamsForSlot(slot)
|
||||
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() }
|
||||
v.noteOn(midi, 110, wave, a, d, s, r)
|
||||
v.noteOn(midi, 110, patchForSlot(slot, project?.tempoBpm ?: 120f))
|
||||
}
|
||||
|
||||
/** Release an auditioned note (matches both synth and sample audition pools). */
|
||||
@@ -971,10 +986,9 @@ class AudioEngine(
|
||||
// Sampler with an empty pad → silent (no synth fallback).
|
||||
type == ToolboxType.SAMPLER -> {}
|
||||
else -> {
|
||||
val (wave, a, d, s, r) = synthParamsForSlot(slot)
|
||||
val v = (0 until BUS_POLYPHONY).map { busVoices[base + it] }
|
||||
.firstOrNull { !it.isActive } ?: busVoices[base].also { it.kill() }
|
||||
v.noteOn(midi, velocity, wave, a, d, s, r)
|
||||
v.noteOn(midi, velocity, patchForSlot(slot, proj.tempoBpm))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1065,35 +1079,17 @@ class AudioEngine(
|
||||
if (patLine >= pat.length) continue
|
||||
for (t in 0 until Pattern.TRACK_COUNT) {
|
||||
val cell = pat.cell(t, patLine)
|
||||
if (pass == 0) { if (cell.isNoteOff) triggerCell(proj, lane, cell) }
|
||||
else { if (cell.isPlayable) triggerCell(proj, lane, cell) }
|
||||
if (pass == 0) { if (cell.isNoteOff) triggerCell(proj, lane, t, cell) }
|
||||
else { if (cell.isPlayable) triggerCell(proj, lane, t, cell) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the NES-synth settings for a track from its mixer->toolbox routing. */
|
||||
private fun synthParamsForTrack(proj: Project, track: Int): SynthParams =
|
||||
synthParamsForSlot(proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot))
|
||||
|
||||
/** Read a slot's NES-synth settings (default synth if it isn't an NES synth). */
|
||||
private fun synthParamsForSlot(slot: ToolboxSlot?): SynthParams {
|
||||
if (slot == null || slot.type != ToolboxType.NES_SYNTH) return DEFAULT_SYNTH
|
||||
val wave = when (slot.string("wave", "Pulse50")) {
|
||||
"Pulse12" -> SynthVoice.Wave.PULSE_12
|
||||
"Pulse25" -> SynthVoice.Wave.PULSE_25
|
||||
"Triangle" -> SynthVoice.Wave.TRIANGLE
|
||||
"Noise" -> SynthVoice.Wave.NOISE
|
||||
else -> SynthVoice.Wave.PULSE_50
|
||||
}
|
||||
return SynthParams(
|
||||
wave,
|
||||
slot.float("attack", 0.01f).toDouble(),
|
||||
slot.float("decay", 0.15f).toDouble(),
|
||||
slot.float("sustain", 0.6f).toDouble(),
|
||||
slot.float("release", 0.1f).toDouble(),
|
||||
)
|
||||
}
|
||||
/** Parse a slot's synth patch (default init patch if it isn't an NES synth).
|
||||
* Allocates one [SynthPatch] per note trigger — off the per-sample hot path. */
|
||||
private fun patchForSlot(slot: ToolboxSlot?, tempoBpm: Float): SynthPatch =
|
||||
if (slot?.type == ToolboxType.NES_SYNTH) SynthPatch.from(slot, tempoBpm) else SynthPatch.DEFAULT
|
||||
|
||||
private fun samplesPerLine(proj: Project): Double =
|
||||
sampleRate * (60.0 / proj.tempoBpm) / proj.timeSignature.linesPerBeat
|
||||
@@ -1124,11 +1120,6 @@ class AudioEngine(
|
||||
}
|
||||
}
|
||||
|
||||
/** Small destructurable holder for a voice's synth settings. */
|
||||
private data class SynthParams(
|
||||
val wave: SynthVoice.Wave, val a: Double, val d: Double, val s: Double, val r: Double,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz
|
||||
private const val POLYPHONY = 8 // max simultaneous voices per instrument (mixer bus)
|
||||
@@ -1138,7 +1129,6 @@ class AudioEngine(
|
||||
private const val MASTER_GAIN = 0.35f
|
||||
private const val LIVE_GAIN = 0.5f
|
||||
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above
|
||||
private val DEFAULT_SYNTH = SynthParams(SynthVoice.Wave.PULSE_50, 0.01, 0.15, 0.6, 0.1)
|
||||
|
||||
/**
|
||||
* Continuous soft-knee limiter: linear (transparent) up to [SOFT_KNEE], then
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import space.rcmd.android.sizzle.model.SynthDefs
|
||||
import space.rcmd.android.sizzle.model.TimeDivision
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
|
||||
/**
|
||||
* An immutable snapshot of every parameter of the subtractive (Minimoog-style)
|
||||
* synth voice, parsed once from a [ToolboxSlot] at note-trigger time. Holding it on
|
||||
* the voice keeps the audio thread allocation-free during [SynthVoice.render].
|
||||
*
|
||||
* Signal path per voice: 3 oscillators (+ noise) → mixer → resonant low-pass filter
|
||||
* (with its own ADSR contour + key tracking) → amplifier (its own ADSR). A single
|
||||
* tempo-synced LFO and a small [MOD_SRC]→[MOD_DST] modulation matrix route
|
||||
* modulation across the path.
|
||||
*/
|
||||
class SynthPatch(
|
||||
// ---- oscillators (index 0..2) ----
|
||||
val oscWave: IntArray, // index into WAVES
|
||||
val oscOct: IntArray, // octave transpose, -2..+2
|
||||
val oscSemi: IntArray, // semitone detune, -12..+12
|
||||
val oscFine: FloatArray, // fine detune in cents, -50..+50
|
||||
val oscLevel: FloatArray, // mixer level, 0..1
|
||||
val noiseLevel: Float, // white-noise mixer level, 0..1
|
||||
// ---- filter ----
|
||||
val cutoff: Float, // 0..1 (mapped to Hz, exponential)
|
||||
val resonance: Float, // 0..1 (self-oscillates near 1)
|
||||
val keytrack: Float, // 0..1 how far the played note opens the cutoff
|
||||
val filterEnvAmt: Float, // -1..1 filter-envelope (contour) amount
|
||||
val fA: Float, val fD: Float, val fS: Float, val fR: Float, // filter ADSR (s / 0..1)
|
||||
// ---- amplifier ----
|
||||
val aA: Float, val aD: Float, val aS: Float, val aR: Float, // amp ADSR (s / 0..1)
|
||||
// ---- LFO ----
|
||||
val lfoWave: Int, // index into LFO_WAVES
|
||||
val lfoHz: Float, // tempo-synced rate, precomputed from the division + tempo
|
||||
// ---- modulation matrix (index 0..MOD_SLOTS-1) ----
|
||||
val modSrc: IntArray, // index into MOD_SRC (0 == Off)
|
||||
val modDst: IntArray, // index into MOD_DST
|
||||
val modAmt: FloatArray, // -1..1
|
||||
) {
|
||||
companion object {
|
||||
const val OSC_COUNT = SynthDefs.OSC_COUNT
|
||||
const val MOD_SLOTS = SynthDefs.MOD_SLOTS
|
||||
|
||||
private fun waveIndex(s: String) = SynthDefs.WAVES.indexOf(s).let { if (it < 0) 0 else it }
|
||||
private fun lfoWaveIndex(s: String) = SynthDefs.LFO_WAVES.indexOf(s).let { if (it < 0) 0 else it }
|
||||
private fun srcIndex(s: String) = SynthDefs.MOD_SRC.indexOf(s).let { if (it < 0) 0 else it }
|
||||
private fun dstIndex(s: String) = SynthDefs.MOD_DST.indexOf(s).let { if (it < 0) 0 else it }
|
||||
|
||||
/** Parse [slot]'s value map into a patch. [tempoBpm] fixes the LFO rate from
|
||||
* its tempo-synced division. A null/empty slot yields a plain init patch. */
|
||||
fun from(slot: ToolboxSlot?, tempoBpm: Float): SynthPatch {
|
||||
val wave = IntArray(OSC_COUNT) { i ->
|
||||
waveIndex(slot?.string("osc${i}wave", SynthDefs.DEF_OSC_WAVE[i]) ?: SynthDefs.DEF_OSC_WAVE[i])
|
||||
}
|
||||
val oct = IntArray(OSC_COUNT) { i -> (slot?.float("osc${i}oct", 0f) ?: 0f).toInt().coerceIn(-2, 2) }
|
||||
val semi = IntArray(OSC_COUNT) { i -> (slot?.float("osc${i}semi", 0f) ?: 0f).toInt().coerceIn(-12, 12) }
|
||||
val fine = FloatArray(OSC_COUNT) { i -> (slot?.float("osc${i}fine", 0f) ?: 0f).coerceIn(-50f, 50f) }
|
||||
val level = FloatArray(OSC_COUNT) { i ->
|
||||
(slot?.float("osc${i}level", SynthDefs.DEF_OSC_LEVEL[i]) ?: SynthDefs.DEF_OSC_LEVEL[i]).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
val divIdx = (slot?.float("lfodiv", 3f) ?: 3f).toInt()
|
||||
val beatFraction = TimeDivision.fromIndex(divIdx).beatFraction.toFloat()
|
||||
val lfoHz = if (beatFraction > 0f) (tempoBpm / 60f) / beatFraction else 2f
|
||||
|
||||
val src = IntArray(MOD_SLOTS) { n -> srcIndex(slot?.string("m${n}src", "Off") ?: "Off") }
|
||||
val dst = IntArray(MOD_SLOTS) { n -> dstIndex(slot?.string("m${n}dst", "Cutoff") ?: "Cutoff") }
|
||||
val amt = FloatArray(MOD_SLOTS) { n -> (slot?.float("m${n}amt", 0f) ?: 0f).coerceIn(-1f, 1f) }
|
||||
|
||||
return SynthPatch(
|
||||
oscWave = wave, oscOct = oct, oscSemi = semi, oscFine = fine, oscLevel = level,
|
||||
noiseLevel = (slot?.float("noise", 0f) ?: 0f).coerceIn(0f, 1f),
|
||||
cutoff = (slot?.float("cutoff", 0.55f) ?: 0.55f).coerceIn(0f, 1f),
|
||||
resonance = (slot?.float("reso", 0.15f) ?: 0.15f).coerceIn(0f, 1f),
|
||||
keytrack = (slot?.float("keytrack", 0.3f) ?: 0.3f).coerceIn(0f, 1f),
|
||||
filterEnvAmt = (slot?.float("fenvamt", 0.4f) ?: 0.4f).coerceIn(-1f, 1f),
|
||||
fA = (slot?.float("fatk", 0.005f) ?: 0.005f), fD = (slot?.float("fdec", 0.25f) ?: 0.25f),
|
||||
fS = (slot?.float("fsus", 0.4f) ?: 0.4f), fR = (slot?.float("frel", 0.2f) ?: 0.2f),
|
||||
aA = (slot?.float("attack", 0.005f) ?: 0.005f), aD = (slot?.float("decay", 0.2f) ?: 0.2f),
|
||||
aS = (slot?.float("sustain", 0.7f) ?: 0.7f), aR = (slot?.float("release", 0.2f) ?: 0.2f),
|
||||
lfoWave = lfoWaveIndex(slot?.string("lfowave", "Tri") ?: "Tri"),
|
||||
lfoHz = lfoHz,
|
||||
modSrc = src, modDst = dst, modAmt = amt,
|
||||
)
|
||||
}
|
||||
|
||||
/** A plain default patch (for generic live audition not tied to a synth slot). */
|
||||
val DEFAULT: SynthPatch = from(null, 120f)
|
||||
}
|
||||
}
|
||||
@@ -3,102 +3,255 @@
|
||||
|
||||
package space.rcmd.android.sizzle.audio
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* One playing note (a "voice"). This is a deliberately simple NES/Famicom-style
|
||||
* tone generator: a few band-limited-ish waveforms plus a linear ADSR envelope.
|
||||
* It renders sample-by-sample into the mix buffer.
|
||||
* One playing note of the subtractive (Minimoog-style) synth. Per voice:
|
||||
*
|
||||
* Everything here runs on the audio thread and must never allocate or block.
|
||||
* 3 oscillators (+ white noise) → mixer → resonant 4-pole low-pass filter
|
||||
* (with its own ADSR contour + key tracking) → amplifier (its own ADSR)
|
||||
*
|
||||
* A single tempo-synced LFO and a small modulation matrix ([SynthPatch]) route
|
||||
* modulation across pitch / detune / cutoff / pulse-width / amplitude.
|
||||
*
|
||||
* All parameters come from an immutable [SynthPatch] captured at [noteOn], so
|
||||
* [render] — which runs on the audio thread — never allocates. Oscillators use
|
||||
* PolyBLEP band-limiting on their discontinuities to keep saw/pulse tones from
|
||||
* aliasing; the filter cutoff is recomputed at control rate to keep the per-sample
|
||||
* cost down.
|
||||
*/
|
||||
class SynthVoice(private val sampleRate: Int) {
|
||||
|
||||
enum class Wave { PULSE_12, PULSE_25, PULSE_50, TRIANGLE, NOISE }
|
||||
private var patch: SynthPatch = SynthPatch.DEFAULT
|
||||
var pitch = -1; private set
|
||||
private var velocity = 0f
|
||||
private var baseFreq = 440.0
|
||||
private var keyNorm = 0f // note position relative to C-4 (60), -1..1
|
||||
|
||||
private enum class Stage { IDLE, ATTACK, DECAY, SUSTAIN, RELEASE }
|
||||
// Oscillator phases (0..1) + per-note precomputed tuning.
|
||||
private val phase = DoubleArray(SynthPatch.OSC_COUNT)
|
||||
private val staticSemis = FloatArray(SynthPatch.OSC_COUNT) // oct+semi+fine, no modulation
|
||||
private val staticInc = DoubleArray(SynthPatch.OSC_COUNT) // phase increment when un-modulated
|
||||
private var pitchModActive = false // any mod slot routes to pitch?
|
||||
|
||||
// --- Note / oscillator state ---
|
||||
private var wave = Wave.PULSE_50
|
||||
private var phase = 0.0 // 0..1
|
||||
private var phaseInc = 0.0 // per-sample phase advance
|
||||
private var noiseReg = 0x7FFF // linear-feedback shift register for noise
|
||||
var pitch = -1; private set // MIDI note this voice is playing, -1 idle
|
||||
// Cheap linear 4-pole ("Moog") ladder state.
|
||||
private var o1 = 0f; private var o2 = 0f; private var o3 = 0f; private var o4 = 0f
|
||||
private var s1 = 0f; private var s2 = 0f; private var s3 = 0f; private var s4 = 0f
|
||||
private var fcNorm = 0.5f // cached cutoff (fraction of Nyquist), refreshed at control rate
|
||||
private var ctrl = 0
|
||||
|
||||
// --- Envelope (times are in seconds) ---
|
||||
private var stage = Stage.IDLE
|
||||
private var env = 0.0
|
||||
private var attack = 0.01; private var decay = 0.15
|
||||
private var sustainLevel = 0.6; private var release = 0.1
|
||||
private var amp = 0.0 // note velocity as 0..1
|
||||
// LFO + sample-and-hold.
|
||||
private var lfoPhase = 0.0
|
||||
private var lfoInc = 0.0
|
||||
private var shValue = 0f
|
||||
private var rng = 0x2545F491
|
||||
|
||||
val isActive: Boolean get() = stage != Stage.IDLE
|
||||
private val ampEnv = Adsr()
|
||||
private val filtEnv = Adsr()
|
||||
|
||||
/** Start (or retrigger) this voice. Called from the sequencer/live input. */
|
||||
fun noteOn(midi: Int, velocity: Int, w: Wave, a: Double, d: Double, s: Double, r: Double) {
|
||||
val isActive: Boolean get() = ampEnv.active
|
||||
|
||||
/** Start (or steal-and-restart) this voice with the given [patch]. */
|
||||
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
|
||||
this.patch = patch
|
||||
pitch = midi
|
||||
wave = w
|
||||
attack = a.coerceAtLeast(0.001); decay = d; sustainLevel = s; release = r
|
||||
amp = (velocity / 127.0).coerceIn(0.0, 1.0)
|
||||
phaseInc = midiToHz(midi) / sampleRate
|
||||
stage = Stage.ATTACK
|
||||
// env is intentionally NOT reset to 0 on retrigger to avoid clicks.
|
||||
this.velocity = (velocity / 127f).coerceIn(0f, 1f)
|
||||
baseFreq = midiToHz(midi)
|
||||
keyNorm = ((midi - 60) / 60f).coerceIn(-1f, 1f)
|
||||
ampEnv.gateOn(patch.aA, patch.aD, patch.aS, patch.aR)
|
||||
filtEnv.gateOn(patch.fA, patch.fD, patch.fS, patch.fR)
|
||||
lfoInc = patch.lfoHz.toDouble() / sampleRate
|
||||
lfoPhase = 0.0 // retrigger the LFO per note for predictable modulation
|
||||
ctrl = 0 // force a cutoff recompute on the first sample
|
||||
|
||||
// Precompute each oscillator's tuning so the per-sample loop skips Math.pow
|
||||
// unless pitch is actually being modulated (the common case).
|
||||
for (i in 0 until SynthPatch.OSC_COUNT) {
|
||||
val semis = patch.oscOct[i] * 12f + patch.oscSemi[i] + patch.oscFine[i] / 100f
|
||||
staticSemis[i] = semis
|
||||
staticInc[i] = (baseFreq * fastPow2(semis / 12.0) / sampleRate).coerceIn(0.0, 0.49)
|
||||
}
|
||||
pitchModActive = (0 until SynthPatch.MOD_SLOTS).any { patch.modSrc[it] != 0 && patch.modDst[it] in 0..2 }
|
||||
}
|
||||
|
||||
fun noteOff() {
|
||||
if (stage != Stage.IDLE) stage = Stage.RELEASE
|
||||
}
|
||||
fun noteOff() { ampEnv.gateOff(); filtEnv.gateOff() }
|
||||
|
||||
/** Hard stop with no release tail (used on transport stop / voice steal). */
|
||||
/** Hard stop with no release tail (transport stop / voice steal). */
|
||||
fun kill() {
|
||||
stage = Stage.IDLE; env = 0.0; pitch = -1
|
||||
ampEnv.kill(); filtEnv.kill()
|
||||
pitch = -1
|
||||
o1 = 0f; o2 = 0f; o3 = 0f; o4 = 0f; s1 = 0f; s2 = 0f; s3 = 0f; s4 = 0f
|
||||
}
|
||||
|
||||
/** Render one sample and return its signed float value (roughly -1..1). */
|
||||
fun render(): Float {
|
||||
if (stage == Stage.IDLE) return 0f
|
||||
advanceEnvelope()
|
||||
val osc = oscillator()
|
||||
// advance oscillator phase
|
||||
phase += phaseInc
|
||||
if (phase >= 1.0) phase -= 1.0
|
||||
return (osc * env * amp).toFloat()
|
||||
if (!ampEnv.active) return 0f
|
||||
val dt = 1f / sampleRate
|
||||
val ampE = ampEnv.advance(dt)
|
||||
val filtE = filtEnv.advance(dt)
|
||||
|
||||
// ---- LFO ----
|
||||
lfoPhase += lfoInc
|
||||
if (lfoPhase >= 1.0) { lfoPhase -= 1.0; shValue = nextRand() } // clock S&H on wrap
|
||||
val lfo = lfoValue()
|
||||
|
||||
// ---- modulation matrix ----
|
||||
var mPitch = 0f; var mOsc2 = 0f; var mOsc3 = 0f
|
||||
var mCut = 0f; var mPwm = 0f; var mAmp = 0f
|
||||
val src = patch.modSrc; val dst = patch.modDst; val amt = patch.modAmt
|
||||
for (n in 0 until SynthPatch.MOD_SLOTS) {
|
||||
val s = when (src[n]) {
|
||||
1 -> lfo // bipolar
|
||||
2 -> filtE // 0..1
|
||||
3 -> ampE // 0..1
|
||||
4 -> velocity // 0..1
|
||||
5 -> keyNorm // bipolar
|
||||
else -> continue // 0 == Off
|
||||
}
|
||||
val v = amt[n] * s
|
||||
when (dst[n]) {
|
||||
0 -> mPitch += v
|
||||
1 -> mOsc2 += v
|
||||
2 -> mOsc3 += v
|
||||
3 -> mCut += v
|
||||
4 -> mPwm += v
|
||||
5 -> mAmp += v
|
||||
}
|
||||
}
|
||||
|
||||
// ---- oscillators + noise ----
|
||||
var mix = 0f
|
||||
for (i in 0 until SynthPatch.OSC_COUNT) {
|
||||
val level = patch.oscLevel[i]
|
||||
if (level <= 0.0001f) continue
|
||||
val inc = if (pitchModActive) {
|
||||
var semis = staticSemis[i] + mPitch * 12f
|
||||
if (i == 1) semis += mOsc2 * 12f
|
||||
if (i == 2) semis += mOsc3 * 12f
|
||||
(baseFreq * fastPow2(semis / 12.0) / sampleRate).coerceIn(0.0, 0.49)
|
||||
} else {
|
||||
staticInc[i]
|
||||
}
|
||||
var ph = phase[i] + inc
|
||||
if (ph >= 1.0) ph -= 1.0
|
||||
phase[i] = ph
|
||||
mix += waveform(patch.oscWave[i], ph, inc, mPwm) * level
|
||||
}
|
||||
if (patch.noiseLevel > 0.0001f) mix += nextRand() * patch.noiseLevel
|
||||
|
||||
// ---- filter (cutoff refreshed at control rate) ----
|
||||
if (ctrl <= 0) {
|
||||
ctrl = CTRL_INTERVAL
|
||||
val exp = (patch.cutoff + patch.filterEnvAmt * filtE + patch.keytrack * keyNorm + mCut)
|
||||
.coerceIn(0f, 1.2f)
|
||||
val hz = (20.0 * fastPow(1000.0, exp.toDouble())).coerceIn(20.0, sampleRate * 0.45)
|
||||
fcNorm = (hz / (sampleRate * 0.5)).toFloat().coerceIn(0.001f, 0.99f)
|
||||
}
|
||||
ctrl--
|
||||
val filtered = ladder(mix, fcNorm, patch.resonance)
|
||||
|
||||
// ---- amplifier ----
|
||||
var a = ampE * velocity * (1f + mAmp)
|
||||
if (a < 0f) a = 0f
|
||||
val y = filtered * a * OUT_GAIN
|
||||
return if (y.isFinite()) y else 0f
|
||||
}
|
||||
|
||||
private fun oscillator(): Double = when (wave) {
|
||||
Wave.PULSE_12 -> if (phase < 0.125) 1.0 else -1.0
|
||||
Wave.PULSE_25 -> if (phase < 0.25) 1.0 else -1.0
|
||||
Wave.PULSE_50 -> if (phase < 0.5) 1.0 else -1.0
|
||||
Wave.TRIANGLE -> 1.0 - 4.0 * kotlin.math.abs((phase % 1.0) - 0.5) // -1..1
|
||||
Wave.NOISE -> {
|
||||
// 15-bit LFSR clocked at the note frequency for a pitched-noise feel.
|
||||
if (phase < phaseInc) {
|
||||
val bit = (noiseReg xor (noiseReg shr 1)) and 1
|
||||
noiseReg = (noiseReg shr 1) or (bit shl 14)
|
||||
}
|
||||
if (noiseReg and 1 == 0) 1.0 else -1.0
|
||||
// ---- oscillator waveforms (PolyBLEP band-limited saw/pulse; naive triangle) ----
|
||||
private fun waveform(wave: Int, t: Double, dt: Double, pwm: Float): Float = when (wave) {
|
||||
1 -> (2.0 * t - 1.0 - polyBlep(t, dt)).toFloat() // Saw
|
||||
2 -> pulse(t, dt, (0.5f + pwm * 0.45f).coerceIn(0.02f, 0.98f)) // Square
|
||||
3 -> pulse(t, dt, (0.25f + pwm * 0.45f).coerceIn(0.02f, 0.98f)) // Pulse 25%
|
||||
4 -> pulse(t, dt, (0.125f + pwm * 0.45f).coerceIn(0.02f, 0.98f)) // Pulse 12.5%
|
||||
else -> (1.0 - 4.0 * abs(t - 0.5)).toFloat() // Triangle
|
||||
}
|
||||
|
||||
private fun pulse(t: Double, dt: Double, w: Float): Float {
|
||||
var y = if (t < w) 1.0 else -1.0
|
||||
y += polyBlep(t, dt)
|
||||
var t2 = t + (1.0 - w)
|
||||
if (t2 >= 1.0) t2 -= 1.0
|
||||
y -= polyBlep(t2, dt)
|
||||
return y.toFloat()
|
||||
}
|
||||
|
||||
/** PolyBLEP residual that rounds off a unit step at a phase discontinuity. */
|
||||
private fun polyBlep(t: Double, dt: Double): Double {
|
||||
if (dt <= 0.0) return 0.0
|
||||
return when {
|
||||
t < dt -> { val x = t / dt; x + x - x * x - 1.0 }
|
||||
t > 1.0 - dt -> { val x = (t - 1.0) / dt; x * x + x + x + 1.0 }
|
||||
else -> 0.0
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceEnvelope() {
|
||||
val dt = 1.0 / sampleRate
|
||||
when (stage) {
|
||||
Stage.ATTACK -> {
|
||||
env += dt / attack
|
||||
if (env >= 1.0) { env = 1.0; stage = Stage.DECAY }
|
||||
private fun lfoValue(): Float {
|
||||
val p = lfoPhase
|
||||
return when (patch.lfoWave) {
|
||||
1 -> (2.0 * p - 1.0).toFloat() // Saw
|
||||
2 -> if (p < 0.5) 1f else -1f // Square
|
||||
3 -> sin(2.0 * Math.PI * p).toFloat() // Sine
|
||||
4 -> shValue // Sample & Hold
|
||||
else -> (1.0 - 4.0 * abs(p - 0.5)).toFloat() // Triangle
|
||||
}
|
||||
}
|
||||
|
||||
/** Cheap linear 4-pole ladder low-pass (Kellett/CSound form). [fc] is a fraction
|
||||
* of Nyquist; [res] 0..1 (rings near 1). Stages are bounded to stay stable. */
|
||||
private fun ladder(input: Float, fc: Float, res: Float): Float {
|
||||
val f = fc * 1.16f
|
||||
val fb = res.coerceIn(0f, 0.98f) * (1f - 0.15f * f * f)
|
||||
var x = input - o4 * fb
|
||||
x *= 0.35013f * (f * f) * (f * f)
|
||||
o1 = (x + 0.3f * s1 + (1f - f) * o1); s1 = x
|
||||
o2 = (o1 + 0.3f * s2 + (1f - f) * o2); s2 = o1
|
||||
o3 = (o2 + 0.3f * s3 + (1f - f) * o3); s3 = o2
|
||||
o4 = (o3 + 0.3f * s4 + (1f - f) * o4); s4 = o3
|
||||
// Makeup: the input scaling above drops level hard at low cutoff; lift it back.
|
||||
return o4 * 4f
|
||||
}
|
||||
|
||||
private fun nextRand(): Float {
|
||||
var s = rng
|
||||
s = s xor (s shl 13); s = s xor (s ushr 17); s = s xor (s shl 5)
|
||||
rng = s
|
||||
return s * 4.656613e-10f // -1..1
|
||||
}
|
||||
|
||||
/** Cheap 2^x for pitch (via Math.pow, only 1-3 oscillators per sample). */
|
||||
private fun fastPow2(x: Double): Double = Math.pow(2.0, x)
|
||||
private fun fastPow(base: Double, x: Double): Double = Math.pow(base, x)
|
||||
|
||||
/** Linear ADSR envelope (levels 0..1). Level is not reset on gate-on so a
|
||||
* stolen-then-restarted voice fades in continuously instead of clicking. */
|
||||
private class Adsr {
|
||||
private var stage = 0 // 0 idle, 1 attack, 2 decay, 3 sustain, 4 release
|
||||
private var level = 0f
|
||||
private var a = 0.01f; private var d = 0.1f; private var s = 0.7f; private var r = 0.2f
|
||||
val active: Boolean get() = stage != 0
|
||||
|
||||
fun gateOn(aa: Float, dd: Float, ss: Float, rr: Float) {
|
||||
a = aa.coerceAtLeast(0.0005f); d = dd; s = ss.coerceIn(0f, 1f); r = rr
|
||||
stage = 1
|
||||
}
|
||||
|
||||
fun gateOff() { if (stage != 0) stage = 4 }
|
||||
fun kill() { stage = 0; level = 0f }
|
||||
|
||||
fun advance(dt: Float): Float {
|
||||
when (stage) {
|
||||
1 -> { level += dt / a; if (level >= 1f) { level = 1f; stage = 2 } }
|
||||
2 -> { level -= dt / d.coerceAtLeast(0.001f) * (1f - s); if (level <= s) { level = s; stage = 3 } }
|
||||
3 -> {}
|
||||
4 -> { level -= dt / r.coerceAtLeast(0.001f) * s.coerceAtLeast(0.001f); if (level <= 0f) { level = 0f; stage = 0 } }
|
||||
}
|
||||
Stage.DECAY -> {
|
||||
env -= dt / decay.coerceAtLeast(0.001) * (1.0 - sustainLevel)
|
||||
if (env <= sustainLevel) { env = sustainLevel; stage = Stage.SUSTAIN }
|
||||
}
|
||||
Stage.SUSTAIN -> { /* hold until noteOff */ }
|
||||
Stage.RELEASE -> {
|
||||
env -= dt / release.coerceAtLeast(0.001) * sustainLevel.coerceAtLeast(0.001)
|
||||
if (env <= 0.0) kill()
|
||||
}
|
||||
Stage.IDLE -> {}
|
||||
return level
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val OUT_GAIN = 0.5f
|
||||
private const val CTRL_INTERVAL = 16 // samples between cutoff recomputes
|
||||
/** Equal-tempered MIDI note -> frequency in Hz (A4 = 69 = 440 Hz). */
|
||||
fun midiToHz(midi: Int): Double = 440.0 * Math.pow(2.0, (midi - 69) / 12.0)
|
||||
}
|
||||
|
||||
@@ -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.NesSynthPresets
|
||||
import space.rcmd.android.sizzle.model.Project
|
||||
import space.rcmd.android.sizzle.model.SamplerPads
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
@@ -40,20 +41,30 @@ class PresetLibrary(private val baseDir: File) {
|
||||
* so they appear in the standard preset browser next to user presets. Call once
|
||||
* at startup. Idempotent and non-destructive: a per-type `.seeded` marker means
|
||||
* a preset the user later renames or deletes is NOT resurrected on the next
|
||||
* launch. Currently seeds the Ambience reverb's 21 factory spaces.
|
||||
* launch. Seeds the Ambience reverb's factory spaces and the synth's chiptune
|
||||
* presets.
|
||||
*/
|
||||
fun seedFactoryPresets() {
|
||||
val type = ToolboxType.AMBIENCE
|
||||
seedType(ToolboxType.AMBIENCE, AmbiencePresets.FACTORY.map { it.name }) { slot, name ->
|
||||
AmbiencePresets.apply(slot, AmbiencePresets.FACTORY.first { it.name == name })
|
||||
}
|
||||
seedType(ToolboxType.NES_SYNTH, NesSynthPresets.FACTORY.map { it.name }) { slot, name ->
|
||||
NesSynthPresets.apply(slot, NesSynthPresets.FACTORY.first { it.name == name })
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed one device [type]'s factory presets (guarded by a per-type marker). */
|
||||
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
|
||||
AmbiencePresets.FACTORY.forEach { preset ->
|
||||
val file = presetFile(type, preset.name)
|
||||
names.forEach { name ->
|
||||
val file = presetFile(type, name)
|
||||
if (file.exists()) return@forEach
|
||||
val slot = ToolboxSlot(0).apply {
|
||||
fill(type)
|
||||
AmbiencePresets.apply(this, preset)
|
||||
name = preset.name
|
||||
apply(this, name)
|
||||
this.name = name
|
||||
}
|
||||
file.writeText(PresetIo.export(slot))
|
||||
}
|
||||
|
||||
@@ -68,9 +68,11 @@ object SngFormat {
|
||||
appendLine("track T${t + 1} $defChannel")
|
||||
for (line in steps) {
|
||||
val cell = block.cell(t, line)
|
||||
// A note-off has no velocity/channel (it's tied to its track), so
|
||||
// those columns are written as ".." (the format's "none").
|
||||
val note = if (cell.isNoteOff) "OFF" else Pitch.name(cell.note)
|
||||
val vel = cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
val chan = cell.channel.toString().padStart(2, '0')
|
||||
val vel = if (cell.isNoteOff) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
val chan = if (cell.isNoteOff) ".." else cell.channel.toString().padStart(2, '0')
|
||||
appendLine("$line $note $vel $chan")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
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
|
||||
* are listed; the rest fall back to the init patch.
|
||||
*/
|
||||
object NesSynthPresets {
|
||||
|
||||
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(
|
||||
Preset("NES Lead", mapOf(
|
||||
"osc0wave" to "Pulse25", "osc0level" to "0.9",
|
||||
"cutoff" to "0.8", "reso" to "0.1", "fenvamt" to "0.2",
|
||||
"attack" to "0.005", "decay" to "0.1", "sustain" to "0.85", "release" to "0.1",
|
||||
)),
|
||||
Preset("Square Bass", mapOf(
|
||||
"osc0wave" to "Square", "osc0oct" to "-1", "osc0level" to "0.9",
|
||||
"osc1wave" to "Pulse25", "osc1oct" to "-1", "osc1fine" to "6", "osc1level" to "0.5",
|
||||
"cutoff" to "0.45", "reso" to "0.25", "fenvamt" to "0.5",
|
||||
"fdec" to "0.18", "fsus" to "0.2",
|
||||
"attack" to "0.002", "decay" to "0.15", "sustain" to "0.6", "release" to "0.12",
|
||||
)),
|
||||
Preset("Pulse Pluck", mapOf(
|
||||
"osc0wave" to "Pulse12", "osc0level" to "0.9",
|
||||
"cutoff" to "0.5", "reso" to "0.3", "fenvamt" to "0.6",
|
||||
"fatk" to "0.002", "fdec" to "0.12", "fsus" to "0.1",
|
||||
"attack" to "0.002", "decay" to "0.2", "sustain" to "0.4", "release" to "0.15",
|
||||
"lfowave" to "Tri", "lfodiv" to "4",
|
||||
"m0src" to "LFO", "m0dst" to "PWM", "m0amt" to "0.5",
|
||||
)),
|
||||
Preset("Triangle Sub", mapOf(
|
||||
"osc0wave" to "Tri", "osc0oct" to "-1", "osc0level" to "1.0",
|
||||
"cutoff" to "0.35", "reso" to "0.05", "fenvamt" to "0.2",
|
||||
"attack" to "0.004", "decay" to "0.2", "sustain" to "0.7", "release" to "0.15",
|
||||
)),
|
||||
Preset("Detune Lead", mapOf(
|
||||
"osc0wave" to "Saw", "osc0level" to "0.7",
|
||||
"osc1wave" to "Saw", "osc1fine" to "8", "osc1level" to "0.7",
|
||||
"osc2wave" to "Square", "osc2oct" to "1", "osc2level" to "0.3",
|
||||
"cutoff" to "0.7", "reso" to "0.15", "fenvamt" to "0.3",
|
||||
"attack" to "0.01", "decay" to "0.3", "sustain" to "0.7", "release" to "0.2",
|
||||
)),
|
||||
Preset("Noise Perc", mapOf(
|
||||
"osc0level" to "0.0", "noise" to "1.0",
|
||||
"cutoff" to "0.85", "reso" to "0.2", "fenvamt" to "-0.3",
|
||||
"fatk" to "0.001", "fdec" to "0.08", "fsus" to "0.0",
|
||||
"attack" to "0.001", "decay" to "0.08", "sustain" to "0.0", "release" to "0.06",
|
||||
)),
|
||||
Preset("Vibrato Lead", mapOf(
|
||||
"osc0wave" to "Square", "osc0level" to "0.9",
|
||||
"cutoff" to "0.75", "reso" to "0.1",
|
||||
"attack" to "0.01", "decay" to "0.2", "sustain" to "0.85", "release" to "0.15",
|
||||
"lfowave" to "Sine", "lfodiv" to "3",
|
||||
"m0src" to "LFO", "m0dst" to "Pitch", "m0amt" to "0.06",
|
||||
)),
|
||||
Preset("Wah Sweep", mapOf(
|
||||
"osc0wave" to "Saw", "osc0level" to "0.9",
|
||||
"cutoff" to "0.4", "reso" to "0.5",
|
||||
"attack" to "0.01", "decay" to "0.3", "sustain" to "0.8", "release" to "0.2",
|
||||
"lfowave" to "Tri", "lfodiv" to "3",
|
||||
"m0src" to "LFO", "m0dst" to "Cutoff", "m0amt" to "0.5",
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.model
|
||||
|
||||
/**
|
||||
* Canonical option lists and shape constants for the subtractive (Minimoog-style)
|
||||
* synth ([ToolboxType.NES_SYNTH]). Kept in the model layer so the parameter specs,
|
||||
* the DSP patch parser, the bespoke editor and the factory presets all agree on the
|
||||
* exact string values that serialize into a slot.
|
||||
*/
|
||||
object SynthDefs {
|
||||
/** Oscillator waveforms. */
|
||||
val WAVES = listOf("Tri", "Saw", "Square", "Pulse25", "Pulse12")
|
||||
/** LFO waveforms. */
|
||||
val LFO_WAVES = listOf("Tri", "Saw", "Square", "Sine", "S&H")
|
||||
/** Modulation-matrix sources; index 0 ("Off") disables the slot. */
|
||||
val MOD_SRC = listOf("Off", "LFO", "FiltEnv", "AmpEnv", "Vel", "KeyTrk")
|
||||
/** Modulation-matrix destinations. */
|
||||
val MOD_DST = listOf("Pitch", "Osc2", "Osc3", "Cutoff", "PWM", "Amp")
|
||||
|
||||
const val OSC_COUNT = 3
|
||||
const val MOD_SLOTS = 4
|
||||
|
||||
/** Init-patch oscillator waveforms (per oscillator). */
|
||||
val DEF_OSC_WAVE = listOf("Saw", "Square", "Tri")
|
||||
/** Init-patch oscillator mixer levels (only oscillator 1 audible by default). */
|
||||
val DEF_OSC_LEVEL = listOf(0.9f, 0f, 0f)
|
||||
}
|
||||
@@ -43,15 +43,44 @@ enum class ToolboxType(
|
||||
val params: List<ParamSpec>,
|
||||
) {
|
||||
// ---------- Instruments ----------
|
||||
// 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.
|
||||
NES_SYNTH(
|
||||
ToolboxKind.INSTRUMENT, "NES Synth",
|
||||
listOf(
|
||||
ParamSpec("wave", "Wave", choices = listOf("Pulse12", "Pulse25", "Pulse50", "Triangle", "Noise")),
|
||||
ParamSpec("attack", "Attack", 0.01f, 0f, 1f),
|
||||
ParamSpec("decay", "Decay", 0.15f, 0f, 1f),
|
||||
ParamSpec("sustain", "Sustain", 0.6f, 0f, 1f),
|
||||
ParamSpec("release", "Release", 0.1f, 0f, 1f),
|
||||
),
|
||||
buildList {
|
||||
for (i in 0 until SynthDefs.OSC_COUNT) {
|
||||
add(ParamSpec("osc${i}wave", "Osc${i + 1} Wave", choices = SynthDefs.WAVES))
|
||||
add(ParamSpec("osc${i}oct", "Osc${i + 1} Oct", 0f, -2f, 2f, isInt = true))
|
||||
add(ParamSpec("osc${i}semi", "Osc${i + 1} Semi", 0f, -12f, 12f, isInt = true))
|
||||
add(ParamSpec("osc${i}fine", "Osc${i + 1} Fine", 0f, -50f, 50f))
|
||||
add(ParamSpec("osc${i}level", "Osc${i + 1} Level", SynthDefs.DEF_OSC_LEVEL[i], 0f, 1f))
|
||||
}
|
||||
add(ParamSpec("noise", "Noise", 0f, 0f, 1f))
|
||||
// Filter + its contour (envelope) amount and key tracking.
|
||||
add(ParamSpec("cutoff", "Cutoff", 0.55f, 0f, 1f))
|
||||
add(ParamSpec("reso", "Resonance", 0.15f, 0f, 1f))
|
||||
add(ParamSpec("keytrack", "Key Track", 0.3f, 0f, 1f))
|
||||
add(ParamSpec("fenvamt", "Filter Env", 0.4f, -1f, 1f))
|
||||
add(ParamSpec("fatk", "F.Attack", 0.005f, 0f, 2f))
|
||||
add(ParamSpec("fdec", "F.Decay", 0.25f, 0f, 2f))
|
||||
add(ParamSpec("fsus", "F.Sustain", 0.4f, 0f, 1f))
|
||||
add(ParamSpec("frel", "F.Release", 0.2f, 0f, 3f))
|
||||
// Amplifier envelope.
|
||||
add(ParamSpec("attack", "Attack", 0.005f, 0f, 2f))
|
||||
add(ParamSpec("decay", "Decay", 0.2f, 0f, 2f))
|
||||
add(ParamSpec("sustain", "Sustain", 0.7f, 0f, 1f))
|
||||
add(ParamSpec("release", "Release", 0.2f, 0f, 3f))
|
||||
// Tempo-synced LFO (rate is an index into [TimeDivision]).
|
||||
add(ParamSpec("lfowave", "LFO Wave", choices = SynthDefs.LFO_WAVES))
|
||||
add(ParamSpec("lfodiv", "LFO Rate", 3f, 0f, 6f, isInt = true))
|
||||
// Modulation matrix.
|
||||
for (n in 0 until SynthDefs.MOD_SLOTS) {
|
||||
add(ParamSpec("m${n}src", "Mod${n + 1} Src", choices = SynthDefs.MOD_SRC))
|
||||
add(ParamSpec("m${n}dst", "Mod${n + 1} Dst", choices = SynthDefs.MOD_DST))
|
||||
add(ParamSpec("m${n}amt", "Mod${n + 1} Amt", 0f, -1f, 1f))
|
||||
}
|
||||
},
|
||||
),
|
||||
SAMPLER(
|
||||
ToolboxKind.INSTRUMENT, "Sampler",
|
||||
|
||||
@@ -204,6 +204,9 @@ class AppViewModel(
|
||||
*/
|
||||
fun editFocused(direction: Int) {
|
||||
val cell = focusedCell()
|
||||
// A note-off has no velocity/channel — only its NOTE column is editable
|
||||
// (which turns it back into a real note).
|
||||
if (cell.isNoteOff && cursorColumn != CellColumn.NOTE) return
|
||||
when (cursorColumn) {
|
||||
CellColumn.NOTE -> {
|
||||
// Seed an empty tick from the last note entered so filling resumes
|
||||
@@ -226,12 +229,14 @@ class AppViewModel(
|
||||
fun clearFocused() { focusedCell().clear(); touched() }
|
||||
|
||||
/** Insert a note-off ("===") at the cursor and advance by the skip step, like a
|
||||
* punch-in. The note-off is stamped with the last-remembered channel so it
|
||||
* releases the same bus the previous note was entered on. */
|
||||
* punch-in. A note-off is tied to its track (no velocity/channel of its own — it
|
||||
* releases whatever that track last played), so those fields are reset to
|
||||
* defaults and ignored. */
|
||||
fun noteOffFocused() {
|
||||
val cell = focusedCell()
|
||||
cell.note = Cell.OFF
|
||||
cell.channel = lastChannel
|
||||
cell.velocity = Cell.MAX_VELOCITY
|
||||
cell.channel = 1
|
||||
moveCursorVerticalWrap(skipStep)
|
||||
touched()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package space.rcmd.android.sizzle.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
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.ui.theme.LocalRetro
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Rotary controls are the tracker's preferred way to show a continuous parameter
|
||||
* (see the synth editor); pair with [RetroDropdown] for enumerated params.
|
||||
*/
|
||||
@Composable
|
||||
fun Knob(
|
||||
label: String,
|
||||
value: Float,
|
||||
min: Float,
|
||||
max: Float,
|
||||
valueText: String,
|
||||
modifier: Modifier = Modifier,
|
||||
default: Float = min,
|
||||
onChange: (Float) -> Unit,
|
||||
) {
|
||||
val c = LocalRetro.current
|
||||
val span = if (max > min) max - min else 1f
|
||||
val frac = ((value - min) / span).coerceIn(0f, 1f)
|
||||
|
||||
Column(
|
||||
modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
valueText, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 9.sp,
|
||||
maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Canvas(
|
||||
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, _ ->
|
||||
change.consume()
|
||||
setFromY(change.position.y)
|
||||
}
|
||||
}
|
||||
.pointerInput(min, max) {
|
||||
detectTapGestures(onDoubleTap = { onChange(default) })
|
||||
},
|
||||
) {
|
||||
val stroke = 4f
|
||||
val r = (size.minDimension - stroke) / 2f
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val start = 135f
|
||||
val sweep = 270f
|
||||
// Track, then the filled value arc.
|
||||
drawArc(
|
||||
color = c.grid, startAngle = start, sweepAngle = sweep, useCenter = false,
|
||||
topLeft = Offset(cx - r, cy - r), size = androidx.compose.ui.geometry.Size(r * 2, r * 2),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
|
||||
)
|
||||
drawArc(
|
||||
color = c.accent, startAngle = start, sweepAngle = sweep * frac, useCenter = false,
|
||||
topLeft = Offset(cx - r, cy - r), size = androidx.compose.ui.geometry.Size(r * 2, r * 2),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
|
||||
)
|
||||
// Pointer to the current position.
|
||||
val ang = Math.toRadians((start + sweep * frac).toDouble())
|
||||
drawLine(
|
||||
color = c.text,
|
||||
start = Offset(cx, cy),
|
||||
end = Offset(cx + (r * 0.85f) * cos(ang).toFloat(), cy + (r * 0.85f) * sin(ang).toFloat()),
|
||||
strokeWidth = 2f,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
label, color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 9.sp,
|
||||
maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().height(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// 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.clickable
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import space.rcmd.android.sizzle.model.SynthDefs
|
||||
import space.rcmd.android.sizzle.model.TimeDivision
|
||||
import space.rcmd.android.sizzle.model.ToolboxSlot
|
||||
import space.rcmd.android.sizzle.ui.AppViewModel
|
||||
import space.rcmd.android.sizzle.ui.components.Knob
|
||||
import space.rcmd.android.sizzle.ui.components.RetroDropdown
|
||||
import space.rcmd.android.sizzle.ui.theme.LocalRetro
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The bespoke editor for the subtractive synth ([space.rcmd.android.sizzle.model.ToolboxType.NES_SYNTH]).
|
||||
* Controls are split across swipeable cards by function — Oscillators, Filter,
|
||||
* Amp/LFO, and the Mod Matrix — with tappable dot pagination. Continuous params use
|
||||
* rotary [Knob]s; enumerated ones use [RetroDropdown]. The audition keyboard is
|
||||
* pinned at the bottom of the editor by the ParamEditor host (see [ToolboxScreen]).
|
||||
*/
|
||||
@Composable
|
||||
fun NesSynthEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
val c = LocalRetro.current
|
||||
val rev = vm.revision
|
||||
val pager = rememberPagerState(pageCount = { 4 })
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
HorizontalPager(
|
||||
state = pager,
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp),
|
||||
pageSpacing = 12.dp,
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> OscCard(vm, slot, rev)
|
||||
1 -> FilterCard(vm, slot, rev)
|
||||
2 -> AmpLfoCard(vm, slot, rev)
|
||||
else -> ModCard(vm, slot, rev)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
for (i in 0 until 4) {
|
||||
val active = i == pager.currentPage
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(if (active) 10.dp else 7.dp)
|
||||
.background(if (active) c.accent else c.grid, CircleShape)
|
||||
.clickable { scope.launch { pager.animateScrollToPage(i) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cards
|
||||
|
||||
@Composable
|
||||
private fun OscCard(vm: AppViewModel, slot: ToolboxSlot, rev: Int) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CardTitle("OSCILLATORS")
|
||||
for (i in 0 until SynthDefs.OSC_COUNT) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
EnumParam(vm, slot, rev, "osc${i}wave", "OSC${i + 1}", SynthDefs.WAVES,
|
||||
SynthDefs.DEF_OSC_WAVE[i], Modifier.weight(1.3f))
|
||||
ParamKnob(vm, slot, rev, "osc${i}oct", "OCT", -2f, 2f, 0f, Modifier.weight(1f), isInt = true)
|
||||
ParamKnob(vm, slot, rev, "osc${i}semi", "SEMI", -12f, 12f, 0f, Modifier.weight(1f), isInt = true)
|
||||
ParamKnob(vm, slot, rev, "osc${i}fine", "FINE", -50f, 50f, 0f, Modifier.weight(1f), isInt = true)
|
||||
ParamKnob(vm, slot, rev, "osc${i}level", "LVL", 0f, 1f, SynthDefs.DEF_OSC_LEVEL[i], Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ParamKnob(vm, slot, rev, "noise", "NOISE", 0f, 1f, 0f, Modifier.weight(1f))
|
||||
Spacer(Modifier.weight(4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterCard(vm: AppViewModel, slot: ToolboxSlot, rev: Int) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
CardTitle("FILTER")
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ParamKnob(vm, slot, rev, "cutoff", "CUTOFF", 0f, 1f, 0.55f, Modifier.weight(1f))
|
||||
ParamKnob(vm, slot, rev, "reso", "RESO", 0f, 1f, 0.15f, Modifier.weight(1f))
|
||||
ParamKnob(vm, slot, rev, "keytrack", "KEYTRK", 0f, 1f, 0.3f, Modifier.weight(1f))
|
||||
ParamKnob(vm, slot, rev, "fenvamt", "ENV AMT", -1f, 1f, 0.4f, Modifier.weight(1f))
|
||||
}
|
||||
CardTitle("FILTER ENVELOPE")
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ParamKnob(vm, slot, rev, "fatk", "ATK", 0f, 2f, 0.005f, Modifier.weight(1f), unit = "s")
|
||||
ParamKnob(vm, slot, rev, "fdec", "DEC", 0f, 2f, 0.25f, Modifier.weight(1f), unit = "s")
|
||||
ParamKnob(vm, slot, rev, "fsus", "SUS", 0f, 1f, 0.4f, Modifier.weight(1f))
|
||||
ParamKnob(vm, slot, rev, "frel", "REL", 0f, 3f, 0.2f, Modifier.weight(1f), unit = "s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmpLfoCard(vm: AppViewModel, slot: ToolboxSlot, rev: Int) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
CardTitle("AMPLIFIER ENVELOPE")
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ParamKnob(vm, slot, rev, "attack", "ATK", 0f, 2f, 0.005f, Modifier.weight(1f), unit = "s")
|
||||
ParamKnob(vm, slot, rev, "decay", "DEC", 0f, 2f, 0.2f, Modifier.weight(1f), unit = "s")
|
||||
ParamKnob(vm, slot, rev, "sustain", "SUS", 0f, 1f, 0.7f, Modifier.weight(1f))
|
||||
ParamKnob(vm, slot, rev, "release", "REL", 0f, 3f, 0.2f, Modifier.weight(1f), unit = "s")
|
||||
}
|
||||
CardTitle("LFO (tempo-synced)")
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
EnumParam(vm, slot, rev, "lfowave", "WAVE", SynthDefs.LFO_WAVES, "Tri", Modifier.weight(1f))
|
||||
LfoRateDropdown(vm, slot, rev, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModCard(vm: AppViewModel, slot: ToolboxSlot, rev: Int) {
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CardTitle("MOD MATRIX")
|
||||
for (n in 0 until SynthDefs.MOD_SLOTS) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
EnumParam(vm, slot, rev, "m${n}src", "SRC", SynthDefs.MOD_SRC, "Off", Modifier.weight(1f))
|
||||
EnumParam(vm, slot, rev, "m${n}dst", "DST", SynthDefs.MOD_DST, "Cutoff", Modifier.weight(1f))
|
||||
ParamKnob(vm, slot, rev, "m${n}amt", "AMT", -1f, 1f, 0f, Modifier.weight(0.9f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- controls
|
||||
|
||||
@Composable
|
||||
private fun CardTitle(text: String) {
|
||||
val c = LocalRetro.current
|
||||
Text(text, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
/** A rotary [Knob] bound to a numeric slot param. [rev] busts Compose strong-skipping
|
||||
* so the knob refreshes when a preset load changes the value. */
|
||||
@Composable
|
||||
private fun ParamKnob(
|
||||
vm: AppViewModel,
|
||||
slot: ToolboxSlot,
|
||||
rev: Int,
|
||||
key: String,
|
||||
label: String,
|
||||
min: Float,
|
||||
max: Float,
|
||||
default: Float,
|
||||
modifier: Modifier,
|
||||
isInt: Boolean = false,
|
||||
unit: String = "",
|
||||
) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev
|
||||
val value = slot.float(key, default)
|
||||
val text = if (isInt) "${value.roundToInt()}$unit" else String.format("%.2f", value) + unit
|
||||
Knob(label, value, min, max, text, modifier, default) { nv ->
|
||||
slot.set(key, if (isInt) nv.roundToInt().toFloat() else nv)
|
||||
vm.bumpForToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
/** A [RetroDropdown] bound to a string (enumerated) slot param. */
|
||||
@Composable
|
||||
private fun EnumParam(
|
||||
vm: AppViewModel,
|
||||
slot: ToolboxSlot,
|
||||
rev: Int,
|
||||
key: String,
|
||||
label: String,
|
||||
options: List<String>,
|
||||
default: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev
|
||||
RetroDropdown(
|
||||
label = label,
|
||||
options = options,
|
||||
selected = slot.string(key, default).let { if (it in options) it else default },
|
||||
modifier = modifier,
|
||||
onSelect = { slot.set(key, it); vm.bumpForToolbar() },
|
||||
)
|
||||
}
|
||||
|
||||
/** The tempo-synced LFO rate, stored as an index into [TimeDivision]. */
|
||||
@Composable
|
||||
private fun LfoRateDropdown(vm: AppViewModel, slot: ToolboxSlot, rev: Int, modifier: Modifier) {
|
||||
@Suppress("UNUSED_PARAMETER") val ignored = rev
|
||||
val current = TimeDivision.fromIndex(slot.float("lfodiv", 3f).toInt())
|
||||
RetroDropdown(
|
||||
label = "RATE",
|
||||
options = TimeDivision.entries,
|
||||
selected = current,
|
||||
modifier = modifier,
|
||||
optionLabel = { it.label },
|
||||
onSelect = { slot.set("lfodiv", it.ordinal.toFloat()); vm.bumpForToolbar() },
|
||||
)
|
||||
}
|
||||
@@ -135,7 +135,7 @@ fun SoundFontEditor(vm: AppViewModel, slot: ToolboxSlot) {
|
||||
}
|
||||
|
||||
// Volume + ADSR as compact vertical faders (one row). The audition keyboard
|
||||
// is pinned separately at the bottom of the editor (see [SoundFontKeyboard]).
|
||||
// is pinned separately at the bottom of the editor (see [SlotAuditionKeyboard]).
|
||||
Text("Volume / envelope:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
VerticalSlider(vm, slot, rev, "volume", "VOL", 0f, 1f, 0.8f, Modifier.weight(1f))
|
||||
@@ -210,13 +210,13 @@ private fun VerticalSlider(
|
||||
private fun formatSliderValue(v: Float): String =
|
||||
if (v == v.toInt().toFloat()) v.toInt().toString() else String.format("%.2f", v)
|
||||
|
||||
/** The SF2/XI audition keyboard, laid out and sized like the shared audition /
|
||||
/** A slot-auditioning keyboard, laid out and sized like the shared audition /
|
||||
* punch-in keyboard ([space.rcmd.android.sizzle.ui.StackedKeyboard]): a small octave
|
||||
* selector above two weighted octaves. It is pinned at the bottom of the editor (by
|
||||
* [space.rcmd.android.sizzle.ui.toolbox] ParamEditor) rather than scrolling with the
|
||||
* controls. Holding a key sounds THIS slot's loaded instrument. */
|
||||
* ToolboxScreen's ParamEditor) rather than scrolling with the controls. Holding a key
|
||||
* sounds THIS slot's instrument — used by the SoundFont and synth editors. */
|
||||
@Composable
|
||||
fun SoundFontKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
|
||||
fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
|
||||
val c = LocalRetro.current
|
||||
var octave by remember(slot.index) { mutableIntStateOf(3) }
|
||||
Column(
|
||||
|
||||
@@ -259,10 +259,10 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
|
||||
val type = slot.type ?: return
|
||||
|
||||
// The SoundFont editor pins its audition keyboard at the bottom (same as the
|
||||
// toolbox / tracker keyboards), so its controls scroll in a weighted region above
|
||||
// the keyboard rather than the whole editor scrolling as one.
|
||||
val pinnedKeyboard = type == ToolboxType.SOUNDFONT
|
||||
// The SoundFont and synth editors pin an audition keyboard at the bottom (same as
|
||||
// the toolbox / tracker keyboards), so their controls scroll in a weighted region
|
||||
// above the keyboard rather than the whole editor scrolling as one.
|
||||
val pinnedKeyboard = type == ToolboxType.SOUNDFONT || type == ToolboxType.NES_SYNTH
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -288,6 +288,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
// LFO adds a modulation-target picker; everything else uses the
|
||||
// auto-generated parameter list, laid out two-per-row to fit more.
|
||||
when (type) {
|
||||
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
|
||||
ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
|
||||
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
|
||||
ToolboxType.DELAY -> DelayEditor(vm, slot)
|
||||
@@ -302,7 +303,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
|
||||
|
||||
if (pinnedKeyboard) {
|
||||
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider
|
||||
SoundFontKeyboard(vm, slot, Modifier.weight(1f))
|
||||
SlotAuditionKeyboard(vm, slot, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,9 @@ private fun noteText(cell: Cell): String = when {
|
||||
cell.isNoteOff -> "==="
|
||||
else -> Pitch.name(cell.note)
|
||||
}
|
||||
// A note-off ("===") has no velocity/channel of its own — it's tied to its track —
|
||||
// so those columns read ".." just like an empty cell.
|
||||
private fun velText(cell: Cell): String =
|
||||
if (cell.isEmpty) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
if (cell.isEmpty || cell.isNoteOff) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
|
||||
private fun chanText(cell: Cell): String =
|
||||
if (cell.isEmpty) ".." else cell.channel.toString().padStart(2, '0')
|
||||
if (cell.isEmpty || cell.isNoteOff) ".." else cell.channel.toString().padStart(2, '0')
|
||||
|
||||
Reference in New Issue
Block a user