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>
This commit is contained in:
@@ -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 = 46
|
||||||
versionName = "0.19.0"
|
versionName = "0.20.0"
|
||||||
|
|
||||||
// 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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ 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
|
||||||
|
|
||||||
class AppViewModel(
|
class AppViewModel(
|
||||||
val project: Project,
|
val project: Project,
|
||||||
private val engine: AudioEngine,
|
private val engine: AudioEngine,
|
||||||
@@ -207,15 +210,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 +532,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() }
|
||||||
|
|||||||
Reference in New Issue
Block a user