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:
Reactorcoremeltdown
2026-07-22 23:26:50 +02:00
parent 9fdef7fee7
commit dfb6ee9c9b
4 changed files with 39 additions and 19 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 = 46
versionName = "0.20.0"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

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

@@ -38,6 +38,9 @@ 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
class AppViewModel(
val project: Project,
private val engine: AudioEngine,
@@ -207,15 +210,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 +532,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() }