diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 25b7c25..766d19c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index ea5ceb6..5e2e12d 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -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@` -> 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(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(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 -> diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt index 601a145..34a277a 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/toolbox/ToolboxScreen.kt @@ -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) } + } + } + } + } } }