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.
minSdk = 26
targetSdk = 34
versionCode = 38
versionName = "0.14.0"
versionCode = 40
versionName = "0.15.1"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -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,22 +899,29 @@ 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).
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)
} 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.
}
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()
playNote(proj, bus, note, velocity, ownerTrack = trackKey)
@@ -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].

View File

@@ -4,6 +4,7 @@
package space.rcmd.android.sizzle.ui.tracker
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -19,8 +20,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
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.
RetroButton(if (isPlaying) "❚❚ PAUSE" else "▶ PLAY",
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) {
RepeatButton("-", onStep = { vm.setTempo(project.tempoBpm - 1) })
Text(
"${project.tempoBpm.toInt()} BPM",
color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
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) })
}
@@ -224,3 +244,8 @@ private fun EditButtons(vm: AppViewModel) {
// Swap the bottom half between the arranger and the punch-in keyboard.
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)
}
}