diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b0187a..25b7c25 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt b/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt index e5b45a4..a3e8693 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/input/InputAction.kt @@ -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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt b/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt index 3d64daa..0ee6771 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/input/MidiInput.kt @@ -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) diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt index 2ff9895..ea5ceb6 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/AppViewModel.kt @@ -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() }