MIDI clock sync + note output over USB/BLE (v0.18.0)

Add a MIDI Sync settings card (before the binding cards) with Send and Receive
MIDI clock toggles, both off by default and persisted.

Send: a dedicated thread emits clock (0xF8) at 24 PPQN plus Start/Stop on the
transport edges to every attached MIDI device (USB + Bluetooth), and — because a
sequencer should drive gear, not just its tempo — also forwards the tracks'
note-on/off on each cell's MIDI channel. Notes are queued from the audio thread
through a lock-free ring and drained by the send thread, mirroring the mono-per-
track cell lifecycle; stop/pause/panic release everything so external notes never
hang. (Per-channel Arpeggiator/Transposer are internal FX and aren't reflected.)

Receive: MidiInput now forwards real-time bytes to the clock module, which follows
an external clock — Start/Continue starts the transport, Stop pauses it, and the
pulse rate drives the tempo (a tempo + transport follow, not a sample-locked slave).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-21 12:10:40 +02:00
parent 682418d69e
commit 96bbc5a4cc
9 changed files with 347 additions and 18 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26
targetSdk = 34
versionCode = 42
versionName = "0.16.1"
versionCode = 44
versionName = "0.18.0"
// We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -52,7 +52,7 @@ class MainActivity : ComponentActivity() {
app.project, app.audioEngine, app.inputRouter,
app.gamepadInput, app.midiInput, app.keyboardInput,
app.bindingStore, app.presetLibrary, app.songLibrary,
app.settingsStore, app.themeLibrary, app.applicationContext,
app.settingsStore, app.themeLibrary, app.midiClock, app.applicationContext,
)
}
},
@@ -80,6 +80,7 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
midi.start()
app.midiClock.start()
}
override fun onResume() {
@@ -95,6 +96,7 @@ class MainActivity : ComponentActivity() {
// system-kill (or crash) resumes from here on next launch.
app.projectStore.save(app.project)
midi.stop()
app.midiClock.stop()
super.onStop()
}

View File

@@ -45,6 +45,11 @@ class SizzleApp : Application() {
val gamepadInput: GamepadInput by lazy { GamepadInput(inputRouter) }
val midiInput: MidiInput by lazy { MidiInput(this, inputRouter) }
/** MIDI clock sync (send/receive) over USB + Bluetooth MIDI. */
val midiClock: space.rcmd.android.sizzle.input.MidiClock by lazy {
space.rcmd.android.sizzle.input.MidiClock(this)
}
/** Persists the input binding maps across restarts (DataStore-backed). */
val bindingStore: BindingStore by lazy { BindingStore(this) }
@@ -80,6 +85,8 @@ class SizzleApp : Application() {
// single small read; blocking briefly at cold start keeps the input maps
// authoritative from the first keypress rather than racing an async load.
runBlocking { bindingStore.loadInto(keyboardInput, gamepadInput, midiInput) }
// Feed incoming MIDI real-time (clock / start / stop) to the clock sync module.
midiInput.onRealtime = midiClock::handleRealtime
installCrashAutosave()
}

View File

@@ -88,6 +88,19 @@ class AudioEngine(
// track last started, on the bus(es) that channel routed to. -1 = nothing yet.
private val trackLastChannel = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }
// ---- outgoing MIDI (send the sequenced notes to external gear) ----
// The sequencer runs on the audio thread, which must not do MIDI I/O; note events
// are packed into a lock-free single-producer/single-consumer ring that the MIDI
// clock thread drains via [pollMidiOut]. Only filled when [midiOutEnabled]. Each
// slot packs (status<<16)|(data1<<8)|data2. Note-on/off mirror the tracks' cells
// (mono per track, matching the internal note lifecycle) on the cell's MIDI channel.
@Volatile var midiOutEnabled = false
private val moutRing = IntArray(256)
@Volatile private var moutHead = 0 // producer (audio thread)
@Volatile private var moutTail = 0 // consumer (MIDI clock thread)
private val moutNote = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT) { -1 }
private val moutCh = IntArray(Arrangement.LANE_COUNT * Pattern.TRACK_COUNT)
// Cached subtractive-synth patch per bus, reparsed only when the slot changes
// (version) or tempo changes — so note triggers don't allocate a patch each time,
// and active voices can be refreshed per block for live parameter feedback.
@@ -520,6 +533,7 @@ class AudioEngine(
busSampleVoices.forEach { it.kill() }
arps.forEach { it.stop() }
trackLastChannel.fill(-1)
allMidiNotesOff() // release any notes sent to external gear
}
/** Stop playback. If already stopped, rewind to the very beginning. */
@@ -870,6 +884,8 @@ class AudioEngine(
private fun triggerCell(proj: Project, lane: Int, track: Int, cell: space.rcmd.android.sizzle.model.Cell) {
val trackKey = lane * Pattern.TRACK_COUNT + track
if (cell.isNoteOff) {
// Release this track's outgoing MIDI note first (external gear).
if (moutNote[trackKey] >= 0) { emitNoteOff(moutCh[trackKey], moutNote[trackKey]); moutNote[trackKey] = -1 }
val ch = trackLastChannel[trackKey]
if (ch < 0) return // this track hasn't played anything yet
for (bus in 0 until Pattern.TRACK_COUNT) {
@@ -882,6 +898,13 @@ class AudioEngine(
if (!cell.isPlayable) return
// Remember the channel so a later note-off on this track releases it.
trackLastChannel[trackKey] = cell.channel
// Send this track's note out to external gear (mono per track): off the note it
// was holding, then on the new one, on the cell's MIDI channel. Mirrors the
// tracks' cells — a channel's Arpeggiator/Transposer are internal FX and aren't
// reflected here.
if (moutNote[trackKey] >= 0) emitNoteOff(moutCh[trackKey], moutNote[trackKey])
emitNoteOn(cell.channel, cell.note, cell.velocity)
moutNote[trackKey] = cell.note; moutCh[trackKey] = cell.channel
// Mono-per-track: a new note releases whatever this track was still holding,
// so a held note is stopped when the next one appears under the playhead — no
// explicit note-off cell needed. Only this track's own voices are released, so
@@ -1382,6 +1405,45 @@ class AudioEngine(
seqVoices.forEach { it.noteOff() }
sampleVoices.forEach { it.noteOff() }
arps.forEach { it.stop() }
allMidiNotesOff() // release any notes we sent to external gear
}
// ------------------------------------------------------------- outgoing MIDI
/** Enqueue one MIDI message for the send thread (audio-thread producer, no alloc). */
private fun emitMidi(status: Int, d1: Int, d2: Int) {
if (!midiOutEnabled) return
val h = moutHead
val next = (h + 1) and (moutRing.size - 1)
if (next == moutTail) return // ring full — drop rather than block the audio thread
moutRing[h] = (status shl 16) or ((d1 and 0x7F) shl 8) or (d2 and 0x7F)
moutHead = next
}
/** Send a note-on for [note] on app channel [ch] (1..16 → MIDI wire 0..15). */
private fun emitNoteOn(ch: Int, note: Int, vel: Int) {
if (ch < 1) return
emitMidi(0x90 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), vel.coerceIn(1, 127))
}
private fun emitNoteOff(ch: Int, note: Int) {
if (ch < 1) return
emitMidi(0x80 or ((ch - 1) and 0x0F), note.coerceIn(0, 127), 0)
}
/** Release every note we've sent out (on transport stop / pause / panic) so an
* external instrument never hangs after Sizzle stops. */
private fun allMidiNotesOff() {
for (k in moutNote.indices) if (moutNote[k] >= 0) { emitNoteOff(moutCh[k], moutNote[k]); moutNote[k] = -1 }
}
/** Drain one queued outgoing MIDI message, or -1 if the queue is empty. Called by
* the MIDI clock thread (single consumer). */
fun pollMidiOut(): Int {
val t = moutTail
if (t == moutHead) return -1
val v = moutRing[t]
moutTail = (t + 1) and (moutRing.size - 1)
return v
}
private fun publish() {

View File

@@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle.input
import android.content.Context
import android.media.midi.MidiDeviceInfo
import android.media.midi.MidiInputPort
import android.media.midi.MidiManager
import android.os.Handler
import android.os.Looper
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.locks.LockSupport
/**
* MIDI clock sync over every attached MIDI device (USB *and* Bluetooth — Android
* surfaces both through the same [MidiManager]).
*
* SEND (drive external gear): while [sendEnabled] and the transport is playing, a
* dedicated thread emits the MIDI real-time clock (0xF8) at 24 PPQN from the current
* tempo, plus Start (0xFA) / Stop (0xFC) on the play/stop edges, to every device we
* can write to. RECEIVE (be driven): [handleRealtime] — fed the real-time status
* bytes by [MidiInput] — follows an external clock, calling back Start / Stop and a
* tempo derived from the incoming pulse rate. Receive is a tempo + transport follow,
* not a sample-locked slave.
*
* Both directions are off until enabled; nothing is sent or acted on otherwise.
*/
class MidiClock(context: Context) {
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
private val handler = Handler(Looper.getMainLooper())
private val outPorts = CopyOnWriteArrayList<MidiInputPort>() // device input ports we WRITE to
@Volatile var sendEnabled = false
@Volatile var receiveEnabled = false
/** Live transport state for the send thread, supplied by the ViewModel. */
var playingProvider: () -> Boolean = { false }
var bpmProvider: () -> Float = { 120f }
/** Source of queued outgoing note messages (packed (status<<16)|(d1<<8)|d2), or -1
* when empty — drained by the send thread while [sendEnabled]. Set by the ViewModel
* to the audio engine's queue, so Sizzle's sequenced notes reach external gear. */
var noteSource: (() -> Int)? = null
/** Receive callbacks (invoked on the MIDI handler thread; the ViewModel hops to
* the main thread as needed). */
var onReceiveStart: (() -> Unit)? = null
var onReceiveStop: (() -> Unit)? = null
var onReceiveTempo: ((Float) -> Unit)? = null
private var thread: Thread? = null
@Volatile private var running = false
fun start() {
val mm = midiManager ?: return
mm.devices.forEach(::openDevice)
mm.registerDeviceCallback(object : MidiManager.DeviceCallback() {
override fun onDeviceAdded(device: MidiDeviceInfo) = openDevice(device)
}, handler)
running = true
thread = Thread({ sendLoop() }, "sizzle-midi-clock").apply {
priority = Thread.MAX_PRIORITY; isDaemon = true; start()
}
}
fun stop() {
running = false
thread?.join(200); thread = null
outPorts.forEach { runCatching { it.close() } }
outPorts.clear()
}
private fun openDevice(info: MidiDeviceInfo) {
if (info.inputPortCount == 0) return // need a port we can send TO
midiManager?.openDevice(info, { device ->
device?.openInputPort(0)?.let { outPorts += it }
}, handler)
}
private fun sendAll(status: Byte) {
val msg = byteArrayOf(status)
for (p in outPorts) runCatching { p.send(msg, 0, 1) }
}
/** Send one packed 3-byte channel message (note-on/off) to every device. */
private fun sendPacked(m: Int) {
val msg = byteArrayOf(
((m shr 16) and 0xFF).toByte(), ((m shr 8) and 0x7F).toByte(), (m and 0x7F).toByte(),
)
for (p in outPorts) runCatching { p.send(msg, 0, 3) }
}
/** Drain and send any queued sequenced notes (bounded so a flood can't stall). */
private fun drainNotes() {
val src = noteSource ?: return
var guard = 0
var m = src()
while (m >= 0 && guard++ < moutDrainMax) { sendPacked(m); m = src() }
}
/** Generates the outgoing clock. Polls at 0.5 ms so pulse timing is steady enough
* for typical gear; sends Start/Stop on the transport's play/stop edges. */
private fun sendLoop() {
var lastPlaying = false
var nextPulse = 0L
while (running) {
if (sendEnabled) {
val playing = playingProvider()
val now = System.nanoTime()
if (playing && !lastPlaying) { sendAll(START); nextPulse = now; lastPlaying = true }
else if (!playing && lastPlaying) { sendAll(STOP); lastPlaying = false }
if (playing && now >= nextPulse) {
sendAll(CLOCK)
val bpm = bpmProvider().coerceIn(20f, 300f)
val interval = (60_000_000_000.0 / (bpm * 24.0)).toLong()
nextPulse += interval
if (nextPulse < now) nextPulse = now + interval // recover from any lag
}
drainNotes() // forward Sizzle's sequenced notes to external gear
} else if (lastPlaying) {
sendAll(STOP); lastPlaying = false // stop cleanly if send is switched off mid-play
}
// 0.5 ms when sending (steady pulse timing); relax to 10 ms when idle.
LockSupport.parkNanos(if (sendEnabled) 500_000L else 10_000_000L)
}
if (lastPlaying) sendAll(STOP)
}
// ---- receive: fed the real-time status bytes by MidiInput ----
private var lastPulseNs = 0L
private var pulseAccum = 0.0
private var pulseCount = 0
fun handleRealtime(status: Int) {
if (!receiveEnabled) return
when (status) {
0xFA, 0xFB -> { resetTempoEstimate(); onReceiveStart?.invoke() } // Start / Continue
0xFC -> onReceiveStop?.invoke() // Stop
0xF8 -> onClockPulse() // Clock
}
}
private fun resetTempoEstimate() { lastPulseNs = 0L; pulseAccum = 0.0; pulseCount = 0 }
/** Derive tempo from the incoming pulse rate, averaged over a quarter note (24
* pulses) to smooth jitter. */
private fun onClockPulse() {
val now = System.nanoTime()
if (lastPulseNs != 0L) {
pulseAccum += (now - lastPulseNs)
if (++pulseCount >= 24) {
val avgNs = pulseAccum / pulseCount
val bpm = (60_000_000_000.0 / (avgNs * 24.0)).toFloat()
if (bpm.isFinite() && bpm in 20f..300f) onReceiveTempo?.invoke(bpm)
pulseAccum = 0.0; pulseCount = 0
}
}
lastPulseNs = now
}
private val moutDrainMax = 256 // cap notes sent per poll so a flood can't stall the loop
private companion object {
val CLOCK: Byte = 0xF8.toByte()
val START: Byte = 0xFA.toByte()
val STOP: Byte = 0xFC.toByte()
}
}

View File

@@ -29,6 +29,9 @@ class MidiInput(
/** CC number -> action id, editable from Settings (MIDI bindings section). */
var ccBindings: MutableMap<Int, String> = mutableMapOf(),
) {
/** Sink for MIDI System Real-Time status bytes (0xF8..0xFF), notably clock (0xF8)
* and Start/Continue/Stop (0xFA/0xFB/0xFC) — wired to [MidiClock] for clock sync. */
var onRealtime: ((Int) -> Unit)? = null
private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
private val handler = Handler(Looper.getMainLooper())
private val openPorts = mutableListOf<MidiOutputPort>()
@@ -67,6 +70,9 @@ class MidiInput(
while (i < end) {
val status = data[i].toInt() and 0xFF
if (status < 0x80) { i++; continue } // skip stray data bytes
// System Real-Time (0xF8..0xFF) are single-byte and may be interleaved
// anywhere; forward them (clock / start / stop) and move on one byte.
if (status >= 0xF8) { onRealtime?.invoke(status); i++; continue }
val type = status and 0xF0
when (type) {
0x90 -> { // Note On

View File

@@ -29,6 +29,10 @@ data class AppSettings(
val kbdOctave: Int = 3,
/** Show the live VU meters inside the mixer volume faders. */
val vuMeters: Boolean = true,
/** Send MIDI clock + start/stop to attached MIDI devices (drive external gear). */
val sendMidiClock: Boolean = false,
/** Follow an external MIDI clock's tempo + start/stop. */
val receiveMidiClock: Boolean = false,
)
/**
@@ -51,6 +55,8 @@ class SettingsStore(private val context: Context) {
kbdChannel = prefs[KBD_CHANNEL] ?: 0,
kbdOctave = prefs[KBD_OCTAVE] ?: 3,
vuMeters = prefs[VU_METERS] ?: true,
sendMidiClock = prefs[MIDI_CLOCK_SEND] ?: false,
receiveMidiClock = prefs[MIDI_CLOCK_RECV] ?: false,
)
}
@@ -82,6 +88,14 @@ class SettingsStore(private val context: Context) {
context.settingsDataStore.edit { prefs -> prefs[VU_METERS] = enabled }
}
/** Persist the MIDI-clock send / receive toggles. */
suspend fun saveMidiClock(send: Boolean, receive: Boolean) {
context.settingsDataStore.edit { prefs ->
prefs[MIDI_CLOCK_SEND] = send
prefs[MIDI_CLOCK_RECV] = receive
}
}
private companion object {
val PALETTE = stringPreferencesKey("palette")
val RECORD_DIR = stringPreferencesKey("recordDirUri")
@@ -89,5 +103,7 @@ class SettingsStore(private val context: Context) {
val KBD_CHANNEL = intPreferencesKey("kbdChannel")
val KBD_OCTAVE = intPreferencesKey("kbdOctave")
val VU_METERS = booleanPreferencesKey("vuMeters")
val MIDI_CLOCK_SEND = booleanPreferencesKey("midiClockSend")
val MIDI_CLOCK_RECV = booleanPreferencesKey("midiClockRecv")
}
}

View File

@@ -50,6 +50,7 @@ class AppViewModel(
private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary,
private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore,
private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary,
private val midiClock: space.rcmd.android.sizzle.input.MidiClock,
private val appContext: android.content.Context,
) : ViewModel() {
@@ -108,6 +109,26 @@ class AppViewModel(
touched()
}
/** Send MIDI clock + start/stop to attached devices; follow an external clock's
* tempo + transport. Both off by default; toggled from Settings and persisted. */
var sendMidiClock by mutableStateOf(false); private set
var receiveMidiClock by mutableStateOf(false); private set
fun updateSendMidiClock(enabled: Boolean) {
sendMidiClock = enabled; midiClock.sendEnabled = enabled
engine.midiOutEnabled = enabled // also forward the tracks' notes out
persistMidiClock(); touched()
}
fun updateReceiveMidiClock(enabled: Boolean) {
receiveMidiClock = enabled; midiClock.receiveEnabled = enabled
persistMidiClock(); touched()
}
private fun persistMidiClock() {
viewModelScope.launch { settingsStore.saveMidiClock(sendMidiClock, receiveMidiClock) }
}
fun updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) }
fun togglePunchIn() { punchInMode = !punchInMode }
fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); persistKeyboard(); touched() }
@@ -810,6 +831,24 @@ class AppViewModel(
kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
kbdOctave = s.kbdOctave.coerceIn(0, 8)
vuMeters = s.vuMeters
// MIDI clock sync: feed the send thread live transport, follow an external
// clock into the transport/tempo, and restore the persisted toggles.
midiClock.playingProvider = { engine.transport.value.isPlaying }
midiClock.bpmProvider = { project.tempoBpm }
midiClock.noteSource = { engine.pollMidiOut() } // forward sequenced notes out
midiClock.onReceiveStart = {
viewModelScope.launch(Dispatchers.Main) { if (!engine.transport.value.isPlaying) engine.play() }
}
midiClock.onReceiveStop = {
viewModelScope.launch(Dispatchers.Main) { engine.pause() }
}
midiClock.onReceiveTempo = { bpm ->
viewModelScope.launch(Dispatchers.Main) { setTempo(bpm) }
}
sendMidiClock = s.sendMidiClock
midiClock.sendEnabled = s.sendMidiClock; engine.midiOutEnabled = s.sendMidiClock
receiveMidiClock = s.receiveMidiClock; midiClock.receiveEnabled = s.receiveMidiClock
engine.recTempDir = appContext.cacheDir
engine.setRecordTailBars(recordTailBars)
engine.onRecordingStarted = {

View File

@@ -98,6 +98,7 @@ fun SettingsScreen(vm: AppViewModel) {
item { AudioDevicesCard(vm) }
item { InterfaceCard(vm) }
item { ThemeCard(vm) }
item { MidiSyncCard(vm) }
item {
NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
section = SettingsSection.GAMEPAD
@@ -501,26 +502,53 @@ private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
}
/** Interface options: currently the mixer VU-meter toggle. */
/** MIDI clock sync over USB + Bluetooth: drive external gear, or follow an external
* clock. Both off by default. */
@Composable
private fun InterfaceCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
SettingsCard("Interface", subtitle = "On-screen display options") {
private fun MidiSyncCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision
SettingsCard("MIDI Sync", subtitle = "MIDI clock over USB & Bluetooth") {
ToggleRow(
"Send MIDI clock",
"Send clock, start/stop and the tracks' notes to attached MIDI devices, so " +
"Sizzle drives them.",
vm.sendMidiClock,
) { vm.updateSendMidiClock(it) }
HorizontalDivider()
ToggleRow(
"Receive MIDI clock",
"Follow an external clock's tempo, and start/stop with it.",
vm.receiveMidiClock,
) { vm.updateReceiveMidiClock(it) }
}
}
/** A labelled switch row (title + supporting text + trailing Switch). */
@Composable
private fun ToggleRow(title: String, subtitle: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Mixer VU meters", style = MaterialTheme.typography.bodyMedium)
Text(
Text(title, style = MaterialTheme.typography.bodyMedium)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
/** Interface options: currently the mixer VU-meter toggle. */
@Composable
private fun InterfaceCard(vm: AppViewModel) {
@Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the switch state
SettingsCard("Interface", subtitle = "On-screen display options") {
ToggleRow(
"Mixer VU meters",
"Show live level meters inside the mixer volume faders.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = vm.vuMeters, onCheckedChange = { vm.updateVuMeters(it) })
}
vm.vuMeters,
) { vm.updateVuMeters(it) }
}
}