Compare commits

...

2 Commits

Author SHA1 Message Date
Reactorcoremeltdown
b305f0a2b5 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>
2026-07-21 09:07:28 +02:00
Reactorcoremeltdown
79a1844512 Tap tempo by tapping the BPM readout (v0.15.0)
Tapping the "### BPM" label sets the tempo from the average interval of recent
taps (up to 8, refining as you go); a pause over 2 s starts a fresh count. The
−/+ nudge buttons are unchanged. Purely UI-side — a small remembered list of tap
timestamps, no model/engine changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:07:06 +02:00
4 changed files with 189 additions and 41 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 = 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"

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

@@ -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

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)
}
}