Compare commits

...

4 Commits

Author SHA1 Message Date
Reactorcoremeltdown
924b84a4d5 Keep MIDI input alive with the screen off / app backgrounded (v0.21.1)
MIDI (and the MIDI clock) were started in onStart and stopped in onStop, so
turning the screen off — which drives the Activity to onStop — closed the MIDI
ports and dropped controller input. Move their lifecycle to onCreate/onDestroy so
input persists for the Activity's whole lifetime; onStop keeps only the project
autosave. The audio engine already runs a continuous output stream via the started
PlaybackService, which keeps the process alive (verified at PERSISTENT_SERVICE_ADJ
when backgrounded) to receive events with the screen off. USB MIDI needs no runtime
permission, so it works unconditionally.

Trade-off: the controller stays claimed by the app while backgrounded, and is
released only when the Activity is torn down (task removed / finishing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:52:14 +02:00
Reactorcoremeltdown
7296a3a72b MIDI-learn: map controller knobs to any toolbox device parameter (v0.21.0)
Every instrument/effect editor gains a "⇄ MIDI" button in its toolbar that opens
a MIDI-learn popup listing the device's adjustable parameters. Tap LEARN on a row,
move a knob/fader on a connected MIDI controller, and its CC binds to that
parameter; the CC's 0..127 sweep then drives the parameter's full range live
(scaled to the spec range, rounded for integer params, nearest choice for enums).

Reuses the existing RawControl path — MidiInput already forwards unbound CCs — so
no parser change was needed. Bindings are stored in the slot's value map under a
reserved `midicc@<param>` key, so they travel with the project/preset and are
thread-safe via the slot's ConcurrentHashMap; one physical control drives one
parameter. Learn is auto-disarmed when the popup or editor closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:43:50 +02:00
Reactorcoremeltdown
dfb6ee9c9b Route MIDI notes by channel; punch-in from MIDI on the Tracker tab (v0.20.0)
Incoming USB/BLE MIDI notes were dropping their channel and always playing the
generic default-synth pool, ignoring the mixer routing. Now the parser keeps the
channel nibble (exposed 1-based, 1..16) on InputAction.NoteOn/NoteOff, and the
ViewModel routes a channelled note through engine.busNoteOn/busNoteOff — sounding
the mixer track(s) whose midiChannel matches, through their instrument and full
insert chain, exactly like the on-screen keyboard. Channel-less sources (physical
keyboard) keep the previous audition path.

On the Tracker tab with punch-in armed, a MIDI note now also punches into the
focused cell, recording its own velocity and channel and advancing by the skip
step (punchNote gained velocity/channel parameters). Off the tracker tab or with
punch-in off, MIDI only auditions.

Note: routing is strict, so a note is audible only on a track whose MIDI channel
matches and that has an instrument assigned (new projects start with buses on the
empty channel 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:26:50 +02:00
Reactorcoremeltdown
9fdef7fee7 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>
2026-07-21 18:04:47 +02:00
8 changed files with 316 additions and 44 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 44
versionName = "0.18.0"
versionCode = 48
versionName = "0.21.1"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -75,10 +75,13 @@ class MainActivity : ComponentActivity() {
SizzleAppUi(viewModel)
}
}
}
override fun onStart() {
super.onStart()
// Start MIDI listening (USB + BLE) and the MIDI clock here — bound to the
// Activity's WHOLE lifetime (onCreate..onDestroy), not onStart/onStop — so a
// connected controller keeps driving the app while the screen is off. The
// audio engine already runs a continuous output stream via the started
// PlaybackService, which keeps the process alive to receive those events in
// the background; closing the ports on onStop was the only reason input died.
midi.start()
app.midiClock.start()
}
@@ -93,11 +96,19 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
// Autosave the project whenever we leave the foreground, so a later
// system-kill (or crash) resumes from here on next launch.
// system-kill (or crash) resumes from here on next launch. MIDI is
// deliberately NOT stopped here (see onCreate / onDestroy) so controllers
// keep working with the screen off or the app backgrounded.
app.projectStore.save(app.project)
super.onStop()
}
override fun onDestroy() {
// Release MIDI only when the Activity is actually torn down (task removed or
// finishing), not merely backgrounded / screen-off.
midi.stop()
app.midiClock.stop()
super.onStop()
super.onDestroy()
}
// ---- Hardware input forwarding ----

View File

