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:
@@ -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"
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user