Compare commits

..

3 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
6 changed files with 244 additions and 27 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 45
versionName = "0.19.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

@@ -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()
}
@@ -528,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() }
@@ -562,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 ->

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) }
}
}
}
}
}
}