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>
103 lines
4.9 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|