Arpeggiate the whole held chord, not just the last note (v0.15.1)

Holding a chord on an arpeggiated channel used to collapse to the last (rightmost)
note and retrigger only that — the other notes were killed by the per-note
releaseBus + single-note arp.start. ChannelArp now holds the whole chord (sorted,
de-duped) and cycles all its notes across the octave range in the chosen mode.

Notes entered on the same line accumulate into one chord via a per-line generation
stamp; a new line starts a fresh chord, and a looping chord keeps its running phase
so it doesn't glitch at the loop point. ChannelArp is made internal to cover the
sequencing with ChannelArpTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-21 09:07:28 +02:00
parent 79a1844512
commit b305f0a2b5
3 changed files with 162 additions and 39 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 = 39 versionCode = 40
versionName = "0.15.0" versionName = "0.15.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

@@ -78,6 +78,11 @@ 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() }
// 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 // 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 // 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. // 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) { 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. // 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 // 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 // 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 stepSamples = div.beatFraction * (60.0 / proj.tempoBpm) * sampleRate
val mode = arpSlot.string("mode", "Up") val mode = arpSlot.string("mode", "Up")
val resetBar = arpSlot.string("resetbar", "Off") == "On" val resetBar = arpSlot.string("resetbar", "Off") == "On"
if (arp.active && arp.base == note) { when {
// The same note is already arpeggiating — this trigger is the arp.active && arp.genStamp == lineTriggerGen -> {
// pattern looping back over a still-held note. Keep the running // Another note of the SAME line's chord — accumulate it so the
// phase (just refresh params) so the pulse stays steady across // arp cycles the whole chord (not just the last note entered).
// the loop point instead of slipping when the loop length isn't arp.addNote(note)
// an exact multiple of the arp step (dotted/triplet divisions). arp.refresh(octaves, mode, stepSamples, resetBar)
arp.refresh(octaves, mode, stepSamples, resetBar) }
} else { arp.active -> {
arp.start(note, velocity, octaves, mode, stepSamples, resetBar) // A new line while still arpeggiating (the pattern looping back
// The arpeggiator is monophonic (it cycles octaves of ONE note), // over a held chord): rebuild the chord from this line but keep
// so it must not stack notes. Releasing the bus first means a // the running phase, so a looping chord doesn't hard-restart or
// chord entered on the same line collapses to a single // slip when the loop length isn't a multiple of the arp step.
// arpeggiated note (the last one), not all of them at once. arp.resetChord(note, velocity, octaves, mode, stepSamples, resetBar)
releaseBus(bus) arp.genStamp = lineTriggerGen
playNote(proj, bus, arp.currentNote(), velocity) }
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 { } else {
arp.stop() arp.stop()
@@ -1050,17 +1063,22 @@ class AudioEngine(
} }
} }
/** Per-(lane,track) arpeggiator state: octave-cycles a captured note. */ /** Per-(lane,track) arpeggiator: cycles through the held chord's notes across the
private class ChannelArp { * octave range. Notes are kept sorted ascending; the linear step sequence is
var active = false; var base = -1; var velocity = 100 * 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 private var octaves = 1; private var mode = 0
var stepSamples = 0.0; var samplesLeft = 0.0 var stepSamples = 0.0; var samplesLeft = 0.0
private var step = 0; private var dir = 1 private var step = 0; private var dir = 1
/** When true, [resetToBar] realigns the pattern to step 0 on each bar downbeat. */
var resetOnBar = false var resetOnBar = false
// Set by [resetToBar] so the next fire plays step 0 without first advancing. var genStamp = -1
private var pendingReset = false private var pendingReset = false
// Per-arp lock-free RNG (Random mode) — no Math.random() on the audio thread.
private var rng = 0x2545F491.toInt() private var rng = 0x2545F491.toInt()
private fun rnd(bound: Int): Int { private fun rnd(bound: Int): Int {
var s = rng var s = rng
@@ -1069,16 +1087,37 @@ class AudioEngine(
return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt() return ((s.toLong() and 0xFFFFFFFFL) % bound).toInt()
} }
fun start(note: Int, vel: Int, octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) { /** Active while it holds at least one note. */
active = true; base = note; velocity = vel; octaves = octs.coerceIn(1, 4) 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) mode = modeOf(modeStr)
stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1 stepSamples = stepSamp.coerceAtLeast(1.0); samplesLeft = stepSamples; step = 0; dir = 1
resetOnBar = resetBar; pendingReset = false resetOnBar = resetBar; pendingReset = false
} }
/** Update params WITHOUT resetting the running phase — used when the pattern /** Replace the chord with [note] (the next same-line notes re-accumulate) while
* loops over a still-arpeggiating note so the steady pulse continues * KEEPING the running phase — used when the pattern loops back over a held
* seamlessly across the loop boundary. */ * 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) { fun refresh(octs: Int, modeStr: String, stepSamp: Double, resetBar: Boolean) {
octaves = octs.coerceIn(1, 4) octaves = octs.coerceIn(1, 4)
mode = modeOf(modeStr) mode = modeOf(modeStr)
@@ -1086,31 +1125,38 @@ class AudioEngine(
resetOnBar = resetBar 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 } 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 } fun consumePendingReset(): Boolean { val p = pendingReset; pendingReset = false; return p }
private fun modeOf(modeStr: String): Int = private fun modeOf(modeStr: String): Int =
when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 } when (modeStr) { "Down" -> 1; "UpDown" -> 2; "Random" -> 3; else -> 0 }
fun stop() { active = false; base = -1 } fun stop() { noteCount = 0; genStamp = -1 }
fun currentNote(): Int = (base + 12 * step).coerceIn(0, 127)
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() { fun advance() {
val total = total()
when (mode) { when (mode) {
1 -> step = (step - 1 + octaves) % octaves 1 -> step = (step - 1 + total) % total
2 -> if (octaves > 1) { 2 -> if (total > 1) {
step += dir 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 } else if (step <= 0) { step = 0; dir = 1 }
} }
3 -> step = if (octaves > 1) rnd(octaves) else 0 3 -> step = if (total > 1) rnd(total) else 0
else -> step = (step + 1) % octaves 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. */ /** Sum of semitone offsets from any MIDI Transposer effects on this channel. */
@@ -1259,6 +1305,7 @@ class AudioEngine(
private fun triggerArrangementLine( private fun triggerArrangementLine(
proj: Project, arr: Arrangement, beat: Int, lineInBeat: Int, linesPerBeat: Int, 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 // 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 // never cancels a note that begins on the same line/bus (a note-off releases
// the whole bus). See [triggerPatternLine]. // the whole bus). See [triggerPatternLine].

View File

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