Compare commits
4 Commits
96bbc5a4cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
924b84a4d5 | ||
|
|
7296a3a72b | ||
|
|
dfb6ee9c9b | ||
|
|
9fdef7fee7 |
@@ -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 = 44
|
versionCode = 48
|
||||||
versionName = "0.18.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"
|
||||||
|
|||||||
@@ -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 ----
|
||||||
|
|||||||
@@ -506,9 +506,34 @@ class AudioEngine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- transport
|
// --------------------------------------------------------------- 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()
|
rebuildArrangement()
|
||||||
rebuildFxChains()
|
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
|
pendingTrigger = true
|
||||||
playing = true
|
playing = true
|
||||||
maybeStartRecording()
|
maybeStartRecording()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -378,13 +397,42 @@ class AppViewModel(
|
|||||||
touched()
|
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
|
// ------------------------------------------------------- transport control
|
||||||
fun playPause() {
|
fun playPause() {
|
||||||
val t = transport.value
|
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. */
|
/** MIDI panic: silence all sounding/stuck notes immediately. */
|
||||||
fun midiPanic() { heldNotes.clear(); engine.panic() }
|
fun midiPanic() { heldNotes.clear(); engine.panic() }
|
||||||
@@ -499,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() }
|
||||||
@@ -533,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 ->
|
||||||
@@ -917,6 +1058,7 @@ class AppViewModel(
|
|||||||
fun loadProjectText(text: String) {
|
fun loadProjectText(text: String) {
|
||||||
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
|
space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
|
||||||
cursorLine = 0; cursorTrack = 0
|
cursorLine = 0; cursorTrack = 0
|
||||||
|
clearArrangementSelection() // the old arrangement's selection is no longer meaningful
|
||||||
touched()
|
touched()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,14 +100,13 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
val beatWidthPx = with(density) { beatWidth.toPx() }
|
val beatWidthPx = with(density) { beatWidth.toPx() }
|
||||||
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
val rulerHeightPx = with(density) { rulerHeight.toPx() }
|
||||||
|
|
||||||
// Rectangular cell selection: anchor (…0) + live corner (…1). Beat < 0 = none.
|
// Rectangular cell selection lives on the ViewModel so the transport (Play/Stop)
|
||||||
// Unlike before, selecting does NOT change any cell; the toolbar toggle acts
|
// can read and clear it — Play starts from the selection's first beat, Stop clears
|
||||||
// on the selection. Read in composition (for the toolbar's FILL/CLEAR label)
|
// it. Selecting still does NOT change any cell; the toolbar toggle acts on it.
|
||||||
// and captured by the Canvas draw below.
|
val selLane0 = vm.arrSelLane0
|
||||||
var selLane0 by remember { mutableIntStateOf(-1) }
|
val selBeat0 = vm.arrSelBeat0
|
||||||
var selBeat0 by remember { mutableIntStateOf(-1) }
|
val selLane1 = vm.arrSelLane1
|
||||||
var selLane1 by remember { mutableIntStateOf(-1) }
|
val selBeat1 = vm.arrSelBeat1
|
||||||
var selBeat1 by remember { mutableIntStateOf(-1) }
|
|
||||||
|
|
||||||
val hasSelection = selBeat0 >= 0
|
val hasSelection = selBeat0 >= 0
|
||||||
val laneRange = if (hasSelection) minOf(selLane0, selLane1)..maxOf(selLane0, selLane1) else IntRange.EMPTY
|
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()
|
val lane = ((off.y - rulerHeightPx) / laneH).toInt()
|
||||||
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
.coerceIn(0, ArrModel.LANE_COUNT - 1)
|
||||||
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
val beat = (off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||||
selLane0 = lane; selLane1 = lane
|
vm.setArrangementSelection(lane, beat, lane, beat)
|
||||||
selBeat0 = beat; selBeat1 = beat
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Long-press then drag sweeps out a rectangular selection.
|
// Long-press then drag sweeps out a rectangular selection.
|
||||||
@@ -240,14 +238,16 @@ fun ArrangementRoll(vm: AppViewModel) {
|
|||||||
}
|
}
|
||||||
fun beatAt(off: Offset): Int =
|
fun beatAt(off: Offset): Int =
|
||||||
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
(off.x / beatWidthPx).toInt().coerceIn(0, lengthBeats - 1)
|
||||||
|
var dragLane0 = 0; var dragBeat0 = 0
|
||||||
detectDragGesturesAfterLongPress(
|
detectDragGesturesAfterLongPress(
|
||||||
onDragStart = { off ->
|
onDragStart = { off ->
|
||||||
selLane0 = laneAt(off); selLane1 = selLane0
|
dragLane0 = laneAt(off); dragBeat0 = beatAt(off)
|
||||||
selBeat0 = beatAt(off); selBeat1 = selBeat0
|
vm.setArrangementSelection(dragLane0, dragBeat0, dragLane0, dragBeat0)
|
||||||
},
|
},
|
||||||
onDrag = { change, _ ->
|
onDrag = { change, _ ->
|
||||||
selLane1 = laneAt(change.position)
|
vm.setArrangementSelection(
|
||||||
selBeat1 = beatAt(change.position)
|
dragLane0, dragBeat0, laneAt(change.position), beatAt(change.position),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user