From 96bbc5a4cce6d8e24ce7f7885977b18d390243f9 Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Tue, 21 Jul 2026 12:10:40 +0200 Subject: [PATCH] MIDI clock sync + note output over USB/BLE (v0.18.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 4 +- .../space/rcmd/android/sizzle/MainActivity.kt | 4 +- .../space/rcmd/android/sizzle/SizzleApp.kt | 7 + .../rcmd/android/sizzle/audio/AudioEngine.kt | 62 +++++++ .../rcmd/android/sizzle/input/MidiClock.kt | 169 ++++++++++++++++++ .../rcmd/android/sizzle/input/MidiInput.kt | 6 + .../rcmd/android/sizzle/io/SettingsStore.kt | 16 ++ .../rcmd/android/sizzle/ui/AppViewModel.kt | 39 ++++ .../sizzle/ui/settings/SettingsScreen.kt | 58 ++++-- 9 files changed, 347 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/space/rcmd/android/sizzle/input/MidiClock.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80a6406..b36481a 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 = 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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/MainActivity.kt b/app/src/main/java/space/rcmd/android/sizzle/MainActivity.kt index 99c2adb..c93831d 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/MainActivity.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/MainActivity.kt @@ -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() } diff --git a/app/src/main/java/space/rcmd/android/sizzle/SizzleApp.kt b/app/src/main/java/space/rcmd/android/sizzle/SizzleApp.kt index 95e0f57..327ba4e 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/SizzleApp.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/SizzleApp.kt @@ -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() } diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index 5dba3fb..1e0fdb2 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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() { diff --git a/app/src/main/java/space/rcmd/android/sizzle/input/MidiClock.kt b/app/src/main/java/space/rcmd/android/sizzle/input/MidiClock.kt new file mode 100644 index 0000000..ca8abf8 --- /dev/null +++ b/app/src/main/java/space/rcmd/android/sizzle/input/MidiClock.kt @@ -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() // 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() + } +} 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 77d8cde..3d64daa 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 @@ -29,6 +29,9 @@ class MidiInput( /** CC number -> action id, editable from Settings (MIDI bindings section). */ var ccBindings: MutableMap = 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() @@ -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 diff --git a/app/src/main/java/space/rcmd/android/sizzle/io/SettingsStore.kt b/app/src/main/java/space/rcmd/android/sizzle/io/SettingsStore.kt index 068863e..b24ef3e 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/io/SettingsStore.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/io/SettingsStore.kt @@ -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") } } 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 15f2c03..a78caa2 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 @@ -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 = { diff --git a/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt b/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt index 4444d2c..b5e7a00 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/ui/settings/SettingsScreen.kt @@ -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))) } } +/** MIDI clock sync over USB + Bluetooth: drive external gear, or follow an external + * clock. Both off by default. */ +@Composable +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(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") { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text("Mixer VU meters", style = MaterialTheme.typography.bodyMedium) - Text( - "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) }) - } + ToggleRow( + "Mixer VU meters", + "Show live level meters inside the mixer volume faders.", + vm.vuMeters, + ) { vm.updateVuMeters(it) } } }