Play starts from the arrangement selection; Stop clears it (v0.19.0)

Highlight a cell on the arrangement roll and Play now begins at that selection's
first beat instead of always from the top. Stop clears the highlight, so the next
Play resumes from the beginning. The rectangular selection is hoisted from
ArrangementRoll to the ViewModel (four ints) so the transport can read/clear it;
engine.play() gains a startBeat that seeks playlistIndex in the built playlist
(A/B expansion respected; falls back to the top if the beat isn't in it) or
currentLine in pattern-loop mode. Loading/importing a project also clears the
selection so a stale one can't apply to a new arrangement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-21 18:04:47 +02:00
parent 96bbc5a4cc
commit 9fdef7fee7
4 changed files with 74 additions and 19 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 = 44 versionCode = 45
versionName = "0.18.0" versionName = "0.19.0"
// 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

@@ -506,9 +506,34 @@ class AudioEngine(
} }
// --------------------------------------------------------------- transport // --------------------------------------------------------------- transport
fun play() { /**
* Start playback. When [startBeat] is >= 0, seek to that beat in the arrangement
* (or that line in pattern-loop mode) so Play resumes from a marker the user
* placed on the arrangement, instead of always from the top. Ignored if it doesn't
* land inside the current playlist / pattern.
*/
fun play(startBeat: Int = -1) {
rebuildArrangement() rebuildArrangement()
rebuildFxChains() rebuildFxChains()
if (startBeat >= 0) {
if (arrangementMode) {
val list = playlist
// Find the first occurrence of that beat in the (possibly A/B-expanded)
// playlist; if it isn't in there just start from the top.
var seek = -1
for (i in list.indices) if (list[i] == startBeat) { seek = i; break }
if (seek >= 0) {
playlistIndex = seek; lineInBeat = 0; arrPlayLine = 0; samplesIntoLine = 0.0
}
} else {
val proj = project
if (proj != null) {
val pat = proj.activePattern()
val line = (startBeat * proj.timeSignature.linesPerBeat).coerceIn(0, pat.length - 1)
currentLine = line; samplesIntoLine = 0.0
}
}
}
pendingTrigger = true pendingTrigger = true
playing = true playing = true
maybeStartRecording() maybeStartRecording()

View File

@@ -378,13 +378,42 @@ class AppViewModel(
touched() touched()
} }
// ---- Arrangement selection (hoisted from ArrangementRoll so Play/Stop can read it).
// Rectangular selection anchor (…0) and live corner (…1); beat < 0 = no selection.
// Play starts from [arrSelBeat0]'s beat if set; Stop clears the selection so the
// highlight disappears and the next Play resumes from the top.
var arrSelLane0 by mutableIntStateOf(-1); private set
var arrSelBeat0 by mutableIntStateOf(-1); private set
var arrSelLane1 by mutableIntStateOf(-1); private set
var arrSelBeat1 by mutableIntStateOf(-1); private set
/** Set/update the arrangement selection rectangle. Pass all corners at once so the
* ArrangementRoll's tap / drag gestures update it atomically. */
fun setArrangementSelection(lane0: Int, beat0: Int, lane1: Int, beat1: Int) {
arrSelLane0 = lane0; arrSelBeat0 = beat0; arrSelLane1 = lane1; arrSelBeat1 = beat1
}
fun clearArrangementSelection() {
arrSelLane0 = -1; arrSelBeat0 = -1; arrSelLane1 = -1; arrSelBeat1 = -1
}
// ------------------------------------------------------- transport control // ------------------------------------------------------- transport control
fun playPause() { fun playPause() {
val t = transport.value val t = transport.value
if (t.isPlaying) engine.pause() else engine.play() if (t.isPlaying) {
engine.pause()
} else {
// Start from the earliest beat of the arrangement selection if there is
// one; otherwise from wherever the engine last was (top after a Stop).
val start = if (arrSelBeat0 >= 0) minOf(arrSelBeat0, arrSelBeat1) else -1
engine.play(start)
}
} }
fun stop() = engine.stop() fun stop() {
clearArrangementSelection() // resets the highlight so the next Play starts from the top
engine.stop()
}
/** MIDI panic: silence all sounding/stuck notes immediately. */ /** MIDI panic: silence all sounding/stuck notes immediately. */
fun midiPanic() { heldNotes.clear(); engine.panic() } fun midiPanic() { heldNotes.clear(); engine.panic() }
@@ -917,6 +946,7 @@ class AppViewModel(
fun loadProjectText(text: String) { fun loadProjectText(text: String) {
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text) space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
cursorLine = 0; cursorTrack = 0 cursorLine = 0; cursorTrack = 0
clearArrangementSelection() // the old arrangement's selection is no longer meaningful
touched() touched()
} }

View File

@@ -100,14 +100,13 @@ fun ArrangementRoll(vm: AppViewModel) {
val beatWidthPx = with(density) { beatWidth.toPx() } val beatWidthPx = with(density) { beatWidth.toPx() }
val rulerHeightPx = with(density) { rulerHeight.toPx() } val rulerHeightPx = with(density) { rulerHeight.toPx() }
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none. // Rectangular cell selection lives on the ViewModel so the transport (Play/Stop)
// Unlike before, selecting does NOT change any cell; the toolbar toggle acts // can read and clear it — Play starts from the selection's first beat, Stop clears
// on the selection. Read in composition (for the toolbar's FILL/CLEAR label) // it. Selecting still does NOT change any cell; the toolbar toggle acts on it.
// and captured by the Canvas draw below. val selLane0 = vm.arrSelLane0
var selLane0 by remember { mutableIntStateOf(-1) } val selBeat0 = vm.arrSelBeat0
var selBeat0 by remember { mutableIntStateOf(-1) } val selLane1 = vm.arrSelLane1
var selLane1 by remember { mutableIntStateOf(-1) } val selBeat1 = vm.arrSelBeat1
var selBeat1 by remember { mutableIntStateOf(-1) }
val hasSelection = selBeat0 >= 0 val hasSelection = selBeat0 >= 0
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
@@ -227,8 +226,7 @@ fun ArrangementRoll(vm: AppViewModel) {
val lane = ((off.y - rulerHeightPx) / laneH).toInt() val lane = ((off.y - rulerHeightPx) / laneH).toInt()
.coerceIn(0, ArrModel.LANE_COUNT - 1) .coerceIn(0, ArrModel.LANE_COUNT - 1)
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1) val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
selLane0 = lane; selLane1 = lane vm.setArrangementSelection(lane, beat, lane, beat)
selBeat0 = beat; selBeat1 = beat
} }
} }
// Long-press then drag sweeps out a rectangular selection. // Long-press then drag sweeps out a rectangular selection.
@@ -240,14 +238,16 @@ fun ArrangementRoll(vm: AppViewModel) {
} }
fun beatAt(off: Offset): Int = fun beatAt(off: Offset): Int =
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1) (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
var dragLane0 = 0; var dragBeat0 = 0
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(
onDragStart = { off -> onDragStart = { off ->
selLane0 = laneAt(off); selLane1 = selLane0 dragLane0 = laneAt(off); dragBeat0 = beatAt(off)
selBeat0 = beatAt(off); selBeat1 = selBeat0 vm.setArrangementSelection(dragLane0, dragBeat0, dragLane0, dragBeat0)
}, },
onDrag = { change, _ -> onDrag = { change, _ ->
selLane1 = laneAt(change.position) vm.setArrangementSelection(
selBeat1 = beatAt(change.position) dragLane0, dragBeat0, laneAt(change.position), beatAt(change.position),
)
}, },
) )
}, },