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>
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 46
|
||||
versionName = "0.20.0"
|
||||
versionCode = 47
|
||||
versionName = "0.21.0"
|
||||
|
||||
// We provide our own instrumentation runner if/when tests are added.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +44,11 @@ import kotlinx.coroutines.launch
|
||||
/** 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,
|
||||
@@ -160,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 {
|
||||
@@ -575,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 ->
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user