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. // (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 5 versionCode = 8
versionName = "0.3.2" versionName = "0.4.1"
// We provide our own instrumentation runner if/when tests are added. // We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 * one. Notes are allocated round-robin, stealing the oldest voice when the pool is
* full — the requested "N sounds per instrument" cap. * full — the requested "N sounds per instrument" cap.
* *
* INSTRUMENTS: NES synth and Sampler are rendered here; the SoundFont loader * INSTRUMENTS: the subtractive [SynthVoice] (NES synth), the Sampler and the
* still falls back to the synth. Each bus pool has both [SynthVoice]s and * 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. * [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 / * Each mixer channel runs its own insert-effect chain (delay / filter / reverb /
* bitcrusher / EQ) — see [Effects.kt] and [rebuildFxChains]. Swapping this * 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) } private val busSampleVoices = Array(Pattern.TRACK_COUNT * BUS_POLYPHONY) { SampleVoice(sampleRate) }
// Per-(lane,track) arpeggiator state, driven by advanceArps. // Per-(lane,track) arpeggiator state, driven by advanceArps.
private val arps = Array(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { ChannelArp() } 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. */ /** Grab a free synth voice from [bus]'s pool, or steal the round-robin one. */
private fun allocSynthVoice(bus: Int): SynthVoice { private fun allocSynthVoice(bus: Int): SynthVoice {
@@ -438,6 +442,7 @@ class AudioEngine(
busVoices.forEach { it.kill() } busVoices.forEach { it.kill() }
busSampleVoices.forEach { it.kill() } busSampleVoices.forEach { it.kill() }
arps.forEach { it.stop() } arps.forEach { it.stop() }
trackLastChannel.fill(-1)
} }
/** Stop playback. If already stopped, rewind to the very beginning. */ /** Stop playback. If already stopped, rewind to the very beginning. */
@@ -457,7 +462,7 @@ class AudioEngine(
// ------------------------------------------------------ live note audition // ------------------------------------------------------ live note audition
fun liveNoteOn(pitch: Int, velocity: Int) { fun liveNoteOn(pitch: Int, velocity: Int) {
val v = liveVoices.firstOrNull { !it.isActive } ?: liveVoices[0].also { it.kill() } 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) { fun liveNoteOff(pitch: Int) {
@@ -666,62 +671,74 @@ class AudioEngine(
// whole bus; without this ordering it would kill a just-triggered voice). // whole bus; without this ordering it would kill a just-triggered voice).
for (t in 0 until Pattern.TRACK_COUNT) { for (t in 0 until Pattern.TRACK_COUNT) {
val cell = pattern.cell(t, line) 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) { for (t in 0 until Pattern.TRACK_COUNT) {
val cell = pattern.cell(t, line) 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 * Trigger one tracker [cell] from source [track] on [lane].
* 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 * A PLAYABLE note is routed to the mixer bus(es) whose MIDI channel matches the
* grid (this is what decouples tracks from buses). Each matching bus applies * cell's channel — NOT the source track — so a note plays whatever instrument
* its own MIDI effects (Transposer shifts pitch; an Arpeggiator captures the * sits on its channel's bus, wherever it's placed in the grid (this decouples
* note and hands retriggering to [advanceArps]). * 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) { for (bus in 0 until Pattern.TRACK_COUNT) {
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
val arp = arps[lane * Pattern.TRACK_COUNT + bus] val arp = arps[lane * Pattern.TRACK_COUNT + bus]
when { val velocity = cell.velocity.coerceIn(0, 127)
cell.isPlayable -> { val note = (cell.note + channelTranspose(proj, bus)).coerceIn(0, 127)
val velocity = cell.velocity.coerceIn(0, 127) val arpSlot = channelArpSlot(proj, bus)
val note = (cell.note + channelTranspose(proj, bus)).coerceIn(0, 127) if (arpSlot != null) {
val arpSlot = channelArpSlot(proj, bus) val octaves = arpSlot.float("octaves", 1f).toInt()
if (arpSlot != null) { val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt())
val octaves = arpSlot.float("octaves", 1f).toInt() val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
val div = TimeDivision.fromIndex(arpSlot.float("division", 3f).toInt()) val mode = arpSlot.string("mode", "Up")
val stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate val resetBar = arpSlot.string("resetbar", "Off") == "On"
val mode = arpSlot.string("mode", "Up") if (arp.active && arp.base == note) {
val resetBar = arpSlot.string("resetbar", "Off") == "On" // The same note is already arpeggiating — this trigger is the
if (arp.active && arp.base == note) { // pattern looping back over a still-held note. Keep the running
// The same note is already arpeggiating — this trigger is the // phase (just refresh params) so the pulse stays steady across
// pattern looping back over a still-held note. Keep the running // the loop point instead of slipping when the loop length isn't
// phase (just refresh params) so the pulse stays steady across // an exact multiple of the arp step (dotted/triplet divisions).
// the loop point instead of slipping when the loop length isn't arp.refresh(octaves, mode, stepSamples, resetBar)
// an exact multiple of the arp step (dotted/triplet divisions). } else {
arp.refresh(octaves, mode, stepSamples, resetBar) arp.start(note, velocity, octaves, mode, stepSamples, resetBar)
} else { // The arpeggiator is monophonic (it cycles octaves of ONE note),
arp.start(note, velocity, octaves, mode, stepSamples, resetBar) // so it must not stack notes. Releasing the bus first means a
// The arpeggiator is monophonic (it cycles octaves of ONE note), // chord entered on the same line collapses to a single
// so it must not stack notes. Releasing the bus first means a // arpeggiated note (the last one), not all of them at once.
// 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()
releaseBus(bus) 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 -> {} slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
// NES synth, or a SoundFont with nothing loaded → the tone generator. // NES synth, or a SoundFont with nothing loaded → the tone generator.
else -> { else -> {
val (wave, a, d, s, r) = synthParamsForTrack(proj, bus) allocSynthVoice(bus).noteOn(note, velocity, patchForSlot(slot, proj.tempoBpm))
allocSynthVoice(bus).noteOn(note, velocity, wave, a, d, s, r)
} }
} }
} }
@@ -934,9 +950,8 @@ class AudioEngine(
// note-off, so a sustaining synth voice would ring forever). A SoundFont // note-off, so a sustaining synth voice would ring forever). A SoundFont
// with nothing loaded still falls through to the synth preview below. // with nothing loaded still falls through to the synth preview below.
if (slot?.type == ToolboxType.SAMPLER) return 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() } 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). */ /** 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). // Sampler with an empty pad → silent (no synth fallback).
type == ToolboxType.SAMPLER -> {} type == ToolboxType.SAMPLER -> {}
else -> { else -> {
val (wave, a, d, s, r) = synthParamsForSlot(slot)
val v = (0 until BUS_POLYPHONY).map { busVoices[base + it] } val v = (0 until BUS_POLYPHONY).map { busVoices[base + it] }
.firstOrNull { !it.isActive } ?: busVoices[base].also { it.kill() } .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 if (patLine >= pat.length) continue
for (t in 0 until Pattern.TRACK_COUNT) { for (t in 0 until Pattern.TRACK_COUNT) {
val cell = pat.cell(t, patLine) val cell = pat.cell(t, patLine)
if (pass == 0) { if (cell.isNoteOff) triggerCell(proj, lane, cell) } if (pass == 0) { if (cell.isNoteOff) triggerCell(proj, lane, t, cell) }
else { if (cell.isPlayable) triggerCell(proj, lane, cell) } else { if (cell.isPlayable) triggerCell(proj, lane, t, cell) }
} }
} }
} }
} }
/** Read the NES-synth settings for a track from its mixer->toolbox routing. */ /** Parse a slot's synth patch (default init patch if it isn't an NES synth).
private fun synthParamsForTrack(proj: Project, track: Int): SynthParams = * Allocates one [SynthPatch] per note trigger — off the per-sample hot path. */
synthParamsForSlot(proj.toolbox.getOrNull(proj.mixer.channels[track].instrumentSlot)) private fun patchForSlot(slot: ToolboxSlot?, tempoBpm: Float): SynthPatch =
if (slot?.type == ToolboxType.NES_SYNTH) SynthPatch.from(slot, tempoBpm) else SynthPatch.DEFAULT
/** 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(),
)
}
private fun samplesPerLine(proj: Project): Double = private fun samplesPerLine(proj: Project): Double =
sampleRate * (60.0 / proj.tempoBpm) / proj.timeSignature.linesPerBeat 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 { companion object {
private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz private const val BLOCK_FRAMES = 192 // ~4 ms blocks at 48 kHz
private const val POLYPHONY = 8 // max simultaneous voices per instrument (mixer bus) 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 MASTER_GAIN = 0.35f
private const val LIVE_GAIN = 0.5f private const val LIVE_GAIN = 0.5f
private const val SOFT_KNEE = 0.6f // limiter is transparent below this, saturates above 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 * 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 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 * One playing note of the subtractive (Minimoog-style) synth. Per voice:
* tone generator: a few band-limited-ish waveforms plus a linear ADSR envelope.
* It renders sample-by-sample into the mix buffer.
* *
* 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) { 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 --- // Cheap linear 4-pole ("Moog") ladder state.
private var wave = Wave.PULSE_50 private var o1 = 0f; private var o2 = 0f; private var o3 = 0f; private var o4 = 0f
private var phase = 0.0 // 0..1 private var s1 = 0f; private var s2 = 0f; private var s3 = 0f; private var s4 = 0f
private var phaseInc = 0.0 // per-sample phase advance private var fcNorm = 0.5f // cached cutoff (fraction of Nyquist), refreshed at control rate
private var noiseReg = 0x7FFF // linear-feedback shift register for noise private var ctrl = 0
var pitch = -1; private set // MIDI note this voice is playing, -1 idle
// --- Envelope (times are in seconds) --- // LFO + sample-and-hold.
private var stage = Stage.IDLE private var lfoPhase = 0.0
private var env = 0.0 private var lfoInc = 0.0
private var attack = 0.01; private var decay = 0.15 private var shValue = 0f
private var sustainLevel = 0.6; private var release = 0.1 private var rng = 0x2545F491
private var amp = 0.0 // note velocity as 0..1
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. */ val isActive: Boolean get() = ampEnv.active
fun noteOn(midi: Int, velocity: Int, w: Wave, a: Double, d: Double, s: Double, r: Double) {
/** Start (or steal-and-restart) this voice with the given [patch]. */
fun noteOn(midi: Int, velocity: Int, patch: SynthPatch) {
this.patch = patch
pitch = midi pitch = midi
wave = w this.velocity = (velocity / 127f).coerceIn(0f, 1f)
attack = a.coerceAtLeast(0.001); decay = d; sustainLevel = s; release = r baseFreq = midiToHz(midi)
amp = (velocity / 127.0).coerceIn(0.0, 1.0) keyNorm = ((midi - 60) / 60f).coerceIn(-1f, 1f)
phaseInc = midiToHz(midi) / sampleRate ampEnv.gateOn(patch.aA, patch.aD, patch.aS, patch.aR)
stage = Stage.ATTACK filtEnv.gateOn(patch.fA, patch.fD, patch.fS, patch.fR)
// env is intentionally NOT reset to 0 on retrigger to avoid clicks. 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() { fun noteOff() { ampEnv.gateOff(); filtEnv.gateOff() }
if (stage != Stage.IDLE) stage = Stage.RELEASE
}
/** Hard stop with no release tail (used on transport stop / voice steal). */ /** Hard stop with no release tail (transport stop / voice steal). */
fun kill() { 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 { fun render(): Float {
if (stage == Stage.IDLE) return 0f if (!ampEnv.active) return 0f
advanceEnvelope() val dt = 1f / sampleRate
val osc = oscillator() val ampE = ampEnv.advance(dt)
// advance oscillator phase val filtE = filtEnv.advance(dt)
phase += phaseInc
if (phase >= 1.0) phase -= 1.0 // ---- LFO ----
return (osc * env * amp).toFloat() 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) { // ---- oscillator waveforms (PolyBLEP band-limited saw/pulse; naive triangle) ----
Wave.PULSE_12 -> if (phase < 0.125) 1.0 else -1.0 private fun waveform(wave: Int, t: Double, dt: Double, pwm: Float): Float = when (wave) {
Wave.PULSE_25 -> if (phase < 0.25) 1.0 else -1.0 1 -> (2.0 * t - 1.0 - polyBlep(t, dt)).toFloat() // Saw
Wave.PULSE_50 -> if (phase < 0.5) 1.0 else -1.0 2 -> pulse(t, dt, (0.5f + pwm * 0.45f).coerceIn(0.02f, 0.98f)) // Square
Wave.TRIANGLE -> 1.0 - 4.0 * kotlin.math.abs((phase % 1.0) - 0.5) // -1..1 3 -> pulse(t, dt, (0.25f + pwm * 0.45f).coerceIn(0.02f, 0.98f)) // Pulse 25%
Wave.NOISE -> { 4 -> pulse(t, dt, (0.125f + pwm * 0.45f).coerceIn(0.02f, 0.98f)) // Pulse 12.5%
// 15-bit LFSR clocked at the note frequency for a pitched-noise feel. else -> (1.0 - 4.0 * abs(t - 0.5)).toFloat() // Triangle
if (phase < phaseInc) { }
val bit = (noiseReg xor (noiseReg shr 1)) and 1
noiseReg = (noiseReg shr 1) or (bit shl 14) private fun pulse(t: Double, dt: Double, w: Float): Float {
} var y = if (t < w) 1.0 else -1.0
if (noiseReg and 1 == 0) 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() { private fun lfoValue(): Float {
val dt = 1.0 / sampleRate val p = lfoPhase
when (stage) { return when (patch.lfoWave) {
Stage.ATTACK -> { 1 -> (2.0 * p - 1.0).toFloat() // Saw
env += dt / attack 2 -> if (p < 0.5) 1f else -1f // Square
if (env >= 1.0) { env = 1.0; stage = Stage.DECAY } 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 -> { return level
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 -> {}
} }
} }
companion object { 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). */ /** 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) 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.audio.SampleStore
import space.rcmd.android.sizzle.model.AmbiencePresets 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.Project
import space.rcmd.android.sizzle.model.SamplerPads import space.rcmd.android.sizzle.model.SamplerPads
import space.rcmd.android.sizzle.model.ToolboxSlot 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 * 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 * 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 * 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() { 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 dir = typeDir(type)
val marker = File(dir, SEEDED_MARKER) val marker = File(dir, SEEDED_MARKER)
if (marker.exists()) return if (marker.exists()) return
AmbiencePresets.FACTORY.forEach { preset -> names.forEach { name ->
val file = presetFile(type, preset.name) val file = presetFile(type, name)
if (file.exists()) return@forEach if (file.exists()) return@forEach
val slot = ToolboxSlot(0).apply { val slot = ToolboxSlot(0).apply {
fill(type) fill(type)
AmbiencePresets.apply(this, preset) apply(this, name)
name = preset.name this.name = name
} }
file.writeText(PresetIo.export(slot)) file.writeText(PresetIo.export(slot))
} }

View File

@@ -68,9 +68,11 @@ object SngFormat {
appendLine("track T${t + 1} $defChannel") appendLine("track T${t + 1} $defChannel")
for (line in steps) { for (line in steps) {
val cell = block.cell(t, line) 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 note = if (cell.isNoteOff) "OFF" else Pitch.name(cell.note)
val vel = cell.velocity.toString(16).uppercase().padStart(2, '0') val vel = if (cell.isNoteOff) ".." else cell.velocity.toString(16).uppercase().padStart(2, '0')
val chan = cell.channel.toString().padStart(2, '0') val chan = if (cell.isNoteOff) ".." else cell.channel.toString().padStart(2, '0')
appendLine("$line $note $vel $chan") 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>, val params: List<ParamSpec>,
) { ) {
// ---------- Instruments ---------- // ---------- 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( NES_SYNTH(
ToolboxKind.INSTRUMENT, "NES Synth", ToolboxKind.INSTRUMENT, "NES Synth",
listOf( buildList {
ParamSpec("wave", "Wave", choices = listOf("Pulse12", "Pulse25", "Pulse50", "Triangle", "Noise")), for (i in 0 until SynthDefs.OSC_COUNT) {
ParamSpec("attack", "Attack", 0.01f, 0f, 1f), add(ParamSpec("osc${i}wave", "Osc${i + 1} Wave", choices = SynthDefs.WAVES))
ParamSpec("decay", "Decay", 0.15f, 0f, 1f), add(ParamSpec("osc${i}oct", "Osc${i + 1} Oct", 0f, -2f, 2f, isInt = true))
ParamSpec("sustain", "Sustain", 0.6f, 0f, 1f), add(ParamSpec("osc${i}semi", "Osc${i + 1} Semi", 0f, -12f, 12f, isInt = true))
ParamSpec("release", "Release", 0.1f, 0f, 1f), 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( SAMPLER(
ToolboxKind.INSTRUMENT, "Sampler", ToolboxKind.INSTRUMENT, "Sampler",

View File

@@ -204,6 +204,9 @@ class AppViewModel(
*/ */
fun editFocused(direction: Int) { fun editFocused(direction: Int) {
val cell = focusedCell() 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) { when (cursorColumn) {
CellColumn.NOTE -> { CellColumn.NOTE -> {
// Seed an empty tick from the last note entered so filling resumes // Seed an empty tick from the last note entered so filling resumes
@@ -226,12 +229,14 @@ class AppViewModel(
fun clearFocused() { focusedCell().clear(); touched() } fun clearFocused() { focusedCell().clear(); touched() }
/** Insert a note-off ("===") at the cursor and advance by the skip step, like a /** 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 * punch-in. A note-off is tied to its track (no velocity/channel of its own — it
* releases the same bus the previous note was entered on. */ * releases whatever that track last played), so those fields are reset to
* defaults and ignored. */
fun noteOffFocused() { fun noteOffFocused() {
val cell = focusedCell() val cell = focusedCell()
cell.note = Cell.OFF cell.note = Cell.OFF
cell.channel = lastChannel cell.velocity = Cell.MAX_VELOCITY
cell.channel = 1
moveCursorVerticalWrap(skipStep) moveCursorVerticalWrap(skipStep)
touched() 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 // 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) Text("Volume / envelope:", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
VerticalSlider(vm, slot, rev, "volume", "VOL", 0f, 1f, 0.8f, Modifier.weight(1f)) 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 = private fun formatSliderValue(v: Float): String =
if (v == v.toInt().toFloat()) v.toInt().toString() else String.format("%.2f", v) 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 * 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 * 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 * ToolboxScreen's ParamEditor) rather than scrolling with the controls. Holding a key
* controls. Holding a key sounds THIS slot's loaded instrument. */ * sounds THIS slot's instrument — used by the SoundFont and synth editors. */
@Composable @Composable
fun SoundFontKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) { fun SlotAuditionKeyboard(vm: AppViewModel, slot: ToolboxSlot, modifier: Modifier) {
val c = LocalRetro.current val c = LocalRetro.current
var octave by remember(slot.index) { mutableIntStateOf(3) } var octave by remember(slot.index) { mutableIntStateOf(3) }
Column( 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 @Suppress("UNUSED_VARIABLE") val rev = vm.revision // subscribe so title/params refresh
val type = slot.type ?: return val type = slot.type ?: return
// The SoundFont editor pins its audition keyboard at the bottom (same as the // The SoundFont and synth editors pin an audition keyboard at the bottom (same as
// toolbox / tracker keyboards), so its controls scroll in a weighted region above // the toolbox / tracker keyboards), so their controls scroll in a weighted region
// the keyboard rather than the whole editor scrolling as one. // above the keyboard rather than the whole editor scrolling as one.
val pinnedKeyboard = type == ToolboxType.SOUNDFONT val pinnedKeyboard = type == ToolboxType.SOUNDFONT || type == ToolboxType.NES_SYNTH
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) { Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
Column(Modifier.fillMaxSize()) { 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 // LFO adds a modulation-target picker; everything else uses the
// auto-generated parameter list, laid out two-per-row to fit more. // auto-generated parameter list, laid out two-per-row to fit more.
when (type) { when (type) {
ToolboxType.NES_SYNTH -> NesSynthEditor(vm, slot)
ToolboxType.SAMPLER -> SamplerEditor(vm, slot) ToolboxType.SAMPLER -> SamplerEditor(vm, slot)
ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot) ToolboxType.SOUNDFONT -> SoundFontEditor(vm, slot)
ToolboxType.DELAY -> DelayEditor(vm, slot) ToolboxType.DELAY -> DelayEditor(vm, slot)
@@ -302,7 +303,7 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
if (pinnedKeyboard) { if (pinnedKeyboard) {
Box(Modifier.fillMaxWidth().height(2.dp).background(c.accent)) // fixed divider 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 -> "===" cell.isNoteOff -> "==="
else -> Pitch.name(cell.note) 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 = 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 = 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')