From 63cad6897f500d42c3ad96aade1cc94804b39aa0 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Sat, 18 Jul 2026 01:05:17 +0200 Subject: [PATCH] Rewrite the NES synth as a 3-oscillator subtractive synth; track-tied note-offs (v0.4.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 4 +- .../rcmd/android/sizzle/audio/AudioEngine.kt | 154 +++++----- .../rcmd/android/sizzle/audio/SynthPatch.kt | 94 ++++++ .../rcmd/android/sizzle/audio/SynthVoice.kt | 289 +++++++++++++----- .../rcmd/android/sizzle/io/PresetLibrary.kt | 23 +- .../space/rcmd/android/sizzle/io/SngFormat.kt | 6 +- .../android/sizzle/model/NesSynthPresets.kt | 76 +++++ .../rcmd/android/sizzle/model/SynthDefs.kt | 29 ++ .../rcmd/android/sizzle/model/Toolbox.kt | 43 ++- .../rcmd/android/sizzle/ui/AppViewModel.kt | 11 +- .../rcmd/android/sizzle/ui/components/Knob.kt | 113 +++++++ .../sizzle/ui/toolbox/NesSynthEditor.kt | 228 ++++++++++++++ .../sizzle/ui/toolbox/SoundFontEditor.kt | 10 +- .../sizzle/ui/toolbox/ToolboxScreen.kt | 11 +- .../android/sizzle/ui/tracker/PatternGrid.kt | 6 +- 15 files changed, 915 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/space/rcmd/android/sizzle/audio/SynthPatch.kt create mode 100644 app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt create mode 100644 app/src/main/java/space/rcmd/android/sizzle/model/SynthDefs.kt create mode 100644 app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt create mode 100644 app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/NesSynthEditor.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bdb31fb..fa0e6e6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index 1b83fc5..3811a24 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SynthPatch.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthPatch.kt new file mode 100644 index 0000000..3747102 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthPatch.kt @@ -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) + } +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt index 400f09f..4d7012d 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt @@ -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) } diff --git a/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt b/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt index 42846fc..2397e88 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/io/PresetLibrary.kt @@ -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, 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)) } diff --git a/app/src/main/java/space/rcmd/android/sizzle/io/SngFormat.kt b/app/src/main/java/space/rcmd/android/sizzle/io/SngFormat.kt index 58be0f3..0a30386 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/io/SngFormat.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/io/SngFormat.kt @@ -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") } } diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt b/app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt new file mode 100644 index 0000000..b3fd4c0 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/model/NesSynthPresets.kt @@ -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) + + /** 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 = 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", + )), + ) +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/SynthDefs.kt b/app/src/main/java/space/rcmd/android/sizzle/model/SynthDefs.kt new file mode 100644 index 0000000..0e02a64 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/model/SynthDefs.kt @@ -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) +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt index 8f1db75..a73696e 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/model/Toolbox.kt @@ -43,15 +43,44 @@ enum class ToolboxType( val params: List, ) { // ---------- 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", diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index 0f053e2..13fa704 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -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() } diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt new file mode 100644 index 0000000..6992740 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/components/Knob.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/NesSynthEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/NesSynthEditor.kt new file mode 100644 index 0000000..5146c35 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/NesSynthEditor.kt @@ -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, + 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() }, + ) +} diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt index acd90c9..2239456 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/SoundFontEditor.kt @@ -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( diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt index 1821c8f..e61cd24 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt @@ -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)) } } } diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt index 1dd0a71..bd0917f 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/tracker/PatternGrid.kt @@ -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')