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. // (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 45 versionCode = 48
versionName = "0.19.0" versionName = "0.21.1"
// 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

@@ -75,10 +75,13 @@ class MainActivity : ComponentActivity() {
SizzleAppUi(viewModel) SizzleAppUi(viewModel)
} }
} }
}
override fun onStart() { // Start MIDI listening (USB + BLE) and the MIDI clock here — bound to the
super.onStart() // 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() midi.start()
app.midiClock.start() app.midiClock.start()
} }
@@ -93,11 +96,19 @@ class MainActivity : ComponentActivity() {
override fun onStop() { override fun onStop() {
// Autosave the project whenever we leave the foreground, so a later // 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) 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() midi.stop()
app.midiClock.stop() app.midiClock.stop()
super.onStop() super.onDestroy()
} }
// ---- Hardware input forwarding ---- // ---- Hardware input forwarding ----

View File

@@ -33,8 +33,12 @@ sealed interface InputAction {
data object NoteOffCell : InputAction data object NoteOffCell : InputAction
// ----- Live note entry (also used to audition instruments) ----- // ----- Live note entry (also used to audition instruments) -----
data class NoteOn(val pitch: Int, val velocity: Int) : InputAction // [channel] is the 1-based MIDI channel (1..16) for MIDI input, so notes can be
data class NoteOff(val pitch: Int) : InputAction // 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 ----- // ----- Transport -----
data object PlayPause : InputAction data object PlayPause : InputAction

View File

@@ -74,16 +74,19 @@ class MidiInput(
// anywhere; forward them (clock / start / stop) and move on one byte. // anywhere; forward them (clock / start / stop) and move on one byte.
if (status >= 0xF8) { onRealtime?.invoke(status); i++; continue } if (status >= 0xF8) { onRealtime?.invoke(status); i++; continue }
val type = status and 0xF0 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) { when (type) {
0x90 -> { // Note On 0x90 -> { // Note On
val note = data.getOrZero(i + 1) val note = data.getOrZero(i + 1)
val vel = data.getOrZero(i + 2) val vel = data.getOrZero(i + 2)
if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel)) if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel, channel))
else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off else router.dispatch(InputAction.NoteOff(note, channel)) // vel 0 == note off
i += 3 i += 3
} }
0x80 -> { // Note Off 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 0xB0 -> { // Control Change
val cc = data.getOrZero(i + 1) 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.input.InputRouter
import space.rcmd.android.sizzle.model.Cell import space.rcmd.android.sizzle.model.Cell
import space.rcmd.android.sizzle.model.CellColumn 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.Pattern
import space.rcmd.android.sizzle.model.Pitch import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature 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.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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 * 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. * 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( class AppViewModel(
val project: Project, val project: Project,
private val engine: AudioEngine, private val engine: AudioEngine,
@@ -157,6 +168,13 @@ class AppViewModel(
var learnTarget by mutableStateOf<String?>(null); private set var learnTarget by mutableStateOf<String?>(null); private set
private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null 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 { init {
// Collect neutral input actions from every non-touch device. // Collect neutral input actions from every non-touch device.
viewModelScope.launch { viewModelScope.launch {
@@ -207,15 +225,16 @@ class AppViewModel(
cursorColumn = CellColumn.entries[flat % cols] cursorColumn = CellColumn.entries[flat % cols]
} }
/** Punch a note into the focused cell (velocity/channel from the keyboard) and /** Punch a note into the focused cell and advance the cursor by [skipStep] rows,
* advance the cursor by [skipStep] rows, cycling past the pattern ends — the * cycling past the pattern ends. Velocity/channel default to the on-screen
* touch-keyboard entry path. */ * keyboard's (the touch-keyboard entry path); MIDI punch-in passes the incoming
fun punchNote(midi: Int) { * 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() val cell = focusedCell()
cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST) cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY) cell.velocity = velocity.coerceIn(0, Cell.MAX_VELOCITY)
cell.channel = kbdChannel cell.channel = channel
lastNote = cell.note; lastChannel = kbdChannel lastNote = cell.note; lastChannel = channel
moveCursorVerticalWrap(skipStep) moveCursorVerticalWrap(skipStep)
touched() touched()
} }
@@ -528,13 +547,22 @@ class AppViewModel(
InputAction.NoteOffCell -> noteOffFocused() InputAction.NoteOffCell -> noteOffFocused()
is InputAction.NoteOn -> { is InputAction.NoteOn -> {
markNoteOn(action.pitch) markNoteOn(action.pitch)
engine.liveNoteOn(action.pitch, action.velocity) // MIDI notes route to the mixer track(s) listening on their channel;
// Live entry also writes the note into the focused NOTE cell. // a channel-less source (physical keyboard) plays the default synth.
if (cursorColumn == CellColumn.NOTE) { if (action.channel >= 1) engine.busNoteOn(action.channel, action.pitch, action.velocity)
focusedCell().note = action.pitch; lastNote = action.pitch; touched() 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.PlayPause -> playPause()
InputAction.Stop -> stop() InputAction.Stop -> stop()
InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() } InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
@@ -562,11 +590,95 @@ class AppViewModel(
router.learnMode = false 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 /** 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 * the binding for [learnTarget]. Each action keeps at most one binding, so any
* previous one for this action (on the same device) is replaced. */ * previous one for this action (on the same device) is replaced. */
private fun onRawControl(action: InputAction.RawControl) { 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 if (action.source != learnSource) return
when (action.source) { when (action.source) {
space.rcmd.android.sizzle.input.InputSource.MIDI -> 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -299,6 +300,8 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER val pinnedBottom = pinnedKeyboard || type == ToolboxType.SAMPLER
// Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree. // Selected pad, hoisted so the scrolling detail area and the pinned pad grid agree.
var samplerPad by remember(slot.index) { mutableIntStateOf(0) } 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))) { Box(Modifier.fillMaxSize().background(c.background.copy(alpha = 0.96f))) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -312,8 +315,13 @@ private fun ParamEditor(vm: AppViewModel, slot: ToolboxSlot, onClose: () -> Unit
) { ) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp) Text(slot.name, color = c.accent, fontFamily = FontFamily.Monospace, fontSize = 15.sp)
Text("CLOSE ✕", color = c.textDim, fontFamily = FontFamily.Monospace, fontSize = 13.sp, // Right-side toolbar: MIDI-learn (map controls to params) + close.
modifier = Modifier.pointerInput(Unit) { detectTapGestures { onClose() } }) 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. // 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) }
}
}
}
}
} }
} }