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:
Reactorcoremeltdown
2026-07-18 01:05:17 +02:00
parent 9ab7660012
commit 63cad6897f
15 changed files with 915 additions and 182 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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