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. // (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"

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

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