@@ -506,9 +506,34 @@ class AudioEngine(
}
// --------------------------------------------------------------- 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()
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
playing = true
maybeStartRecording()

View File

@@ -33,8 +33,12 @@ sealed interface InputAction {
data object NoteOffCell : InputAction
// ----- Live note entry (also used to audition instruments) -----
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction
data class NoteOff(val pitch: Int) : InputAction
// [channel] is the 1-based MIDI channel (1..16) for MIDI input, so notes can be
// routed to the mixer track(s) listening on it; it is -1 when the source has no
// channel of its own (physical / on-screen keyboard), which falls back to the
// on-screen keyboard's channel.
data class NoteOn(val pitch: Int, val velocity: Int, val channel: Int = -1) : InputAction
data class NoteOff(val pitch: Int, val channel: Int = -1) : InputAction
// ----- Transport -----
data object PlayPause : InputAction

View File

@@ -74,16 +74,19 @@ class MidiInput(
// anywhere; forward them (clock / start / stop) and move on one byte.
if (status >= 0xF8) { onRealtime?.invoke(status); i++; continue }
val type = status and 0xF0
// Low nibble is the MIDI channel (0..15); expose it 1-based (1..16) so
// it matches the mixer channels' `midiChannel` for routing.
val channel = (status and 0x0F) + 1
when (type) {
0x90 -> { // Note On
val note = data.getOrZero(i + 1)
val vel = data.getOrZero(i + 2)
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel, channel))
else router.dispatch(InputAction.NoteOff(note, channel)) // vel 0 == note off
i += 3
}
0x80 -> { // Note Off
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1), channel)); i += 3
}
0xB0 -> { // Control Change
val cc = data.getOrZero(i + 1)

View File

@@ -15,10 +15,13 @@ import space.rcmd.android.sizzle.input.InputAction
import space.rcmd.android.sizzle.input.InputRouter
import space.rcmd.android.sizzle.model.Cell
import space.rcmd.android.sizzle.model.CellColumn
import space.rcmd.android.sizzle.model.ParamSpec
import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature
import space.rcmd.android.sizzle.model.ToolboxSlot
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -38,6 +41,14 @@ import kotlinx.coroutines.launch
* performance, so after we mutate the grid we bump [revision]. Compose reads that
* value while drawing the grid, so an increment forces a redraw. Simple and fast.
*/
/** Tab index of the Tracker tab (see [App]'s tab order). */
private const val TAB_TRACKER = 0
/** Reserved prefix for a toolbox slot's MIDI-CC parameter bindings, stored in the
* slot's value map (`midicc@<paramKey>` -> CC number) so they persist with the
* project / preset like any other parameter and never collide with a real key. */
private const val CC_PREFIX = "midicc@"
class AppViewModel(
val project: Project,
private val engine: AudioEngine,
@@ -157,6 +168,13 @@ class AppViewModel(
var learnTarget by mutableStateOf<String?>(null); private set
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null
// ----- Toolbox parameter MIDI-learn state (device editors) -----
/** The toolbox slot + parameter key currently waiting to capture a MIDI control,
* or (-1 / null) when nothing is arming. The device editor's MIDI-learn popup
* reads these to highlight the armed row. */
var paramLearnSlot by mutableIntStateOf(-1); private set
var paramLearnKey by mutableStateOf<String?>(null); private set
init {
// Collect neutral input actions from every non-touch device.
viewModelScope.launch {
@@ -207,15 +225,16 @@ class AppViewModel(
cursorColumn = CellColumn.entries[flat % cols]
}
/** Punch a note into the focused cell (velocity/channel from the keyboard) and
* advance the cursor by [skipStep] rows, cycling past the pattern ends — the
* touch-keyboard entry path. */
fun punchNote(midi: Int) {
/** Punch a note into the focused cell and advance the cursor by [skipStep] rows,
* cycling past the pattern ends. Velocity/channel default to the on-screen
* keyboard's (the touch-keyboard entry path); MIDI punch-in passes the incoming
* note's own velocity and channel so the recorded cell plays back correctly. */
fun punchNote(midi: Int, velocity: Int = kbdVelocity, channel: Int = kbdChannel) {
val cell = focusedCell()
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = kbdChannel
lastNote = cell.note; lastChannel = kbdChannel
cell.velocity = velocity.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = channel
lastNote = cell.note; lastChannel = channel
moveCursorVerticalWrap(skipStep)
touched()
}
@@ -378,13 +397,42 @@ class AppViewModel(
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
fun playPause() {
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. */
fun midiPanic() { heldNotes.clear(); engine.panic() }
@@ -499,13 +547,22 @@ class AppViewModel(
InputAction.NoteOffCell -> noteOffFocused()
is InputAction.NoteOn -> {
markNoteOn(action.pitch)
engine.liveNoteOn(action.pitch, action.velocity)
// Live entry also writes the note into the focused NOTE cell.
if (cursorColumn == CellColumn.NOTE) {
focusedCell().note = action.pitch; lastNote = action.pitch; touched()
// MIDI notes route to the mixer track(s) listening on their channel;
// a channel-less source (physical keyboard) plays the default synth.
if (action.channel >= 1) engine.busNoteOn(action.channel, action.pitch, action.velocity)
else engine.liveNoteOn(action.pitch, action.velocity)
// On the Tracker tab with punch-in armed, also enter the note into the
// grid — recording its own velocity/channel so it plays back correctly.
if (selectedTab == TAB_TRACKER && punchInMode) {
val ch = if (action.channel >= 1) action.channel else kbdChannel
punchNote(action.pitch, action.velocity, ch)
}
}
is InputAction.NoteOff -> { markNoteOff(action.pitch); engine.liveNoteOff(action.pitch) }
is InputAction.NoteOff -> {
markNoteOff(action.pitch)
if (action.channel >= 1) engine.busNoteOff(action.channel, action.pitch)
else engine.liveNoteOff(action.pitch)
}
InputAction.PlayPause -> playPause()
InputAction.Stop -> stop()
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
@@ -533,11 +590,95 @@ class AppViewModel(
router.learnMode = false
}
// ---------------------------------------------- toolbox parameter MIDI-learn
private fun ccKey(paramKey: String) = "$CC_PREFIX$paramKey"
/** The CC number currently bound to [slotIndex]'s [paramKey], or null. */
fun paramCcFor(slotIndex: Int, paramKey: String): Int? =
project.toolbox.getOrNull(slotIndex)?.string(ccKey(paramKey))?.toIntOrNull()
/** Arm MIDI-learn for a toolbox parameter: the next MIDI control moved binds to it. */
fun beginParamLearn(slotIndex: Int, paramKey: String) {
cancelLearn() // don't clash with a Settings nav/transport learn
paramLearnSlot = slotIndex
paramLearnKey = paramKey
router.learnSource = space.rcmd.android.sizzle.input.InputSource.MIDI
router.learnMode = true
touched()
}
fun cancelParamLearn() {
paramLearnSlot = -1
paramLearnKey = null
router.learnSource = null
router.learnMode = false
touched()
}
/** Remove the MIDI-CC binding for [slotIndex]'s [paramKey]. */
fun clearParamCc(slotIndex: Int, paramKey: String) {
project.toolbox.getOrNull(slotIndex)?.values?.remove(ccKey(paramKey))
touched()
}
/** Bind [cc] to [slotIndex]'s [paramKey]. A physical control drives one parameter,
* so the CC is first removed from any parameter it was previously bound to. */
private fun bindParamCc(slotIndex: Int, paramKey: String, cc: Int) {
for (s in project.toolbox) {
s.values.entries.removeAll { it.key.startsWith(CC_PREFIX) && it.value.toIntOrNull() == cc }
}
project.toolbox.getOrNull(slotIndex)?.set(ccKey(paramKey), cc.toString())
touched()
}
/** Drive every toolbox parameter bound to [cc] from a normalized ([0,1]) value. */
private fun applyParamCc(cc: Int, norm: Float) {
var changed = false
for (slot in project.toolbox) {
val type = slot.type ?: continue
for (spec in type.params) {
if (slot.values[ccKey(spec.key)]?.toIntOrNull() != cc) continue
applyNormToParam(slot, spec, norm)
changed = true
}
}
if (changed) touched()
}
private fun applyNormToParam(slot: ToolboxSlot, spec: ParamSpec, norm: Float) {
val n = norm.coerceIn(0f, 1f)
if (spec.isEnum) {
val idx = (n * (spec.choices.size - 1)).roundToInt().coerceIn(0, spec.choices.lastIndex)
slot.set(spec.key, spec.choices[idx])
} else {
var v = spec.min + n * (spec.max - spec.min)
if (spec.isInt) v = v.roundToInt().toFloat()
slot.set(spec.key, v)
}
}
/** A control arrived during learn. If it matches the armed source, store it as
* the binding for [learnTarget]. Each action keeps at most one binding, so any
* previous one for this action (on the same device) is replaced. */
private fun onRawControl(action: InputAction.RawControl) {
val target = learnTarget ?: return
// Toolbox parameter MIDI-learn takes priority: the first MIDI control captured
// binds to the armed parameter.
val plKey = paramLearnKey
if (plKey != null) {
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
bindParamCc(paramLearnSlot, plKey, action.code)
cancelParamLearn()
}
return
}
val target = learnTarget
if (target == null) {
// Not learning: a MIDI CC bound to a toolbox parameter drives it live.
if (action.source == space.rcmd.android.sizzle.input.InputSource.MIDI) {
applyParamCc(action.code, action.value)
}
return
}
if (action.source != learnSource) return
when (action.source) {
space.rcmd.android.sizzle.input.InputSource.MIDI ->
@@ -917,6 +1058,7 @@ class AppViewModel(
fun loadProjectText(text: String) {
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
cursorLine = 0; cursorTrack = 0
clearArrangementSelection() // the old arrangement's selection is no longer meaningful
touched()
}

View File

@@ -39,6 +39,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -299,6 +300,8 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER
// Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree.
var samplerPad by remember(slot.index) { mutableIntStateOf(0) }
// Whether the MIDI-learn popup (map controls to this device's parameters) is open.
var showMidiLearn by remember(slot.index) { mutableStateOf(false) }
Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
Column(Modifier.fillMaxSize()) {
@@ -312,8 +315,13 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
// Right-side toolbar: MIDI-learn (map controls to params) + close.
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Text("⇄ MIDI", color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { showMidiLearn = true } })
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
}
}
// Preset browser: load / save / import / share for this device.
@@ -349,6 +357,85 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
}
}
}
if (showMidiLearn) {
MidiLearnPopup(vm, slot, onClose = { showMidiLearn = false })
}
}
}
/**
* Maps a MIDI controller's knobs/faders to this device's adjustable parameters.
* Lists every [ParamSpec] of the slot's device; tap LEARN on a row, then move a
* control on the connected MIDI device and its CC binds to that parameter (the CC's
* 0..127 sweep then drives the parameter's full range live). The binding is stored
* in the slot (so it travels with the project / preset); ✕ clears it.
*/
@Composable
private fun MidiLearnPopup(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit) {
val c = LocalRetro.current
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh CC readouts + armed row
val type = slot.type ?: return
// Always disarm learn when the popup leaves the composition (closed / editor exited).
DisposableEffect(slot.index) { onDispose { vm.cancelParamLearn() } }
Box(
Modifier.fillMaxSize().background(c.background.copy(alpha = 0.92f))
.pointerInput(Unit) { detectTapGestures { onClose() } },
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.fillMaxWidth(0.96f)
.fillMaxHeight(0.9f)
.border(1.dp, c.accent, RectangleShape)
.background(c.surface)
.pointerInput(Unit) { detectTapGestures { } } // swallow taps inside the card
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("MIDI LEARN — ${slot.name}", color = c.accent,
fontFamily = FontFamily.Monospace, fontSize = 14.sp)
Text("DONE", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp,
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } })
}
Text(
"Tap LEARN, then move a knob/fader on your MIDI controller.",
color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 10.sp,
)
Column(
Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
type.params.forEach { spec ->
val arming = vm.paramLearnSlot == slot.index && vm.paramLearnKey == spec.key
val cc = vm.paramCcFor(slot.index, spec.key)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(spec.label, color = c.text, fontFamily = FontFamily.Monospace, fontSize = 12.sp,
modifier = Modifier.weight(1f))
Text(
when {
arming -> "waiting…"
cc != null -> "CC $cc"
else -> ""
},
color = if (arming) c.accent else c.textDim,
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
modifier = Modifier.width(72.dp),
)
RetroButton(if (arming) "ARMING" else "LEARN", active = arming, width = 78.dp) {
if (arming) vm.cancelParamLearn() else vm.beginParamLearn(slot.index, spec.key)
}
RetroButton("") { vm.clearParamCc(slot.index, spec.key) }
}
}
}
}
}
}

View File

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