diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 708ec71..886251b 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 = 39 - versionName = "0.15.0" + versionCode = 40 + versionName = "0.15.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 0595182..5dba3fb 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 @@ -78,6 +78,11 @@ 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() } + // Bumped once per triggered line. Notes reaching an arpeggiator with the same + // stamp are the same line's chord (accumulated into one arp); a new stamp starts + // a fresh chord. Lets a held chord arpeggiate all its notes instead of collapsing + // to the last one entered. Audio-thread only. + private var lineTriggerGen = 0 // 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. @@ -833,6 +838,7 @@ class AudioEngine( } private fun triggerPatternLine(proj: Project, pattern: Pattern, line: Int) { + lineTriggerGen++ // new line — notes below form one chord per arpeggiator // Pattern-loop plays on lane 0's voices; each cell is routed by its channel. // Apply note-offs FIRST, then note-ons, so a note-off on one track never // cancels a note that begins on the same line/bus (a note-off releases the @@ -893,21 +899,28 @@ class AudioEngine( 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) + when { + arp.active && arp.genStamp == lineTriggerGen -> { + // Another note of the SAME line's chord — accumulate it so the + // arp cycles the whole chord (not just the last note entered). + arp.addNote(note) + arp.refresh(octaves, mode, stepSamples, resetBar) + } + arp.active -> { + // A new line while still arpeggiating (the pattern looping back + // over a held chord): rebuild the chord from this line but keep + // the running phase, so a looping chord doesn't hard-restart or + // slip when the loop length isn't a multiple of the arp step. + arp.resetChord(note, velocity, octaves, mode, stepSamples, resetBar) + arp.genStamp = lineTriggerGen + } + else -> { + // Fresh arp: start on this note and sound it immediately. + arp.startChord(note, velocity, octaves, mode, stepSamples, resetBar) + arp.genStamp = lineTriggerGen + releaseBus(bus) + playNote(proj, bus, arp.currentNote(), velocity) + } } } else { arp.stop() @@ -1050,17 +1063,22 @@ class AudioEngine( } } - /** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */ - private class ChannelArp { - var active = false; var base = -1; var velocity = 100 + /** Per-(lane,track) arpeggiator: cycles through the held chord's notes across the + * octave range. Notes are kept sorted ascending; the linear step sequence is + * octave-major (all chord notes in octave 0, then octave 1, …), walked up / down / + * up-down / randomly by [advance]. [genStamp] identifies the line whose chord this + * holds (see [lineTriggerGen]). Internal (not private) only so a unit test can + * exercise its pure note-sequencing. */ + internal class ChannelArp { + private val notes = IntArray(MAX_CHORD) + var noteCount = 0; private set + var velocity = 100 private var octaves = 1; private var mode = 0 var stepSamples = 0.0; var samplesLeft = 0.0 private var step = 0; private var dir = 1 - /** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */ var resetOnBar = false - // Set by [resetToBar] so the next fire plays step 0 without first advancing. + var genStamp = -1 private var pendingReset = false - // Per-arp lock-free RNG (Random mode) — no Math.random() on the audio thread. private var rng = 0x2545F491.toInt() private fun rnd(bound: Int): Int { var s = rng @@ -1069,16 +1087,37 @@ class AudioEngine( return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt() } - fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) { - active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4) + /** Active while it holds at least one note. */ + val active: Boolean get() = noteCount > 0 + + /** Start a fresh chord on [note], resetting the phase to step 0. */ + fun startChord(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) { + noteCount = 0; addNote(note); velocity = vel; octaves = octs.coerceIn(1, 4) mode = modeOf(modeStr) stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1 resetOnBar = resetBar; pendingReset = false } - /** Update params WITHOUT resetting the running phase — used when the pattern - * loops over a still-arpeggiating note so the steady pulse continues - * seamlessly across the loop boundary. */ + /** Replace the chord with [note] (the next same-line notes re-accumulate) while + * KEEPING the running phase — used when the pattern loops back over a held + * chord so the pulse stays steady instead of restarting. */ + fun resetChord(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) { + noteCount = 0; addNote(note); velocity = vel + refresh(octs, modeStr, stepSamp, resetBar) + } + + /** Add a note to the held chord (sorted ascending, de-duplicated). */ + fun addNote(note: Int) { + val n = note.coerceIn(0, 127) + var i = 0 + while (i < noteCount && notes[i] < n) i++ + if (i < noteCount && notes[i] == n) return // already held + if (noteCount >= MAX_CHORD) return + for (j in noteCount downTo i + 1) notes[j] = notes[j - 1] + notes[i] = n; noteCount++ + } + + /** Update params WITHOUT resetting the running phase. */ fun refresh(octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) { octaves = octs.coerceIn(1, 4) mode = modeOf(modeStr) @@ -1086,31 +1125,38 @@ class AudioEngine( resetOnBar = resetBar } - /** Realign the pattern to step 0 at a bar downbeat: the next [advanceArps] - * fire (scheduled immediately) plays step 0 without advancing first. */ fun resetToBar() { step = 0; dir = 1; samplesLeft = 0.0; pendingReset = true } - - /** True (once) if a bar reset is pending, so the caller skips [advance]. */ fun consumePendingReset(): Boolean { val p = pendingReset; pendingReset = false; return p } private fun modeOf(modeStr: String): Int = when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 } - fun stop() { active = false; base = -1 } - fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127) + fun stop() { noteCount = 0; genStamp = -1 } + + private fun total(): Int = (noteCount * octaves).coerceAtLeast(1) + + fun currentNote(): Int { + if (noteCount <= 0) return 0 + val idx = step.coerceIn(0, total() - 1) + val oct = idx / noteCount + return (notes[idx % noteCount] + 12 * oct).coerceIn(0, 127) + } fun advance() { + val total = total() when (mode) { - 1 -> step = (step - 1 + octaves) % octaves - 2 -> if (octaves > 1) { + 1 -> step = (step - 1 + total) % total + 2 -> if (total > 1) { step += dir - if (step >= octaves - 1) { step = octaves - 1; dir = -1 } + if (step >= total - 1) { step = total - 1; dir = -1 } else if (step <= 0) { step = 0; dir = 1 } } - 3 -> step = if (octaves > 1) rnd(octaves) else 0 - else -> step = (step + 1) % octaves + 3 -> step = if (total > 1) rnd(total) else 0 + else -> step = (step + 1) % total } } + + private companion object { const val MAX_CHORD = 8 } } /** Sum of semitone offsets from any MIDI Transposer effects on this channel. */ @@ -1259,6 +1305,7 @@ class AudioEngine( private fun triggerArrangementLine( proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int, ) { + lineTriggerGen++ // new line — notes below form one chord per arpeggiator // Two passes across all lanes: note-offs first, then note-ons, so a note-off // never cancels a note that begins on the same line/bus (a note-off releases // the whole bus). See [triggerPatternLine]. diff --git a/app/src/test/java/space/rcmd/android/sizzle/audio/ChannelArpTest.kt b/app/src/test/java/space/rcmd/android/sizzle/audio/ChannelArpTest.kt new file mode 100644 index 0000000..6ad4c01 --- /dev/null +++ b/app/src/test/java/space/rcmd/android/sizzle/audio/ChannelArpTest.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown +// SPDX-License-Identifier: GPL-3.0-or-later + +package space.rcmd.android.sizzle.audio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The arpeggiator must cycle through ALL notes of a held chord (across the octave + * range), not collapse to the last one entered — the fix for a held chord ringing + * only its rightmost note. + */ +class ChannelArpTest { + + private fun arp() = AudioEngine.ChannelArp() + + /** The Up sequence over a chord × octaves is octave-major over the sorted notes. */ + @Test fun upCyclesWholeChordAcrossOctaves() { + val a = arp() + // Enter the chord out of order; it should sort ascending. + a.startChord(67, 100, octs = 2, modeStr = "Up", stepSamp = 100.0, resetBar = false) + a.addNote(60) + a.addNote(64) + assertEquals(3, a.noteCount) + + // step 0 is the first note before any advance. + val seq = ArrayList() + seq.add(a.currentNote()) + repeat(5) { a.advance(); seq.add(a.currentNote()) } + // 3 notes × 2 octaves = C4 E4 G4 C5 E5 G5, then wraps to C4. + assertEquals(listOf(60, 64, 67, 72, 76, 79), seq) + a.advance() + assertEquals("wraps back to the start", 60, a.currentNote()) + } + + @Test fun downStartsHandledAndStaysInRange() { + val a = arp() + a.startChord(60, 100, octs = 1, modeStr = "Down", stepSamp = 100.0, resetBar = false) + a.addNote(64); a.addNote(67) + val seen = HashSet() + repeat(9) { seen.add(a.currentNote()); a.advance() } + // Only the three chord notes (one octave) ever sound. + assertEquals(setOf(60, 64, 67), seen) + } + + @Test fun addNoteDeDuplicates() { + val a = arp() + a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false) + a.addNote(60); a.addNote(60) + assertEquals(1, a.noteCount) + } + + @Test fun resetChordKeepsGoingWithNewChord() { + val a = arp() + a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false) + a.addNote(64) + a.advance() // move off step 0 + // A loop-back rebuilds the chord but keeps arpeggiating. + a.resetChord(48, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false) + a.addNote(55) + assertTrue(a.active) + val seen = HashSet() + repeat(4) { seen.add(a.currentNote()); a.advance() } + assertEquals(setOf(48, 55), seen) + } + + @Test fun stopClearsChord() { + val a = arp() + a.startChord(60, 100, octs = 1, modeStr = "Up", stepSamp = 100.0, resetBar = false) + a.stop() + assertEquals(0, a.noteCount) + assertTrue(!a.active) + } +}