Files
sizzletracker-android/app/src/main/java/space/rcmd/android/sizzle/SizzleApp.kt
Reactorcoremeltdown 96bbc5a4cc 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>
2026-07-21 12:10:40 +02:00

103 lines
4.9 KiB
Kotlin

// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later
package space.rcmd.android.sizzle
import android.app.Application
import space.rcmd.android.sizzle.audio.AudioEngine
import space.rcmd.android.sizzle.input.BindingStore
import space.rcmd.android.sizzle.input.GamepadInput
import space.rcmd.android.sizzle.input.InputRouter
import space.rcmd.android.sizzle.input.KeyboardInput
import space.rcmd.android.sizzle.input.MidiInput
import space.rcmd.android.sizzle.io.PresetLibrary
import space.rcmd.android.sizzle.io.ProjectStore
import space.rcmd.android.sizzle.io.SettingsStore
import space.rcmd.android.sizzle.io.SongLibrary
import space.rcmd.android.sizzle.io.ThemeLibrary
import space.rcmd.android.sizzle.model.Project
import java.io.File
import kotlinx.coroutines.runBlocking
/**
* The Application object is our (very small) dependency container. Instead of a
* DI framework — overkill for a learning-friendly codebase — the handful of
* app-wide singletons live here and are reached via `application as SizzleApp`.
*
* These objects outlive any single screen so playback and input keep working
* across rotation and tab switches.
*/
class SizzleApp : Application() {
/** The single in-memory song. Everything edits this one object. */
val project: Project by lazy { Project() }
/** Neutral input bus shared by touch / keyboard / gamepad / MIDI. */
val inputRouter: InputRouter by lazy { InputRouter() }
/** The real-time sound engine (started/stopped by the PlaybackService). */
val audioEngine: AudioEngine by lazy { AudioEngine() }
// Input source handlers are singletons so their (re)bindable maps are shared
// between MainActivity (which feeds them hardware events) and the Settings
// screen (which edits their bindings via MIDI-learn / gamepad rebind).
val keyboardInput: KeyboardInput by lazy { KeyboardInput(inputRouter) }
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) }
/** On-disk instrument/effect preset library in the app's scoped storage. */
val presetLibrary: PresetLibrary by lazy { PresetLibrary(File(filesDir, "presets")) }
/** On-disk `.sng` song library in the app's scoped storage. */
val songLibrary: SongLibrary by lazy { SongLibrary(File(filesDir, "songs")) }
/** On-disk user colour-theme library (`.szt`) in the app's scoped storage. */
val themeLibrary: ThemeLibrary by lazy { ThemeLibrary(File(filesDir, "themes")) }
/** Autosave of the whole project (crash recovery / resume). */
val projectStore: ProjectStore by lazy { ProjectStore(File(filesDir, "autosave.sng")) }
/** App-level settings persistence (theme, audio backend). */
val settingsStore: SettingsStore by lazy { SettingsStore(this) }
override fun onCreate() {
super.onCreate()
// Crash recovery: restore the last autosaved project before the engine or
// UI read it, so a crash / process-death resumes where the user left off.
projectStore.restore(project)
// Reload the sample audio for any preset-backed Sampler/SoundFont slots —
// the autosave keeps file references, not the decoded PCM, so this makes a
// selected preset's samples sound again on a fresh start.
presetLibrary.rehydrate(project)
// Write the built-in factory presets (e.g. the Ambience reverb spaces) to the
// on-disk library on first launch so they appear in the standard browser.
presetLibrary.seedFactoryPresets()
audioEngine.setProject(project)
// Restore saved bindings before any hardware event can arrive. This is a
// 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()
}
/** Flush an autosave of the current project on any uncaught exception, then let
* the default handler crash/report as usual — so state survives a crash. */
private fun installCrashAutosave() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching { projectStore.save(project) }
previous?.uncaughtException(thread, throwable)
}
}
}