Compare commits
2 Commits
8c44ed5151
...
b305f0a2b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b305f0a2b5 | ||
|
|
79a1844512 |
@@ -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 = 38
|
versionCode = 40
|
||||||
versionName = "0.14.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"
|
||||||
|
|||||||
@@ -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].
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package space.rcmd.android.sizzle.ui.tracker
|
package space.rcmd.android.sizzle.ui.tracker
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -19,8 +20,10 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -147,14 +150,31 @@ private fun TransportButtons(vm: AppViewModel) {
|
|||||||
// Fixed width so the button doesn't resize as the label toggles.
|
// Fixed width so the button doesn't resize as the label toggles.
|
||||||
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
|
||||||
active = isPlaying, width = 112.dp, onClick = vm::playPause)
|
active = isPlaying, width = 112.dp, onClick = vm::playPause)
|
||||||
// Tempo nudger: tap -/+ around a fixed-width readout.
|
// Tempo nudger: tap -/+ around a fixed-width readout. Tapping the readout itself
|
||||||
|
// is TAP TEMPO — two or more taps in time set the tempo from the average interval.
|
||||||
|
val tapTimes = remember { ArrayList<Long>() }
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
|
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
|
||||||
Text(
|
Text(
|
||||||
"${project.tempoBpm.toInt()} BPM",
|
"${project.tempoBpm.toInt()} BPM",
|
||||||
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
|
||||||
textAlign = TextAlign.Center, maxLines = 1,
|
textAlign = TextAlign.Center, maxLines = 1,
|
||||||
modifier = Modifier.width(72.dp).padding(vertical = 6.dp),
|
modifier = Modifier
|
||||||
|
.width(72.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// A long pause starts a fresh count rather than averaging across it.
|
||||||
|
if (tapTimes.isNotEmpty() && now - tapTimes.last() > TAP_RESET_MS) tapTimes.clear()
|
||||||
|
tapTimes.add(now)
|
||||||
|
while (tapTimes.size > TAP_MAX) tapTimes.removeAt(0)
|
||||||
|
if (tapTimes.size >= 2) {
|
||||||
|
val span = tapTimes.last() - tapTimes.first()
|
||||||
|
if (span > 0) vm.setTempo(60_000f * (tapTimes.size - 1) / span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
)
|
)
|
||||||
RepeatButton("+", onStep = { vm.setTempo(project.tempoBpm + 1) })
|
RepeatButton("+", onStep = { vm.setTempo(project.tempoBpm + 1) })
|
||||||
}
|
}
|
||||||
@@ -224,3 +244,8 @@ private fun EditButtons(vm: AppViewModel) {
|
|||||||
// Swap the bottom half between the arranger and the punch-in keyboard.
|
// Swap the bottom half between the arranger and the punch-in keyboard.
|
||||||
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
RetroButton("⌨ KBD", active = vm.punchInMode, onClick = vm::togglePunchIn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tap-tempo tuning: a gap longer than this starts a fresh count; keep at most this
|
||||||
|
// many recent taps in the averaging window.
|
||||||
|
private const val TAP_RESET_MS = 2000L
|
||||||
|
private const val TAP_MAX = 8
|
||||||
|
|||||||
@@ -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